openvibers/openvibe.sdk Commits

openvibe-sdk 0.43.0: no sequence number from Events (#40)

OP OpenVibers committed 11 Oct 2026 · 223af4842f · 16 files, +103 −87

Plan T7, ADR-042 decision 7, the last step: OpenVibe.Events hands out no sequence number (Contracts 0.136.x). The client follows. **Breaking** only for code that read `seq`, and nothing in the estate does (checked across every repository).

- **`publish()`:** answers `{ event_id, cursor, duplicate }`. A repeat whose stored copy retention removed is `{ event_id, duplicate: true, pruned: true }`.
- **Reads:** `pull()`/`iterate()` items and `get()` are `{ cursor, event }`.
- **Realtime:** callbacks get `{ cursor }`; dedupe stays on `event_id`, inside the client.
- **`parseDelivery()`:** `{ event, subscriptionId, attempt }`.
- **Outbox kits (SQLite and PostgreSQL):** no longer record a sequence number from the publish answer. A table's old `seq` column stays empty, and the PostgreSQL update is one `WHERE id = ANY(...)`.
- **Mock Events:**
  - publish answers carry the event's cursor (they had no cursor before);
  - pages, single reads and SSE data carry no `seq`;
  - deliveries are `{ event }` without `X-OpenVibe-Seq`.
  - `platform.publishEvent()` (the mock's own API) still returns the stored `seq`, so tests can build a cursor.
- Types, README, CHANGELOG, STATUS, `SDK_VERSION`, browser bundle.

**Tests:** positions are read from the mock's cursor with a test-only helper.
- `events.test.js`:
  - a publish answer has a cursor and no `seq`;
  - items and single reads are `{ cursor, event }`;
  - a delivery carries no sequence number (body or header);
  - delivery order is checked by subject.
- `realtime.test.js`: callbacks carry no `seq`; replays are deduped by event id.
- `pg-outbox.test.js`: nothing is recorded in `seq`.

`ov test`: green.

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 95a24a7..17d2db8 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -3,6 +3,21 @@
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.
++## 0.43.0 (2026-10-10)
++
++No sequence number from Events (Contracts 0.136.x, ADR-042 decision 7, its last step). **Breaking** only for code that
++read `seq`; nothing in the estate does.
++
++- **`publish()`** answers `{ event_id, cursor, duplicate }` (a repeat whose stored copy retention removed:
++ `{ event_id, duplicate: true, pruned: true }`).
++- **Reads:** `pull()`/`iterate()` items and `get()` are `{ cursor, event }`.
++- **Realtime:** callbacks get `{ cursor }`.
++- **`parseDelivery()`:** `{ event, subscriptionId, attempt }`. Dedupe on `event.event_id`.
++- **Outbox kits:** no longer record a sequence number (a table's old `seq` column stays empty).
++- **Mock Events:** publish answers carry the cursor; pages, single reads, SSE data and deliveries carry no `seq`
++ (no `X-OpenVibe-Seq`). The mock's own `platform.publishEvent()` still returns the stored `seq`, for tests that
++ build a cursor.
++
## 0.42.0 (2026-10-10)
An Events position is an opaque cursor and nothing else (ADR-042 decision 7, plan T7). **Breaking** for a caller that
ddiff --git a/README.md b/README.md
iindex 6558751..bcdc345 100644
---- a/README.md
++++ b/README.md
@@@ -254,7 +254,7 @@ Server-only subpaths are declared `"browser": null` in the exports map, so a bun
### Receiving event webhooks
--OpenVibe.Events POSTs each delivery as `{ event, seq }` with two signatures:
++OpenVibe.Events POSTs each delivery as `{ event }` (no sequence number: dedupe on `event.event_id`) with two signatures:
- `X-OpenVibe-Signature: sha256=<hex HMAC-SHA256 of the raw body>` (v1). It covers only the body, so a captured delivery verifies forever.
- `X-OpenVibe-Timestamp: <unix seconds>` and `X-OpenVibe-Signature-V2: t=<that timestamp>,v2=<hex HMAC-SHA256 of "<t>.<raw body>">` (v2). Every attempt, retries included, is signed with the time it is sent, so a delivery stops verifying 300 s later.
ddiff --git a/STATUS.json b/STATUS.json
iindex 60038bd..65adbed 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -3,7 +3,7 @@
"stage": "alpha",
"code": true,
"wave": "W2",
-- "version": "0.42.0",
++ "version": "0.43.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",
ddiff --git a/browser/openvibe-sdk.mjs b/browser/openvibe-sdk.mjs
iindex 947e789..5048b00 100644
---- a/browser/openvibe-sdk.mjs
++++ b/browser/openvibe-sdk.mjs
@@@ -1,4 +1,4 @@
--// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.42.0, MIT). Do not edit.
++// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.43.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.42.0';
++const SDK_VERSION = '0.43.0';
module.exports = {
createClient,
@@@ -1019,8 +1019,7 @@ function subscribe(topics, onEvent, opts = {}) {
if (state.seen.size > SEEN_MAX) state.seen.delete(state.seen.values().next().value);
}
if (id !== undefined && id !== '') state.lastId = String(id);
-- const seq = msg && msg.seq != null && Number.isFinite(Number(msg.seq)) ? Number(msg.seq) : null; // informational
-- try { onEvent(msg && msg.event, { seq, cursor: id !== undefined && id !== '' ? String(id) : null }); } catch (err) { report(err); }
++ try { onEvent(msg && msg.event, { cursor: id !== undefined && id !== '' ? String(id) : null }); } catch (err) { report(err); }
}
async function streamUrl() {
ddiff --git a/package-lock.json b/package-lock.json
iindex 573b998..730d2e7 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,12 +1,12 @@
{
"name": "openvibe-sdk",
-- "version": "0.42.0",
++ "version": "0.43.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-sdk",
-- "version": "0.42.0",
++ "version": "0.43.0",
"license": "MIT",
"devDependencies": {
"@electric-sql/pglite": "^0.5.8",
ddiff --git a/package.json b/package.json
iindex 98786c8..5218c12 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-sdk",
-- "version": "0.42.0",
++ "version": "0.43.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",
ddiff --git a/src/core/index.js b/src/core/index.js
iindex a1ac9d6..a82813b 100644
---- a/src/core/index.js
++++ b/src/core/index.js
@@@ -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.42.0';
++const SDK_VERSION = '0.43.0';
module.exports = {
createClient,
ddiff --git a/src/events.js b/src/events.js
iindex 61d856d..b880c60 100644
---- a/src/events.js
++++ b/src/events.js
@@@ -111,9 +111,8 @@ function verifyDeliveryV2(rawBody, headers, secret, { toleranceSec = V2_TOLERANC
}
/**
-- * Verify and parse one delivery: { event, seq, subscriptionId, attempt } or null when it does not
-- * verify. `headers` is req.headers (or a Fetch Headers). `seq` is informational (null when the delivery
-- * carries none): dedupe by event.event_id, never by seq.
++ * Verify and parse one delivery: { event, subscriptionId, attempt } or null when it does not verify.
++ * `headers` is req.headers (or a Fetch Headers). There is no sequence number: dedupe by event.event_id.
*
* X-OpenVibe-Signature-V2 present it must verify and be within ±toleranceSec (default 300) of
* `now`; a bad or stale v2 is null, never a fallback to v1
@@@ -131,10 +130,8 @@ function parseDelivery(rawBody, headers, secret, { requireV2 = false, toleranceS
let body;
try { body = JSON.parse(toBuffer(rawBody).toString('utf8')); } catch { return null; }
if (!body || !body.event) return null;
-- const seq = body.seq ?? get('x-openvibe-seq');
return {
event: body.event,
-- seq: seq == null || seq === '' || !Number.isFinite(Number(seq)) ? null : Number(seq),
subscriptionId: get('x-openvibe-subscription-id') || null,
attempt: Number(get('x-openvibe-delivery-attempt')) || 1,
};
@@@ -170,7 +167,8 @@ function createEventsClient(client, { source, baseUrl } = {}) {
}
/**
-- * publish(envelope | envelope[], { traceparent }) -> { event_id, seq, cursor, duplicate } | { results }.
++ * publish(envelope | envelope[], { traceparent }) -> { event_id, cursor, duplicate } | { results }; a repeat whose
++ * stored copy retention removed is { event_id, duplicate: true, pruned: true } (no cursor).
* Safe to retry: Events stores an event_id once and answers a repeat with the stored position.
*/
async function publish(input, { traceparent } = {}) {
@@@ -184,7 +182,7 @@ function createEventsClient(client, { source, baseUrl } = {}) {
}
/**
-- * One page: { events: [{ seq, cursor, event }], next_cursor, latest_cursor, gap? }.
++ * One page: { events: [{ cursor, event }], next_cursor, latest_cursor, gap? }.
* Pass `after` — a page's opaque `next_cursor` — to resume exactly where the previous page ended, or
* `latest_cursor` to start at the head without history; without it the page starts at the oldest retained
* event. A position is only ever an opaque cursor (ADR-042): the numeric `afterSeq` is gone, and passing it
@@@ -199,7 +197,7 @@ function createEventsClient(client, { source, baseUrl } = {}) {
}
/**
-- * Async iterator over { seq, cursor, event } from `after` (an opaque cursor; the oldest retained event
++ * Async iterator over { cursor, event } from `after` (an opaque cursor; the oldest retained event
* without one) up to the current head.
*
* onGap(gap) called before a page's items when retention already pruned part of the range
@@@ -244,7 +242,7 @@ function createEventsClient(client, { source, baseUrl } = {}) {
publish,
pull,
iterate,
-- /** { seq, cursor, event } or null. */
++ /** { cursor, event } or null. */
get: (eventId) => orNull(call({ path: `/api/v1/events/${encodeURIComponent(eventId)}` })),
/** { consumer, topic, cursor, carrier, updated_at }: the stored opaque cursor, or cursor null when none is stored. */
getCheckpoint: (topic) => call({ path: '/api/v1/checkpoints', query: { topic } }),
ddiff --git a/src/outbox.js b/src/outbox.js
iindex 8083052..8e0f9b3 100644
---- a/src/outbox.js
++++ b/src/outbox.js
@@@ -86,7 +86,7 @@ function createOutbox(db, {
stmts = {
insert: db.prepare(`INSERT INTO ${table} (event_id, envelope, traceparent, created_at, next_attempt_at) VALUES (?, ?, ?, ?, 0)`),
due: db.prepare(`SELECT * FROM ${table} WHERE sent_at IS NULL AND rejected_at IS NULL AND next_attempt_at <= ? ORDER BY id LIMIT ?`),
-- sent: db.prepare(`UPDATE ${table} SET sent_at = ?, seq = ?, attempts = attempts + 1, last_error = NULL WHERE id = ?`),
++ sent: db.prepare(`UPDATE ${table} SET sent_at = ?, attempts = attempts + 1, last_error = NULL WHERE id = ?`),
failed: db.prepare(`UPDATE ${table} SET attempts = attempts + 1, next_attempt_at = ?, last_error = ? WHERE id = ?`),
rejected: db.prepare(`UPDATE ${table} SET rejected_at = ?, attempts = attempts + 1, last_error = ? WHERE id = ?`),
pending: db.prepare(`SELECT COUNT(*) AS n FROM ${table} WHERE sent_at IS NULL AND rejected_at IS NULL`),
@@@ -122,11 +122,8 @@ function createOutbox(db, {
const envelopes = rows.map((r) => JSON.parse(r.envelope));
const traceparent = rows[0].traceparent || undefined;
try {
-- const out = rows.length === 1
-- ? { results: [await events.publish(envelopes[0], { traceparent })] }
-- : await events.publish(envelopes, { traceparent });
-- const byId = new Map(((out && out.results) || []).map((r) => [r.event_id, r]));
-- db.transaction(() => { for (const row of rows) q().sent.run(now(), byId.get(row.event_id)?.seq ?? null, row.id); })();
++ await events.publish(rows.length === 1 ? envelopes[0] : envelopes, { traceparent });
++ db.transaction(() => { for (const row of rows) q().sent.run(now(), row.id); })();
stats.sent += rows.length;
} catch (err) {
if (rows.length > 1 && isPermanent(err)) {
@@@ -311,13 +308,10 @@ function createPgOutbox(db, {
const envelopes = rows.map((r) => (typeof r.envelope === 'string' ? JSON.parse(r.envelope) : r.envelope));
const traceparent = rows[0].traceparent || undefined;
try {
-- const out = rows.length === 1
-- ? { results: [await events.publish(envelopes[0], { traceparent })] }
-- : await events.publish(envelopes, { traceparent });
-- const byId = new Map(((out && out.results) || []).map((r) => [r.event_id, r]));
-- await db.query(`UPDATE ${table} AS o SET sent_at = $1, seq = v.seq, attempts = o.attempts + 1, last_error = NULL
-- FROM unnest($2::bigint[], $3::bigint[]) AS v(id, seq) WHERE o.id = v.id`,
-- [now(), rows.map((r) => r.id), rows.map((r) => (byId.get(r.event_id) && byId.get(r.event_id).seq) ?? null)]);
++ await events.publish(rows.length === 1 ? envelopes[0] : envelopes, { traceparent });
++ // Events hands out no sequence number (ADR-042): the `seq` column, where a table still has one, stays empty.
++ await db.query(`UPDATE ${table} SET sent_at = $1, attempts = attempts + 1, last_error = NULL WHERE id = ANY($2::bigint[])`,
++ [now(), rows.map((r) => r.id)]);
stats.sent += rows.length;
} catch (err) {
if (rows.length > 1 && isPermanent(err)) {
ddiff --git a/src/realtime.js b/src/realtime.js
iindex 626c8f2..259450f 100644
---- a/src/realtime.js
++++ b/src/realtime.js
@@@ -66,8 +66,7 @@ function subscribe(topics, onEvent, opts = {}) {
if (state.seen.size > SEEN_MAX) state.seen.delete(state.seen.values().next().value);
}
if (id !== undefined && id !== '') state.lastId = String(id);
-- const seq = msg && msg.seq != null && Number.isFinite(Number(msg.seq)) ? Number(msg.seq) : null; // informational
-- try { onEvent(msg && msg.event, { seq, cursor: id !== undefined && id !== '' ? String(id) : null }); } catch (err) { report(err); }
++ try { onEvent(msg && msg.event, { cursor: id !== undefined && id !== '' ? String(id) : null }); } catch (err) { report(err); }
}
async function streamUrl() {
ddiff --git a/src/testing/index.js b/src/testing/index.js
iindex 13e1bc3..d7f1902 100644
---- a/src/testing/index.js
++++ b/src/testing/index.js
@@@ -448,7 +448,8 @@ function createMockPlatform(opts = {}) {
if (items.findIndex((x) => x.event_id === e.event_id) !== i) return problem(422, 'events.invalid_envelope', `events[${i}]: event_id repeated within the batch`);
}
const meta = p.kind === 'app' ? { projectId: p.projectId, env: p.env } : {};
-- const results = items.map((e) => publishEvent(e, p.sub, meta));
++ // Like Events: the answer carries the event's cursor, never a sequence number.
++ const results = items.map((e) => publishEvent(e, p.sub, meta)).map((r) => ({ event_id: r.event_id, cursor: eventCursor(r.seq), duplicate: r.duplicate }));
return json(results.some((x) => !x.duplicate) ? 201 : 200, batch ? { results } : results[0]);
}
if (path === '/api/v1/events' && req.method === 'GET') {
@@@ -478,7 +479,7 @@ function createMockPlatform(opts = {}) {
if (e.seq <= from) continue;
if (out.length >= limit) break;
cursor = e.seq;
-- if (pats.some(([x, re]) => re.test(e.event.event_type) && readable(who.principal, e, x))) out.push({ seq: e.seq, cursor: eventCursor(e.seq), event: e.event });
++ if (pats.some(([x, re]) => re.test(e.event.event_type) && readable(who.principal, e, x))) out.push({ cursor: eventCursor(e.seq), event: e.event });
}
if (out.length < limit) cursor = Math.max(cursor, lastSeq);
return json(200, { ...page, events: out, next_cursor: eventCursor(cursor), latest_cursor: eventCursor(lastSeq) });
@@@ -488,7 +489,7 @@ function createMockPlatform(opts = {}) {
if (who.res) return who.res;
const e = events.find((x) => x.event.event_id === decodeURIComponent(r[1]));
const ok = e && (who.principal.kind === 'app' ? appRules.visibleToApp(e, who.principal) : (e.env || 'production') === 'production');
-- return ok ? json(200, { seq: e.seq, cursor: eventCursor(e.seq), event: e.event }) : problem(404, 'events.not_found', 'no such event (or pruned by retention)');
++ return ok ? json(200, { cursor: eventCursor(e.seq), event: e.event }) : problem(404, 'events.not_found', 'no such event (or pruned by retention)');
}
if (path === '/api/v1/checkpoints') {
const who = eventsPrincipal(req, 'events.event.read', 'events.app.read');
@@@ -587,7 +588,7 @@ function createMockPlatform(opts = {}) {
push(e) {
if (e.seq <= conn.lastSeq || !pats.some((re) => re.test(e.event.event_type)) || !visible(viewer, e)) return;
conn.lastSeq = e.seq;
-- send(`id: ${eventCursor(e.seq)}\ndata: ${JSON.stringify({ seq: e.seq, event: e.event })}\n\n`);
++ send(`id: ${eventCursor(e.seq)}\ndata: ${JSON.stringify({ event: e.event })}\n\n`);
},
close() { streams.delete(conn); try { controller.close(); } catch { /* closed */ } },
};
@@@ -614,7 +615,7 @@ function createMockPlatform(opts = {}) {
/**
* deliverEvents({ fetch, subscriptionId, timeoutMs }) plays the Events delivery worker once:
* for each enabled subscription, POSTs every event published after it was created (in seq
-- * order, one at a time) to its endpoint as { event, seq }, signed like Events signs deliveries
++ * order, one at a time) to its endpoint as { event }, signed like Events signs deliveries
* (X-OpenVibe-Signature: sha256=<HMAC of the raw body with the subscription secret>;
* X-OpenVibe-Timestamp and X-OpenVibe-Signature-V2: t=<ts>,v2=<HMAC of "<ts>.<raw body>">,
* signed afresh on every attempt; plus X-OpenVibe-Event-Id, -Event-Type, -Seq, -Subscription-Id, -Delivery-Attempt, -Hops,
@@@ -637,7 +638,7 @@ function createMockPlatform(opts = {}) {
if (!wanted) { sub.cursor = e.seq; continue; }
const attempt = (sub.attempts.get(e.event.event_id) || 0) + 1;
sub.attempts.set(e.event.event_id, attempt);
-- const body = JSON.stringify({ event: e.event, seq: e.seq });
++ const body = JSON.stringify({ event: e.event });
const trace = /^[0-9a-f]{32}$/.test(e.event.trace_id || '') ? e.event.trace_id : crypto.randomBytes(16).toString('hex');
let status = null;
let error;
@@@ -646,7 +647,7 @@ function createMockPlatform(opts = {}) {
method: 'POST', redirect: 'manual', signal: AbortSignal.timeout(timeoutMs), body,
headers: {
'Content-Type': 'application/json', 'User-Agent': 'OpenVibe.Events/mock',
-- 'X-OpenVibe-Event-Id': e.event.event_id, 'X-OpenVibe-Event-Type': e.event.event_type, 'X-OpenVibe-Seq': String(e.seq),
++ 'X-OpenVibe-Event-Id': e.event.event_id, 'X-OpenVibe-Event-Type': e.event.event_type,
'X-OpenVibe-Subscription-Id': sub.id, 'X-OpenVibe-Delivery-Attempt': String(attempt), 'X-OpenVibe-Hops': '0',
...signDeliveryHeaders(body, sub.secret),
traceparent: `00-${trace}-${crypto.randomBytes(8).toString('hex')}-01`,
ddiff --git a/test/events.test.js b/test/events.test.js
iindex ff64681..3271940 100644
---- a/test/events.test.js
++++ b/test/events.test.js
@@@ -10,6 +10,9 @@ const { subscribe } = require('../src/realtime');
const { createMockPlatform } = require('../src/testing');
const actor = { type: 'service', id: 'live' };
++// The mock's cursor → the position it names. Tests read positions this way only to assert order; a consumer never parses
++// a cursor, and Events hands out no sequence number (ADR-042).
++const posOf = (c) => Number(Buffer.from(String(c).split('.')[2], 'base64url').toString('utf8'));
function setup() {
const platform = createMockPlatform({
@@@ -28,7 +31,8 @@ run([
const { platform, events } = setup();
const tp = '00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01';
const out = await events.publish({ event_type: 'live.stream.started', actor, subject: { type: 'stream', id: '12' } }, { traceparent: tp });
-- assert.equal(out.seq, 1);
++ assert.equal(out.cursor, 'c1.0.MQ', 'the answer carries the event\'s cursor');
++ assert.ok(!('seq' in out), 'and no sequence number');
assert.equal(out.duplicate, false);
const stored = platform.state.events[0].event;
assert.match(stored.event_id, /^evt_[0-9A-HJKMNP-TV-Z]{26}$/);
@@@ -68,7 +72,7 @@ run([
const { platform, client, events } = setup();
for (let i = 1; i <= 5; i++) platform.publishEvent({ event_type: i % 2 ? 'media.vod.ready' : 'live.stream.started', source: 'media', actor, subject: { type: 'vod', id: String(i) } });
const page = await events.pull({ topic: 'media.vod.*', limit: 2 });
-- assert.deepEqual(page.events.map((e) => e.seq), [1, 3], 'without after, from the oldest retained event');
++ assert.deepEqual(page.events.map((e) => posOf(e.cursor)), [1, 3], 'without after, from the oldest retained event');
assert.ok(!('next_after_seq' in page) && !('latest_seq' in page), 'no numeric position on the page');
assert.match(page.next_cursor, /^c1\./, 'the page carries an opaque next_cursor');
assert.match(page.latest_cursor, /^c1\./, 'and the head as an opaque latest_cursor');
@@@ -76,7 +80,7 @@ run([
assert.match(page.events[0].cursor, /^c1\./, 'each event carries its cursor');
// The opaque cursor round-trips: `after` resumes exactly where the page ended.
const rest = await events.pull({ topic: 'media.vod.*', after: page.next_cursor, limit: 2 });
-- assert.deepEqual(rest.events.map((e) => e.seq), [5]);
++ assert.deepEqual(rest.events.map((e) => posOf(e.cursor)), [5]);
assert.equal((await events.get(platform.state.events[0].event.event_id)).cursor, page.events[0].cursor);
// The numeric position is gone: passing it throws instead of reading from the start, and Events refuses it.
assert.throws(() => events.pull({ topic: 'media.vod.*', afterSeq: 3 }), /afterSeq is retired/);
@@@ -85,8 +89,9 @@ run([
{ status: 400, code: 'events.bad_request' }, 'after_seq is refused, never read as "from the start"');
const seen = [];
const cursors = [];
-- for await (const { seq, event } of events.iterate({ topic: ['media.vod.*'], limit: 2, onPage: (p) => cursors.push(p.next_cursor) })) {
-- seen.push(seq);
++ for await (const { cursor, event, ...rest } of events.iterate({ topic: ['media.vod.*'], limit: 2, onPage: (p) => cursors.push(p.next_cursor) })) {
++ assert.ok(!('seq' in rest), 'an item is { cursor, event }');
++ seen.push(posOf(cursor));
assert.equal(event.event_type, 'media.vod.ready');
}
assert.deepEqual(seen, [1, 3, 5]);
@@@ -94,9 +99,10 @@ run([
assert.equal(cursors.at(-1), (await events.pull({ topic: 'media.vod.*', limit: 1 })).latest_cursor, 'iterate ends on the head');
assert.equal(cursors.length, 2, 'and stops there: the last page reached latest_cursor');
const resumed = [];
-- for await (const { seq } of events.iterate({ topic: 'media.vod.*', after: page.next_cursor, limit: 2 })) resumed.push(seq);
++ for await (const { cursor } of events.iterate({ topic: 'media.vod.*', after: page.next_cursor, limit: 2 })) resumed.push(posOf(cursor));
assert.deepEqual(resumed, [5], 'iterate resumes from an opaque cursor');
-- assert.equal((await events.get(platform.state.events[0].event.event_id)).seq, 1);
++ const one = await events.get(platform.state.events[0].event.event_id);
++ assert.deepEqual([posOf(one.cursor), 'seq' in one], [1, false], 'a single read is { cursor, event }');
assert.equal(await events.get('evt_01J00000000000000000000000'), null);
assert.equal((await events.getCheckpoint('media.vod.*')).cursor, null, 'none stored yet');
await events.setCheckpoint('media.vod.*', page.next_cursor, { carrier: 'pg' });
@@@ -118,7 +124,7 @@ run([
const saved = pages[0].next_cursor;
platform.publishEvent({ event_type: 'media.vod.ready', source: 'media', actor, subject: { type: 'vod', id: '2' } });
const seen = [];
-- for await (const e of events.iterate({ topic: 'media.vod.*', after: saved })) seen.push(e.seq);
++ for await (const e of events.iterate({ topic: 'media.vod.*', after: saved })) seen.push(posOf(e.cursor));
assert.deepEqual(seen, [2]);
}],
@@@ -127,13 +133,13 @@ run([
const srv = await stubServer((req, res) => {
const q = new URL(req.url, 'http://x').searchParams;
asked.push([q.get('after'), q.get('after_seq')]);
-- if (!q.get('after')) return send(res, 200, { gap: { from_seq: 1, to_seq: 9 }, events: [{ seq: 10, cursor: 'c1.0.MTA', event: { event_type: 'a.b.c' } }], next_cursor: 'c1.0.MTA', latest_cursor: 'c1.0.MTE' });
-- return send(res, 200, { events: [{ seq: 11, cursor: 'c1.0.MTE', event: { event_type: 'a.b.c' } }], next_cursor: 'c1.0.MTE', latest_cursor: 'c1.0.MTE' });
++ if (!q.get('after')) return send(res, 200, { gap: { from_seq: 1, to_seq: 9 }, events: [{ cursor: 'c1.0.MTA', event: { event_type: 'a.b.c' } }], next_cursor: 'c1.0.MTA', latest_cursor: 'c1.0.MTE' });
++ return send(res, 200, { events: [{ cursor: 'c1.0.MTE', event: { event_type: 'a.b.c' } }], next_cursor: 'c1.0.MTE', latest_cursor: 'c1.0.MTE' });
});
const events = createEventsClient(createClient({ baseUrls: { events: srv.url }, token: 't' }), { source: 'live' });
const gaps = [];
const seqs = [];
-- for await (const e of events.iterate({ topic: 'a.*', onGap: (g) => gaps.push(g) })) seqs.push(e.seq);
++ for await (const e of events.iterate({ topic: 'a.*', onGap: (g) => gaps.push(g) })) seqs.push(posOf(e.cursor));
assert.deepEqual(gaps, [{ from_seq: 1, to_seq: 9 }]);
assert.deepEqual(seqs, [10, 11]);
assert.deepEqual(asked, [[null, null], ['c1.0.MTA', null]], 'no after_seq is ever sent; the second page reads on from the cursor and the head ends it');
@@@ -156,25 +162,25 @@ run([
['verifyDelivery / parseDelivery check X-OpenVibe-Signature over the raw body', async () => {
const secret = `whsec_${'ab'.repeat(32)}`;
-- const raw = Buffer.from(JSON.stringify({ event: { event_id: 'evt_1', event_type: 'media.vod.ready' }, seq: 7 }));
++ const raw = Buffer.from(JSON.stringify({ event: { event_id: 'evt_1', event_type: 'media.vod.ready' } }));
const sig = signDelivery(raw, secret);
assert.equal(sig, `sha256=${crypto.createHmac('sha256', secret).update(raw).digest('hex')}`);
assert.equal(verifyDelivery(raw, sig, secret), true);
assert.equal(verifyDelivery(raw.toString(), ` ${sig} `, secret), true);
assert.equal(verifyDelivery(new Uint8Array(raw), sig, secret), true);
assert.equal(verifyDelivery(raw, sig, 'other-secret'), false);
-- assert.equal(verifyDelivery(Buffer.from(raw.toString().replace('7', '8')), sig, secret), false);
++ assert.equal(verifyDelivery(Buffer.from(raw.toString().replace('evt_1', 'evt_2')), sig, secret), false);
assert.equal(verifyDelivery(raw, 'sha256=00', secret), false);
assert.equal(verifyDelivery(raw, undefined, secret), false);
assert.equal(verifyDelivery(raw, sig, ''), false);
const d = parseDelivery(raw, { 'x-openvibe-signature': sig, 'x-openvibe-subscription-id': 'sub_1', 'x-openvibe-delivery-attempt': '2' }, secret);
-- assert.deepEqual(d, { event: { event_id: 'evt_1', event_type: 'media.vod.ready' }, seq: 7, subscriptionId: 'sub_1', attempt: 2 });
++ assert.deepEqual(d, { event: { event_id: 'evt_1', event_type: 'media.vod.ready' }, subscriptionId: 'sub_1', attempt: 2 });
assert.equal(parseDelivery(raw, new Headers({ 'X-OpenVibe-Signature': 'sha256=bad' }), secret), null);
}],
['verifyDeliveryV2 checks X-OpenVibe-Signature-V2 (HMAC of "<t>.<raw body>") and the ±300 s window', async () => {
const secret = `whsec_${'ab'.repeat(32)}`;
-- const raw = Buffer.from(JSON.stringify({ event: { event_id: 'evt_1', event_type: 'media.vod.ready' }, seq: 7 }));
++ const raw = Buffer.from(JSON.stringify({ event: { event_id: 'evt_1', event_type: 'media.vod.ready' } }));
const now = 1790000000000;
const t = now / 1000;
const v2 = signDeliveryV2(raw, secret, t);
@@@ -190,7 +196,7 @@ run([
assert.equal(verifyDeliveryV2(raw, headers, secret, { now: now + 600000, toleranceSec: 600 }), true, 'toleranceSec');
assert.equal(verifyDeliveryV2(raw, headers, secret), false, 'defaults to Date.now()');
assert.equal(verifyDeliveryV2(raw, headers, 'other-secret', { now }), false);
-- assert.equal(verifyDeliveryV2(Buffer.from(raw.toString().replace('7', '8')), headers, secret, { now }), false, 'body changed');
++ assert.equal(verifyDeliveryV2(Buffer.from(raw.toString().replace('evt_1', 'evt_9')), headers, secret, { now }), false, 'body changed');
assert.equal(verifyDeliveryV2(raw, { ...headers, 'x-openvibe-signature-v2': v2.replace(`t=${t}`, `t=${t + 1}`) }, secret, { now }), false, 'timestamp changed');
assert.equal(verifyDeliveryV2(raw, { ...headers, 'x-openvibe-timestamp': String(t - 1) }, secret, { now }), false, 'X-OpenVibe-Timestamp disagrees with t');
assert.equal(verifyDeliveryV2(raw, { 'x-openvibe-signature-v2': `${v2},v2=${'0'.repeat(64)}` }, secret, { now }), true, 'one of several v2 values');
@@@ -209,10 +215,10 @@ run([
['parseDelivery: a present v2 must verify and be fresh (no v1 fallback); requireV2 refuses v1-only', async () => {
const secret = `whsec_${'cd'.repeat(32)}`;
-- const raw = Buffer.from(JSON.stringify({ event: { event_id: 'evt_2', event_type: 'media.vod.ready' }, seq: 9 }));
++ const raw = Buffer.from(JSON.stringify({ event: { event_id: 'evt_2', event_type: 'media.vod.ready' } }));
const now = 1790000000000;
const signed = signDeliveryHeaders(raw, secret, { now });
-- const want = { event: { event_id: 'evt_2', event_type: 'media.vod.ready' }, seq: 9, subscriptionId: null, attempt: 1 };
++ const want = { event: { event_id: 'evt_2', event_type: 'media.vod.ready' }, subscriptionId: null, attempt: 1 };
const v1only = { 'x-openvibe-signature': signDelivery(raw, secret) };
// v2 (what Events sends): v2 decides.
assert.deepEqual(parseDelivery(raw, signed, secret, { now }), want);
@@@ -239,7 +245,8 @@ run([
// The mock's cursor format, decoded only to label the log readably; a consumer never parses a cursor.
const at = (c) => Number(Buffer.from(String(c).split('.')[2], 'base64url').toString('utf8'));
const consume = async (failAt) => {
-- for await (const { seq } of events.iterate({ topic: 'media.vod.*', after: saved, limit: 3, onPage: (p) => { log.push(`page ${at(p.next_cursor)}`); saved = p.next_cursor; } })) {
++ for await (const { cursor } of events.iterate({ topic: 'media.vod.*', after: saved, limit: 3, onPage: (p) => { log.push(`page ${at(p.next_cursor)}`); saved = p.next_cursor; } })) {
++ const seq = posOf(cursor);
if (seq === failAt) throw new Error(`crash at ${seq}`);
log.push(`item ${seq}`);
}
@@@ -267,21 +274,21 @@ run([
assert.equal(platform.pruneEvents(3), 3);
const page = await events.pull({ topic: 'media.vod.*', after: cur(1) });
assert.deepEqual(page.gap, { from_seq: 2, to_seq: 3 });
-- assert.deepEqual(page.events.map((e) => e.seq), [4, 5]);
++ assert.deepEqual(page.events.map((e) => posOf(e.cursor)), [4, 5]);
assert.equal((await events.pull({ topic: 'media.vod.*', after: cur(3) })).gap, undefined);
const gaps = [];
const seqs = [];
-- for await (const e of events.iterate({ topic: 'media.vod.*', onGap: (g) => gaps.push(g) })) seqs.push(e.seq);
++ for await (const e of events.iterate({ topic: 'media.vod.*', onGap: (g) => gaps.push(g) })) seqs.push(posOf(e.cursor));
assert.deepEqual(gaps, [{ from_seq: 1, to_seq: 3 }]);
assert.deepEqual(seqs, [4, 5]);
const rtGaps = [];
const got = [];
-- const sub = subscribe('media.vod.*', (ev, { seq }) => got.push(seq), { client: createClient({ fetch: platform.fetch }), fetch: platform.fetch, transport: 'fetch', lastEventId: cur(1), onGap: (g) => rtGaps.push(g) });
++ const sub = subscribe('media.vod.*', (ev, { cursor }) => got.push(posOf(cursor)), { client: createClient({ fetch: platform.fetch }), fetch: platform.fetch, transport: 'fetch', lastEventId: cur(1), onGap: (g) => rtGaps.push(g) });
await waitFor(() => got.length === 2);
sub.close();
assert.deepEqual(rtGaps, [{ reason: 'retention', from_seq: 2, to_seq: 3, latest_seq: 5 }]);
platform.publishEvent({ event_type: 'media.vod.ready', source: 'media', actor, subject: { type: 'vod', id: '6' } });
-- assert.equal((await events.pull({ after: cur(5) })).events[0].seq, 6, 'positions keep counting after a prune');
++ assert.equal(posOf((await events.pull({ after: cur(5) })).events[0].cursor), 6, 'positions keep counting after a prune');
}],
['deliverEvents: signed deliveries to a local endpoint, retried in order, dead after max attempts', async () => {
@@@ -296,7 +303,8 @@ run([
const d = parseDelivery(body, req.headers, secret, { requireV2: true });
if (!d) return send(res, 401, { error: 'bad signature' });
if (failNext > 0) { failNext--; return send(res, 503, { error: 'busy' }); }
-- const r = inbox.once('webhook', d.event.event_id, () => handled.push([d.seq, d.attempt, req.headers['x-openvibe-event-type']]));
++ assert.ok(!('seq' in d) && !('seq' in JSON.parse(body)) && req.headers['x-openvibe-seq'] === undefined, 'a delivery carries no sequence number');
++ const r = inbox.once('webhook', d.event.event_id, () => handled.push([d.event.subject.id, d.attempt, req.headers['x-openvibe-event-type']]));
return send(res, 200, { duplicate: r.duplicate });
});
platform.publishEvent({ event_type: 'media.vod.ready', source: 'media', actor, subject: { type: 'vod', id: '0' } }); // before the subscription: not delivered
@@@ -308,7 +316,7 @@ run([
assert.deepEqual([first.delivered, first.failed, first.dead], [0, 1, 0], 'the first attempt failed: the subscription waits');
const second = await platform.deliverEvents();
assert.deepEqual([second.delivered, second.failed], [2, 0]);
-- assert.deepEqual(handled, [[2, 2, 'media.vod.ready'], [4, 1, 'media.vod.ready']], 'in seq order, attempt counted, non-matching skipped');
++ assert.deepEqual(handled, [['1', 2, 'media.vod.ready'], ['3', 1, 'media.vod.ready']], 'in order, attempt counted, non-matching skipped');
const req = srv.requests.at(-1);
assert.equal(req.headers['x-openvibe-subscription-id'], sub.id);
assert.equal(req.headers['x-openvibe-signature'], undefined, 'no v1 header, like Events');
@@@ -335,8 +343,8 @@ run([
['iterate pages by cursor alone: the unchanged cursor or latest_cursor ends it', async () => {
const asked = [];
// A server that omits latest_cursor: the page that does not move the position ends the walk.
-- const pages = { '': { events: [{ seq: 1, cursor: 'c1', event: { id: 'a' } }], next_cursor: 'c1' },
-- c1: { events: [{ seq: 2, cursor: 'c2', event: { id: 'b' } }], next_cursor: 'c2' },
++ const pages = { '': { events: [{ cursor: 'c1', event: { id: 'a' } }], next_cursor: 'c1' },
++ c1: { events: [{ cursor: 'c2', event: { id: 'b' } }], next_cursor: 'c2' },
c2: { events: [], next_cursor: 'c2' } };
const fetch = async (url) => {
const u = new URL(url);
ddiff --git a/test/pg-outbox.test.js b/test/pg-outbox.test.js
iindex 104fe68..2b05e5c 100644
---- a/test/pg-outbox.test.js
++++ b/test/pg-outbox.test.js
@@@ -49,7 +49,7 @@ function cases(label, open) {
assert.equal(platform.state.events[0].event.event_id, env.event_id);
await outbox.flush();
assert.equal(platform.state.events.length, 1, 'nothing republished');
-- assert.ok((await db.value(sql`SELECT seq FROM event_outbox WHERE event_id = ${env.event_id}`)) >= 1, 'the seq Events assigned is kept');
++ assert.strictEqual(await db.value(sql`SELECT seq FROM event_outbox WHERE event_id = ${env.event_id}`), null, 'Events hands out no sequence number (ADR-042): nothing is recorded');
// Published in the order written (UPDATE … RETURNING alone has no order).
const written = [];
for (let i = 0; i < 12; i++) {
ddiff --git a/test/realtime.test.js b/test/realtime.test.js
iindex 9a828a1..3355209 100644
---- a/test/realtime.test.js
++++ b/test/realtime.test.js
@@@ -6,7 +6,8 @@ const { subscribe, createRealtimeClient, parseSSE, DEFAULT_ORIGIN } = require('.
const { createClient } = require('../src/core');
const { createMockPlatform } = require('../src/testing');
--const frame = (seq, type = 'live.stream.started') => `id: ${seq}\ndata: ${JSON.stringify({ seq, event: { event_type: type, event_id: `e${seq}` } })}\n\n`;
++// Events' frames: the id is the position as Events sent it (opaque to the client), the data the event alone.
++const frame = (seq, type = 'live.stream.started') => `id: ${seq}\ndata: ${JSON.stringify({ event: { event_type: type, event_id: `e${seq}` } })}\n\n`;
run([
['uses the new Events public origin when no URL or client is supplied', async () => {
@@@ -45,12 +46,12 @@ run([
const got = [];
const gaps = [];
let opens = 0;
-- const sub = subscribe(['live.stream.*', 'network.notification.*'], (event, { seq }) => got.push([seq, event.event_id]), {
++ const sub = subscribe(['live.stream.*', 'network.notification.*'], (event, { cursor }) => got.push([cursor, event.event_id]), {
url: srv.url, token: 'user-jwt', onGap: (g) => gaps.push(g), onOpen: () => opens++, reconnectDelayMs: 10,
});
assert.equal(sub.transport, 'fetch');
await waitFor(() => got.length === 3);
-- assert.deepEqual(got, [[1, 'e1'], [2, 'e2'], [5, 'e5']]);
++ assert.deepEqual(got, [['1', 'e1'], ['2', 'e2'], ['5', 'e5']], 'each event once (the replayed e2 deduped by its event_id), with its id as the cursor');
assert.deepEqual(gaps, [{ reason: 'retention', from_seq: 3, to_seq: 4, latest_seq: 5 }]);
assert.equal(connections[0].lastEventId, undefined);
assert.equal(connections[1].lastEventId, '2');
@@@ -63,9 +64,9 @@ run([
await srv.close();
}],
-- ['cursor ids (ADR-042): resumes with the cursor Events sent, dedupes on the seq in the data', async () => {
++ ['cursor ids (ADR-042): resumes with the cursor Events sent, dedupes on the event id', async () => {
const cur = (seq) => `c1.0.${Buffer.from([seq]).toString('base64url')}`;
-- const cframe = (seq) => `id: ${cur(seq)}\ndata: ${JSON.stringify({ seq, event: { event_type: 'live.stream.started', event_id: `e${seq}` } })}\n\n`;
++ const cframe = (seq) => `id: ${cur(seq)}\ndata: ${JSON.stringify({ event: { event_type: 'live.stream.started', event_id: `e${seq}` } })}\n\n`;
const seen = [];
const srv = await stubServer((req, res) => {
seen.push(req.headers['last-event-id']);
@@@ -76,9 +77,9 @@ run([
});
const got = [];
const cursors = [];
-- const sub = subscribe('live.*', (event, { seq, cursor }) => { got.push([seq, event.event_id]); cursors.push(cursor); }, { url: srv.url, token: 't', reconnectDelayMs: 10 });
++ const sub = subscribe('live.*', (event, meta) => { got.push(event.event_id); cursors.push(meta.cursor); assert.ok(!('seq' in meta), 'the callback gets the cursor, no sequence number'); }, { url: srv.url, token: 't', reconnectDelayMs: 10 });
await waitFor(() => got.length === 3);
-- assert.deepEqual(got, [[1, 'e1'], [2, 'e2'], [3, 'e3']], 'the replayed event is not delivered twice');
++ assert.deepEqual(got, ['e1', 'e2', 'e3'], 'the replayed event is not delivered twice');
assert.deepEqual(cursors, [cur(1), cur(2), cur(3)], 'each callback gets its event\'s opaque cursor, to save and resume from');
assert.equal(seen[1], cur(2), 'the reconnect resumes from the cursor');
assert.equal(sub.lastEventId, cur(3));
@@@ -112,17 +113,17 @@ run([
}
const got = [];
const gaps = [];
-- const sub = subscribe(['live.stream.*'], (event, { seq, cursor }) => got.push([seq, cursor]), { url: 'https://events.example', EventSource: FakeES, onGap: (g) => gaps.push(g), reconnectDelayMs: 5 });
++ const sub = subscribe(['live.stream.*'], (event, { cursor }) => got.push([event.event_id, cursor]), { url: 'https://events.example', EventSource: FakeES, onGap: (g) => gaps.push(g), reconnectDelayMs: 5 });
await waitFor(() => made.length === 1);
assert.equal(sub.transport, 'eventsource');
const es = made[0];
assert.deepEqual(es.init, { withCredentials: true });
assert.equal(new URL(es.url).searchParams.get('last_event_id'), null);
es.onopen();
-- es.onmessage({ data: JSON.stringify({ seq: 7, event: { event_id: 'evt_7', event_type: 'live.stream.started' } }), lastEventId: 'c1.0.Nw' });
-- es.onmessage({ data: JSON.stringify({ seq: 7, event: { event_id: 'evt_7', event_type: 'live.stream.started' } }), lastEventId: 'c1.0.Nw' });
++ es.onmessage({ data: JSON.stringify({ event: { event_id: 'evt_7', event_type: 'live.stream.started' } }), lastEventId: 'c1.0.Nw' });
++ es.onmessage({ data: JSON.stringify({ event: { event_id: 'evt_7', event_type: 'live.stream.started' } }), lastEventId: 'c1.0.Nw' });
es.listeners.gap({ data: JSON.stringify({ reason: 'replay_limit', from_seq: 8, to_seq: 9 }) });
-- assert.deepEqual(got, [[7, 'c1.0.Nw']], 'a repeated event_id is delivered once');
++ assert.deepEqual(got, [['evt_7', 'c1.0.Nw']], 'a repeated event_id is delivered once');
assert.equal(gaps[0].reason, 'replay_limit');
es.readyState = 2;
es.onerror();
@@@ -142,7 +143,8 @@ run([
const got = [];
const realtime = createRealtimeClient(client, { fetch: platform.fetch, reconnectDelayMs: 10 });
// Resume from the cursor of position 0 (the mock's single epoch): everything retained is replayed.
-- const sub = realtime.subscribe('live.stream.*', (event, { seq }) => got.push([seq, event.event_type]), { lastEventId: 'c1.0.MA' });
++ const posOf = (c) => Number(Buffer.from(String(c).split('.')[2], 'base64url').toString('utf8')); // the mock's cursor → its position (test only)
++ const sub = realtime.subscribe('live.stream.*', (event, { cursor }) => got.push([posOf(cursor), event.event_type]), { lastEventId: 'c1.0.MA' });
await waitFor(() => got.length === 2);
assert.deepEqual(got, [[1, 'live.stream.started'], [3, 'live.stream.updated']], 'internal events never reach an anonymous browser');
pub('live.stream.ended');
ddiff --git a/types/events.d.ts b/types/events.d.ts
iindex 6abd7cc..735e588 100644
---- a/types/events.d.ts
++++ b/types/events.d.ts
@@@ -2,9 +2,9 @@ import type { OpenVibeClient, EventEnvelope } from './core';
/** What a producer passes: event_id, timestamp, source, version, payload and trace_id are filled in. */
export type EventInput = Omit<EventEnvelope, 'event_id' | 'timestamp' | 'source' | 'version' | 'payload'> & Partial<Pick<EventEnvelope, 'event_id' | 'timestamp' | 'source' | 'version' | 'payload'>>;
--/** `seq` is informational; the position is `cursor`. */
--export interface PublishResult { event_id: string; seq: number; cursor: string; duplicate: boolean; }
--export interface StoredEvent { seq: number; cursor: string; event: EventEnvelope; }
++/** `cursor` is the event's position; `pruned` (no cursor) answers a repeat whose stored copy retention removed. */
++export interface PublishResult { event_id: string; cursor?: string; duplicate: boolean; pruned?: true; }
++export interface StoredEvent { cursor: string; event: EventEnvelope; }
export interface Gap { from_seq: number; to_seq: number; reason?: string; latest_seq?: number; }
/** A position is only ever an opaque cursor: `next_cursor` to read on, `latest_cursor` for the head. */
export interface EventsPage { events: StoredEvent[]; next_cursor: string; latest_cursor: string; gap?: Gap; }
@@@ -118,7 +118,7 @@ export interface ParseDeliveryOptions extends DeliveryV2Options {
/** Refuse deliveries without X-OpenVibe-Signature-V2 (v1-only). Default false. A present v2 header must always verify. */
requireV2?: boolean;
}
--export declare function parseDelivery(rawBody: RawBody, headers: Record<string, any> | Headers, secret: string, opts?: ParseDeliveryOptions): { event: EventEnvelope; seq: number | null; subscriptionId: string | null; attempt: number } | null;
++export declare function parseDelivery(rawBody: RawBody, headers: Record<string, any> | Headers, secret: string, opts?: ParseDeliveryOptions): { event: EventEnvelope; subscriptionId: string | null; attempt: number } | null;
/** The subset of a better-sqlite3 Database the outbox and inbox use. */
export interface SqliteDatabase {
ddiff --git a/types/realtime.d.ts b/types/realtime.d.ts
iindex 32a131d..7297d1c 100644
---- a/types/realtime.d.ts
++++ b/types/realtime.d.ts
@@@ -1,8 +1,8 @@
import type { OpenVibeClient, EventEnvelope, FetchLike, TokenContext } from './core';
export interface RealtimeGap { reason: 'retention' | 'replay_limit' | 'cursor_ahead' | string; from_seq: number; to_seq: number; latest_seq?: number; }
--/** `cursor` is the position to save; `seq` is informational. */
--export interface RealtimeMeta { seq: number | null; cursor: string | null; }
++/** `cursor` is the position to save (dedupe is by event_id, inside the client). */
++export interface RealtimeMeta { cursor: string | null; }
export interface SubscribeOptions {
client?: OpenVibeClient;
/** Events origin or the full …/realtime/stream URL; default from the client's registry, else https://openvibe.events */