@syncular/server 0.15.48 → 0.17.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.
Files changed (44) hide show
  1. package/README.md +66 -9
  2. package/dist/admin.js +1 -5
  3. package/dist/authoritative-query.d.ts +14 -6
  4. package/dist/authoritative-query.js +60 -82
  5. package/dist/d1-storage.d.ts +13 -1
  6. package/dist/d1-storage.js +468 -121
  7. package/dist/operations.d.ts +2 -0
  8. package/dist/operations.js +23 -4
  9. package/dist/postgres-storage.d.ts +6 -1
  10. package/dist/postgres-storage.js +80 -24
  11. package/dist/prune.d.ts +3 -1
  12. package/dist/prune.js +18 -14
  13. package/dist/pull.js +79 -39
  14. package/dist/push.js +5 -5
  15. package/dist/realtime.d.ts +1 -1
  16. package/dist/realtime.js +16 -10
  17. package/dist/relational-rows.d.ts +7 -1
  18. package/dist/relational-rows.js +17 -2
  19. package/dist/sqlite-bun.js +2 -2
  20. package/dist/sqlite-image.d.ts +3 -3
  21. package/dist/sqlite-image.js +10 -6
  22. package/dist/sqlite-node.js +2 -2
  23. package/dist/sqlite-storage.d.ts +6 -1
  24. package/dist/sqlite-storage.js +93 -32
  25. package/dist/storage-errors.d.ts +1 -1
  26. package/dist/storage-errors.js +7 -0
  27. package/dist/storage.d.ts +29 -5
  28. package/package.json +2 -2
  29. package/src/admin.ts +4 -6
  30. package/src/authoritative-query.ts +89 -94
  31. package/src/d1-storage.ts +641 -159
  32. package/src/operations.ts +31 -3
  33. package/src/postgres-storage.ts +146 -46
  34. package/src/prune.ts +29 -15
  35. package/src/pull.ts +102 -49
  36. package/src/push.ts +5 -5
  37. package/src/realtime.ts +20 -9
  38. package/src/relational-rows.ts +18 -2
  39. package/src/sqlite-bun.ts +2 -2
  40. package/src/sqlite-image.ts +26 -15
  41. package/src/sqlite-node.ts +2 -2
  42. package/src/sqlite-storage.ts +131 -37
  43. package/src/storage-errors.ts +22 -1
  44. package/src/storage.ts +50 -5
package/README.md CHANGED
@@ -15,6 +15,11 @@ registry, command mutations use the ordinary serialized push path, and
15
15
  specified in [`docs/REMOTE.md`](../../docs/REMOTE.md) and the practical setup is
