openvibers/openvibe.examples Commits
Examples: read Events by opaque cursor everywhere; openvibe-sdk 0.37.2; the structure test reads the pin (#11)
Changes
@@@ -9,7 +9,7 @@ of them (Media, Events pull, a Tools job) against the real platform with your owis not part of CI or `npm test`, and it was not run against production as part of this release.`npm run developer-path` is the Wave 20 exit check (account to Media and Events to revokedcredentials); CI runs it against the mock platform, and a person runs it against production.--**Built on:** [openvibe-sdk v0.35.2](https://github.com/OpenVibers/OpenVibe.SDK/tree/v0.35.2), and++**Built on:** [openvibe-sdk v0.37.2](https://github.com/OpenVibers/OpenVibe.SDK/tree/v0.37.2), andopenvibe-contracts v0.127.0 for the mod manifest.**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §15.1and §18.9; roadmap Wave 20, §30 (public SDK surface), ADR-014 (developer projects).@@@ -265,7 +265,7 @@ Repository checks (`test/`):value in a `.env.example`.- `structure.test.js` checks the nine examples are complete and copyable: README with "What itproves" and "Run it against the real platform", a `.env.example` that names every variable the-- code reads, MIT license, `openvibe-sdk` pinned to the v0.35.2 tag, no `file:` links; and that++ code reads, MIT license, `openvibe-sdk` pinned to one tag (the root package.json's, v0.37.2 now), no `file:` links; and that`npm run e2e` is outside CI and `npm test` and refuses to start without its variables; and thatCI runs the developer path against the mock platform only.- `developer-path.test.js` runs the developer path against the mock platform and checks the@@@ -298,7 +298,7 @@ test/public-surface.test.js, test/structure.test.js## Depends on--- OpenVibe.SDK (v0.35.2), OpenVibe.Contracts (v0.113.0, mod manifest only)++- OpenVibe.SDK (v0.37.2), OpenVibe.Contracts (v0.113.0, mod manifest only)- at run time against the real platform: Network (tokens, registry, developer projects), Events,Media, Tools, Chat (on openvibe.live)@@@ -349,5 +349,5 @@ own app.Part of the [OpenVibe network](https://openvibe.network). Built in the open by [OpenVibers](https://github.com/OpenVibers).<!-- versions:start -->--- openvibe-sdk: v0.35.2++- openvibe-sdk: v0.37.2<!-- versions:end -->@@@ -4,7 +4,7 @@"code": true,"wave": "W20","version": "0.3.0",-- "sdk": "openvibe-sdk v0.35.2",++ "sdk": "openvibe-sdk v0.37.2","contracts": "openvibe-contracts v0.126.0 (mod-manifest example)","deployed": false,"deployment": "none: a repository with CI, not a runtime and not a published package",@@@ -13,6 +13,6 @@"node": ">=22"},"dependencies": {-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.35.2"++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.37.2"}}@@@ -21,17 +21,19 @@ OV_EVENTS_MODE=realtime OV_TOPICS=chat.message.created node subscriber.js # puanswered as a duplicate, so retries are safe). Another project's `app.*` topic is refused by theSDK before anything is sent (and by Events with `403 events.topic_not_allowed`).`OV_PLATFORM_TOPICS` adds public first-party topics (`live.stream.*`) to a pull.--- **Pull** (`openvibe-sdk/events` `iterate()`): pages from the saved cursor to the head. The-- cursor is saved after each handled event, and moved past non-matching events (the page's-- `next_after_seq`) in `onPage`, which the SDK calls only after every item of that page was-- handled. A handler that throws leaves the cursor on the last event that succeeded, so the failed++- **Pull** (`openvibe-sdk/events` `iterate()`): pages from the saved cursor to the head. Every++ position is Events' opaque cursor (ADR-042): kept as a string, handed back as `after`, never++ computed with. The cursor is saved after each handled event, and moved past non-matching events++ (the page's `next_cursor`) in `onPage`, which the SDK calls only after every item of that page was++ handled. `startAtHead()` starts from now instead (the head's `latest_cursor`), skipping history. A handler that throws leaves the cursor on the last event that succeeded, so the failedevent is retried next time.- **Gaps**: when Events can no longer supply part of the range (retention pruned it: sandbox appevents are kept 7 days; or a restored cursor is ahead of the stream), `onResync(gap)` is called.Nothing can replay the missing events, so reload whatever state you derive from them.- **Realtime** (`openvibe-sdk/realtime` `subscribe()`): SSE from `/realtime/stream`,-- anonymously, for public first-party events. It resumes from the saved cursor with-- `Last-Event-ID`, and nothing is delivered twice. The app token is never sent on it.++ anonymously, for public first-party events. Each callback gets the event's `cursor` (its SSE id,++ openvibe-sdk 0.37.2), saved after the handler finished; it resumes from it with `Last-Event-ID`,++ and nothing is delivered twice. The app token is never sent on it.- The cursor file is replaced atomically (write + rename).## Files@@@ -13,6 +13,6 @@"node": ">=22"},"dependencies": {-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.35.2"++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.37.2"}}@@@ -18,8 +18,9 @@** The cursor is saved only AFTER an event was handled, so a crash replays at most the event in* hand (make your handler idempotent on event_id). In pull mode the cursor also moves past events-- * that did not match the topic, to the page's next_after_seq: iterate() calls onPage only after-- * every item of that page was handled, so saving it there is crash-safe.++ * that did not match the topic, to the page's next_cursor: iterate() calls onPage only after++ * every item of that page was handled, so saving it there is crash-safe. Every position saved is++ * Events' opaque cursor (ADR-042): keep it as a string and hand it back; never compute with it.** A gap means Events can no longer give you part of the range (retention pruned it: sandbox app* events are kept 7 days; or the cursor is ahead of the stream after a restore). Nothing can replay@@@ -58,15 +59,16 @@ function loadConfig(env = process.env) {};}--/** A tiny durable cursor: { realtime: seq, pull: seq } in a JSON file, replaced atomically. */++/** A tiny durable cursor: { realtime: <cursor>, pull: <cursor> } in a JSON file, replaced atomically. */function createCursorStore(file) {let state = {};try { state = JSON.parse(fs.readFileSync(file, 'utf8')) || {}; } catch { state = {}; }++ const usable = (v) => typeof v === 'string' && v.length > 0;return {-- get: (name) => (Number.isFinite(state[name]) ? state[name] : null),-- set(name, seq) {-- if (!Number.isFinite(seq) || seq === state[name]) return;-- state = { ...state, [name]: seq };++ get: (name) => (usable(state[name]) ? state[name] : null),++ set(name, cursor) {++ if (!usable(cursor) || cursor === state[name]) return;++ state = { ...state, [name]: cursor };fs.mkdirSync(path.dirname(file), { recursive: true });const tmp = `${file}.${process.pid}.tmp`;fs.writeFileSync(tmp, JSON.stringify(state));@@@ -123,15 +125,15 @@ function createSubscriber(config, { onEvent, onResync = () => {}, fetch, log = cconst iterator = ev.iterate({topic: config.topics.length ? config.topics : '*',platformTopics: config.platformTopics,-- afterSeq: cursor.get('pull') || 0,++ ...(cursor.get('pull') ? { after: cursor.get('pull') } : {}),limit,onGap: (g) => gap(g, 'pull'),// After every item of the page was handled: also moves past events of other topics.-- onPage: (page) => cursor.set('pull', page.next_after_seq),++ onPage: (page) => cursor.set('pull', page.next_cursor),});-- for await (const { seq, event } of iterator) {++ for await (const { seq, cursor: at, event } of iterator) {await onEvent(event, { seq, via: 'pull' });-- cursor.set('pull', seq);++ cursor.set('pull', at);handled++;stats.handled++;}@@@ -155,11 +157,11 @@ function createSubscriber(config, { onEvent, onResync = () => {}, fetch, log = c// ── realtime ────────────────────────────────────────────function startRealtime() {let chain = Promise.resolve();-- sub = subscribe(config.topics, (event, { seq }) => {-- // Handle strictly in order; save the cursor only after the handler finished.++ sub = subscribe(config.topics, (event, { seq, cursor: at }) => {++ // Handle strictly in order; save the cursor (the event's SSE id) only after the handler finished.chain = chain.then(async () => {await onEvent(event, { seq, via: 'realtime' });-- cursor.set('realtime', seq);++ cursor.set('realtime', at);stats.handled++;}).catch((err) => log.error(`[events] handler failed at seq ${seq}: ${err.message}`));}, {@@@ -168,17 +170,26 @@ function createSubscriber(config, { onEvent, onResync = () => {}, fetch, log = ctransport: 'fetch',lastEventId: cursor.get('realtime'),onGap: (g) => { chain = chain.then(() => gap(g, 'realtime')); },-- onOpen: () => log.log(`[events] realtime connected (${config.topics.join(', ')}) from seq ${sub ? sub.lastEventId : cursor.get('realtime')}`),++ onOpen: () => log.log(`[events] realtime connected (${config.topics.join(', ')}) from ${(sub ? sub.lastEventId : cursor.get('realtime')) || 'now'}`),onError: (err) => log.error(`[events] realtime: ${isOpenVibeError(err) ? err.code : err.message}`),});return sub;}++ /** Start pulling from now: the pull cursor becomes Events' head (latest_cursor), so history is skipped. → the cursor */++ async function startAtHead() {++ const ev = await eventsForApp();++ const page = await ev.pull({ topic: config.topics.length ? config.topics : '*', platformTopics: config.platformTopics, limit: 1 });++ if (page.latest_cursor) cursor.set('pull', page.latest_cursor);++ return page.latest_cursor || null;++ }++return {client,stats,cursor,pullOnce,++ startAtHead,resolveTopics,start() {stopped = false;@@@ -66,6 +66,8 @@ const firstParty = (visibility = 'public', type = 'media.object.uploaded') => ({const b = await pub(env, { name: 'order.paid' });// ── pull: own topic by default, durable cursor ──────────++ // Saved positions are Events' opaque cursors; the mock makes them as Events does (c1.<epoch>.<base64url(seq)>).++ const cur = (seq) => `c1.0.${Buffer.from(String(seq), 'utf8').toString('base64url')}`;const pullEnv = { ...env, OV_CURSOR_PATH: path.join(dir, 'pull.json') };let seen = [];let s = createSubscriber(loadConfig(pullEnv), { fetch, log: quiet, onEvent: (e, { seq }) => { seen.push(seq); } });@@@ -73,7 +75,7 @@ const firstParty = (visibility = 'public', type = 'media.object.uploaded') => ({assert.equal(await s.pullOnce({ limit: 2 }), 2);assert.deepEqual(seen, [a.seq, b.seq], 'own project only: not the other project, not the forged one');const head = platform.state.events.at(-1).seq;-- assert.equal(createCursorStore(pullEnv.OV_CURSOR_PATH).get('pull'), head, 'cursor saved at the head');++ assert.equal(createCursorStore(pullEnv.OV_CURSOR_PATH).get('pull'), cur(head), 'cursor saved at the head, as an opaque cursor');// A new process resumes from the cursor: only what is new.const c = await pub(env, { name: 'order.shipped' });@@@ -91,14 +93,14 @@ const firstParty = (visibility = 'public', type = 'media.object.uploaded') => ({onEvent: (ev, { seq }) => { if (seq === e.seq) throw new Error('boom'); seen.push(seq); },});await assert.rejects(s.pullOnce(), /boom/);-- assert.equal(createCursorStore(pullEnv.OV_CURSOR_PATH).get('pull'), d.seq);++ assert.equal(createCursorStore(pullEnv.OV_CURSOR_PATH).get('pull'), cur(d.seq));s = createSubscriber(loadConfig(pullEnv), { fetch, log: quiet, onEvent: (ev, { seq }) => { seen.push(seq); } });await s.pullOnce();assert.deepEqual(seen, [d.seq, e.seq], 'the failed event is retried, nothing is skipped');// A gap: retention pruned the range the cursor still needs. onResync is told, reading continues.const gapEnv = { ...env, OV_CURSOR_PATH: path.join(dir, 'gap.json') };-- createCursorStore(gapEnv.OV_CURSOR_PATH).set('pull', a.seq);++ createCursorStore(gapEnv.OV_CURSOR_PATH).set('pull', cur(a.seq));platform.pruneEvents(c.seq);const f = await pub(env);const resyncs = [];@@@ -143,7 +145,8 @@ const firstParty = (visibility = 'public', type = 'media.object.uploaded') => ({const p2 = platform.publishEvent(firstParty('public', 'media.object.deleted'));await waitFor(() => live.length === 2, 'two public events');assert.deepEqual(live.map((x) => x.seq), [p1.seq, p2.seq]);-- await waitFor(() => createCursorStore(rtEnv.OV_CURSOR_PATH).get('realtime') === p2.seq, 'the realtime cursor');++ // The realtime position is the SSE id as sent: an opaque cursor from Events, a bare seq from this mock.++ await waitFor(() => createCursorStore(rtEnv.OV_CURSOR_PATH).get('realtime') === String(p2.seq), 'the realtime cursor');// The connection drops; an event published meanwhile arrives once after the reconnect.platform.dropRealtime();@@@ -165,7 +168,7 @@ const firstParty = (visibility = 'public', type = 'media.object.uploaded') => ({// A cursor ahead of the stream (restored from another environment): a gap, then live events.const aheadPath = path.join(dir, 'ahead.json');-- createCursorStore(aheadPath).set('realtime', 9999);++ createCursorStore(aheadPath).set('realtime', '9999');const rtGaps = [];const rt3 = createSubscriber(loadConfig({ ...rtEnv, OV_CURSOR_PATH: aheadPath }), { fetch, log: quiet, onEvent() {}, onResync: (g, { via }) => rtGaps.push({ ...g, via }) });rt3.start();@@@ -13,6 +13,6 @@"node": ">=22"},"dependencies": {-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.35.2"++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.37.2"}}@@@ -13,7 +13,7 @@"node": ">=22"},"dependencies": {-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.35.2",++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.37.2","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.127.0"}}@@@ -13,6 +13,6 @@"node": ">=22"},"dependencies": {-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.35.2"++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.37.2"}}@@@ -13,6 +13,6 @@"node": ">=22"},"dependencies": {-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.35.2"++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.37.2"}}@@@ -13,6 +13,6 @@"node": ">=22"},"dependencies": {-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.35.2"++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.37.2"}}@@@ -13,7 +13,7 @@"node": ">=22"},"dependencies": {-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.35.2",++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.37.2","better-sqlite3": "^11.10.0"}}@@@ -15,7 +15,7 @@"create-openvibe-app": "scripts/create-openvibe-app.js"},"devDependencies": {-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.35.2"++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.37.2"},"engines": {"node": ">=22"@@@ -26,7 +26,7 @@"version": "0.2.0","license": "MIT","dependencies": {-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.35.2"++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.37.2"},"engines": {"node": ">=22"@@@ -48,7 +48,7 @@"version": "0.2.0","license": "MIT","dependencies": {-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.35.2"++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.37.2"},"engines": {"node": ">=22"@@@ -59,7 +59,7 @@"version": "0.2.0","license": "MIT","dependencies": {-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.35.2"++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.37.2"},"engines": {"node": ">=22"@@@ -71,7 +71,7 @@"license": "MIT","dependencies": {"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.127.0",-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.35.2"++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.37.2"},"engines": {"node": ">=22"@@@ -82,7 +82,7 @@"version": "0.2.0","license": "MIT","dependencies": {-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.35.2"++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.37.2"},"engines": {"node": ">=22"@@@ -93,7 +93,7 @@"version": "0.2.0","license": "MIT","dependencies": {-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.35.2"++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.37.2"},"engines": {"node": ">=22"@@@ -104,7 +104,7 @@"version": "0.2.0","license": "MIT","dependencies": {-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.35.2"++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.37.2"},"engines": {"node": ">=22"@@@ -116,7 +116,7 @@"license": "MIT","dependencies": {"better-sqlite3": "^11.10.0",-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.35.2"++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.37.2"},"engines": {"node": ">=22"@@@ -472,9 +472,9 @@"link": true},"node_modules/openvibe-sdk": {-- "version": "0.35.2",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.35.2",-- "integrity": "sha512-HudKZJK3lZ9i0+yRdMrz9pJmIrYNTN8AFqbHFCz5jDCF68CfZwUJQyhE61pHODzGRyTvUi4DtAzkd0WEzIWx/g==",++ "version": "0.37.2",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.37.2",++ "integrity": "sha512-7kZbDVi35cXAC3J6zFffz/NvcKRVFxI4SQnrl0m45HEo4Tig6cB25K/lYlPzCGzNaVsA3K8MjbOkGRGd9lvF9A==","license": "MIT","engines": {"node": ">=20"@@@ -23,6 +23,6 @@"node": ">=22"},"devDependencies": {-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.35.2"++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.37.2"}}@@@ -209,15 +209,16 @@ async function runDeveloperPath({ network = PRODUCTION_NETWORK, services = PRODU});await step('events', async () => {-- const pub = await publishAppEvent(publishConfig(creds()), { name: 'developer_path.ran', subject: { type: 'check', id: 'developer-path' }, payload: { at: now().toISOString() } }, { fetch: fetchImpl });-- say(`published ${pub.event_type} ${pub.event_id} -> seq ${pub.seq}`);-- st.eventId = pub.event_id;const cursorPath = path.join(dir, 'cursor.json');-- createCursorStore(cursorPath).set('pull', Math.max(0, pub.seq - 1));const seen = [];const s = createSubscriber(subscriberConfig({ ...creds(), OV_EVENTS_MODE: 'pull', OV_CURSOR_PATH: cursorPath }), {fetch: fetchImpl, log: { log: () => {}, error: (m) => say(m) }, onEvent: (event, { seq }) => { seen.push({ seq, id: event.event_id }); },});++ // Start the subscriber at the head (an opaque cursor), then publish: the pull finds what came after it.++ await s.startAtHead();++ const pub = await publishAppEvent(publishConfig(creds()), { name: 'developer_path.ran', subject: { type: 'check', id: 'developer-path' }, payload: { at: now().toISOString() } }, { fetch: fetchImpl });++ say(`published ${pub.event_type} ${pub.event_id}`);++ st.eventId = pub.event_id;const topics = await s.resolveTopics();if (topics[0] !== `app.${projectKey(st.projectId)}.*`) throw new Error(`unexpected topic ${topics[0]}`);const n = await s.pullOnce();@@@ -249,7 +250,7 @@ async function runDeveloperPath({ network = PRODUCTION_NETWORK, services = PRODUif (write.status !== 403) throw new Error(`a write with the export token answered ${write.status}, not 403`);say('a write with it is refused (403)');const et = await mint('openvibe.events');-- const pull = await fetchImpl(`${events.replace(/\/+$/, '')}/api/v1/events?topic=${encodeURIComponent(`app.${projectKey(st.projectId)}.*`)}&after_seq=0&limit=100`, { headers: auth(et) });++ const pull = await fetchImpl(`${events.replace(/\/+$/, '')}/api/v1/events?topic=${encodeURIComponent(`app.${projectKey(st.projectId)}.*`)}&limit=100`, { headers: auth(et) });const got = pull.status === 200 ? ((await pull.json()).events || []) : [];if (!got.some((e) => (e.event || e).event_id === st.eventId)) throw new Error(`Events did not return the event published in step 7 with the export token (${pull.status})`);say(`Events returns the project's event ${st.eventId} with the other`);@@@ -107,9 +107,10 @@ async function runToolsJobProof({ network = PRODUCTION_NETWORK, toolsUrl, client});await step('cursor', async () => {-- const page = await st.events.pull({ topic: 'tools.job.*', afterSeq: Number.MAX_SAFE_INTEGER, limit: 1 });-- st.cursor = Number(page.latest_seq) || 0;-- say(`Events head: seq ${st.cursor}`);++ const page = await st.events.pull({ topic: 'tools.job.*', limit: 1 });++ st.cursor = page.latest_cursor;++ if (!st.cursor) throw new Error('Events answered no latest_cursor (Events#45)');++ say('Events head noted (an opaque cursor)');});await step('submit', async () => {@@@ -162,13 +163,15 @@ async function runToolsJobProof({ network = PRODUCTION_NETWORK, toolsUrl, clientconst deadline = Date.now() + eventsWaitMs;for (;;) {let page;++ let moved;do {-- page = await st.events.pull({ topic: 'tools.job.*', afterSeq: after, limit: 200 });++ page = await st.events.pull({ topic: 'tools.job.*', after, limit: 200 });for (const { seq, event } of page.events || []) {if (event && event.subject && event.subject.id === st.jobId && EVENT_TYPES.includes(event.event_type)) found.set(event.event_type, seq);}-- after = page.next_after_seq != null ? page.next_after_seq : after;-- } while ((page.events || []).length && after < page.latest_seq);++ moved = Boolean(page.next_cursor) && page.next_cursor !== after;++ if (moved) after = page.next_cursor;++ } while (moved && (page.events || []).length === 200);if (EVENT_TYPES.every((t) => found.has(t))) break;if (Date.now() >= deadline) throw new Error(`not in the Events store after ${eventsWaitMs / 1000} s: ${EVENT_TYPES.filter((t) => !found.has(t)).join(', ')}`);await sleep(pollMs);@@@ -12,7 +12,9 @@ const path = require('node:path');const ROOT = path.join(__dirname, '..');const EXPECTED = ['browser-app', 'chat-bot', 'event-subscriber', 'media-uploader', 'mod-manifest', 'node-server-app', 'oauth-app', 'tool-job', 'webhook-consumer'];--const SDK = 'https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.35.2';++// The pin every example must share: the repository's own (the pin bot moves them all at once).++const ROOT_PKG = require(path.join(ROOT, 'package.json'));++const SDK = (ROOT_PKG.dependencies || {})['openvibe-sdk'] || (ROOT_PKG.devDependencies || {})['openvibe-sdk'];const dirs = fs.readdirSync(path.join(ROOT, 'examples')).filter((d) => fs.statSync(path.join(ROOT, 'examples', d)).isDirectory()).sort();assert.deepEqual(dirs, EXPECTED, 'the nine charter examples');@@@ -38,7 +40,7 @@ for (const name of EXPECTED) {assert.equal(pkg.scripts.test, 'node test/smoke.test.js', `${name}: npm test runs the smoke test`);for (const [dep, spec] of Object.entries({ ...pkg.dependencies, ...pkg.devDependencies })) {assert.ok(!/^(file|link):/.test(spec), `${name}: ${dep} is not a local link`);-- if (dep === 'openvibe-sdk') assert.equal(spec, SDK, `${name}: openvibe-sdk pinned to v0.35.0`);++ if (dep === 'openvibe-sdk') assert.equal(spec, SDK, `${name}: openvibe-sdk pinned as the repository is (${SDK.split('/').pop()})`);}const readme = fs.readFileSync(path.join(dir, 'README.md'), 'utf8');
@@ -9,7 +9,7 @@ of them (Media, Events pull, a Tools job) against the real platform with your ow
@@ -9,7 +9,7 @@ of them (Media, Events pull, a Tools job) against the real platform with your ow
is not part of CI or `npm test`, and it was not run against production as part of this release.
is not part of CI or `npm test`, and it was not run against production as part of this release.
`npm run developer-path` is the Wave 20 exit check (account to Media and Events to revoked
`npm run developer-path` is the Wave 20 exit check (account to Media and Events to revoked
credentials); CI runs it against the mock platform, and a person runs it against production.
credentials); CI runs it against the mock platform, and a person runs it against production.
-**Built on:** [openvibe-sdk v0.35.2](https://github.com/OpenVibers/OpenVibe.SDK/tree/v0.35.2), and
+**Built on:** [openvibe-sdk v0.37.2](https://github.com/OpenVibers/OpenVibe.SDK/tree/v0.37.2), and
openvibe-contracts v0.127.0 for the mod manifest.
openvibe-contracts v0.127.0 for the mod manifest.
**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §15.1
**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §15.1
and §18.9; roadmap Wave 20, §30 (public SDK surface), ADR-014 (developer projects).
and §18.9; roadmap Wave 20, §30 (public SDK surface), ADR-014 (developer projects).
@@ -265,7 +265,7 @@ Repository checks (`test/`):
@@ -265,7 +265,7 @@ Repository checks (`test/`):
value in a `.env.example`.
value in a `.env.example`.
- `structure.test.js` checks the nine examples are complete and copyable: README with "What it
- `structure.test.js` checks the nine examples are complete and copyable: README with "What it
proves" and "Run it against the real platform", a `.env.example` that names every variable the
proves" and "Run it against the real platform", a `.env.example` that names every variable the
- code reads, MIT license, `openvibe-sdk` pinned to the v0.35.2 tag, no `file:` links; and that
+ code reads, MIT license, `openvibe-sdk` pinned to one tag (the root package.json's, v0.37.2 now), no `file:` links; and that
`npm run e2e` is outside CI and `npm test` and refuses to start without its variables; and that
`npm run e2e` is outside CI and `npm test` and refuses to start without its variables; and that
CI runs the developer path against the mock platform only.
CI runs the developer path against the mock platform only.
- `developer-path.test.js` runs the developer path against the mock platform and checks the
- `developer-path.test.js` runs the developer path against the mock platform and checks the
@@ -298,7 +298,7 @@ test/public-surface.test.js, test/structure.test.js
@@ -298,7 +298,7 @@ test/public-surface.test.js, test/structure.test.js
## Depends on
## Depends on
-- OpenVibe.SDK (v0.35.2), OpenVibe.Contracts (v0.113.0, mod manifest only)
+- OpenVibe.SDK (v0.37.2), OpenVibe.Contracts (v0.113.0, mod manifest only)
- at run time against the real platform: Network (tokens, registry, developer projects), Events,
- at run time against the real platform: Network (tokens, registry, developer projects), Events,
Media, Tools, Chat (on openvibe.live)
Media, Tools, Chat (on openvibe.live)
@@ -349,5 +349,5 @@ own app.
@@ -349,5 +349,5 @@ own app.
Part of the [OpenVibe network](https://openvibe.network). Built in the open by [OpenVibers](https://github.com/OpenVibers).
Part of the [OpenVibe network](https://openvibe.network). Built in the open by [OpenVibers](https://github.com/OpenVibers).
<!-- versions:start -->
<!-- versions:start -->
-- openvibe-sdk: v0.35.2
+- openvibe-sdk: v0.37.2
<!-- versions:end -->
<!-- versions:end -->
@@ -4,7 +4,7 @@
@@ -4,7 +4,7 @@
"code": true,
"code": true,
"wave": "W20",
"wave": "W20",
"version": "0.3.0",
"version": "0.3.0",
- "sdk": "openvibe-sdk v0.35.2",
+ "sdk": "openvibe-sdk v0.37.2",
"contracts": "openvibe-contracts v0.126.0 (mod-manifest example)",
"contracts": "openvibe-contracts v0.126.0 (mod-manifest example)",
"deployed": false,
"deployed": false,
"deployment": "none: a repository with CI, not a runtime and not a published package",
"deployment": "none: a repository with CI, not a runtime and not a published package",
@@ -13,6 +13,6 @@
@@ -13,6 +13,6 @@
"node": ">=22"
"node": ">=22"
},
},
"dependencies": {
"dependencies": {
- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.35.2"
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.37.2"
}
}
}
}
@@ -21,17 +21,19 @@ OV_EVENTS_MODE=realtime OV_TOPICS=chat.message.created node subscriber.js # pu
@@ -21,17 +21,19 @@ OV_EVENTS_MODE=realtime OV_TOPICS=chat.message.created node subscriber.js # pu
answered as a duplicate, so retries are safe). Another project's `app.*` topic is refused by the
answered as a duplicate, so retries are safe). Another project's `app.*` topic is refused by the
SDK before anything is sent (and by Events with `403 events.topic_not_allowed`).
SDK before anything is sent (and by Events with `403 events.topic_not_allowed`).
`OV_PLATFORM_TOPICS` adds public first-party topics (`live.stream.*`) to a pull.
`OV_PLATFORM_TOPICS` adds public first-party topics (`live.stream.*`) to a pull.
-- **Pull** (`openvibe-sdk/events` `iterate()`): pages from the saved cursor to the head. The
- cursor is saved after each handled event, and moved past non-matching events (the page's
- `next_after_seq`) in `onPage`, which the SDK calls only after every item of that page was
- handled. A handler that throws leaves the cursor on the last event that succeeded, so the failed
+- **Pull** (`openvibe-sdk/events` `iterate()`): pages from the saved cursor to the head. Every
+ position is Events' opaque cursor (ADR-042): kept as a string, handed back as `after`, never
+ computed with. The cursor is saved after each handled event, and moved past non-matching events
+ (the page's `next_cursor`) in `onPage`, which the SDK calls only after every item of that page was
+ handled. `startAtHead()` starts from now instead (the head's `latest_cursor`), skipping history. A handler that throws leaves the cursor on the last event that succeeded, so the failed
event is retried next time.
event is retried next time.
- **Gaps**: when Events can no longer supply part of the range (retention pruned it: sandbox app
- **Gaps**: when Events can no longer supply part of the range (retention pruned it: sandbox app
events are kept 7 days; or a restored cursor is ahead of the stream), `onResync(gap)` is called.
events are kept 7 days; or a restored cursor is ahead of the stream), `onResync(gap)` is called.
Nothing can replay the missing events, so reload whatever state you derive from them.
Nothing can replay the missing events, so reload whatever state you derive from them.
- **Realtime** (`openvibe-sdk/realtime` `subscribe()`): SSE from `/realtime/stream`,
- **Realtime** (`openvibe-sdk/realtime` `subscribe()`): SSE from `/realtime/stream`,
- anonymously, for public first-party events. It resumes from the saved cursor with
- `Last-Event-ID`, and nothing is delivered twice. The app token is never sent on it.
+ anonymously, for public first-party events. Each callback gets the event's `cursor` (its SSE id,
+ openvibe-sdk 0.37.2), saved after the handler finished; it resumes from it with `Last-Event-ID`,
+ and nothing is delivered twice. The app token is never sent on it.
- The cursor file is replaced atomically (write + rename).
- The cursor file is replaced atomically (write + rename).
## Files
## Files
@@ -13,6 +13,6 @@
@@ -13,6 +13,6 @@
"node": ">=22"
"node": ">=22"
},
},
"dependencies": {
"dependencies": {
- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.35.2"
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.37.2"
}
}
}
}
@@ -18,8 +18,9 @@
@@ -18,8 +18,9 @@
*
*
* The cursor is saved only AFTER an event was handled, so a crash replays at most the event in
* The cursor is saved only AFTER an event was handled, so a crash replays at most the event in
* hand (make your handler idempotent on event_id). In pull mode the cursor also moves past events
* hand (make your handler idempotent on event_id). In pull mode the cursor also moves past events
- * that did not match the topic, to the page's next_after_seq: iterate() calls onPage only after
- * every item of that page was handled, so saving it there is crash-safe.
+ * that did not match the topic, to the page's next_cursor: iterate() calls onPage only after
+ * every item of that page was handled, so saving it there is crash-safe. Every position saved is
+ * Events' opaque cursor (ADR-042): keep it as a string and hand it back; never compute with it.
*
*
* A gap means Events can no longer give you part of the range (retention pruned it: sandbox app
* A gap means Events can no longer give you part of the range (retention pruned it: sandbox app
* events are kept 7 days; or the cursor is ahead of the stream after a restore). Nothing can replay
* events are kept 7 days; or the cursor is ahead of the stream after a restore). Nothing can replay
@@ -58,15 +59,16 @@ function loadConfig(env = process.env) {
@@ -58,15 +59,16 @@ function loadConfig(env = process.env) {
};
};
}
}
-/** A tiny durable cursor: { realtime: seq, pull: seq } in a JSON file, replaced atomically. */
+/** A tiny durable cursor: { realtime: <cursor>, pull: <cursor> } in a JSON file, replaced atomically. */
function createCursorStore(file) {
function createCursorStore(file) {
let state = {};
let state = {};
try { state = JSON.parse(fs.readFileSync(file, 'utf8')) || {}; } catch { state = {}; }
try { state = JSON.parse(fs.readFileSync(file, 'utf8')) || {}; } catch { state = {}; }
+ const usable = (v) => typeof v === 'string' && v.length > 0;
return {
return {
- get: (name) => (Number.isFinite(state[name]) ? state[name] : null),
- set(name, seq) {
- if (!Number.isFinite(seq) || seq === state[name]) return;
- state = { ...state, [name]: seq };
+ get: (name) => (usable(state[name]) ? state[name] : null),
+ set(name, cursor) {
+ if (!usable(cursor) || cursor === state[name]) return;
+ state = { ...state, [name]: cursor };
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.mkdirSync(path.dirname(file), { recursive: true });
const tmp = `${file}.${process.pid}.tmp`;
const tmp = `${file}.${process.pid}.tmp`;
fs.writeFileSync(tmp, JSON.stringify(state));
fs.writeFileSync(tmp, JSON.stringify(state));
@@ -123,15 +125,15 @@ function createSubscriber(config, { onEvent, onResync = () => {}, fetch, log = c
@@ -123,15 +125,15 @@ function createSubscriber(config, { onEvent, onResync = () => {}, fetch, log = c
const iterator = ev.iterate({
const iterator = ev.iterate({
topic: config.topics.length ? config.topics : '*',
topic: config.topics.length ? config.topics : '*',
platformTopics: config.platformTopics,
platformTopics: config.platformTopics,
- afterSeq: cursor.get('pull') || 0,
+ ...(cursor.get('pull') ? { after: cursor.get('pull') } : {}),
limit,
limit,
onGap: (g) => gap(g, 'pull'),
onGap: (g) => gap(g, 'pull'),
// After every item of the page was handled: also moves past events of other topics.
// After every item of the page was handled: also moves past events of other topics.
- onPage: (page) => cursor.set('pull', page.next_after_seq),
+ onPage: (page) => cursor.set('pull', page.next_cursor),
});
});
- for await (const { seq, event } of iterator) {
+ for await (const { seq, cursor: at, event } of iterator) {
await onEvent(event, { seq, via: 'pull' });
await onEvent(event, { seq, via: 'pull' });
- cursor.set('pull', seq);
+ cursor.set('pull', at);
handled++;
handled++;
stats.handled++;
stats.handled++;
}
}
@@ -155,11 +157,11 @@ function createSubscriber(config, { onEvent, onResync = () => {}, fetch, log = c
@@ -155,11 +157,11 @@ function createSubscriber(config, { onEvent, onResync = () => {}, fetch, log = c
// ── realtime ────────────────────────────────────────────
// ── realtime ────────────────────────────────────────────
function startRealtime() {
function startRealtime() {
let chain = Promise.resolve();
let chain = Promise.resolve();
- sub = subscribe(config.topics, (event, { seq }) => {
- // Handle strictly in order; save the cursor only after the handler finished.
+ sub = subscribe(config.topics, (event, { seq, cursor: at }) => {
+ // Handle strictly in order; save the cursor (the event's SSE id) only after the handler finished.
chain = chain.then(async () => {
chain = chain.then(async () => {
await onEvent(event, { seq, via: 'realtime' });
await onEvent(event, { seq, via: 'realtime' });
- cursor.set('realtime', seq);
+ cursor.set('realtime', at);
stats.handled++;
stats.handled++;
}).catch((err) => log.error(`[events] handler failed at seq ${seq}: ${err.message}`));
}).catch((err) => log.error(`[events] handler failed at seq ${seq}: ${err.message}`));
}, {
}, {
@@ -168,17 +170,26 @@ function createSubscriber(config, { onEvent, onResync = () => {}, fetch, log = c
@@ -168,17 +170,26 @@ function createSubscriber(config, { onEvent, onResync = () => {}, fetch, log = c
transport: 'fetch',
transport: 'fetch',
lastEventId: cursor.get('realtime'),
lastEventId: cursor.get('realtime'),
onGap: (g) => { chain = chain.then(() => gap(g, 'realtime')); },
onGap: (g) => { chain = chain.then(() => gap(g, 'realtime')); },
- onOpen: () => log.log(`[events] realtime connected (${config.topics.join(', ')}) from seq ${sub ? sub.lastEventId : cursor.get('realtime')}`),
+ onOpen: () => log.log(`[events] realtime connected (${config.topics.join(', ')}) from ${(sub ? sub.lastEventId : cursor.get('realtime')) || 'now'}`),
onError: (err) => log.error(`[events] realtime: ${isOpenVibeError(err) ? err.code : err.message}`),
onError: (err) => log.error(`[events] realtime: ${isOpenVibeError(err) ? err.code : err.message}`),
});
});
return sub;
return sub;
}
}
+ /** Start pulling from now: the pull cursor becomes Events' head (latest_cursor), so history is skipped. → the cursor */
+ async function startAtHead() {
+ const ev = await eventsForApp();
+ const page = await ev.pull({ topic: config.topics.length ? config.topics : '*', platformTopics: config.platformTopics, limit: 1 });
+ if (page.latest_cursor) cursor.set('pull', page.latest_cursor);
+ return page.latest_cursor || null;
+ }
+
return {
return {
client,
client,
stats,
stats,
cursor,
cursor,
pullOnce,
pullOnce,
+ startAtHead,
resolveTopics,
resolveTopics,
start() {
start() {
stopped = false;
stopped = false;
@@ -66,6 +66,8 @@ const firstParty = (visibility = 'public', type = 'media.object.uploaded') => ({
@@ -66,6 +66,8 @@ const firstParty = (visibility = 'public', type = 'media.object.uploaded') => ({
const b = await pub(env, { name: 'order.paid' });
const b = await pub(env, { name: 'order.paid' });
// ── pull: own topic by default, durable cursor ──────────
// ── pull: own topic by default, durable cursor ──────────
+ // Saved positions are Events' opaque cursors; the mock makes them as Events does (c1.<epoch>.<base64url(seq)>).
+ const cur = (seq) => `c1.0.${Buffer.from(String(seq), 'utf8').toString('base64url')}`;
const pullEnv = { ...env, OV_CURSOR_PATH: path.join(dir, 'pull.json') };
const pullEnv = { ...env, OV_CURSOR_PATH: path.join(dir, 'pull.json') };
let seen = [];
let seen = [];
let s = createSubscriber(loadConfig(pullEnv), { fetch, log: quiet, onEvent: (e, { seq }) => { seen.push(seq); } });
let s = createSubscriber(loadConfig(pullEnv), { fetch, log: quiet, onEvent: (e, { seq }) => { seen.push(seq); } });
@@ -73,7 +75,7 @@ const firstParty = (visibility = 'public', type = 'media.object.uploaded') => ({
@@ -73,7 +75,7 @@ const firstParty = (visibility = 'public', type = 'media.object.uploaded') => ({
assert.equal(await s.pullOnce({ limit: 2 }), 2);
assert.equal(await s.pullOnce({ limit: 2 }), 2);
assert.deepEqual(seen, [a.seq, b.seq], 'own project only: not the other project, not the forged one');
assert.deepEqual(seen, [a.seq, b.seq], 'own project only: not the other project, not the forged one');
const head = platform.state.events.at(-1).seq;
const head = platform.state.events.at(-1).seq;
- assert.equal(createCursorStore(pullEnv.OV_CURSOR_PATH).get('pull'), head, 'cursor saved at the head');
+ assert.equal(createCursorStore(pullEnv.OV_CURSOR_PATH).get('pull'), cur(head), 'cursor saved at the head, as an opaque cursor');
// A new process resumes from the cursor: only what is new.
// A new process resumes from the cursor: only what is new.
const c = await pub(env, { name: 'order.shipped' });
const c = await pub(env, { name: 'order.shipped' });
@@ -91,14 +93,14 @@ const firstParty = (visibility = 'public', type = 'media.object.uploaded') => ({
@@ -91,14 +93,14 @@ const firstParty = (visibility = 'public', type = 'media.object.uploaded') => ({
onEvent: (ev, { seq }) => { if (seq === e.seq) throw new Error('boom'); seen.push(seq); },
onEvent: (ev, { seq }) => { if (seq === e.seq) throw new Error('boom'); seen.push(seq); },
});
});
await assert.rejects(s.pullOnce(), /boom/);
await assert.rejects(s.pullOnce(), /boom/);
- assert.equal(createCursorStore(pullEnv.OV_CURSOR_PATH).get('pull'), d.seq);
+ assert.equal(createCursorStore(pullEnv.OV_CURSOR_PATH).get('pull'), cur(d.seq));
s = createSubscriber(loadConfig(pullEnv), { fetch, log: quiet, onEvent: (ev, { seq }) => { seen.push(seq); } });
s = createSubscriber(loadConfig(pullEnv), { fetch, log: quiet, onEvent: (ev, { seq }) => { seen.push(seq); } });
await s.pullOnce();
await s.pullOnce();
assert.deepEqual(seen, [d.seq, e.seq], 'the failed event is retried, nothing is skipped');
assert.deepEqual(seen, [d.seq, e.seq], 'the failed event is retried, nothing is skipped');
// A gap: retention pruned the range the cursor still needs. onResync is told, reading continues.
// A gap: retention pruned the range the cursor still needs. onResync is told, reading continues.
const gapEnv = { ...env, OV_CURSOR_PATH: path.join(dir, 'gap.json') };
const gapEnv = { ...env, OV_CURSOR_PATH: path.join(dir, 'gap.json') };
- createCursorStore(gapEnv.OV_CURSOR_PATH).set('pull', a.seq);
+ createCursorStore(gapEnv.OV_CURSOR_PATH).set('pull', cur(a.seq));
platform.pruneEvents(c.seq);
platform.pruneEvents(c.seq);
const f = await pub(env);
const f = await pub(env);
const resyncs = [];
const resyncs = [];
@@ -143,7 +145,8 @@ const firstParty = (visibility = 'public', type = 'media.object.uploaded') => ({
@@ -143,7 +145,8 @@ const firstParty = (visibility = 'public', type = 'media.object.uploaded') => ({
const p2 = platform.publishEvent(firstParty('public', 'media.object.deleted'));
const p2 = platform.publishEvent(firstParty('public', 'media.object.deleted'));
await waitFor(() => live.length === 2, 'two public events');
await waitFor(() => live.length === 2, 'two public events');
assert.deepEqual(live.map((x) => x.seq), [p1.seq, p2.seq]);
assert.deepEqual(live.map((x) => x.seq), [p1.seq, p2.seq]);
- await waitFor(() => createCursorStore(rtEnv.OV_CURSOR_PATH).get('realtime') === p2.seq, 'the realtime cursor');
+ // The realtime position is the SSE id as sent: an opaque cursor from Events, a bare seq from this mock.
+ await waitFor(() => createCursorStore(rtEnv.OV_CURSOR_PATH).get('realtime') === String(p2.seq), 'the realtime cursor');
// The connection drops; an event published meanwhile arrives once after the reconnect.
// The connection drops; an event published meanwhile arrives once after the reconnect.
platform.dropRealtime();
platform.dropRealtime();
@@ -165,7 +168,7 @@ const firstParty = (visibility = 'public', type = 'media.object.uploaded') => ({
@@ -165,7 +168,7 @@ const firstParty = (visibility = 'public', type = 'media.object.uploaded') => ({
// A cursor ahead of the stream (restored from another environment): a gap, then live events.
// A cursor ahead of the stream (restored from another environment): a gap, then live events.
const aheadPath = path.join(dir, 'ahead.json');
const aheadPath = path.join(dir, 'ahead.json');
- createCursorStore(aheadPath).set('realtime', 9999);
+ createCursorStore(aheadPath).set('realtime', '9999');
const rtGaps = [];
const rtGaps = [];
const rt3 = createSubscriber(loadConfig({ ...rtEnv, OV_CURSOR_PATH: aheadPath }), { fetch, log: quiet, onEvent() {}, onResync: (g, { via }) => rtGaps.push({ ...g, via }) });
const rt3 = createSubscriber(loadConfig({ ...rtEnv, OV_CURSOR_PATH: aheadPath }), { fetch, log: quiet, onEvent() {}, onResync: (g, { via }) => rtGaps.push({ ...g, via }) });
rt3.start();
rt3.start();
@@ -13,6 +13,6 @@
@@ -13,6 +13,6 @@
"node": ">=22"
"node": ">=22"
},
},
"dependencies": {
"dependencies": {
- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.35.2"
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.37.2"
}
}
}
}
@@ -13,7 +13,7 @@
@@ -13,7 +13,7 @@
"node": ">=22"
"node": ">=22"
},
},
"dependencies": {
"dependencies": {
- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.35.2",
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.37.2",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.127.0"
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.127.0"
}
}
}
}
@@ -13,6 +13,6 @@
@@ -13,6 +13,6 @@
"node": ">=22"
"node": ">=22"
},
},
"dependencies": {
"dependencies": {
- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.35.2"
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.37.2"
}
}
}
}
@@ -13,6 +13,6 @@
@@ -13,6 +13,6 @@
"node": ">=22"
"node": ">=22"
},
},
"dependencies": {
"dependencies": {
- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.35.2"
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.37.2"
}
}
}
}
@@ -13,6 +13,6 @@
@@ -13,6 +13,6 @@
"node": ">=22"
"node": ">=22"
},
},
"dependencies": {
"dependencies": {
- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.35.2"
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.37.2"
}
}
}
}
@@ -13,7 +13,7 @@
@@ -13,7 +13,7 @@
"node": ">=22"
"node": ">=22"
},
},
"dependencies": {
"dependencies": {
- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.35.2",
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.37.2",
"better-sqlite3": "^11.10.0"
"better-sqlite3": "^11.10.0"
}
}
}
}
@@ -15,7 +15,7 @@
@@ -15,7 +15,7 @@
"create-openvibe-app": "scripts/create-openvibe-app.js"
"create-openvibe-app": "scripts/create-openvibe-app.js"
},
},
"devDependencies": {
"devDependencies": {
- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.35.2"
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.37.2"
},
},
"engines": {
"engines": {
"node": ">=22"
"node": ">=22"
@@ -26,7 +26,7 @@
@@ -26,7 +26,7 @@
"version": "0.2.0",
"version": "0.2.0",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.35.2"
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.37.2"
},
},
"engines": {
"engines": {
"node": ">=22"
"node": ">=22"
@@ -48,7 +48,7 @@
@@ -48,7 +48,7 @@
"version": "0.2.0",
"version": "0.2.0",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.35.2"
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.37.2"
},
},
"engines": {
"engines": {
"node": ">=22"
"node": ">=22"
@@ -59,7 +59,7 @@
@@ -59,7 +59,7 @@
"version": "0.2.0",
"version": "0.2.0",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.35.2"
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.37.2"
},
},
"engines": {
"engines": {
"node": ">=22"
"node": ">=22"
@@ -71,7 +71,7 @@
@@ -71,7 +71,7 @@
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.127.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.127.0",
- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.35.2"
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.37.2"
},
},
"engines": {
"engines": {
"node": ">=22"
"node": ">=22"
@@ -82,7 +82,7 @@
@@ -82,7 +82,7 @@
"version": "0.2.0",
"version": "0.2.0",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.35.2"
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.37.2"
},
},
"engines": {
"engines": {
"node": ">=22"
"node": ">=22"
@@ -93,7 +93,7 @@
@@ -93,7 +93,7 @@
"version": "0.2.0",
"version": "0.2.0",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.35.2"
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.37.2"
},
},
"engines": {
"engines": {
"node": ">=22"
"node": ">=22"
@@ -104,7 +104,7 @@
@@ -104,7 +104,7 @@
"version": "0.2.0",
"version": "0.2.0",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.35.2"
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.37.2"
},
},
"engines": {
"engines": {
"node": ">=22"
"node": ">=22"
@@ -116,7 +116,7 @@
@@ -116,7 +116,7 @@
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"better-sqlite3": "^11.10.0",
"better-sqlite3": "^11.10.0",
- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.35.2"
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.37.2"
},
},
"engines": {
"engines": {
"node": ">=22"
"node": ">=22"
@@ -472,9 +472,9 @@
@@ -472,9 +472,9 @@
"link": true
"link": true
},
},
"node_modules/openvibe-sdk": {
"node_modules/openvibe-sdk": {
- "version": "0.35.2",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.35.2",
- "integrity": "sha512-HudKZJK3lZ9i0+yRdMrz9pJmIrYNTN8AFqbHFCz5jDCF68CfZwUJQyhE61pHODzGRyTvUi4DtAzkd0WEzIWx/g==",
+ "version": "0.37.2",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.37.2",
+ "integrity": "sha512-7kZbDVi35cXAC3J6zFffz/NvcKRVFxI4SQnrl0m45HEo4Tig6cB25K/lYlPzCGzNaVsA3K8MjbOkGRGd9lvF9A==",
"license": "MIT",
"license": "MIT",
"engines": {
"engines": {
"node": ">=20"
"node": ">=20"
@@ -23,6 +23,6 @@
@@ -23,6 +23,6 @@
"node": ">=22"
"node": ">=22"
},
},
"devDependencies": {
"devDependencies": {
- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.35.2"
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.37.2"
}
}
}
}
@@ -209,15 +209,16 @@ async function runDeveloperPath({ network = PRODUCTION_NETWORK, services = PRODU
@@ -209,15 +209,16 @@ async function runDeveloperPath({ network = PRODUCTION_NETWORK, services = PRODU
});
});
await step('events', async () => {
await step('events', async () => {
- const pub = await publishAppEvent(publishConfig(creds()), { name: 'developer_path.ran', subject: { type: 'check', id: 'developer-path' }, payload: { at: now().toISOString() } }, { fetch: fetchImpl });
- say(`published ${pub.event_type} ${pub.event_id} -> seq ${pub.seq}`);
- st.eventId = pub.event_id;
const cursorPath = path.join(dir, 'cursor.json');
const cursorPath = path.join(dir, 'cursor.json');
- createCursorStore(cursorPath).set('pull', Math.max(0, pub.seq - 1));
const seen = [];
const seen = [];
const s = createSubscriber(subscriberConfig({ ...creds(), OV_EVENTS_MODE: 'pull', OV_CURSOR_PATH: cursorPath }), {
const s = createSubscriber(subscriberConfig({ ...creds(), OV_EVENTS_MODE: 'pull', OV_CURSOR_PATH: cursorPath }), {
fetch: fetchImpl, log: { log: () => {}, error: (m) => say(m) }, onEvent: (event, { seq }) => { seen.push({ seq, id: event.event_id }); },
fetch: fetchImpl, log: { log: () => {}, error: (m) => say(m) }, onEvent: (event, { seq }) => { seen.push({ seq, id: event.event_id }); },
});
});
+ // Start the subscriber at the head (an opaque cursor), then publish: the pull finds what came after it.
+ await s.startAtHead();
+ const pub = await publishAppEvent(publishConfig(creds()), { name: 'developer_path.ran', subject: { type: 'check', id: 'developer-path' }, payload: { at: now().toISOString() } }, { fetch: fetchImpl });
+ say(`published ${pub.event_type} ${pub.event_id}`);
+ st.eventId = pub.event_id;
const topics = await s.resolveTopics();
const topics = await s.resolveTopics();
if (topics[0] !== `app.${projectKey(st.projectId)}.*`) throw new Error(`unexpected topic ${topics[0]}`);
if (topics[0] !== `app.${projectKey(st.projectId)}.*`) throw new Error(`unexpected topic ${topics[0]}`);
const n = await s.pullOnce();
const n = await s.pullOnce();
@@ -249,7 +250,7 @@ async function runDeveloperPath({ network = PRODUCTION_NETWORK, services = PRODU
@@ -249,7 +250,7 @@ async function runDeveloperPath({ network = PRODUCTION_NETWORK, services = PRODU
if (write.status !== 403) throw new Error(`a write with the export token answered ${write.status}, not 403`);
if (write.status !== 403) throw new Error(`a write with the export token answered ${write.status}, not 403`);
say('a write with it is refused (403)');
say('a write with it is refused (403)');
const et = await mint('openvibe.events');
const et = await mint('openvibe.events');
- const pull = await fetchImpl(`${events.replace(/\/+$/, '')}/api/v1/events?topic=${encodeURIComponent(`app.${projectKey(st.projectId)}.*`)}&after_seq=0&limit=100`, { headers: auth(et) });
+ const pull = await fetchImpl(`${events.replace(/\/+$/, '')}/api/v1/events?topic=${encodeURIComponent(`app.${projectKey(st.projectId)}.*`)}&limit=100`, { headers: auth(et) });
const got = pull.status === 200 ? ((await pull.json()).events || []) : [];
const got = pull.status === 200 ? ((await pull.json()).events || []) : [];
if (!got.some((e) => (e.event || e).event_id === st.eventId)) throw new Error(`Events did not return the event published in step 7 with the export token (${pull.status})`);
if (!got.some((e) => (e.event || e).event_id === st.eventId)) throw new Error(`Events did not return the event published in step 7 with the export token (${pull.status})`);
say(`Events returns the project's event ${st.eventId} with the other`);
say(`Events returns the project's event ${st.eventId} with the other`);
@@ -107,9 +107,10 @@ async function runToolsJobProof({ network = PRODUCTION_NETWORK, toolsUrl, client
@@ -107,9 +107,10 @@ async function runToolsJobProof({ network = PRODUCTION_NETWORK, toolsUrl, client
});
});
await step('cursor', async () => {
await step('cursor', async () => {
- const page = await st.events.pull({ topic: 'tools.job.*', afterSeq: Number.MAX_SAFE_INTEGER, limit: 1 });
- st.cursor = Number(page.latest_seq) || 0;
- say(`Events head: seq ${st.cursor}`);
+ const page = await st.events.pull({ topic: 'tools.job.*', limit: 1 });
+ st.cursor = page.latest_cursor;
+ if (!st.cursor) throw new Error('Events answered no latest_cursor (Events#45)');
+ say('Events head noted (an opaque cursor)');
});
});
await step('submit', async () => {
await step('submit', async () => {
@@ -162,13 +163,15 @@ async function runToolsJobProof({ network = PRODUCTION_NETWORK, toolsUrl, client
@@ -162,13 +163,15 @@ async function runToolsJobProof({ network = PRODUCTION_NETWORK, toolsUrl, client
const deadline = Date.now() + eventsWaitMs;
const deadline = Date.now() + eventsWaitMs;
for (;;) {
for (;;) {
let page;
let page;
+ let moved;
do {
do {
- page = await st.events.pull({ topic: 'tools.job.*', afterSeq: after, limit: 200 });
+ page = await st.events.pull({ topic: 'tools.job.*', after, limit: 200 });
for (const { seq, event } of page.events || []) {
for (const { seq, event } of page.events || []) {
if (event && event.subject && event.subject.id === st.jobId && EVENT_TYPES.includes(event.event_type)) found.set(event.event_type, seq);
if (event && event.subject && event.subject.id === st.jobId && EVENT_TYPES.includes(event.event_type)) found.set(event.event_type, seq);
}
}
- after = page.next_after_seq != null ? page.next_after_seq : after;
- } while ((page.events || []).length && after < page.latest_seq);
+ moved = Boolean(page.next_cursor) && page.next_cursor !== after;
+ if (moved) after = page.next_cursor;
+ } while (moved && (page.events || []).length === 200);
if (EVENT_TYPES.every((t) => found.has(t))) break;
if (EVENT_TYPES.every((t) => found.has(t))) break;
if (Date.now() >= deadline) throw new Error(`not in the Events store after ${eventsWaitMs / 1000} s: ${EVENT_TYPES.filter((t) => !found.has(t)).join(', ')}`);
if (Date.now() >= deadline) throw new Error(`not in the Events store after ${eventsWaitMs / 1000} s: ${EVENT_TYPES.filter((t) => !found.has(t)).join(', ')}`);
await sleep(pollMs);
await sleep(pollMs);
@@ -12,7 +12,9 @@ const path = require('node:path');
@@ -12,7 +12,9 @@ const path = require('node:path');
const ROOT = path.join(__dirname, '..');
const ROOT = path.join(__dirname, '..');
const EXPECTED = ['browser-app', 'chat-bot', 'event-subscriber', 'media-uploader', 'mod-manifest', 'node-server-app', 'oauth-app', 'tool-job', 'webhook-consumer'];
const EXPECTED = ['browser-app', 'chat-bot', 'event-subscriber', 'media-uploader', 'mod-manifest', 'node-server-app', 'oauth-app', 'tool-job', 'webhook-consumer'];
-const SDK = 'https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.35.2';
+// The pin every example must share: the repository's own (the pin bot moves them all at once).
+const ROOT_PKG = require(path.join(ROOT, 'package.json'));
+const SDK = (ROOT_PKG.dependencies || {})['openvibe-sdk'] || (ROOT_PKG.devDependencies || {})['openvibe-sdk'];
const dirs = fs.readdirSync(path.join(ROOT, 'examples')).filter((d) => fs.statSync(path.join(ROOT, 'examples', d)).isDirectory()).sort();
const dirs = fs.readdirSync(path.join(ROOT, 'examples')).filter((d) => fs.statSync(path.join(ROOT, 'examples', d)).isDirectory()).sort();
assert.deepEqual(dirs, EXPECTED, 'the nine charter examples');
assert.deepEqual(dirs, EXPECTED, 'the nine charter examples');
@@ -38,7 +40,7 @@ for (const name of EXPECTED) {
@@ -38,7 +40,7 @@ for (const name of EXPECTED) {
assert.equal(pkg.scripts.test, 'node test/smoke.test.js', `${name}: npm test runs the smoke test`);
assert.equal(pkg.scripts.test, 'node test/smoke.test.js', `${name}: npm test runs the smoke test`);
for (const [dep, spec] of Object.entries({ ...pkg.dependencies, ...pkg.devDependencies })) {
for (const [dep, spec] of Object.entries({ ...pkg.dependencies, ...pkg.devDependencies })) {
assert.ok(!/^(file|link):/.test(spec), `${name}: ${dep} is not a local link`);
assert.ok(!/^(file|link):/.test(spec), `${name}: ${dep} is not a local link`);
- if (dep === 'openvibe-sdk') assert.equal(spec, SDK, `${name}: openvibe-sdk pinned to v0.35.0`);
+ if (dep === 'openvibe-sdk') assert.equal(spec, SDK, `${name}: openvibe-sdk pinned as the repository is (${SDK.split('/').pop()})`);
}
}
const readme = fs.readFileSync(path.join(dir, 'README.md'), 'utf8');
const readme = fs.readFileSync(path.join(dir, 'README.md'), 'utf8');