@syncular/server 0.31.0 → 0.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -169,13 +169,28 @@ export declare class RealtimeSession {
169
169
  deliverCommit(commit: StoredCommit): void;
170
170
  close(): void;
171
171
  }
172
+ /** One open realtime socket. */
173
+ export interface RealtimeConnection {
174
+ readonly sessionId: string;
175
+ readonly clientId: string;
176
+ readonly actorId: string;
177
+ readonly openedAtMs: number;
178
+ }
172
179
  export declare class RealtimeHub {
173
180
  #private;
174
181
  /** §8.6 presence registry — ephemeral, in-memory, never persisted. */
175
182
  readonly presence: PresenceRegistry;
176
183
  constructor(config: RealtimeHubConfig);
177
184
  projectPayload(registration: Registration, payload: Uint8Array): Uint8Array;
185
+ /**
186
+ * Compose `sink` into the hub's `events`, so socket rounds and sessions
187
+ * opened afterwards report to it as well. `createSyncularHono` uses it to
188
+ * feed the admin console's event ring.
189
+ */
190
+ addEventSink(sink: SyncularServerEvents): void;
178
191
  get sessionCount(): number;
192
+ /** The partition's open sockets, for the admin console. */
193
+ connections(partition: string): RealtimeConnection[];
179
194
  /** §8.6.2 published-document size cap (bytes). */
180
195
  get maxPresenceBytes(): number;
181
196
  /** §8.6.4 presence rate cap (ms); 0 = off (reference default). */
package/dist/realtime.js CHANGED
@@ -20,6 +20,7 @@ import { DecodeError, decodeMessage, encodeMessage, encodePresenceError, encodeP
20
20
  import { REMOTE_COMMAND_CLIENT_ID_PREFIX, RESOLVER_OUTAGE, touchAuthenticatedPartition, } from './context.js';
21
21
  import { adapterSyncError, reportError, SyncError, syncError, } from './errors.js';
22
22
  import { emitEvent } from './events.js';
23
+ import { composeEvents } from './events-ring.js';
23
24
  import { createSyncResponseStream } from './handler.js';
24
25
  import { compileSchema, } from './schema.js';
25
26
  import { schemaWindowOf, projectRowPayload } from './schema-window.js';
@@ -714,9 +715,32 @@ export class RealtimeHub {
714
715
  projectPayload(registration, payload) {
715
716
  return projectRowPayload(this.#schema.tables.get(registration.table), registration.codec, payload);
716
717
  }
718
+ /**
719
+ * Compose `sink` into the hub's `events`, so socket rounds and sessions
720
+ * opened afterwards report to it as well. `createSyncularHono` uses it to
721
+ * feed the admin console's event ring.
722
+ */
723
+ addEventSink(sink) {
724
+ const events = this.#config.events;
725
+ this.#config = {
726
+ ...this.#config,
727
+ events: events === undefined ? sink : composeEvents(events, sink),
728
+ };
729
+ }
717
730
  get sessionCount() {
718
731
  return this.#sessions.size;
719
732
  }
733
+ /** The partition's open sockets, for the admin console. */
734
+ connections(partition) {
735
+ return [...this.#sessions]
736
+ .filter((session) => session.partition === partition)
737
+ .map((session) => ({
738
+ sessionId: session.sessionId,
739
+ clientId: session.clientId,
740
+ actorId: session.actorId,
741
+ openedAtMs: session.openedAtMs,
742
+ }));
743
+ }
720
744
  /** §8.6.2 published-document size cap (bytes). */
721
745
  get maxPresenceBytes() {
722
746
  return this.#config.maxPresenceBytes ?? DEFAULT_MAX_PRESENCE_BYTES;
@@ -2,7 +2,7 @@ import type { CommitPruneQuery, CommitPruneResult } from './storage.js';
2
2
  import type { ScopeMap } from '@syncular/core';
3
3
  import type { CompiledSchema, CompiledTable } from './schema.js';
4
4
  import { type SqliteDatabase } from './sqlite-driver.js';
5
- import type { AuthoritativeQueryRequest, AuthoritativeQueryResult, CheckpointDeclaration, ClientCursorInfo, ClientRecord, CommitMetadata, CommitMetadataQuery, CommitWindowQuery, IndexRowScanQuery, PartitionRegistryEntry, PrunedReactionCounts, ReactionClaimQuery, ReactionFailureUpdate, ReactionListQuery, ReactionPruneQuery, RowScanQuery, ScopeActivityQuery, ScopeCommitActivity, ServerStorage, ServeGate, StorageTransaction, StoredCommit, StoredCheckpoint, StoredPushResult, StoredReaction, StoredRow } from './storage.js';
5
+ import type { AuthoritativeQueryRequest, AuthoritativeQueryResult, CheckpointDeclaration, ClientCursorInfo, ClientPushResult, ClientRecord, CommitDetail, CommitMetadata, CommitMetadataQuery, CommitWindowQuery, IndexRowScanQuery, PartitionRegistryEntry, PrunedReactionCounts, ReactionClaimQuery, ReactionFailureUpdate, ReactionListQuery, ReactionPruneQuery, RowChangeRecord, RowChangesQuery, RowListQuery, RowScanQuery, ScopeActivityQuery, ScopeCommitActivity, ServerStorage, ServeGate, StorageTransaction, StoredCommit, StoredCheckpoint, StoredPushResult, StoredReaction, StoredRow } from './storage.js';
6
6
  export declare class SqliteServerStorage implements ServerStorage {
7
7
  #private;
8
8
  readonly db: SqliteDatabase;
@@ -69,10 +69,11 @@ export declare class SqliteServerStorage implements ServerStorage {
69
69
  listReferencedBlobIds(partition: string): Promise<string[]>;
70
70
  listClientRecords(partition: string): Promise<ClientRecord[]>;
71
71
  listCommitMetadata(partition: string, query: CommitMetadataQuery): Promise<CommitMetadata[]>;
72
+ getCommit(partition: string, commitSeq: number): Promise<CommitDetail | undefined>;
73
+ listRowChanges(partition: string, query: RowChangesQuery): Promise<RowChangeRecord[]>;
74
+ listRows(partition: string, query: RowListQuery): Promise<StoredRow[]>;
75
+ countRows(partition: string, table: string): Promise<number>;
76
+ listPushResults(partition: string, clientCommitId: string): Promise<ClientPushResult[]>;
72
77
  scopeActivity(partition: string, query: ScopeActivityQuery): Promise<ScopeCommitActivity[]>;
73
- getRowScopes(partition: string, table: string, rowId: string): Promise<{
74
- serverVersion: number;
75
- scopes: Record<string, string>;
76
- } | undefined>;
77
78
  listPartitions(): Promise<string[]>;
78
79
  }
@@ -13,9 +13,9 @@ import { serveNotReadyError } from './readiness.js';
13
13
  */
14
14
  import { bindAuthoritativePartition, prepareAuthoritativeQuery, } from './authoritative-query.js';
15
15
  import { syncError } from './errors.js';
16
- import { assertPhysicalColumns, assertStoredLayouts, commitWindowPageSql, latestScopedChangeSeqSql, deleteRowSql, deleteSqliteRowScopesSql, dropTableDdl, indexRowPageStatement, layoutsOf, migratePayload, parseLayouts, retiredTableNames, rewritePlan, rewriteRowSql, rewriteValues, SCHEMA_META_DDL_SQLITE, scanRowPageSql, schemaDdl, selectRowScopesSql, selectRowSql, selectRowsForRewriteSql, upsertSql, upsertValues, } from './relational-rows.js';
16
+ import { assertPhysicalColumns, assertStoredLayouts, commitWindowPageSql, latestScopedChangeSeqSql, deleteRowSql, deleteSqliteRowScopesSql, dropTableDdl, indexRowPageStatement, layoutsOf, migratePayload, parseLayouts, quoteIdent, retiredTableNames, rewritePlan, rewriteRowSql, rewriteValues, SCHEMA_META_DDL_SQLITE, SYNC_COLUMN_VERSIONS_COLUMN, SYNC_PARTITION_COLUMN, SYNC_PAYLOAD_COLUMN, SYNC_ROW_ID_COLUMN, SYNC_SCOPES_COLUMN, SYNC_VERSION_COLUMN, scanRowPageSql, schemaDdl, selectRowScopesSql, selectRowSql, selectRowsForRewriteSql, upsertSql, upsertValues, } from './relational-rows.js';
17
17
  import { matchesEffective } from './scopes.js';
18
- import { collectCommitWindowPage, deserializePushResult, placeholders, SQLITE_DDL, serializePushResult, toStoredRow, } from './sqlite-dialect.js';
18
+ import { collectCommitWindowPage, deserializePushResult, placeholders, SQLITE_DDL, serializePushResult, toStoredChange, toStoredRow, } from './sqlite-dialect.js';
19
19
  import { SqliteAdapterRequiredError, } from './sqlite-driver.js';
20
20
  import { isSqliteConstraintError, StorageConstraintError, } from './storage-errors.js';
21
21
  import { assertScopeIndexedScan, resolveIndexRowScan } from './storage-query.js';
@@ -1218,22 +1218,51 @@ END`);
1218
1218
  }));
1219
1219
  }
1220
1220
  async listCommitMetadata(partition, query) {
1221
- // Newest-first window, resumed by `afterSeq` (exclusive lower bound).
1221
+ // Newest-first window, resumed by `afterSeq` (exclusive lower bound) and
1222
+ // ended by `beforeSeq` (exclusive upper bound) when paging older. An
1223
+ // omitted bound is the largest safe commitSeq, so one statement serves
1224
+ // both the bounded and unbounded read.
1225
+ const beforeSeq = query.beforeSeq ?? Number.MAX_SAFE_INTEGER;
1222
1226
  const rows = query.table
1223
1227
  ? this.db
1224
1228
  .query(`SELECT c.commit_seq, c.client_id, c.client_commit_id, c.actor_id, c.created_at_ms
1225
1229
  FROM sync_commits c
1226
- WHERE c.partition=? AND c.commit_seq>?
1230
+ WHERE c.partition=? AND c.commit_seq>? AND c.commit_seq<?
1227
1231
  AND EXISTS (SELECT 1 FROM sync_changes ch
1228
1232
  WHERE ch.partition=c.partition AND ch.commit_seq=c.commit_seq AND ch.tbl=?)
1229
1233
  ORDER BY c.commit_seq DESC LIMIT ?`)
1230
- .all(partition, query.afterSeq, query.table, query.limit)
1234
+ .all(partition, query.afterSeq, beforeSeq, query.table, query.limit)
1231
1235
  : this.db
1232
1236
  .query(`SELECT commit_seq, client_id, client_commit_id, actor_id, created_at_ms
1233
1237
  FROM sync_commits
1234
- WHERE partition=? AND commit_seq>?
1238
+ WHERE partition=? AND commit_seq>? AND commit_seq<?
1235
1239
  ORDER BY commit_seq DESC LIMIT ?`)
1236
- .all(partition, query.afterSeq, query.limit);
1240
+ .all(partition, query.afterSeq, beforeSeq, query.limit);
1241
+ // `changeSample`: one extra statement for the whole page — the first
1242
+ // `sample` changes (change-index order) of every listed commit.
1243
+ const sampleCount = query.changeSample !== undefined &&
1244
+ Number.isInteger(query.changeSample) &&
1245
+ query.changeSample > 0
1246
+ ? query.changeSample
1247
+ : 0;
1248
+ const samples = new Map();
1249
+ if (sampleCount > 0 && rows.length > 0) {
1250
+ const seqs = rows.map((row) => row.commit_seq);
1251
+ const records = this.db
1252
+ .query(`SELECT commit_seq, tbl, row_id, op FROM sync_changes
1253
+ WHERE partition=? AND commit_seq IN (${placeholders(seqs.length)}) AND idx<?
1254
+ ORDER BY commit_seq, idx`)
1255
+ .all(partition, ...seqs, sampleCount);
1256
+ for (const record of records) {
1257
+ const sample = samples.get(record.commit_seq) ?? [];
1258
+ sample.push({
1259
+ table: record.tbl,
1260
+ rowId: record.row_id,
1261
+ op: record.op === 1 ? 'upsert' : 'delete',
1262
+ });
1263
+ samples.set(record.commit_seq, sample);
1264
+ }
1265
+ }
1237
1266
  return rows.map((row) => {
1238
1267
  const changes = this.db
1239
1268
  .query('SELECT tbl, count(*) AS n FROM sync_changes WHERE partition=? AND commit_seq=? GROUP BY tbl')
@@ -1246,9 +1275,88 @@ END`);
1246
1275
  createdAtMs: row.created_at_ms,
1247
1276
  changeCount: changes.reduce((sum, c) => sum + c.n, 0),
1248
1277
  tables: changes.map((c) => c.tbl),
1278
+ ...(sampleCount > 0
1279
+ ? { sample: samples.get(row.commit_seq) ?? [] }
1280
+ : {}),
1249
1281
  };
1250
1282
  });
1251
1283
  }
1284
+ async getCommit(partition, commitSeq) {
1285
+ const meta = this.db
1286
+ .query('SELECT client_id, client_commit_id, actor_id, created_at_ms FROM sync_commits WHERE partition=? AND commit_seq=?')
1287
+ .get(partition, commitSeq);
1288
+ if (meta === null)
1289
+ return undefined;
1290
+ const changes = this.db
1291
+ .query('SELECT tbl, row_id, op, row_version, scopes, payload FROM sync_changes WHERE partition=? AND commit_seq=? ORDER BY idx')
1292
+ .all(partition, commitSeq)
1293
+ .map(toStoredChange);
1294
+ return {
1295
+ commitSeq,
1296
+ clientId: meta.client_id,
1297
+ clientCommitId: meta.client_commit_id,
1298
+ actorId: meta.actor_id,
1299
+ createdAtMs: meta.created_at_ms,
1300
+ changeCount: changes.length,
1301
+ tables: [...new Set(changes.map((change) => change.table))].sort(),
1302
+ changes,
1303
+ };
1304
+ }
1305
+ async listRowChanges(partition, query) {
1306
+ // Retained-log read for one row: no dedicated index (the write path pays
1307
+ // nothing for it), bounded by retention (§4.6).
1308
+ return this.db
1309
+ .query(`SELECT ch.commit_seq, ch.op, ch.row_version, c.actor_id, c.client_id, c.created_at_ms
1310
+ FROM sync_changes ch
1311
+ JOIN sync_commits c
1312
+ ON c.partition=ch.partition AND c.commit_seq=ch.commit_seq
1313
+ WHERE ch.partition=? AND ch.tbl=? AND ch.row_id=?
1314
+ ORDER BY ch.commit_seq DESC, ch.idx DESC LIMIT ?`)
1315
+ .all(partition, query.table, query.rowId, query.limit)
1316
+ .map((row) => ({
1317
+ commitSeq: row.commit_seq,
1318
+ op: row.op === 1 ? 'upsert' : 'delete',
1319
+ ...(row.row_version !== null ? { rowVersion: row.row_version } : {}),
1320
+ actorId: row.actor_id,
1321
+ clientId: row.client_id,
1322
+ createdAtMs: row.created_at_ms,
1323
+ }));
1324
+ }
1325
+ async listRows(partition, query) {
1326
+ // Keyset page over the row table's (partition, rowId) primary key: an
1327
+ // index range read, no scope filter, no per-row lookup.
1328
+ return this.db
1329
+ .query(`SELECT ${quoteIdent(SYNC_ROW_ID_COLUMN)} AS row_id, ${quoteIdent(SYNC_VERSION_COLUMN)} AS server_version, ${quoteIdent(SYNC_SCOPES_COLUMN)} AS scopes, ${quoteIdent(SYNC_PAYLOAD_COLUMN)} AS payload, ${quoteIdent(SYNC_COLUMN_VERSIONS_COLUMN)} AS column_versions
1330
+ FROM ${quoteIdent(this.table(query.table).name)}
1331
+ WHERE ${quoteIdent(SYNC_PARTITION_COLUMN)}=? AND ${quoteIdent(SYNC_ROW_ID_COLUMN)}>?
1332
+ ORDER BY ${quoteIdent(SYNC_ROW_ID_COLUMN)} LIMIT ?`)
1333
+ .all(partition, query.afterRowId ?? '', query.limit)
1334
+ .map(toStoredRow);
1335
+ }
1336
+ async countRows(partition, table) {
1337
+ const record = this.db
1338
+ .query(`SELECT count(*) AS n FROM ${quoteIdent(this.table(table).name)} WHERE ${quoteIdent(SYNC_PARTITION_COLUMN)}=?`)
1339
+ .get(partition);
1340
+ return record?.n ?? 0;
1341
+ }
1342
+ async listPushResults(partition, clientCommitId) {
1343
+ // Retained outcomes for one clientCommitId: no dedicated index, ordered
1344
+ // by client so the read is deterministic.
1345
+ return this.db
1346
+ .query('SELECT client_id, result FROM sync_push_results WHERE partition=? AND client_commit_id=? ORDER BY client_id')
1347
+ .all(partition, clientCommitId)
1348
+ .map((record) => {
1349
+ try {
1350
+ return {
1351
+ clientId: record.client_id,
1352
+ result: deserializePushResult(record.result),
1353
+ };
1354
+ }
1355
+ catch {
1356
+ throw syncError('sync.idempotency_cache_miss', 'persisted push result unreadable (§6.3)');
1357
+ }
1358
+ });
1359
+ }
1252
1360
  async scopeActivity(partition, query) {
1253
1361
  // Candidate commits via the change-scope index (§3.1) — never a scan.
1254
1362
  const rows = this.db
@@ -1276,17 +1384,6 @@ END`);
1276
1384
  }
1277
1385
  return out;
1278
1386
  }
1279
- async getRowScopes(partition, table, rowId) {
1280
- const record = this.db
1281
- .query(selectRowScopesSql(this.table(table), 'sqlite'))
1282
- .get(partition, rowId);
1283
- if (record === null)
1284
- return undefined;
1285
- return {
1286
- serverVersion: record.server_version,
1287
- scopes: JSON.parse(record.scopes),
1288
- };
1289
- }
1290
1387
  async listPartitions() {
1291
1388
  return (await this.listPartitionRegistry()).map((entry) => entry.partition);
1292
1389
  }
package/dist/storage.d.ts CHANGED
@@ -303,13 +303,55 @@ export interface CommitMetadata {
303
303
  readonly createdAtMs: number;
304
304
  readonly changeCount: number;
305
305
  readonly tables: readonly string[];
306
+ /** The first `changeSample` changes in commit order, when requested. */
307
+ readonly sample?: readonly CommitChangeSummary[];
308
+ }
309
+ /** One change without its payload, for commit lists. */
310
+ export interface CommitChangeSummary {
311
+ readonly table: string;
312
+ readonly rowId: string;
313
+ readonly op: 'upsert' | 'delete';
306
314
  }
307
315
  export interface CommitMetadataQuery {
308
- /** Window `afterSeq < commitSeq`, oldest first. */
316
+ /** Window lower bound, exclusive: `afterSeq < commitSeq`. */
309
317
  readonly afterSeq: number;
318
+ /** Window upper bound, exclusive: `commitSeq < beforeSeq` (older pages). */
319
+ readonly beforeSeq?: number;
320
+ /** Newest first, at most `limit` commits. */
310
321
  readonly limit: number;
311
322
  /** Restrict to commits that touched this table (via the change index). */
312
323
  readonly table?: string;
324
+ /** Attach the first N changes of each commit as `sample`. */
325
+ readonly changeSample?: number;
326
+ }
327
+ export interface RowListQuery {
328
+ readonly table: string;
329
+ /** Resume after this rowId (exclusive); `null` = start of table. */
330
+ readonly afterRowId: string | null;
331
+ readonly limit: number;
332
+ }
333
+ /** A stored push outcome with the client that sent it. */
334
+ export interface ClientPushResult {
335
+ readonly clientId: string;
336
+ readonly result: StoredPushResult;
337
+ }
338
+ /** One retained commit with its changes, for the admin commit drill-down. */
339
+ export interface CommitDetail extends CommitMetadata {
340
+ readonly changes: readonly StoredChange[];
341
+ }
342
+ /** One retained change to a single row, for the admin row history. */
343
+ export interface RowChangeRecord {
344
+ readonly commitSeq: number;
345
+ readonly op: 'upsert' | 'delete';
346
+ readonly rowVersion?: number;
347
+ readonly actorId: string;
348
+ readonly clientId: string;
349
+ readonly createdAtMs: number;
350
+ }
351
+ export interface RowChangesQuery {
352
+ readonly table: string;
353
+ readonly rowId: string;
354
+ readonly limit: number;
313
355
  }
314
356
  /** One recent commit touching a scope key, for the admin scope activity view. */
315
357
  export interface ScopeCommitActivity {
@@ -671,18 +713,27 @@ export interface ServerStorage {
671
713
  * touched tables), the newest-oriented window an operator inspects.
672
714
  * `scopeActivity`: the recent commits touching one scope key, via the
673
715
  * change scope index (never a log scan).
674
- * `getRowScopes`: the (table, rowId) row's current server_version and
675
- * stored scopes without decoding its payload — the row inspector.
716
+ * `getCommit`: one retained commit with its changes and payloads;
717
+ * `undefined` when the commit does not exist or was pruned.
718
+ * `listRowChanges`: the retained changes to one row, newest first. It
719
+ * reads the partition's retained log without a dedicated index, so the
720
+ * write path pays nothing for it and its cost is bounded by retention.
721
+ * `listRows`: one table's rows ordered by rowId, without a scope filter.
722
+ * `countRows`: one table's row count.
723
+ * `listPushResults`: every stored idempotency outcome for one
724
+ * clientCommitId across the partition's clients, scanning the retained
725
+ * outcomes without a dedicated index.
676
726
  * `listPartitions`: the partition-only compatibility view of
677
727
  * `listPartitionRegistry`, sorted.
678
728
  */
679
729
  listClientRecords?(partition: string): Promise<ClientRecord[]>;
680
730
  listCommitMetadata?(partition: string, query: CommitMetadataQuery): Promise<CommitMetadata[]>;
681
731
  scopeActivity?(partition: string, query: ScopeActivityQuery): Promise<ScopeCommitActivity[]>;
682
- getRowScopes?(partition: string, table: string, rowId: string): Promise<{
683
- serverVersion: number;
684
- scopes: Record<string, string>;
685
- } | undefined>;
732
+ getCommit?(partition: string, commitSeq: number): Promise<CommitDetail | undefined>;
733
+ listRowChanges?(partition: string, query: RowChangesQuery): Promise<RowChangeRecord[]>;
734
+ listRows?(partition: string, query: RowListQuery): Promise<StoredRow[]>;
735
+ countRows?(partition: string, table: string): Promise<number>;
736
+ listPushResults?(partition: string, clientCommitId: string): Promise<ClientPushResult[]>;
686
737
  listPartitions(): Promise<string[]>;
687
738
  }
688
739
  /** A row referencing a blob, with the scopes needed to authorize download. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syncular/server",
3
- "version": "0.31.0",
3
+ "version": "0.32.0",
4
4
  "description": "Syncular server: handleSyncRequest + storage/auth interfaces for the sync protocol",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Benjamin Kniffler",
@@ -68,7 +68,7 @@
68
68
  "verify:node": "cd ../.. && bun build ./packages/server/test/sqlite-runtime/verify-node.mjs --target=node --conditions=bun --outfile=./packages/server/.verify-node.built.mjs && node ./packages/server/.verify-node.built.mjs"
69
69
  },
70
70
  "dependencies": {
71
- "@syncular/core": "0.31.0"
71
+ "@syncular/core": "0.32.0"
72
72
  },
73
73
  "devDependencies": {
74
74
  "@electric-sql/pglite": "^0.5.4"