@syncular/server 0.15.46 → 0.15.48

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.
Files changed (91) hide show
  1. package/README.md +17 -4
  2. package/dist/admin.d.ts +1 -5
  3. package/dist/admin.js +2 -7
  4. package/dist/blob-handlers.js +4 -1
  5. package/dist/context.d.ts +5 -2
  6. package/dist/context.js +4 -0
  7. package/dist/d1-storage.d.ts +4 -1
  8. package/dist/d1-storage.js +75 -11
  9. package/dist/errors.js +6 -0
  10. package/dist/events.d.ts +2 -1
  11. package/dist/frame-bytes.d.ts +2 -2
  12. package/dist/frame-bytes.js +33 -14
  13. package/dist/handler.js +58 -14
  14. package/dist/index-bun.d.ts +2 -0
  15. package/dist/index-bun.js +2 -0
  16. package/dist/index-node.d.ts +2 -0
  17. package/dist/index-node.js +2 -0
  18. package/dist/index.d.ts +4 -2
  19. package/dist/index.js +4 -6
  20. package/dist/operations.js +2 -1
  21. package/dist/postgres-storage.d.ts +5 -2
  22. package/dist/postgres-storage.js +71 -10
  23. package/dist/pull.d.ts +1 -1
  24. package/dist/pull.js +12 -6
  25. package/dist/realtime.d.ts +4 -1
  26. package/dist/realtime.js +33 -9
  27. package/dist/restore.d.ts +13 -0
  28. package/dist/restore.js +13 -0
  29. package/dist/s3-segment-store.js +10 -1
  30. package/dist/seed.js +36 -4
  31. package/dist/segment-download.js +5 -2
  32. package/dist/segment-store.d.ts +3 -0
  33. package/dist/segment-store.js +1 -0
  34. package/dist/sqlite-blob-store.d.ts +4 -9
  35. package/dist/sqlite-blob-store.js +5 -10
  36. package/dist/sqlite-bun-driver.d.ts +12 -0
  37. package/dist/sqlite-bun-driver.js +30 -0
  38. package/dist/sqlite-bun.d.ts +24 -0
  39. package/dist/sqlite-bun.js +40 -0
  40. package/dist/sqlite-dialect.d.ts +8 -8
  41. package/dist/sqlite-dialect.js +9 -2
  42. package/dist/sqlite-driver.d.ts +26 -0
  43. package/dist/sqlite-driver.js +8 -0
  44. package/dist/sqlite-image.d.ts +7 -9
  45. package/dist/sqlite-image.js +26 -28
  46. package/dist/sqlite-lease-store.d.ts +4 -9
  47. package/dist/sqlite-lease-store.js +5 -10
  48. package/dist/sqlite-node-driver.d.ts +10 -0
  49. package/dist/sqlite-node-driver.js +30 -0
  50. package/dist/sqlite-node.d.ts +24 -0
  51. package/dist/sqlite-node.js +50 -0
  52. package/dist/sqlite-segment-store.d.ts +4 -10
  53. package/dist/sqlite-segment-store.js +20 -14
  54. package/dist/sqlite-storage.d.ts +7 -12
  55. package/dist/sqlite-storage.js +89 -16
  56. package/dist/storage-errors.js +4 -1
  57. package/dist/storage.d.ts +18 -5
  58. package/package.json +18 -3
  59. package/src/admin.ts +3 -9
  60. package/src/blob-handlers.ts +8 -1
  61. package/src/context.ts +18 -2
  62. package/src/d1-storage.ts +107 -12
  63. package/src/errors.ts +6 -0
  64. package/src/events.ts +2 -1
  65. package/src/frame-bytes.ts +40 -14
  66. package/src/handler.ts +102 -29
  67. package/src/index-bun.ts +9 -0
  68. package/src/index-node.ts +9 -0
  69. package/src/index.ts +9 -6
  70. package/src/operations.ts +6 -1
  71. package/src/postgres-storage.ts +102 -13
  72. package/src/pull.ts +12 -1
  73. package/src/realtime.ts +46 -7
  74. package/src/restore.ts +28 -0
  75. package/src/s3-segment-store.ts +10 -1
  76. package/src/seed.ts +46 -4
  77. package/src/segment-download.ts +11 -2
  78. package/src/segment-store.ts +4 -0
  79. package/src/sqlite-blob-store.ts +11 -10
  80. package/src/sqlite-bun-driver.ts +46 -0
  81. package/src/sqlite-bun.ts +53 -0
  82. package/src/sqlite-dialect.ts +14 -7
  83. package/src/sqlite-driver.ts +44 -0
  84. package/src/sqlite-image.ts +44 -49
  85. package/src/sqlite-lease-store.ts +11 -10
  86. package/src/sqlite-node-driver.ts +46 -0
  87. package/src/sqlite-node.ts +62 -0
  88. package/src/sqlite-segment-store.ts +29 -15
  89. package/src/sqlite-storage.ts +131 -19
  90. package/src/storage-errors.ts +4 -1
  91. package/src/storage.ts +28 -5