16
16
  in the [remote operations guide](https://syncular.dev/guide-remote-operations/).
17
17
 
18
+ Remote query registration requires generated `relationPlans` for each selected
19
+ SQL statement. Run `syncular generate` before upgrading existing query modules.
20
+ The server uses those boundaries to bind every physical table occurrence to
21
+ the authenticated partition, including quoted self joins and CTE bodies.
22
+
18
23
  Application intent belongs in immutable domain event rows written in the same
19
24
  commit as the state change. `SyncularServerEvents` below remains operational
20
25
  telemetry. See the [domain event guide](https://syncular.dev/guide-domain-events/).
@@ -105,6 +110,11 @@ Log the cause for operators and stop startup. Do not catch readiness errors in
105
110
  authentication or convert them to a 401; request-time schema checks are only a
106
111
  defensive fallback.
107
112
 
113
+ For D1, finish `storage.migrateSchema(compileSchema(schema))` across separate
114
+ Worker invocations before admitting traffic. Each call returns `complete` and
115
+ `statementsExecuted`; the default budget is 50 statements. See
116
+ [D1 schema migration](https://syncular.dev/server-workers/#schema-migration).
117
+
108
118
  After restoring an authoritative database, keep traffic stopped and call
109
119
  `rotatePartitionLogEpoch({ storage, partition })` for every restored
110
120
  partition. The rotation clears stale client cursors and requires version 2
@@ -927,10 +937,22 @@ at or below it. Nothing prunes automatically — the host schedules it.
927
937
 
928
938
  **When to run.** A periodic job per partition — hourly to daily is the
929
939
  sensible range; there is no benefit below the granularity of your
930
- `activeWindowMs`. Prune is cheap when there is nothing to do (one cursor
931
- scan + two point reads), so err on the side of running it often rather
932
- than letting a backlog build. Pass `events` to get `prune.completed`
933
- per pass.
940
+ `activeWindowMs`. Pass `events` to get `prune.completed` per pass.
941
+
942
+ Pruning verifies the captured log epoch and updates the horizon together with
943
+ commit/change/scope deletion in one transaction. Concurrent passes cannot
944
+ lower the horizon. A retry cleans up eligible records even when the horizon
945
+ already covers them. A restore invalidates a pending pass with
946
+ `sync.storage.prune_epoch_mismatch`; recompute its retention inputs.
947
+ Unregistered partitions cannot be pruned. D1 maintenance enters the owning
948
+ Durable Object's existing write queue through
949
+ `SyncularRealtimeHost.pruneCommitLog`.
950
+
951
+ Custom storage adapters must add `getPartitionLogEpoch(partition)` and replace
952
+ `pruneCommitsThrough(partition, seq)` with
953
+ `pruneCommitsThrough(partition, { logEpoch, throughSeq })`, returning
954
+ `{ previousHorizonSeq, horizonSeq, removedCommits }` from the transaction.
955
+ `setHorizonSeq` remains monotonic and is no longer used by the pruning helper.
934
956
 
935
957
  **The retention floors (§4.6, encoded in `RetentionPolicy`).** The
936
958
  horizon never advances past `min(cursor)` of *active* clients — clients
@@ -1094,13 +1116,19 @@ cannot silently return.
1094
1116
  ### commitSeq allocation under concurrency
1095
1117
 
1096
1118
  Per-partition `commitSeq` is dense and gap-free (§2.1). `appendCommit`
1097
- allocates it with `UPDATE sync_partitions SET max_commit_seq =
1098
- max_commit_seq + 1 RETURNING`, which takes a row-level write lock on the
1099
- partition row for the transaction's duration concurrent pushes to the
1100
- same partition serialize on that row; cross-partition pushes never
1101
- contend. A Postgres `SEQUENCE` is deliberately **not** used: it would leave
1119
+ allocates it with an upsert that increments `sync_partitions.max_commit_seq`
1120
+ and returns the allocated value. A common table expression feeds that value
1121
+ into the commit metadata insert in the same SQL statement. The upsert takes a
1122
+ row-level write lock on the partition for the transaction duration. Concurrent
1123
+ pushes to that partition serialize; separate partitions use separate locks. A Postgres `SEQUENCE` is deliberately **not** used: it would leave
1102
1124
  gaps on rollback, which the §4.5 pull-window arithmetic does not tolerate.
1103
1125
 
1126
+ Each change insert expands its scope object into the inverted scope entries in
1127
+ the same statement. Serialized scopes bind as text before JSONB parsing; this
1128
+ avoids driver-specific JSON string encoding. Historical string-form scopes
1129
+ remain readable. Empty scopes still produce a change row, and repeated scopes
1130
+ within a commit retain one index entry.
1131
+
1104
1132
  ### Multi-instance fanout (LISTEN/NOTIFY)
1105
1133
 
1106
1134
  Behind a load balancer, a commit applied on instance A fans out to A's
@@ -1149,3 +1177,32 @@ deterministic in-process sqlite loopback):
1149
1177
  ```sh
1150
1178
  SYNCULAR_PG_URL=postgres://user:pass@localhost:5432/db bun run bench
1151
1179
  ```
1180
+
1181
+ Custom storage adapters must implement
1182
+ `getActiveClientCursorFloor(partition, cutoffMs)`. Return the minimum cursor
1183
+ whose `updatedAtMs >= cutoffMs`, or `null` when no client qualifies. Preserve
1184
+ negative bootstrap cursors. Pruning and admin horizon status use this scalar
1185
+ aggregate; `listClientCursors` remains the explicit listing interface.
1186
+
1187
+ SQLite image builders now return `Promise<Uint8Array>` and receive
1188
+ `rowBatches`, an iterable or async iterable of row arrays. Replace custom
1189
+ builders' `input.rows` loop with `for await (const rows of input.rowBatches)`,
1190
+ insert each batch into the dedicated image database, and count rows during
1191
+ consumption. Write the final row count into `_syncular_segment` before
1192
+ serialization. Await `buildSqliteImage(input)` when calling the built-in
1193
+ Bun or Node builder directly.
1194
+
1195
+ The server shares in-flight builds for the same storage pair and artifact
1196
+ identity after authorization. Sharing is local to one process. Signed URL
1197
+ grants remain per request. The first eligibility probe has at most
1198
+ `limitSnapshotRows + 1` rows; subsequent builder batches have at most 5,000
1199
+ rows. The image database and serialized output still consume memory.
1200
+
1201
+
1202
+ Realtime acknowledgements call `advanceClientCursor(partition, clientId,
1203
+ actorId, logEpoch, cursor, updatedAtMs)`. Custom storage adapters must implement
1204
+ this atomic update: advance the cursor and activity timestamp to their respective
1205
+ maxima, preserve registration fields, and require a matching actor and current
1206
+ partition log epoch. Leave missing records unchanged. SQLite, Postgres, and D1
1207
+ perform one update without reading or serializing the subscription list. HTTP
1208
+ registration keeps its existing cursor and subscription replacement rules.
package/dist/admin.js CHANGED
@@ -173,11 +173,7 @@ export class SyncularAdmin {
173
173
  const nowMs = this.#clock();
174
174
  const maxCommitSeq = await this.#storage.getMaxCommitSeq(partition);
175
175
  const horizonSeq = await this.#storage.getHorizonSeq(partition);
176
- const cursors = await this.#storage.listClientCursors(partition);
177
- const activeCursors = cursors
178
- .filter((c) => c.updatedAtMs >= nowMs - this.#retention.activeWindowMs)
179
- .map((c) => c.cursor);
180
- const activeCursorFloor = activeCursors.length > 0 ? Math.min(...activeCursors) : null;
176
+ const activeCursorFloor = await this.#storage.getActiveClientCursorFloor(partition, nowMs - this.#retention.activeWindowMs);
181
177
  const cursorFloor = activeCursorFloor ?? Number.MAX_SAFE_INTEGER;
182
178
  const forcedSeq = await this.#storage.getCommitSeqBefore(partition, nowMs - this.#retention.ageForceMs);
183
179
  const retainFloor = maxCommitSeq - this.#retention.minRetainedCommits;
@@ -9,12 +9,20 @@ export interface BoundAuthoritativeQuery {
9
9
  readonly params: readonly AuthoritativeQueryValue[];
10
10
  }
11
11
  declare const PARTITION_BIND: unique symbol;
12
- /**
13
- * Turn generated local SQL into a partition-local authoritative statement.
14
- * Only relations declared by the generated descriptor are rewritten. Values
15
- * remain parameters; request data is never interpolated into SQL.
16
- */
17
- export declare function prepareAuthoritativeQuery(sql: string, params: readonly AuthoritativeQueryValue[], declaredTables: readonly string[], tables: ReadonlyMap<string, CompiledTable>): PreparedAuthoritativeQuery;
12
+ /** Compiler-proven occurrences in the exact generated positional statement. */
13
+ export interface AuthoritativeRelationPlan {
14
+ readonly sql: string;
15
+ readonly relations: readonly {
16
+ readonly table: string;
17
+ readonly start: number;
18
+ readonly end: number;
19
+ readonly alias?: string;
20
+ }[];
21
+ }
22
+ /** Validate trusted generated metadata before registration or storage execution. */
23
+ export declare function validateAuthoritativeRelationPlan(plan: AuthoritativeRelationPlan, declaredTables: readonly string[]): void;
24
+ /** Bind every compiler-proven table occurrence to the authenticated partition. */
25
+ export declare function prepareAuthoritativeQuery(plan: AuthoritativeRelationPlan, params: readonly AuthoritativeQueryValue[], declaredTables: readonly string[], tables: ReadonlyMap<string, CompiledTable>): PreparedAuthoritativeQuery;
18
26
  export declare function bindAuthoritativePartition(prepared: PreparedAuthoritativeQuery, partition: string): BoundAuthoritativeQuery;
19
27
  export declare function postgresPlaceholders(sql: string): string;
20
28
  export {};
@@ -1,24 +1,5 @@
1
1
  import { quoteIdent, SYNC_PARTITION_COLUMN } from './relational-rows.js';
2
2
  const PARTITION_BIND = Symbol('syncular.authoritative_partition');
3
- const RESERVED_ALIAS = new Set([
4
- 'on',
5
- 'where',
6
- 'group',
7
- 'order',
8
- 'inner',
9
- 'left',
10
- 'right',
11
- 'full',
12
- 'outer',
13
- 'natural',
14
- 'join',
15
- 'cross',
16
- 'using',
17
- 'limit',
18
- 'having',
19
- ]);
20
- const IDENT = '[A-Za-z_][A-Za-z0-9_]*';
21
- const TABLE_REF_RE = new RegExp(`\\b(FROM|(?:NATURAL\\s+)?(?:(?:LEFT|RIGHT|FULL)(?:\\s+OUTER)?|INNER|CROSS)?\\s*JOIN)\\s+((?:\\(\\s*)*)(${IDENT})(?:\\s+(?:AS\\s+)?((?!(?:${[...RESERVED_ALIAS].join('|')})\\b)${IDENT}))?`, 'gi');
22
3
  function protectedSqlEnd(sql, index) {
23
4
  const char = sql[index];
24
5
  const next = sql[index + 1];
@@ -48,97 +29,94 @@ function protectedSqlEnd(sql, index) {
48
29
  }
49
30
  return undefined;
50
31
  }
51
- function maskedSql(sql) {
52
- let out = '';
53
- let index = 0;
54
- while (index < sql.length) {
55
- const end = protectedSqlEnd(sql, index);
56
- if (end === undefined)
57
- out += sql[index];
58
- else
59
- out += sql.slice(index, end).replace(/[^\n]/g, ' ');
60
- index = end ?? index + 1;
32
+ /** Validate trusted generated metadata before registration or storage execution. */
33
+ export function validateAuthoritativeRelationPlan(plan, declaredTables) {
34
+ if (plan === undefined ||
35
+ typeof plan.sql !== 'string' ||
36
+ !Array.isArray(plan.relations)) {
37
+ throw new Error('registered query requires generated relation plans; regenerate queries');
61
38
  }
62
- return out;
63
- }
64
- /**
65
- * Turn generated local SQL into a partition-local authoritative statement.
66
- * Only relations declared by the generated descriptor are rewritten. Values
67
- * remain parameters; request data is never interpolated into SQL.
68
- */
69
- export function prepareAuthoritativeQuery(sql, params, declaredTables, tables) {
70
- if (maskedSql(sql).includes(';'))
71
- throw new Error('registered query must be one SELECT');
39
+ const sql = plan.sql;
72
40
  const declared = new Set(declaredTables);
73
- const masked = maskedSql(sql);
74
- const replacements = [];
75
41
  const found = new Set();
76
- for (const match of masked.matchAll(TABLE_REF_RE)) {
77
- const rawTable = match[3];
78
- const table = tables.get(rawTable);
79
- if (table === undefined)
80
- continue;
81
- if (!declared.has(table.name)) {
82
- throw new Error('registered query table metadata does not match its SQL');
42
+ let previousEnd = 0;
43
+ for (const relation of plan.relations) {
44
+ if (!Number.isSafeInteger(relation.start) ||
45
+ !Number.isSafeInteger(relation.end) ||
46
+ relation.start < previousEnd ||
47
+ relation.end <= relation.start ||
48
+ relation.end > sql.length) {
49
+ throw new Error('registered query relation boundaries do not match its SQL; regenerate queries');
83
50
  }
84
- if (!table.materialize) {
85
- throw new Error('registered query targets a non-materialized table');
51
+ previousEnd = relation.end;
52
+ if (!declared.has(relation.table)) {
53
+ throw new Error('registered query table metadata does not match its SQL');
86
54
  }
87
- let alias = match[4];
88
- if (alias !== undefined && RESERVED_ALIAS.has(alias.toLowerCase())) {
89
- alias = undefined;
55
+ const spelling = sql.slice(relation.start, relation.end);
56
+ const quote = spelling[0];
57
+ const name = quote === '['
58
+ ? spelling.slice(1, -1)
59
+ : quote === '"' || quote === '`'
60
+ ? spelling
61
+ .slice(1, -1)
62
+ .split(quote + quote)
63
+ .join(quote)
64
+ : spelling;
65
+ if (name.toLowerCase() !== relation.table.toLowerCase()) {
66
+ throw new Error('registered query relation name does not match its SQL; regenerate queries');
90
67
  }
91
- const matchStart = match.index ?? 0;
92
- const afterOperator = match[1].length;
93
- const relative = match[0]
94
- .toLowerCase()
95
- .indexOf(rawTable.toLowerCase(), afterOperator);
96
- const start = matchStart + relative;
97
- const projection = table.columns.map((column) => quoteIdent(column.name));
98
- replacements.push({
99
- start,
100
- end: start + rawTable.length,
101
- text: `(SELECT ${projection.join(', ')} FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(SYNC_PARTITION_COLUMN)}=/*syncular_partition*/?)${alias === undefined ? ` AS ${quoteIdent(table.name)}` : ''}`,
102
- });
103
- found.add(table.name);
68
+ found.add(relation.table);
104
69
  }
105
70
  if (found.size !== declared.size ||
106
71
  [...declared].some((table) => !found.has(table))) {
107
72
  throw new Error('registered query table metadata does not match its SQL');
108
73
  }
109
- let rewritten = sql;
110
- for (const replacement of replacements.sort((left, right) => right.start - left.start)) {
111
- rewritten =
112
- rewritten.slice(0, replacement.start) +
113
- replacement.text +
114
- rewritten.slice(replacement.end);
115
- }
74
+ }
75
+ /** Bind every compiler-proven table occurrence to the authenticated partition. */
76
+ export function prepareAuthoritativeQuery(plan, params, declaredTables, tables) {
77
+ validateAuthoritativeRelationPlan(plan, declaredTables);
78
+ const sql = plan.sql;
79
+ const replacements = plan.relations.map((relation) => {
80
+ const table = tables.get(relation.table);
81
+ if (table === undefined)
82
+ throw new Error('registered query targets an unknown table');
83
+ if (!table.materialize)
84
+ throw new Error('registered query targets a non-materialized table');
85
+ return {
86
+ start: relation.start,
87
+ end: relation.end,
88
+ text: `(SELECT ${table.columns.map((column) => quoteIdent(column.name)).join(', ')} FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(SYNC_PARTITION_COLUMN)}=?)${relation.alias === undefined ? ` AS ${quoteIdent(table.name)}` : ''}`,
89
+ };
90
+ });
116
91
  const bound = [];
117
92
  let anonymousIndex = 0;
118
93
  let rendered = '';
119
- for (let index = 0; index < rewritten.length; index += 1) {
120
- if (rewritten.startsWith('/*syncular_partition*/?', index)) {
121
- rendered += '?';
94
+ let nextRelation = 0;
95
+ for (let index = 0; index < sql.length; index += 1) {
96
+ const replacement = replacements[nextRelation];
97
+ if (replacement?.start === index) {
98
+ rendered += replacement.text;
122
99
  bound.push(PARTITION_BIND);
123
- index += '/*syncular_partition*/?'.length - 1;
100
+ index = replacement.end - 1;
101
+ nextRelation += 1;
124
102
  continue;
125
103
  }
126
- const protectedEnd = protectedSqlEnd(rewritten, index);
104
+ const protectedEnd = protectedSqlEnd(sql, index);
127
105
  if (protectedEnd !== undefined) {
128
- rendered += rewritten.slice(index, protectedEnd);
106
+ rendered += sql.slice(index, protectedEnd);
129
107
  index = protectedEnd - 1;
130
108
  continue;
131
109
  }
132
- const char = rewritten[index];
110
+ const char = sql[index];
133
111
  if (char !== '?') {
134
112
  rendered += char;
135
113
  continue;
136
114
  }
137
115
  let end = index + 1;
138
- while (end < rewritten.length && /[0-9]/.test(rewritten[end])) {
116
+ while (end < sql.length && /[0-9]/.test(sql[end])) {
139
117
  end += 1;
140
118
  }
141
- const numbered = rewritten.slice(index + 1, end);
119
+ const numbered = sql.slice(index + 1, end);
142
120
  const parameterIndex = numbered.length > 0
143
121
  ? Number.parseInt(numbered, 10) - 1
144
122
  : anonymousIndex++;
@@ -1,3 +1,4 @@
1
+ import type { CommitPruneQuery, CommitPruneResult } from './storage.js';
1
2
  import type { CompiledSchema, CompiledTable } from './schema.js';
2
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';
3
4
  export interface D1PreparedStatement {
@@ -36,15 +37,23 @@ export declare class D1ServerStorage implements ServerStorage {
36
37
  /** Resolve a table's compiled schema; row operations require `ensureSchema`. */
37
38
  table(name: string): CompiledTable;
38
39
  ensureSchema(schema: CompiledSchema): Promise<void>;
40
+ /** Run once per Worker invocation. Resume an incomplete result in another invocation. */
41
+ migrateSchema(schema: CompiledSchema, options?: {
42
+ readonly maxStatements?: number;
43
+ }): Promise<{
44
+ readonly complete: boolean;
45
+ readonly statementsExecuted: number;
46
+ }>;
39
47
  touchPartition(partition: string, authenticatedAtMs: number, initialLogEpoch: string): Promise<PartitionRegistryEntry>;
40
48
  rotatePartitionLogEpoch(partition: string, logEpoch: string, authenticatedAtMs: number): Promise<PartitionRegistryEntry>;
41
49
  listPartitionRegistry(): Promise<PartitionRegistryEntry[]>;
42
50
  begin(partition: string): Promise<StorageTransaction>;
43
51
  getMaxCommitSeq(partition: string): Promise<number>;
44
52
  queryAuthoritative(partition: string, query: AuthoritativeQueryRequest): Promise<AuthoritativeQueryResult>;
53
+ getPartitionLogEpoch(partition: string): Promise<string | undefined>;
45
54
  getHorizonSeq(partition: string): Promise<number>;
46
55
  setHorizonSeq(partition: string, seq: number): Promise<void>;
47
- pruneCommitsThrough(partition: string, seq: number): Promise<number>;
56
+ pruneCommitsThrough(partition: string, query: CommitPruneQuery): Promise<CommitPruneResult>;
48
57
  getCommitSeqBefore(partition: string, createdBeforeMs: number): Promise<number>;
49
58
  getRow(partition: string, table: string, rowId: string): Promise<StoredRow | undefined>;
50
59
  getPushResult(partition: string, clientId: string, clientCommitId: string): Promise<StoredPushResult | undefined>;
@@ -61,6 +70,9 @@ export declare class D1ServerStorage implements ServerStorage {
61
70
  scanRowsByIndex(partition: string, query: IndexRowScanQuery): Promise<StoredRow[]>;
62
71
  getClientRecord(partition: string, clientId: string): Promise<ClientRecord | undefined>;
63
72
  putClientRecord(partition: string, record: ClientRecord): Promise<void>;
73
+ advanceClientCursor(partition: string, clientId: string, actorId: string, logEpoch: string, cursor: number, updatedAtMs: number): Promise<void>;
74
+ updateClientCursor(partition: string, clientId: string, cursor: number, updatedAtMs: number): Promise<void>;
75
+ getActiveClientCursorFloor(partition: string, cutoffMs: number): Promise<number | null>;
64
76
  listClientCursors(partition: string): Promise<ClientCursorInfo[]>;
65
77
  listRowsReferencingBlob(partition: string, blobId: string): Promise<{
66
78
  readonly table: string;