openvibers/openvibe.sdk Commits
SDK 0.50.0: openvibe-sdk/play cloud saves (put, get, list, remove) (#55)
Changes
@@@ -3,6 +3,12 @@All notable changes to `openvibe-sdk`. The package follows semver; while it is `0.x`, a minorrelease may change an API and says so here.++## 0.50.0 (2026-10-16)++++- `openvibe-sdk/play`: **cloud saves** — `play.saves.put(title, player, slot, { data, version })`, `get` (null when the++ slot is empty), `list` and `remove`, for OpenVibe.Play's new save slots (8 per player per title, 64 KB of JSON each,++ versioned: a stale `version` is `409 play.save_conflict`). Same token and capabilities as scores and achievements.++## 0.49.1 (2026-10-16)- Outbox: Events' `409 events.loop_detected` is a permanent refusal: the row is marked rejected instead of being@@@ -3,7 +3,7 @@"stage": "alpha","code": true,"wave": "W2",-- "version": "0.49.1",++ "version": "0.50.0","plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30","deployed": false,"deployment": "none: a package released as git tags; services and apps pin a tag",@@@ -1,4 +1,4 @@--// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.49.1, MIT). Do not edit.++// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.50.0, MIT). Do not edit.// One self-contained ES module: core at the top level plus the browser-safe subpaths as namespaces// (auth, registry, modules, realtime, media, community, bot, search, chat, ai, jobs, tools, projects, geo). No server-only code: no service tokens, JWT verification, events, identity./* eslint-disable */@@@ -42,7 +42,7 @@ const { paginate, offsetPager } = __load(7);const semver = __load(6);/** The package version (test/version.test.js keeps it equal to package.json). */--const SDK_VERSION = '0.49.1';++const SDK_VERSION = '0.50.0';module.exports = {createClient,@@@ -1,12 +1,12 @@{"name": "openvibe-sdk",-- "version": "0.49.1",++ "version": "0.50.0","lockfileVersion": 3,"requires": true,"packages": {"": {"name": "openvibe-sdk",-- "version": "0.49.1",++ "version": "0.50.0","license": "MIT","bin": {"openvibe-download": "bin/openvibe-download.js"@@@ -1,6 +1,6 @@{"name": "openvibe-sdk",-- "version": "0.49.1",++ "version": "0.50.0","description": "Supported browser and server clients for the OpenVibe platform: auth (incl. developer apps), registry discovery, identity, user modules, events, realtime, media, community, Bot robots, search, chat, AI runs, Tools (registry, run API and jobs), VIP memberships and developer projects, the async PostgreSQL data layer (db) and the Valkey cache, limits store, queue and pub/sub, with deadlines, safe retries, problem errors and trace propagation.","main": "index.js","types": "types/index.d.ts",@@@ -8,7 +8,7 @@ const { paginate, offsetPager } = require('./paginate');const semver = require('./semver');/** The package version (test/version.test.js keeps it equal to package.json). */--const SDK_VERSION = '0.49.1';++const SDK_VERSION = '0.50.0';module.exports = {createClient,@@@ -1,6 +1,6 @@'use strict';/**-- * openvibe-sdk/play — OpenVibe.Play, the network's game services (achievements and leaderboards, Steamworks-style),++ * openvibe-sdk/play — OpenVibe.Play, the network's game services (achievements, leaderboards and cloud saves, Steamworks-style),* for any game: register a title, post scores and unlocks, read a player's achievements. Server-side: a game posts* with its own principal's token (audience openvibe.play): a first-party service token, or a developer app's token* granted play.title.manage / play.result.write / play.player.read. The principal that registers a title is its only@@@ -17,6 +17,9 @@* await play.players.adopt('gem-run', 'abc12345', 'usr_…'); // a guest signed in: their rows become the person's* await play.players.achievements('gem-run', 'usr_…'); // play.player-achievements@1* await play.boards.get('gem-run', 'high-score', { period: 'week' }); // public: rank, name, score (never a player id)++ * await play.saves.put('gem-run', 'usr_…', 'main', { data: { level: 3 }, version: 0 }); // → { slot, version: 1, … }++ * await play.saves.get('gem-run', 'usr_…', 'main'); // → { slot, version, data, … } or null++ * // a stale version is OpenVibeError 409 play.save_conflict: read again, merge, write with the new version** Shapes: Contracts play.title-definition@1, play.result-request@1, play.result-response@1, play.title-result@1,* play.player-achievements@1. Errors are OpenVibeError (status, code); a missing title or board is null from get().@@@ -62,6 +65,20 @@ function createPlayClient(client, { baseUrl } = {}) {/** A player's unlocks in a title you manage (play.player.read). */achievements: (title, who, { signal } = {}) => call({ path: `/api/v1/titles/${enc(title)}/players/${enc(player(who))}/achievements`, signal }),},++ saves: {++ /** Write a player's save slot (play.result.write): any JSON up to 64 KB. `version` is the one you read (0 for a++ * new slot); a stale one is refused (409 play.save_conflict). Leave it out to overwrite. At most 8 slots. */++ put: (title, who, slot, { data, version } = {}, { signal } = {}) => call({++ method: 'PUT', path: `/api/v1/titles/${enc(title)}/players/${enc(player(who))}/saves/${enc(slot)}`, signal, idempotencyKey: false,++ json: { data, ...(version !== undefined && version !== null ? { version } : {}) },++ }),++ /** One slot with its data (play.player.read), or null when the slot is empty. */++ get: (title, who, slot, { signal } = {}) => orNull(call({ path: `/api/v1/titles/${enc(title)}/players/${enc(player(who))}/saves/${enc(slot)}`, signal })),++ /** A player's slots in your title, without their data (play.player.read). */++ list: (title, who, { signal } = {}) => call({ path: `/api/v1/titles/${enc(title)}/players/${enc(player(who))}/saves`, signal }),++ /** Delete a slot (play.result.write). */++ remove: (title, who, slot, { signal } = {}) => call({ method: 'DELETE', path: `/api/v1/titles/${enc(title)}/players/${enc(player(who))}/saves/${enc(slot)}`, signal, idempotencyKey: false }),++ },boards: {/** Public: a leaderboard page (rank, name, score; never a player id), or null. period: e.g. 'all', 'week'. */get: (title, board, { period, page, signal } = {}) => orNull(open({ path: `/api/v1/titles/${enc(title)}/boards/${enc(board)}`, query: { period, page }, signal })),@@@ -32,7 +32,25 @@ const { OpenVibeError } = require('../src/core/errors');assert.deepStrictEqual(calls[3].json, { person: KIM });assert.deepStrictEqual(calls[5].query, { period: 'week', page: undefined });assert.throws(() => play.achievements.unlock('gem-run', { player: 'kim', achievements: ['x'] }), /player/);++++ // Cloud saves: the title's own token, the slot in the path, the version only when given.++ calls.length = 0;++ await play.saves.put('gem-run', KIM, 'main', { data: { level: 3 }, version: 0 });++ await play.saves.put('gem-run', KIM, 'main', { data: { level: 4 } });++ await play.saves.get('gem-run', KIM, 'main');++ await play.saves.list('gem-run', KIM);++ await play.saves.remove('gem-run', KIM, 'main');++ assert.deepStrictEqual(calls.map((c) => [c.method || 'GET', c.path, c.audience]), [++ ['PUT', `/api/v1/titles/gem-run/players/${KIM}/saves/main`, 'openvibe.play'],++ ['PUT', `/api/v1/titles/gem-run/players/${KIM}/saves/main`, 'openvibe.play'],++ ['GET', `/api/v1/titles/gem-run/players/${KIM}/saves/main`, 'openvibe.play'],++ ['GET', `/api/v1/titles/gem-run/players/${KIM}/saves`, 'openvibe.play'],++ ['DELETE', `/api/v1/titles/gem-run/players/${KIM}/saves/main`, 'openvibe.play'],++ ]);++ assert.deepStrictEqual([calls[0].json, calls[1].json], [{ data: { level: 3 }, version: 0 }, { data: { level: 4 } }]);++ assert.throws(() => play.saves.get('gem-run', 'kim', 'main'), /player/);missing = true;++ assert.strictEqual(await play.saves.get('gem-run', KIM, 'empty'), null, 'an empty slot is null');assert.strictEqual(await play.titles.get('nope'), null);assert.strictEqual(await play.boards.get('gem-run', 'nope'), null);await assert.rejects(play.titles.define('nope', { name: 'x' }), (e) => e.status === 404, 'a write is not swallowed');
@@ -3,6 +3,12 @@
@@ -3,6 +3,12 @@
All notable changes to `openvibe-sdk`. The package follows semver; while it is `0.x`, a minor
All notable changes to `openvibe-sdk`. The package follows semver; while it is `0.x`, a minor
release may change an API and says so here.
release may change an API and says so here.
+## 0.50.0 (2026-10-16)
+
+- `openvibe-sdk/play`: **cloud saves** — `play.saves.put(title, player, slot, { data, version })`, `get` (null when the
+ slot is empty), `list` and `remove`, for OpenVibe.Play's new save slots (8 per player per title, 64 KB of JSON each,
+ versioned: a stale `version` is `409 play.save_conflict`). Same token and capabilities as scores and achievements.
+
## 0.49.1 (2026-10-16)
## 0.49.1 (2026-10-16)
- Outbox: Events' `409 events.loop_detected` is a permanent refusal: the row is marked rejected instead of being
- Outbox: Events' `409 events.loop_detected` is a permanent refusal: the row is marked rejected instead of being
@@ -3,7 +3,7 @@
@@ -3,7 +3,7 @@
"stage": "alpha",
"stage": "alpha",
"code": true,
"code": true,
"wave": "W2",
"wave": "W2",
- "version": "0.49.1",
+ "version": "0.50.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30",
"deployed": false,
"deployed": false,
"deployment": "none: a package released as git tags; services and apps pin a tag",
"deployment": "none: a package released as git tags; services and apps pin a tag",
@@ -1,4 +1,4 @@
@@ -1,4 +1,4 @@
-// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.49.1, MIT). Do not edit.
+// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.50.0, MIT). Do not edit.
// One self-contained ES module: core at the top level plus the browser-safe subpaths as namespaces
// One self-contained ES module: core at the top level plus the browser-safe subpaths as namespaces
// (auth, registry, modules, realtime, media, community, bot, search, chat, ai, jobs, tools, projects, geo). No server-only code: no service tokens, JWT verification, events, identity.
// (auth, registry, modules, realtime, media, community, bot, search, chat, ai, jobs, tools, projects, geo). No server-only code: no service tokens, JWT verification, events, identity.
/* eslint-disable */
/* eslint-disable */
@@ -42,7 +42,7 @@ const { paginate, offsetPager } = __load(7);
@@ -42,7 +42,7 @@ const { paginate, offsetPager } = __load(7);
const semver = __load(6);
const semver = __load(6);
/** The package version (test/version.test.js keeps it equal to package.json). */
/** The package version (test/version.test.js keeps it equal to package.json). */
-const SDK_VERSION = '0.49.1';
+const SDK_VERSION = '0.50.0';
module.exports = {
module.exports = {
createClient,
createClient,
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-sdk",
"name": "openvibe-sdk",
- "version": "0.49.1",
+ "version": "0.50.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-sdk",
"name": "openvibe-sdk",
- "version": "0.49.1",
+ "version": "0.50.0",
"license": "MIT",
"license": "MIT",
"bin": {
"bin": {
"openvibe-download": "bin/openvibe-download.js"
"openvibe-download": "bin/openvibe-download.js"
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-sdk",
"name": "openvibe-sdk",
- "version": "0.49.1",
+ "version": "0.50.0",
"description": "Supported browser and server clients for the OpenVibe platform: auth (incl. developer apps), registry discovery, identity, user modules, events, realtime, media, community, Bot robots, search, chat, AI runs, Tools (registry, run API and jobs), VIP memberships and developer projects, the async PostgreSQL data layer (db) and the Valkey cache, limits store, queue and pub/sub, with deadlines, safe retries, problem errors and trace propagation.",
"description": "Supported browser and server clients for the OpenVibe platform: auth (incl. developer apps), registry discovery, identity, user modules, events, realtime, media, community, Bot robots, search, chat, AI runs, Tools (registry, run API and jobs), VIP memberships and developer projects, the async PostgreSQL data layer (db) and the Valkey cache, limits store, queue and pub/sub, with deadlines, safe retries, problem errors and trace propagation.",
"main": "index.js",
"main": "index.js",
"types": "types/index.d.ts",
"types": "types/index.d.ts",
@@ -8,7 +8,7 @@ const { paginate, offsetPager } = require('./paginate');
@@ -8,7 +8,7 @@ const { paginate, offsetPager } = require('./paginate');
const semver = require('./semver');
const semver = require('./semver');
/** The package version (test/version.test.js keeps it equal to package.json). */
/** The package version (test/version.test.js keeps it equal to package.json). */
-const SDK_VERSION = '0.49.1';
+const SDK_VERSION = '0.50.0';
module.exports = {
module.exports = {
createClient,
createClient,
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
'use strict';
'use strict';
/**
/**
- * openvibe-sdk/play — OpenVibe.Play, the network's game services (achievements and leaderboards, Steamworks-style),
+ * openvibe-sdk/play — OpenVibe.Play, the network's game services (achievements, leaderboards and cloud saves, Steamworks-style),
* for any game: register a title, post scores and unlocks, read a player's achievements. Server-side: a game posts
* for any game: register a title, post scores and unlocks, read a player's achievements. Server-side: a game posts
* with its own principal's token (audience openvibe.play): a first-party service token, or a developer app's token
* with its own principal's token (audience openvibe.play): a first-party service token, or a developer app's token
* granted play.title.manage / play.result.write / play.player.read. The principal that registers a title is its only
* granted play.title.manage / play.result.write / play.player.read. The principal that registers a title is its only
@@ -17,6 +17,9 @@
@@ -17,6 +17,9 @@
* await play.players.adopt('gem-run', 'abc12345', 'usr_…'); // a guest signed in: their rows become the person's
* await play.players.adopt('gem-run', 'abc12345', 'usr_…'); // a guest signed in: their rows become the person's
* await play.players.achievements('gem-run', 'usr_…'); // play.player-achievements@1
* await play.players.achievements('gem-run', 'usr_…'); // play.player-achievements@1
* await play.boards.get('gem-run', 'high-score', { period: 'week' }); // public: rank, name, score (never a player id)
* await play.boards.get('gem-run', 'high-score', { period: 'week' }); // public: rank, name, score (never a player id)
+ * await play.saves.put('gem-run', 'usr_…', 'main', { data: { level: 3 }, version: 0 }); // → { slot, version: 1, … }
+ * await play.saves.get('gem-run', 'usr_…', 'main'); // → { slot, version, data, … } or null
+ * // a stale version is OpenVibeError 409 play.save_conflict: read again, merge, write with the new version
*
*
* Shapes: Contracts play.title-definition@1, play.result-request@1, play.result-response@1, play.title-result@1,
* Shapes: Contracts play.title-definition@1, play.result-request@1, play.result-response@1, play.title-result@1,
* play.player-achievements@1. Errors are OpenVibeError (status, code); a missing title or board is null from get().
* play.player-achievements@1. Errors are OpenVibeError (status, code); a missing title or board is null from get().
@@ -62,6 +65,20 @@ function createPlayClient(client, { baseUrl } = {}) {
@@ -62,6 +65,20 @@ function createPlayClient(client, { baseUrl } = {}) {
/** A player's unlocks in a title you manage (play.player.read). */
/** A player's unlocks in a title you manage (play.player.read). */
achievements: (title, who, { signal } = {}) => call({ path: `/api/v1/titles/${enc(title)}/players/${enc(player(who))}/achievements`, signal }),
achievements: (title, who, { signal } = {}) => call({ path: `/api/v1/titles/${enc(title)}/players/${enc(player(who))}/achievements`, signal }),
},
},
+ saves: {
+ /** Write a player's save slot (play.result.write): any JSON up to 64 KB. `version` is the one you read (0 for a
+ * new slot); a stale one is refused (409 play.save_conflict). Leave it out to overwrite. At most 8 slots. */
+ put: (title, who, slot, { data, version } = {}, { signal } = {}) => call({
+ method: 'PUT', path: `/api/v1/titles/${enc(title)}/players/${enc(player(who))}/saves/${enc(slot)}`, signal, idempotencyKey: false,
+ json: { data, ...(version !== undefined && version !== null ? { version } : {}) },
+ }),
+ /** One slot with its data (play.player.read), or null when the slot is empty. */
+ get: (title, who, slot, { signal } = {}) => orNull(call({ path: `/api/v1/titles/${enc(title)}/players/${enc(player(who))}/saves/${enc(slot)}`, signal })),
+ /** A player's slots in your title, without their data (play.player.read). */
+ list: (title, who, { signal } = {}) => call({ path: `/api/v1/titles/${enc(title)}/players/${enc(player(who))}/saves`, signal }),
+ /** Delete a slot (play.result.write). */
+ remove: (title, who, slot, { signal } = {}) => call({ method: 'DELETE', path: `/api/v1/titles/${enc(title)}/players/${enc(player(who))}/saves/${enc(slot)}`, signal, idempotencyKey: false }),
+ },
boards: {
boards: {
/** Public: a leaderboard page (rank, name, score; never a player id), or null. period: e.g. 'all', 'week'. */
/** Public: a leaderboard page (rank, name, score; never a player id), or null. period: e.g. 'all', 'week'. */
get: (title, board, { period, page, signal } = {}) => orNull(open({ path: `/api/v1/titles/${enc(title)}/boards/${enc(board)}`, query: { period, page }, signal })),
get: (title, board, { period, page, signal } = {}) => orNull(open({ path: `/api/v1/titles/${enc(title)}/boards/${enc(board)}`, query: { period, page }, signal })),
@@ -32,7 +32,25 @@ const { OpenVibeError } = require('../src/core/errors');
@@ -32,7 +32,25 @@ const { OpenVibeError } = require('../src/core/errors');
assert.deepStrictEqual(calls[3].json, { person: KIM });
assert.deepStrictEqual(calls[3].json, { person: KIM });
assert.deepStrictEqual(calls[5].query, { period: 'week', page: undefined });
assert.deepStrictEqual(calls[5].query, { period: 'week', page: undefined });
assert.throws(() => play.achievements.unlock('gem-run', { player: 'kim', achievements: ['x'] }), /player/);
assert.throws(() => play.achievements.unlock('gem-run', { player: 'kim', achievements: ['x'] }), /player/);
+
+ // Cloud saves: the title's own token, the slot in the path, the version only when given.
+ calls.length = 0;
+ await play.saves.put('gem-run', KIM, 'main', { data: { level: 3 }, version: 0 });
+ await play.saves.put('gem-run', KIM, 'main', { data: { level: 4 } });
+ await play.saves.get('gem-run', KIM, 'main');
+ await play.saves.list('gem-run', KIM);
+ await play.saves.remove('gem-run', KIM, 'main');
+ assert.deepStrictEqual(calls.map((c) => [c.method || 'GET', c.path, c.audience]), [
+ ['PUT', `/api/v1/titles/gem-run/players/${KIM}/saves/main`, 'openvibe.play'],
+ ['PUT', `/api/v1/titles/gem-run/players/${KIM}/saves/main`, 'openvibe.play'],
+ ['GET', `/api/v1/titles/gem-run/players/${KIM}/saves/main`, 'openvibe.play'],
+ ['GET', `/api/v1/titles/gem-run/players/${KIM}/saves`, 'openvibe.play'],
+ ['DELETE', `/api/v1/titles/gem-run/players/${KIM}/saves/main`, 'openvibe.play'],
+ ]);
+ assert.deepStrictEqual([calls[0].json, calls[1].json], [{ data: { level: 3 }, version: 0 }, { data: { level: 4 } }]);
+ assert.throws(() => play.saves.get('gem-run', 'kim', 'main'), /player/);
missing = true;
missing = true;
+ assert.strictEqual(await play.saves.get('gem-run', KIM, 'empty'), null, 'an empty slot is null');
assert.strictEqual(await play.titles.get('nope'), null);
assert.strictEqual(await play.titles.get('nope'), null);
assert.strictEqual(await play.boards.get('gem-run', 'nope'), null);
assert.strictEqual(await play.boards.get('gem-run', 'nope'), null);
await assert.rejects(play.titles.define('nope', { name: 'x' }), (e) => e.status === 404, 'a write is not swallowed');
await assert.rejects(play.titles.define('nope', { name: 'x' }), (e) => e.status === 404, 'a write is not swallowed');