@@ -1,23 +1,21 @@
1
1
  /**
2
- * SQLite-backed segment store via `bun:sqlite` (dev/bench convenience,
3
- * dependency-free). Bun-specific by design: it imports `bun:sqlite` at the
4
- * top level, so it lives in its own module — importing it opts into the Bun
5
- * runtime. The runtime-neutral `SegmentStore` interface, `MemorySegmentStore`,
6
- * and `segmentIdFor` stay in `segment-store.ts` so the Workers/edge core can
7
- * import them without pulling in `bun:sqlite` (runtime neutrality is enforced
8
- * by `test/runtime-neutrality.test.ts`).
2
+ * SQLite-backed segment store over the shared synchronous driver.
9
3
  */
10
- import { Database } from 'bun:sqlite';
11
4
  import { DEFAULT_SEGMENT_TTL_MS, segmentIdFor, } from './segment-store.js';
5
+ import { SqliteAdapterRequiredError, } from './sqlite-driver.js';
12
6
  export class SqliteSegmentStore {
13
7
  db;
14
8
  #ttlMs;
15
9
  constructor(db = ':memory:', options) {
16
- this.db = typeof db === 'string' ? new Database(db) : db;
10
+ if (typeof db === 'string') {
11
+ throw new SqliteAdapterRequiredError();
12
+ }
13
+ this.db = db;
17
14
  this.#ttlMs = options?.ttlMs ?? DEFAULT_SEGMENT_TTL_MS;
18
15
  this.db.exec(`
19
16
  CREATE TABLE IF NOT EXISTS sync_segments(
20
17
  segment_id TEXT PRIMARY KEY, partition TEXT NOT NULL,
18
+ log_epoch TEXT NOT NULL,
21
19
  tbl TEXT NOT NULL, schema_version INTEGER NOT NULL,
22
20
  media_type TEXT NOT NULL, scope_digest TEXT NOT NULL,
23
21
  as_of_commit_seq INTEGER NOT NULL, row_count INTEGER NOT NULL,
@@ -26,6 +24,12 @@ export class SqliteSegmentStore {
26
24
  expires_at_ms INTEGER NOT NULL, bytes BLOB NOT NULL
27
25
  );
28
26
  `);
27
+ const columns = this.db
28
+ .query('PRAGMA table_info(sync_segments)')
29
+ .all();
30
+ if (!columns.some((column) => column.name === 'log_epoch')) {
31
+ this.db.exec("ALTER TABLE sync_segments ADD COLUMN log_epoch TEXT NOT NULL DEFAULT ''");
32
+ }
29
33
  }
30
34
  async put(metadata, bytes, nowMs) {
31
35
  const segmentId = await segmentIdFor(bytes);
@@ -38,11 +42,11 @@ export class SqliteSegmentStore {
38
42
  };
39
43
  this.db
40
44
  .query(`INSERT OR REPLACE INTO sync_segments(
41
- segment_id, partition, tbl, schema_version, media_type,
45
+ segment_id, partition, log_epoch, tbl, schema_version, media_type,
42
46
  scope_digest, as_of_commit_seq, row_count, row_cursor,
43
47
  next_row_cursor, byte_length, created_at_ms, expires_at_ms, bytes
44
- ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
45
- .run(record.segmentId, record.partition, record.table, record.schemaVersion, record.mediaType, record.scopeDigest, record.asOfCommitSeq, record.rowCount, record.rowCursor, record.nextRowCursor, record.byteLength, record.createdAtMs, record.expiresAtMs, bytes);
48
+ ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
49
+ .run(record.segmentId, record.partition, record.logEpoch, record.table, record.schemaVersion, record.mediaType, record.scopeDigest, record.asOfCommitSeq, record.rowCount, record.rowCursor, record.nextRowCursor, record.byteLength, record.createdAtMs, record.expiresAtMs, bytes);
46
50
  return record;
47
51
  }
48
52
  async get(segmentId) {
@@ -55,6 +59,7 @@ export class SqliteSegmentStore {
55
59
  record: {
56
60
  segmentId: row.segment_id,
57
61
  partition: row.partition,
62
+ logEpoch: row.log_epoch,
58
63
  table: row.tbl,
59
64
  schemaVersion: row.schema_version,
60
65
  mediaType: row.media_type === 'sqlite' ? 'sqlite' : 'rows',
@@ -75,16 +80,17 @@ export class SqliteSegmentStore {
75
80
  .query(`SELECT segment_id, row_count, next_row_cursor, byte_length,
76
81
  created_at_ms, expires_at_ms
77
82
  FROM sync_segments
78
- WHERE partition=? AND tbl=? AND schema_version=? AND media_type=?
83
+ WHERE partition=? AND log_epoch=? AND tbl=? AND schema_version=? AND media_type=?
79
84
  AND scope_digest=? AND as_of_commit_seq=? AND row_cursor IS NULL
80
85
  AND expires_at_ms > ?
81
86
  LIMIT 1`)
82
- .get(key.partition, key.table, key.schemaVersion, key.mediaType, key.scopeDigest, key.asOfCommitSeq, nowMs);
87
+ .get(key.partition, key.logEpoch, key.table, key.schemaVersion, key.mediaType, key.scopeDigest, key.asOfCommitSeq, nowMs);
83
88
  if (row === null)
84
89
  return undefined;
85
90
  return {
86
91
  segmentId: row.segment_id,
87
92
  partition: key.partition,
93
+ logEpoch: key.logEpoch,
88
94
  table: key.table,
89
95
  schemaVersion: key.schemaVersion,
90
96
  mediaType: key.mediaType,
@@ -1,21 +1,16 @@
1
- /**
2
- * SQLite storage via `bun:sqlite` (dev-speed, dependency-free).
3
- *
4
- * Scope fanout is index-first: both the commit log and the
5
- * current-row table carry a (table, variable, value) inverted index; reads
6
- * select candidates from the index and verify the full multi-variable
7
- * match against the stored scope map — never a log scan.
8
- */
9
- import { Database } from 'bun:sqlite';
10
1
  import type { CompiledSchema, CompiledTable } from './schema.js';
11
- import type { AuthoritativeQueryRequest, AuthoritativeQueryResult, ClientCursorInfo, ClientRecord, CommitMetadata, CommitMetadataQuery, CommitWindowQuery, IndexRowScanQuery, PrunedReactionCounts, ReactionClaimQuery, ReactionFailureUpdate, ReactionListQuery, ReactionPruneQuery, RowScanQuery, ScopeActivityQuery, ScopeCommitActivity, ServerStorage, StorageTransaction, StoredCommit, StoredPushResult, StoredReaction, StoredRow } from './storage.js';
2
+ import { type SqliteDatabase } from './sqlite-driver.js';
3
+ import type { AuthoritativeQueryRequest, AuthoritativeQueryResult, ClientCursorInfo, ClientRecord, CommitMetadata, CommitMetadataQuery, CommitWindowQuery, IndexRowScanQuery, PartitionRegistryEntry, PrunedReactionCounts, ReactionClaimQuery, ReactionFailureUpdate, ReactionListQuery, ReactionPruneQuery, RowScanQuery, ScopeActivityQuery, ScopeCommitActivity, ServerStorage, StorageTransaction, StoredCommit, StoredPushResult, StoredReaction, StoredRow } from './storage.js';
12
4
  export declare class SqliteServerStorage implements ServerStorage {
13
5
  #private;
14
- readonly db: Database;
15
- constructor(db?: Database | string);
6
+ readonly db: SqliteDatabase;
7
+ constructor(db?: SqliteDatabase | string);
16
8
  /** Resolve a table's compiled schema; row operations require `ensureSchema`. */
17
9
  table(name: string): CompiledTable;
18
10
  ensureSchema(schema: CompiledSchema): Promise<void>;
11
+ touchPartition(partition: string, authenticatedAtMs: number, initialLogEpoch: string): Promise<PartitionRegistryEntry>;
12
+ rotatePartitionLogEpoch(partition: string, logEpoch: string, authenticatedAtMs: number): Promise<PartitionRegistryEntry>;
13
+ listPartitionRegistry(): Promise<PartitionRegistryEntry[]>;
19
14
  begin(partition: string): Promise<StorageTransaction>;
20
15
  /** Internal: write a row + refresh its scope-index entries. */
21
16
  writeRow(partition: string, table: string, row: StoredRow): void;
@@ -1,17 +1,17 @@
1
1
  /**
2
- * SQLite storage via `bun:sqlite` (dev-speed, dependency-free).
2
+ * SQLite server storage over the shared synchronous driver.
3
3
  *
4
4
  * Scope fanout is index-first: both the commit log and the
5
5
  * current-row table carry a (table, variable, value) inverted index; reads
6
6
  * select candidates from the index and verify the full multi-variable
7
7
  * match against the stored scope map — never a log scan.
8
8
  */
9
- import { Database } from 'bun:sqlite';
10
9
  import { bindAuthoritativePartition, prepareAuthoritativeQuery, } from './authoritative-query.js';
11
10
  import { syncError } from './errors.js';
12
11
  import { commitWindowPageSql, deleteRowSql, dropTableDdl, indexRowPageStatement, layoutsOf, migratePayload, parseLayouts, retiredTableNames, rewritePlan, rewriteRowSql, rewriteValues, SCHEMA_META_DDL_SQLITE, scanRowPageSql, schemaDdl, selectRowScopesSql, selectRowSql, selectRowsForRewriteSql, upsertSql, upsertValues, } from './relational-rows.js';
13
12
  import { matchesEffective } from './scopes.js';
14
13
  import { collectCommitWindowPage, deserializePushResult, SQLITE_DDL, serializePushResult, toStoredRow, } from './sqlite-dialect.js';
14
+ import { SqliteAdapterRequiredError, } from './sqlite-driver.js';
15
15
  import { isSqliteConstraintError, StorageConstraintError, } from './storage-errors.js';
16
16
  import { assertScopeIndexedScan, resolveIndexRowScan } from './storage-query.js';
17
17
  function toStoredReaction(record) {
@@ -62,7 +62,7 @@ class SqliteTransaction {
62
62
  }
63
63
  getPushResult(clientId, clientCommitId) {
64
64
  this.#assertOpen();
65
- // One shared bun:sqlite connection this read runs inside this
65
+ // One shared SQLite connection: this read runs inside this
66
66
  // transaction's BEGIN IMMEDIATE.
67
67
  return this.#storage.getPushResult(this.#partition, clientId, clientCommitId);
68
68
  }
@@ -194,7 +194,7 @@ class SqliteTransaction {
194
194
  }
195
195
  export class SqliteServerStorage {
196
196
  db;
197
- /** One bun:sqlite connection can own only one transaction at a time. */
197
+ /** One SQLite connection can own only one transaction at a time. */
198
198
  #transactionTail = Promise.resolve();
199
199
  /** Set by `ensureSchema`: app-table lookup for the relational row store. */
200
200
  #tables;
@@ -214,8 +214,17 @@ export class SqliteServerStorage {
214
214
  }
215
215
  }
216
216
  constructor(db = ':memory:') {
217
- this.db = typeof db === 'string' ? new Database(db) : db;
217
+ if (typeof db === 'string') {
218
+ throw new SqliteAdapterRequiredError();
219
+ }
220
+ this.db = db;
218
221
  this.db.exec(SQLITE_DDL);
222
+ const clientColumns = this.db
223
+ .query('PRAGMA table_info("sync_clients")')
224
+ .all();
225
+ if (!clientColumns.some((column) => column.name === 'wire_version')) {
226
+ this.db.exec('ALTER TABLE sync_clients ADD COLUMN wire_version INTEGER NOT NULL DEFAULT 1');
227
+ }
219
228
  }
220
229
  /** Resolve a table's compiled schema; row operations require `ensureSchema`. */
221
230
  table(name) {
@@ -291,6 +300,74 @@ export class SqliteServerStorage {
291
300
  this.#tables = schema.tables;
292
301
  this.#schemaVersion = schema.version;
293
302
  }
303
+ async touchPartition(partition, authenticatedAtMs, initialLogEpoch) {
304
+ if (initialLogEpoch.length === 0) {
305
+ throw new Error('initial log epoch must be non-empty');
306
+ }
307
+ this.db
308
+ .query(`INSERT INTO sync_partition_registry(
309
+ partition, log_epoch, last_authenticated_at_ms
310
+ ) VALUES (?,?,?)
311
+ ON CONFLICT(partition) DO UPDATE SET
312
+ last_authenticated_at_ms=excluded.last_authenticated_at_ms`)
313
+ .run(partition, initialLogEpoch, authenticatedAtMs);
314
+ const row = this.db
315
+ .query(`SELECT log_epoch, epoch_required, last_authenticated_at_ms
316
+ FROM sync_partition_registry WHERE partition=?`)
317
+ .get(partition);
318
+ if (row === null)
319
+ throw new Error('partition registry write did not persist');
320
+ return {
321
+ partition,
322
+ logEpoch: row.log_epoch,
323
+ epochRequired: row.epoch_required === 1,
324
+ lastAuthenticatedAtMs: row.last_authenticated_at_ms,
325
+ };
326
+ }
327
+ async rotatePartitionLogEpoch(partition, logEpoch, authenticatedAtMs) {
328
+ if (logEpoch.length === 0)
329
+ throw new Error('log epoch must be non-empty');
330
+ return this.#serializeReactionWrite(() => {
331
+ this.db.exec('BEGIN IMMEDIATE');
332
+ try {
333
+ this.db
334
+ .query(`INSERT INTO sync_partition_registry(
335
+ partition, log_epoch, epoch_required, last_authenticated_at_ms
336
+ ) VALUES (?,?,1,?)
337
+ ON CONFLICT(partition) DO UPDATE SET
338
+ log_epoch=excluded.log_epoch,
339
+ epoch_required=1,
340
+ last_authenticated_at_ms=excluded.last_authenticated_at_ms`)
341
+ .run(partition, logEpoch, authenticatedAtMs);
342
+ this.db
343
+ .query('DELETE FROM sync_clients WHERE partition=?')
344
+ .run(partition);
345
+ this.db.exec('COMMIT');
346
+ }
347
+ catch (error) {
348
+ this.db.exec('ROLLBACK');
349
+ throw error;
350
+ }
351
+ return {
352
+ partition,
353
+ logEpoch,
354
+ epochRequired: true,
355
+ lastAuthenticatedAtMs: authenticatedAtMs,
356
+ };
357
+ });
358
+ }
359
+ async listPartitionRegistry() {
360
+ return this.db
361
+ .query(`SELECT partition, log_epoch, epoch_required, last_authenticated_at_ms
362
+ FROM sync_partition_registry ORDER BY partition`)
363
+ .all()
364
+ .map((row) => ({
365
+ partition: row.partition,
366
+ logEpoch: row.log_epoch,
367
+ epochRequired: row.epoch_required === 1,
368
+ lastAuthenticatedAtMs: row.last_authenticated_at_ms,
369
+ }));
370
+ }
294
371
  /**
295
372
  * Migration rewrite: keyset-paged walk of a row table. When `oldLayout` is
296
373
  * given every payload re-encodes under
@@ -656,13 +733,14 @@ export class SqliteServerStorage {
656
733
  }
657
734
  async getClientRecord(partition, clientId) {
658
735
  const record = this.db
659
- .query('SELECT client_id, actor_id, cursor, subscriptions, updated_at_ms FROM sync_clients WHERE partition=? AND client_id=?')
736
+ .query('SELECT client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms FROM sync_clients WHERE partition=? AND client_id=?')
660
737
  .get(partition, clientId);
661
738
  if (record === null)
662
739
  return undefined;
663
740
  return {
664
741
  clientId: record.client_id,
665
742
  actorId: record.actor_id,
743
+ wireVersion: record.wire_version,
666
744
  cursor: record.cursor,
667
745
  updatedAtMs: record.updated_at_ms,
668
746
  subscriptions: JSON.parse(record.subscriptions),
@@ -670,8 +748,8 @@ export class SqliteServerStorage {
670
748
  }
671
749
  async putClientRecord(partition, record) {
672
750
  this.db
673
- .query('INSERT OR REPLACE INTO sync_clients(partition, client_id, actor_id, cursor, subscriptions, updated_at_ms) VALUES (?,?,?,?,?,?)')
674
- .run(partition, record.clientId, record.actorId, record.cursor, JSON.stringify(record.subscriptions), record.updatedAtMs);
751
+ .query('INSERT OR REPLACE INTO sync_clients(partition, client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms) VALUES (?,?,?,?,?,?,?)')
752
+ .run(partition, record.clientId, record.actorId, record.wireVersion, record.cursor, JSON.stringify(record.subscriptions), record.updatedAtMs);
675
753
  }
676
754
  async listClientCursors(partition) {
677
755
  const records = this.db
@@ -716,11 +794,12 @@ export class SqliteServerStorage {
716
794
  // -- admin/console read surface --------------------------------------------
717
795
  async listClientRecords(partition) {
718
796
  const records = this.db
719
- .query('SELECT client_id, actor_id, cursor, subscriptions, updated_at_ms FROM sync_clients WHERE partition=? ORDER BY updated_at_ms DESC')
797
+ .query('SELECT client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms FROM sync_clients WHERE partition=? ORDER BY updated_at_ms DESC')
720
798
  .all(partition);
721
799
  return records.map((record) => ({
722
800
  clientId: record.client_id,
723
801
  actorId: record.actor_id,
802
+ wireVersion: record.wire_version,
724
803
  cursor: record.cursor,
725
804
  updatedAtMs: record.updated_at_ms,
726
805
  subscriptions: JSON.parse(record.subscriptions),
@@ -797,12 +876,6 @@ export class SqliteServerStorage {
797
876
  };
798
877
  }
799
878
  async listPartitions() {
800
- // Union: the registry row appears on first commit, the client row on
801
- // first pull — a partition with only one of the two still shows up.
802
- const rows = this.db
803
- .query(`SELECT partition FROM sync_partitions
804
- UNION SELECT partition FROM sync_clients ORDER BY partition`)
805
- .all();
806
- return rows.map((r) => r.partition);
879
+ return (await this.listPartitionRegistry()).map((entry) => entry.partition);
807
880
  }
808
881
  }
@@ -44,7 +44,10 @@ export function isSqliteConstraintError(error) {
44
44
  return true;
45
45
  }
46
46
  const errno = candidate?.errno;
47
- return typeof errno === 'number' && (errno & 0xff) === 19;
47
+ if (typeof errno === 'number' && (errno & 0xff) === 19)
48
+ return true;
49
+ const errcode = candidate?.errcode;
50
+ return typeof errcode === 'number' && (errcode & 0xff) === 19;
48
51
  }
49
52
  /** PostgreSQL SQLSTATE class 23: integrity constraint violation. */
50
53
  export function isPostgresConstraintError(error) {
package/dist/storage.d.ts CHANGED
@@ -144,6 +144,8 @@ export interface ClientSubscription {
144
144
  export interface ClientRecord {
145
145
  readonly clientId: string;
146
146
  readonly actorId: string;
147
+ /** SSP2 version last accepted from this client; selects realtime deltas. */
148
+ readonly wireVersion: number;
147
149
  /** Minimum `nextCursor` across the last pull's active subscriptions. */
148
150
  readonly cursor: number;
149
151
  readonly updatedAtMs: number;
@@ -195,6 +197,13 @@ export interface ClientCursorInfo {
195
197
  readonly cursor: number;
196
198
  readonly updatedAtMs: number;
197
199
  }
200
+ /** Durable partition identity refreshed after host authentication (§2.1). */
201
+ export interface PartitionRegistryEntry {
202
+ readonly partition: string;
203
+ readonly logEpoch: string;
204
+ readonly epochRequired: boolean;
205
+ readonly lastAuthenticatedAtMs: number;
206
+ }
198
207
  /**
199
208
  * Commit-log metadata (no change payloads) for the admin/console read
200
209
  * surface. `changeCount` is the number of changes the commit carries;
@@ -340,6 +349,12 @@ export interface ServerStorage {
340
349
  * lazily as a defensive backstop.
341
350
  */
342
351
  ensureSchema(schema: CompiledSchema): Promise<void>;
352
+ /** Create or refresh the authenticated partition registry row (§2.1). */
353
+ touchPartition(partition: string, authenticatedAtMs: number, initialLogEpoch: string): Promise<PartitionRegistryEntry>;
354
+ /** Rotate log continuity after restore and discard stale cursor records. */
355
+ rotatePartitionLogEpoch(partition: string, logEpoch: string, authenticatedAtMs: number): Promise<PartitionRegistryEntry>;
356
+ /** Registry entries ordered by partition for maintenance loops. */
357
+ listPartitionRegistry(): Promise<PartitionRegistryEntry[]>;
343
358
  begin(partition: string): Promise<StorageTransaction>;
344
359
  getMaxCommitSeq(partition: string): Promise<number>;
345
360
  getHorizonSeq(partition: string): Promise<number>;
@@ -427,10 +442,8 @@ export interface ServerStorage {
427
442
  * change scope index (never a log scan).
428
443
  * `getRowScopes`: the (table, rowId) row's current server_version and
429
444
  * stored scopes without decoding its payload — the row inspector.
430
- * `listPartitions`: every partition this storage holds state for — the
431
- * union of the partition registry (commit log counters) and client
432
- * records, sorted. Powers the console's fleet view / partition picker;
433
- * deliberately NOT partition-scoped (the one cross-partition read).
445
+ * `listPartitions`: the partition-only compatibility view of
446
+ * `listPartitionRegistry`, sorted.
434
447
  */
435
448
  listClientRecords?(partition: string): Promise<ClientRecord[]>;
436
449
  listCommitMetadata?(partition: string, query: CommitMetadataQuery): Promise<CommitMetadata[]>;
@@ -439,7 +452,7 @@ export interface ServerStorage {
439
452
  serverVersion: number;
440
453
  scopes: Record<string, string>;
441
454
  } | undefined>;
442
- listPartitions?(): Promise<string[]>;
455
+ listPartitions(): Promise<string[]>;
443
456
  }
444
457
  /** A row referencing a blob, with the scopes needed to authorize download. */
445
458
  export interface BlobReferencingRow {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syncular/server",
3
- "version": "0.15.46",
3
+ "version": "0.15.48",
4
4
  "description": "Syncular server: handleSyncRequest + storage/auth interfaces for the sync protocol",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Benjamin Kniffler",
@@ -27,13 +27,25 @@
27
27
  },
28
28
  "exports": {
29
29
  ".": {
30
- "bun": "./src/index.ts",
30
+ "bun": "./src/index-bun.ts",
31
+ "node": {
32
+ "types": "./dist/index-node.d.ts",
33
+ "default": "./dist/index-node.js"
34
+ },
31
35
  "browser": "./dist/index.js",
32
36
  "import": {
33
37
  "types": "./dist/index.d.ts",
34
38
  "default": "./dist/index.js"
35
39
  }
36
40
  },
41
+ "./sqlite": {
42
+ "bun": "./src/sqlite-bun.ts",
43
+ "node": {
44
+ "types": "./dist/sqlite-node.d.ts",
45
+ "default": "./dist/sqlite-node.js"
46
+ },
47
+ "types": "./dist/sqlite-node.d.ts"
48
+ },
37
49
  "./pglite": {
38
50
  "bun": "./src/pg-executor-pglite.ts",
39
51
  "browser": "./dist/pg-executor-pglite.js",
@@ -52,8 +64,11 @@
52
64
  "!dist/**/*.test.js",
53
65
  "!dist/**/*.test.d.ts"
54
66
  ],
67
+ "scripts": {
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
+ },
55
70
  "dependencies": {
56
- "@syncular/core": "0.15.46"
71
+ "@syncular/core": "0.15.48"
57
72
  },
58
73
  "devDependencies": {
59
74
  "@electric-sql/pglite": "^0.5.4"
package/src/admin.ts CHANGED
@@ -520,17 +520,11 @@ export class SyncularAdmin {
520
520
  };
521
521
  }
522
522
 
523
- /**
524
- * Every partition the storage knows (commit log + client records) — the
525
- * fleet-view backing and the console's partition picker. Fails loud when
526
- * the backend omits the optional `listPartitions`.
527
- */
523
+ /** Every authenticated partition in the storage-backed registry. */
528
524
  async listPartitions(): Promise<string[]> {
529
- const list = required(
530
- this.#storage.listPartitions?.bind(this.#storage),
531
- 'storage',
525
+ return (await this.#storage.listPartitionRegistry()).map(
526
+ (entry) => entry.partition,
532
527
  );
533
- return list();
534
528
  }
535
529
 
536
530
  /**
@@ -8,7 +8,11 @@
8
8
  */
9
9
  import { blobIdFor, isBlobId } from './blob-store';
10
10
  import type { SyncRequestContext } from './context';
11
- import { clockOf, RESOLVER_OUTAGE } from './context';
11
+ import {
12
+ clockOf,
13
+ RESOLVER_OUTAGE,
14
+ touchAuthenticatedPartition,
15
+ } from './context';
12
16
  import { SyncError, syncError } from './errors';
13
17
  import { emitEvent } from './events';
14
18
  import { compileSchema } from './schema';
@@ -72,6 +76,7 @@ export async function handleBlobUpload(
72
76
  ctx: SyncRequestContext,
73
77
  request: BlobUploadRequest,
74
78
  ): Promise<void> {
79
+ await touchAuthenticatedPartition(ctx);
75
80
  const store = ctx.blobs;
76
81
  if (store === undefined) {
77
82
  throw syncError('blob.not_found', 'this server has no blob store (§5.9)');
@@ -125,6 +130,7 @@ export async function handleBlobDownload(
125
130
  ctx: SyncRequestContext,
126
131
  blobId: string,
127
132
  ): Promise<BlobDownloadResult> {
133
+ await touchAuthenticatedPartition(ctx);
128
134
  const events = ctx.events;
129
135
  if (events === undefined) return downloadBlob(ctx, blobId);
130
136
  const clock = clockOf(ctx);
@@ -301,6 +307,7 @@ export async function handleBlobUploadGrant(
301
307
  ctx: SyncRequestContext,
302
308
  request: BlobUploadGrantRequest,
303
309
  ): Promise<BlobUploadGrantResult> {
310
+ await touchAuthenticatedPartition(ctx);
304
311
  const store = ctx.blobs;
305
312
  if (store === undefined) {
306
313
  throw syncError('blob.not_found', 'this server has no blob store (§5.9)');
package/src/context.ts CHANGED
@@ -18,7 +18,11 @@ import type {
18
18
  SegmentUrlConfig,
19
19
  } from './signed-url';
20
20
  import type { SqliteImageBuilder } from './sqlite-image';
21
- import type { ServerStorage, StoredCommit } from './storage';
21
+ import type {
22
+ PartitionRegistryEntry,
23
+ ServerStorage,
24
+ StoredCommit,
25
+ } from './storage';
22
26
  import type { CommitValidator, ValidatorRegistry } from './validate';
23
27
 
24
28
  /** SSP2 body content type (§1.1). */
@@ -167,7 +171,8 @@ export interface SyncServerConfig {
167
171
  * §5.3 sqlite-image builder, injected so the pull path never
168
172
  * statically imports `bun:sqlite`. Absent ⇒ the sqlite-image lane is off
169
173
  * (bit-2 clients are served the rows lane) — the Workers/edge posture. A
170
- * Bun/Node host wires `buildSqliteImage` from `@syncular/server`.
174
+ * Bun or Node host wires `buildSqliteImage` from
175
+ * `@syncular/server/sqlite`.
171
176
  */
172
177
  readonly sqliteImageBuilder?: SqliteImageBuilder;
173
178
  readonly realtime?: RealtimeNotifier;
@@ -191,3 +196,14 @@ export function clockOf(ctx: SyncServerConfig): () => number {
191
196
  export function limitsOf(ctx: SyncServerConfig): ServerLimits {
192
197
  return { ...DEFAULT_LIMITS, ...ctx.limits };
193
198
  }
199
+
200
+ /** Refresh the registry after host authentication and return log continuity. */
201
+ export function touchAuthenticatedPartition(
202
+ ctx: SyncRequestContext,
203
+ ): Promise<PartitionRegistryEntry> {
204
+ return ctx.storage.touchPartition(
205
+ ctx.partition,
206
+ clockOf(ctx)(),
207
+ crypto.randomUUID(),
208
+ );
209
+ }