@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
@@ -0,0 +1,53 @@
1
+ import { SqliteBlobStore as SharedSqliteBlobStore } from './sqlite-blob-store';
2
+ import { BunSqliteDatabase } from './sqlite-bun-driver';
3
+ import type { SqliteDatabase } from './sqlite-driver';
4
+ import { type SqliteImageBuilder, writeSqliteImage } from './sqlite-image';
5
+ import { SqliteLeaseStore as SharedSqliteLeaseStore } from './sqlite-lease-store';
6
+ import { SqliteSegmentStore as SharedSqliteSegmentStore } from './sqlite-segment-store';
7
+ import { SqliteServerStorage as SharedSqliteServerStorage } from './sqlite-storage';
8
+
9
+ function database(value: SqliteDatabase | string): SqliteDatabase {
10
+ return typeof value === 'string' ? new BunSqliteDatabase(value) : value;
11
+ }
12
+
13
+ export class SqliteServerStorage extends SharedSqliteServerStorage {
14
+ constructor(value: SqliteDatabase | string = ':memory:') {
15
+ super(database(value));
16
+ }
17
+ }
18
+
19
+ export class SqliteSegmentStore extends SharedSqliteSegmentStore {
20
+ constructor(
21
+ value: SqliteDatabase | string = ':memory:',
22
+ options?: { ttlMs?: number },
23
+ ) {
24
+ super(database(value), options);
25
+ }
26
+ }
27
+
28
+ export class SqliteBlobStore extends SharedSqliteBlobStore {
29
+ constructor(value: SqliteDatabase | string = ':memory:') {
30
+ super(database(value));
31
+ }
32
+ }
33
+
34
+ export class SqliteLeaseStore extends SharedSqliteLeaseStore {
35
+ constructor(
36
+ value: SqliteDatabase | string = ':memory:',
37
+ options?: { readonly leaseId?: () => string },
38
+ ) {
39
+ super(database(value), options);
40
+ }
41
+ }
42
+
43
+ export const buildSqliteImage: SqliteImageBuilder = (input) => {
44
+ const db = new BunSqliteDatabase();
45
+ try {
46
+ writeSqliteImage(db, input);
47
+ return db.serialize();
48
+ } finally {
49
+ db.close();
50
+ }
51
+ };
52
+
53
+ export { BunSqliteDatabase } from './sqlite-bun-driver';
@@ -1,12 +1,12 @@
1
1
  /**
2
- * Shared SQLite dialect for the two SQLite-family storages: `bun:sqlite`
3
- * (synchronous, `SqliteServerStorage`) and Cloudflare D1 (async,
4
- * `D1ServerStorage`). D1 *is* SQLite — same DDL, same statement grammar,
2
+ * Shared SQLite dialect for synchronous `SqliteServerStorage` on Bun or Node
3
+ * and asynchronous `D1ServerStorage` on Cloudflare Workers. D1 is SQLite:
4
+ * same DDL, same statement grammar,
5
5
  * same `?` positional placeholders, same `INSERT ... ON CONFLICT` / `INSERT
6
6
  * OR IGNORE` upsert idioms — so the schema and the value (de)serialization are
7
7
  * genuinely common ground and live here.
8
8
  *
9
- * What is NOT shared: statement *execution*. `bun:sqlite` is sync
9
+ * What is not shared: statement execution. The server SQLite driver is sync
10
10
  * (`db.query(sql).get(...)`) and D1 is async (`await
11
11
  * db.prepare(sql).bind(...).all()`); a shared execution layer would have to
12
12
  * pick one calling convention and adapt the other, which is uglier than two
@@ -25,7 +25,7 @@ import type {
25
25
  } from './storage';
26
26
 
27
27
  /**
28
- * Schema DDL one statement per `;`-delimited chunk. `bun:sqlite` applies
28
+ * Schema DDL, one statement per `;`-delimited chunk. Native SQLite applies
29
29
  * the whole string via `db.exec(SQLITE_DDL)`; D1 applies each statement
30
30
  * separately (its `prepare`/`batch` API is one statement per call). Types
31
31
  * are SQLite's: `INTEGER`/`TEXT`/`BLOB`. Scopes are stored as JSON `TEXT`
@@ -43,6 +43,12 @@ CREATE TABLE IF NOT EXISTS sync_partitions(
43
43
  max_commit_seq INTEGER NOT NULL DEFAULT 0,
44
44
  horizon_seq INTEGER NOT NULL DEFAULT 0
45
45
  );
46
+ CREATE TABLE IF NOT EXISTS sync_partition_registry(
47
+ partition TEXT PRIMARY KEY,
48
+ log_epoch TEXT NOT NULL,
49
+ epoch_required INTEGER NOT NULL DEFAULT 0,
50
+ last_authenticated_at_ms INTEGER NOT NULL
51
+ );
46
52
  CREATE TABLE IF NOT EXISTS sync_row_scopes(
47
53
  partition TEXT NOT NULL, tbl TEXT NOT NULL,
48
54
  var TEXT NOT NULL, value TEXT NOT NULL, row_id TEXT NOT NULL,
@@ -96,6 +102,7 @@ CREATE INDEX IF NOT EXISTS sync_reactions_dead_letter
96
102
  ON sync_reactions(partition, status, available_at_ms, idempotency_key);
97
103
  CREATE TABLE IF NOT EXISTS sync_clients(
98
104
  partition TEXT NOT NULL, client_id TEXT NOT NULL, actor_id TEXT NOT NULL,
105
+ wire_version INTEGER NOT NULL DEFAULT 1,
99
106
  cursor INTEGER NOT NULL, subscriptions TEXT NOT NULL,
100
107
  updated_at_ms INTEGER NOT NULL,
101
108
  PRIMARY KEY(partition, client_id)
@@ -246,7 +253,7 @@ export interface SqliteChangeRecord {
246
253
  payload: Uint8Array | null;
247
254
  }
248
255
 
249
- /** `bun:sqlite` returns `Uint8Array`; D1 returns `ArrayBuffer` for BLOBs. */
256
+ /** Native SQLite returns `Uint8Array`; D1 returns `ArrayBuffer` for BLOBs. */
250
257
  export function asUint8Array(value: unknown): Uint8Array {
251
258
  if (value instanceof Uint8Array) return value;
252
259
  if (value instanceof ArrayBuffer) return new Uint8Array(value);
@@ -280,7 +287,7 @@ export function toStoredChange(record: SqliteChangeRecord): StoredChange {
280
287
  * One result row of `commitWindowPageSql` (candidate LEFT JOIN commit meta
281
288
  * LEFT JOIN changes): meta/change columns are NULL when the joined row
282
289
  * vanished (see the builder's LEFT JOIN contract). `payload` is a BLOB —
283
- * `bun:sqlite` hands back `Uint8Array`, D1 `ArrayBuffer`; `toStoredChange`
290
+ * Native SQLite hands back `Uint8Array`, D1 `ArrayBuffer`; `toStoredChange`
284
291
  * normalizes via `asUint8Array`.
285
292
  */
286
293
  export interface SqliteCommitWindowRecord {
@@ -0,0 +1,44 @@
1
+ /** Values accepted by the synchronous SQLite adapters. */
2
+ export type SqliteValue =
3
+ | string
4
+ | number
5
+ | bigint
6
+ | boolean
7
+ | Uint8Array
8
+ | null;
9
+
10
+ /** Result of a SQLite statement that does not return rows. */
11
+ export interface SqliteRunResult {
12
+ readonly changes: number | bigint;
13
+ readonly lastInsertRowid: number | bigint;
14
+ }
15
+
16
+ /** Prepared synchronous SQLite statement used by the server stores. */
17
+ export interface SqliteStatement<Row, Params extends readonly SqliteValue[]> {
18
+ run(...params: Params): SqliteRunResult;
19
+ get(...params: Params): Row | null;
20
+ all(...params: Params): Row[];
21
+ }
22
+
23
+ /** Runtime-neutral database surface shared by the Bun and Node adapters. */
24
+ export interface SqliteDatabase {
25
+ exec(sql: string): void;
26
+ run(sql: string, bindings?: readonly SqliteValue[]): SqliteRunResult;
27
+ query<
28
+ Row = Record<string, SqliteValue>,
29
+ Params extends readonly SqliteValue[] = SqliteValue[],
30
+ >(
31
+ sql: string,
32
+ ): SqliteStatement<Row, Params>;
33
+ close(): void;
34
+ }
35
+
36
+ /** Raised when a neutral-runtime import is used without a SQLite adapter. */
37
+ export class SqliteAdapterRequiredError extends Error {
38
+ override readonly name = 'SqliteAdapterRequiredError';
39
+ readonly code = 'sync.sqlite_adapter_required';
40
+
41
+ constructor() {
42
+ super('SQLite paths require the @syncular/server/sqlite runtime adapter');
43
+ }
44
+ }
@@ -2,15 +2,15 @@
2
2
  * SQLite-image segment generation (SPEC.md §5.3): a complete SQLite
3
3
  * database file carrying one table's whole effective-scope snapshot at
4
4
  * the bootstrap pin, plus the single-row `_syncular_segment` metadata
5
- * table. Built in memory on bun:sqlite dependency-free.
5
+ * table. Runtime entries provide the concrete SQLite database.
6
6
  *
7
7
  * Images are NOT byte-deterministic (§5.3): the content address pins the
8
8
  * served bytes, and cross-client dedup comes from the segment store's
9
9
  * metadata lookup (`SegmentStore.find`), not from hash convergence.
10
10
  */
11
- import { Database } from 'bun:sqlite';
12
11
  import { decodeRow, type RowColumn, type RowValue } from '@syncular/core';
13
12
  import type { CompiledTable } from './schema';
13
+ import type { SqliteDatabase } from './sqlite-driver';
14
14
  import type { StoredRow } from './storage';
15
15
 
16
16
  /** The §5.6 version column as it appears inside a sqlite image (§5.3). */
@@ -63,62 +63,57 @@ export interface SqliteImageInput {
63
63
  /**
64
64
  * The §5.3 image-builder capability, injected through
65
65
  * `SyncServerConfig.sqliteImageBuilder`. Building an image needs
66
- * a real SQLite engine (`bun:sqlite` here), which is not available on every
67
- * runtime Cloudflare Workers has none. So the core takes the builder as an
68
- * optional capability rather than importing `bun:sqlite` on the pull path:
69
- * a Bun/Node host passes `buildSqliteImage`; a Workers host omits it and the
70
- * pull serves the rows lane (§5.3 clients advertise sqlite as an *accept*,
71
- * never a requirement — the host chooses the served format from what it can
72
- * produce; this is a support floor, not a fallback).
66
+ * a real SQLite engine, which is not available on every runtime. The core
67
+ * takes the builder as an optional capability rather than importing a driver
68
+ * on the pull path. A Bun or Node host passes `buildSqliteImage`; a Workers
69
+ * host omits it and serves the rows lane.
73
70
  */
74
71
  export type SqliteImageBuilder = (input: SqliteImageInput) => Uint8Array;
75
72
 
76
- /** Build the §5.3 image bytes for a whole-table snapshot. */
77
- export const buildSqliteImage: SqliteImageBuilder = (input) => {
73
+ /** Populate a §5.3 image database for a whole-table snapshot. */
74
+ export function writeSqliteImage(
75
+ db: SqliteDatabase,
76
+ input: SqliteImageInput,
77
+ ): void {
78
78
  const { table, rows } = input;
79
79
  const primaryKey = table.columns[table.primaryKeyIndex]?.name;
80
- const db = new Database(':memory:');
80
+ const columnDefs = table.columns.map((column) => {
81
+ const notNull = column.nullable ? '' : ' NOT NULL';
82
+ const pk = column.name === primaryKey ? ' PRIMARY KEY' : '';
83
+ return `${quoteIdent(column.name)} ${sqlType(column)}${notNull}${pk}`;
84
+ });
85
+ columnDefs.push(`${quoteIdent(IMAGE_VERSION_COLUMN)} INTEGER NOT NULL`);
86
+ db.exec(`CREATE TABLE ${quoteIdent(table.name)} (${columnDefs.join(', ')})`);
87
+ db.exec(
88
+ `CREATE TABLE ${IMAGE_METADATA_TABLE} (
89
+ format INTEGER NOT NULL, "table" TEXT NOT NULL,
90
+ "schemaVersion" INTEGER NOT NULL, "asOfCommitSeq" INTEGER NOT NULL,
91
+ "scopeDigest" TEXT NOT NULL, "rowCount" INTEGER NOT NULL)`,
92
+ );
93
+ db.query(`INSERT INTO ${IMAGE_METADATA_TABLE} VALUES (1, ?, ?, ?, ?, ?)`).run(
94
+ table.name,
95
+ input.schemaVersion,
96
+ input.asOfCommitSeq,
97
+ input.scopeDigest,
98
+ rows.length,
99
+ );
100
+ const names = [
101
+ ...table.columns.map((column) => quoteIdent(column.name)),
102
+ quoteIdent(IMAGE_VERSION_COLUMN),
103
+ ];
104
+ const insert = db.query(
105
+ `INSERT INTO ${quoteIdent(table.name)} (${names.join(', ')})
106
+ VALUES (${names.map(() => '?').join(', ')})`,
107
+ );
108
+ db.exec('BEGIN');
81
109
  try {
82
- const columnDefs = table.columns.map((column) => {
83
- const notNull = column.nullable ? '' : ' NOT NULL';
84
- const pk = column.name === primaryKey ? ' PRIMARY KEY' : '';
85
- return `${quoteIdent(column.name)} ${sqlType(column)}${notNull}${pk}`;
86
- });
87
- columnDefs.push(`${quoteIdent(IMAGE_VERSION_COLUMN)} INTEGER NOT NULL`);
88
- db.exec(
89
- `CREATE TABLE ${quoteIdent(table.name)} (${columnDefs.join(', ')})`,
90
- );
91
- db.exec(
92
- `CREATE TABLE ${IMAGE_METADATA_TABLE} (
93
- format INTEGER NOT NULL, "table" TEXT NOT NULL,
94
- "schemaVersion" INTEGER NOT NULL, "asOfCommitSeq" INTEGER NOT NULL,
95
- "scopeDigest" TEXT NOT NULL, "rowCount" INTEGER NOT NULL)`,
96
- );
97
- db.query(
98
- `INSERT INTO ${IMAGE_METADATA_TABLE} VALUES (1, ?, ?, ?, ?, ?)`,
99
- ).run(
100
- table.name,
101
- input.schemaVersion,
102
- input.asOfCommitSeq,
103
- input.scopeDigest,
104
- rows.length,
105
- );
106
- const names = [
107
- ...table.columns.map((column) => quoteIdent(column.name)),
108
- quoteIdent(IMAGE_VERSION_COLUMN),
109
- ];
110
- const insert = db.query(
111
- `INSERT INTO ${quoteIdent(table.name)} (${names.join(', ')})
112
- VALUES (${names.map(() => '?').join(', ')})`,
113
- );
114
- db.exec('BEGIN');
115
110
  for (const row of rows) {
116
111
  const values = decodeRow(table.columns, row.payload);
117
112
  insert.run(...values.map(toSql), row.serverVersion);
118
113
  }
119
114
  db.exec('COMMIT');
120
- return new Uint8Array(db.serialize());
121
- } finally {
122
- db.close();
115
+ } catch (error) {
116
+ db.exec('ROLLBACK');
117
+ throw error;
123
118
  }
124
- };
119
+ }
@@ -1,28 +1,29 @@
1
1
  /**
2
- * SQLite-backed auth-lease store via `bun:sqlite` (§7.3.1 reference store,
3
- * dependency-free). Bun-specific by design (top-level `bun:sqlite` import),
4
- * so it lives in its own module — the runtime-neutral `LeaseStore` interface,
5
- * `LeaseRecord`, and `MemoryLeaseStore` stay in `lease-store.ts` for the
6
- * Workers/edge core (runtime neutrality is enforced by
7
- * `test/runtime-neutrality.test.ts`).
2
+ * SQLite-backed auth-lease store over the shared synchronous driver.
8
3
  */
9
- import { Database } from 'bun:sqlite';
10
4
  import type { ScopeMap } from '@syncular/core';
11
5
  import type { LeaseIdFactory, LeaseRecord, LeaseStore } from './lease-store';
6
+ import {
7
+ SqliteAdapterRequiredError,
8
+ type SqliteDatabase,
9
+ } from './sqlite-driver';
12
10
 
13
11
  function defaultLeaseId(): string {
14
12
  return `lease_${crypto.randomUUID()}`;
15
13
  }
16
14
 
17
15
  export class SqliteLeaseStore implements LeaseStore {
18
- readonly db: Database;
16
+ readonly db: SqliteDatabase;
19
17
  readonly #newId: LeaseIdFactory;
20
18
 
21
19
  constructor(
22
- db: Database | string = ':memory:',
20
+ db: SqliteDatabase | string = ':memory:',
23
21
  options?: { readonly leaseId?: LeaseIdFactory },
24
22
  ) {
25
- this.db = typeof db === 'string' ? new Database(db) : db;
23
+ if (typeof db === 'string') {
24
+ throw new SqliteAdapterRequiredError();
25
+ }
26
+ this.db = db;
26
27
  this.#newId = options?.leaseId ?? defaultLeaseId;
27
28
  this.db.exec(`
28
29
  CREATE TABLE IF NOT EXISTS sync_leases(
@@ -0,0 +1,46 @@
1
+ import { DatabaseSync, type SQLInputValue } from 'node:sqlite';
2
+ import type {
3
+ SqliteDatabase,
4
+ SqliteRunResult,
5
+ SqliteStatement,
6
+ SqliteValue,
7
+ } from './sqlite-driver';
8
+
9
+ function bind(value: SqliteValue): SQLInputValue {
10
+ return typeof value === 'boolean' ? (value ? 1 : 0) : value;
11
+ }
12
+
13
+ function row<Row>(value: Record<string, unknown> | undefined): Row | null {
14
+ return (value ?? null) as Row | null;
15
+ }
16
+
17
+ export class NodeSqliteDatabase implements SqliteDatabase {
18
+ readonly native: DatabaseSync;
19
+
20
+ constructor(path = ':memory:') {
21
+ this.native = new DatabaseSync(path);
22
+ }
23
+
24
+ exec(sql: string): void {
25
+ this.native.exec(sql);
26
+ }
27
+
28
+ run(sql: string, bindings: readonly SqliteValue[] = []): SqliteRunResult {
29
+ return this.native.prepare(sql).run(...bindings.map(bind));
30
+ }
31
+
32
+ query<Row, Params extends readonly SqliteValue[]>(
33
+ sql: string,
34
+ ): SqliteStatement<Row, Params> {
35
+ const statement = this.native.prepare(sql);
36
+ return {
37
+ run: (...params) => statement.run(...params.map(bind)),
38
+ get: (...params) => row<Row>(statement.get(...params.map(bind))),
39
+ all: (...params) => statement.all(...params.map(bind)) as Row[],
40
+ };
41
+ }
42
+
43
+ close(): void {
44
+ this.native.close();
45
+ }
46
+ }
@@ -0,0 +1,62 @@
1
+ import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
2
+ import { tmpdir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { SqliteBlobStore as SharedSqliteBlobStore } from './sqlite-blob-store';
5
+ import type { SqliteDatabase } from './sqlite-driver';
6
+ import { type SqliteImageBuilder, writeSqliteImage } from './sqlite-image';
7
+ import { SqliteLeaseStore as SharedSqliteLeaseStore } from './sqlite-lease-store';
8
+ import { NodeSqliteDatabase } from './sqlite-node-driver';
9
+ import { SqliteSegmentStore as SharedSqliteSegmentStore } from './sqlite-segment-store';
10
+ import { SqliteServerStorage as SharedSqliteServerStorage } from './sqlite-storage';
11
+
12
+ function database(value: SqliteDatabase | string): SqliteDatabase {
13
+ return typeof value === 'string' ? new NodeSqliteDatabase(value) : value;
14
+ }
15
+
16
+ export class SqliteServerStorage extends SharedSqliteServerStorage {
17
+ constructor(value: SqliteDatabase | string = ':memory:') {
18
+ super(database(value));
19
+ }
20
+ }
21
+
22
+ export class SqliteSegmentStore extends SharedSqliteSegmentStore {
23
+ constructor(
24
+ value: SqliteDatabase | string = ':memory:',
25
+ options?: { ttlMs?: number },
26
+ ) {
27
+ super(database(value), options);
28
+ }
29
+ }
30
+
31
+ export class SqliteBlobStore extends SharedSqliteBlobStore {
32
+ constructor(value: SqliteDatabase | string = ':memory:') {
33
+ super(database(value));
34
+ }
35
+ }
36
+
37
+ export class SqliteLeaseStore extends SharedSqliteLeaseStore {
38
+ constructor(
39
+ value: SqliteDatabase | string = ':memory:',
40
+ options?: { readonly leaseId?: () => string },
41
+ ) {
42
+ super(database(value), options);
43
+ }
44
+ }
45
+
46
+ export const buildSqliteImage: SqliteImageBuilder = (input) => {
47
+ const directory = mkdtempSync(join(tmpdir(), 'syncular-server-image-'));
48
+ const path = join(directory, 'segment.db');
49
+ const db = new NodeSqliteDatabase(path);
50
+ try {
51
+ try {
52
+ writeSqliteImage(db, input);
53
+ } finally {
54
+ db.close();
55
+ }
56
+ return new Uint8Array(readFileSync(path));
57
+ } finally {
58
+ rmSync(directory, { recursive: true, force: true });
59
+ }
60
+ };
61
+
62
+ export { NodeSqliteDatabase } from './sqlite-node-driver';
@@ -1,13 +1,6 @@
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 {
12
5
  DEFAULT_SEGMENT_TTL_MS,
13
6
  type SegmentFindKey,
@@ -17,20 +10,28 @@ import {
17
10
  type SegmentStoreStats,
18
11
  segmentIdFor,
19
12
  } from './segment-store';
13
+ import {
14
+ SqliteAdapterRequiredError,
15
+ type SqliteDatabase,
16
+ } from './sqlite-driver';
20
17
 
21
18
  export class SqliteSegmentStore implements SegmentStore {
22
- readonly db: Database;
19
+ readonly db: SqliteDatabase;
23
20
  #ttlMs: number;
24
21
 
25
22
  constructor(
26
- db: Database | string = ':memory:',
23
+ db: SqliteDatabase | string = ':memory:',
27
24
  options?: { ttlMs?: number },
28
25
  ) {
29
- this.db = typeof db === 'string' ? new Database(db) : db;
26
+ if (typeof db === 'string') {
27
+ throw new SqliteAdapterRequiredError();
28
+ }
29
+ this.db = db;
30
30
  this.#ttlMs = options?.ttlMs ?? DEFAULT_SEGMENT_TTL_MS;
31
31
  this.db.exec(`
32
32
  CREATE TABLE IF NOT EXISTS sync_segments(
33
33
  segment_id TEXT PRIMARY KEY, partition TEXT NOT NULL,
34
+ log_epoch TEXT NOT NULL,
34
35
  tbl TEXT NOT NULL, schema_version INTEGER NOT NULL,
35
36
  media_type TEXT NOT NULL, scope_digest TEXT NOT NULL,
36
37
  as_of_commit_seq INTEGER NOT NULL, row_count INTEGER NOT NULL,
@@ -39,6 +40,14 @@ export class SqliteSegmentStore implements SegmentStore {
39
40
  expires_at_ms INTEGER NOT NULL, bytes BLOB NOT NULL
40
41
  );
41
42
  `);
43
+ const columns = this.db
44
+ .query<{ name: string }, []>('PRAGMA table_info(sync_segments)')
45
+ .all();
46
+ if (!columns.some((column) => column.name === 'log_epoch')) {
47
+ this.db.exec(
48
+ "ALTER TABLE sync_segments ADD COLUMN log_epoch TEXT NOT NULL DEFAULT ''",
49
+ );
50
+ }
42
51
  }
43
52
 
44
53
  async put(
@@ -57,14 +66,15 @@ export class SqliteSegmentStore implements SegmentStore {
57
66
  this.db
58
67
  .query(
59
68
  `INSERT OR REPLACE INTO sync_segments(
60
- segment_id, partition, tbl, schema_version, media_type,
69
+ segment_id, partition, log_epoch, tbl, schema_version, media_type,
61
70
  scope_digest, as_of_commit_seq, row_count, row_cursor,
62
71
  next_row_cursor, byte_length, created_at_ms, expires_at_ms, bytes
63
- ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
72
+ ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
64
73
  )
65
74
  .run(
66
75
  record.segmentId,
67
76
  record.partition,
77
+ record.logEpoch,
68
78
  record.table,
69
79
  record.schemaVersion,
70
80
  record.mediaType,
@@ -89,6 +99,7 @@ export class SqliteSegmentStore implements SegmentStore {
89
99
  {
90
100
  segment_id: string;
91
101
  partition: string;
102
+ log_epoch: string;
92
103
  tbl: string;
93
104
  schema_version: number;
94
105
  media_type: string;
@@ -110,6 +121,7 @@ export class SqliteSegmentStore implements SegmentStore {
110
121
  record: {
111
122
  segmentId: row.segment_id,
112
123
  partition: row.partition,
124
+ logEpoch: row.log_epoch,
113
125
  table: row.tbl,
114
126
  schemaVersion: row.schema_version,
115
127
  mediaType: row.media_type === 'sqlite' ? 'sqlite' : 'rows',
@@ -140,18 +152,19 @@ export class SqliteSegmentStore implements SegmentStore {
140
152
  created_at_ms: number;
141
153
  expires_at_ms: number;
142
154
  },
143
- [string, string, number, string, string, number, number]
155
+ [string, string, string, number, string, string, number, number]
144
156
  >(
145
157
  `SELECT segment_id, row_count, next_row_cursor, byte_length,
146
158
  created_at_ms, expires_at_ms
147
159
  FROM sync_segments
148
- WHERE partition=? AND tbl=? AND schema_version=? AND media_type=?
160
+ WHERE partition=? AND log_epoch=? AND tbl=? AND schema_version=? AND media_type=?
149
161
  AND scope_digest=? AND as_of_commit_seq=? AND row_cursor IS NULL
150
162
  AND expires_at_ms > ?
151
163
  LIMIT 1`,
152
164
  )
153
165
  .get(
154
166
  key.partition,
167
+ key.logEpoch,
155
168
  key.table,
156
169
  key.schemaVersion,
157
170
  key.mediaType,
@@ -163,6 +176,7 @@ export class SqliteSegmentStore implements SegmentStore {
163
176
  return {
164
177
  segmentId: row.segment_id,
165
178
  partition: key.partition,
179
+ logEpoch: key.logEpoch,
166
180
  table: key.table,
167
181
  schemaVersion: key.schemaVersion,
168
182
  mediaType: key.mediaType,