@syncular/server 0.26.4 → 0.26.6

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 (49) hide show
  1. package/dist/content-encoding.d.ts +17 -0
  2. package/dist/content-encoding.js +22 -0
  3. package/dist/context.d.ts +9 -0
  4. package/dist/d1-storage.d.ts +46 -0
  5. package/dist/d1-storage.js +12 -1
  6. package/dist/errors.d.ts +19 -0
  7. package/dist/errors.js +29 -0
  8. package/dist/handler.js +1 -1
  9. package/dist/index.d.ts +1 -1
  10. package/dist/index.js +1 -1
  11. package/dist/operations-realtime.js +4 -1
  12. package/dist/operations.js +6 -1
  13. package/dist/postgres-storage.d.ts +42 -0
  14. package/dist/postgres-storage.js +110 -45
  15. package/dist/pull.d.ts +46 -6
  16. package/dist/pull.js +129 -34
  17. package/dist/push.js +19 -2
  18. package/dist/realtime.d.ts +3 -0
  19. package/dist/realtime.js +32 -26
  20. package/dist/relational-rows.d.ts +19 -0
  21. package/dist/relational-rows.js +38 -0
  22. package/dist/s3-segment-store.d.ts +3 -1
  23. package/dist/s3-segment-store.js +16 -0
  24. package/dist/segment-download.d.ts +16 -0
  25. package/dist/segment-download.js +71 -21
  26. package/dist/segment-store.d.ts +16 -0
  27. package/dist/segment-store.js +19 -0
  28. package/dist/sqlite-storage.d.ts +5 -0
  29. package/dist/sqlite-storage.js +18 -1
  30. package/dist/storage.d.ts +20 -0
  31. package/package.json +2 -2
  32. package/src/content-encoding.ts +35 -0
  33. package/src/context.ts +9 -0
  34. package/src/d1-storage.ts +20 -1
  35. package/src/errors.ts +56 -0
  36. package/src/handler.ts +2 -0
  37. package/src/index.ts +1 -0
  38. package/src/operations-realtime.ts +4 -1
  39. package/src/operations.ts +6 -1
  40. package/src/postgres-storage.ts +145 -60
  41. package/src/pull.ts +212 -39
  42. package/src/push.ts +18 -2
  43. package/src/realtime.ts +45 -32
  44. package/src/relational-rows.ts +43 -0
  45. package/src/s3-segment-store.ts +15 -0
  46. package/src/segment-download.ts +129 -21
  47. package/src/segment-store.ts +36 -0
  48. package/src/sqlite-storage.ts +27 -0
  49. package/src/storage.ts +21 -0
@@ -25,3 +25,20 @@ export interface EncodedSegmentBody {
25
25
  * tiny, or the runtime has no codec.
26
26
  */
27
27
  export declare function encodeSegmentBody(bytes: Uint8Array, acceptEncoding: string | undefined): EncodedSegmentBody;
28
+ /**
29
+ * Segments above this size are relayed as a stream instead of being
30
+ * buffered and compressed in memory: a sqlite image can exceed the memory of
31
+ * a Workers isolate (128 MB).
32
+ */
33
+ export declare const SEGMENT_STREAM_THRESHOLD_BYTES: number;
34
+ export interface EncodedSegmentStream {
35
+ readonly body: ReadableStream<Uint8Array<ArrayBuffer>>;
36
+ /** Absent ⇒ identity (no `Content-Encoding` header). */
37
+ readonly contentEncoding?: 'gzip';
38
+ }
39
+ /**
40
+ * Compress a streamed segment body per §5.8 with the runtime's
41
+ * `CompressionStream`: gzip when the client accepts it, identity otherwise
42
+ * (zstd has no standard streaming codec).
43
+ */
44
+ export declare function encodeSegmentStream(body: ReadableStream<Uint8Array<ArrayBuffer>>, acceptEncoding: string | undefined): EncodedSegmentStream;
@@ -67,3 +67,25 @@ export function encodeSegmentBody(bytes, acceptEncoding) {
67
67
  }
68
68
  return { bytes };
69
69
  }
70
+ /**
71
+ * Segments above this size are relayed as a stream instead of being
72
+ * buffered and compressed in memory: a sqlite image can exceed the memory of
73
+ * a Workers isolate (128 MB).
74
+ */
75
+ export const SEGMENT_STREAM_THRESHOLD_BYTES = 16 * 1024 * 1024;
76
+ /**
77
+ * Compress a streamed segment body per §5.8 with the runtime's
78
+ * `CompressionStream`: gzip when the client accepts it, identity otherwise
79
+ * (zstd has no standard streaming codec).
80
+ */
81
+ export function encodeSegmentStream(body, acceptEncoding) {
82
+ if (acceptEncoding === undefined ||
83
+ !accepts(acceptEncoding, 'gzip') ||
84
+ typeof CompressionStream !== 'function') {
85
+ return { body };
86
+ }
87
+ return {
88
+ body: body.pipeThrough(new CompressionStream('gzip')),
89
+ contentEncoding: 'gzip',
90
+ };
91
+ }
package/dist/context.d.ts CHANGED
@@ -7,6 +7,7 @@
7
7
  import type { ScopeMap } from '@syncular/core';
8
8
  import type { BlobStore } from './blob-store.js';
9
9
  import type { CrdtMergerRegistry } from './crdt-merger.js';
10
+ import type { SyncularErrorHandler } from './errors.js';
10
11
  import type { SyncularServerEvents } from './events.js';
11
12
  import type { LeaseStore } from './lease-store.js';
12
13
  import type { AnyReactionPlanner } from './reactions.js';
@@ -167,6 +168,14 @@ export interface SyncServerConfig {
167
168
  * event objects are built. A throwing sink never affects processing.
168
169
  */
169
170
  readonly events?: SyncularServerEvents;
171
+ /**
172
+ * Host error reporting (§10.2 `sync.internal_error`). Receives the original
173
+ * exception whenever a request, socket round, segment or blob download,
174
+ * or remote operation fails with something other than a `SyncError`; the
175
+ * client sees only the catalog code. Like the events sink, a throwing
176
+ * handler never changes the response.
177
+ */
178
+ readonly onError?: SyncularErrorHandler;
170
179
  }
171
180
  /** Per-request context: the config plus host-authenticated identity. */
172
181
  export interface SyncRequestContext extends SyncServerConfig {
@@ -1,4 +1,46 @@
1
1
  import type { CommitPruneQuery, CommitPruneResult } from './storage.js';
2
+ /**
3
+ * Cloudflare D1 server storage for Workers deployments.
4
+ *
5
+ * D1 *is* SQLite exposed over an async, statement-at-a-time API
6
+ * (`prepare(sql).bind(...).all()` / `.first()` / `.run()`, plus `batch([…])`
7
+ * for atomic multi-statement writes). So this storage shares the schema and
8
+ * value codecs with `SqliteServerStorage` via `sqlite-dialect.ts` — same
9
+ * DDL, same `?` placeholders, same JSON-scope / base64-push-result
10
+ * serialization — and differs only in the execution shape (async, no
11
+ * interactive transaction).
12
+ *
13
+ * ## Transaction model (the one real divergence)
14
+ *
15
+ * D1 has **no interactive transaction**: you cannot `BEGIN`, run
16
+ * conditional reads and writes on a pinned connection, then `COMMIT`. The
17
+ * only atomic primitive is `db.batch([stmt, …])` — a set of statements
18
+ * applied in one implicit transaction, all-or-nothing.
19
+ *
20
+ * The push handler (§6) reads first (conflict detection via `getRow`) and
21
+ * only then writes (`upsertRow`/`deleteRow`/`appendCommit`/
22
+ * `putPushResult`). `D1Transaction` exploits exactly that ordering:
23
+ *
24
+ * - reads (`getRow`) execute immediately against D1 (autocommit);
25
+ * - writes are **buffered** as prepared statements;
26
+ * - `commit()` flushes the whole buffer as one `db.batch(...)` — the same
27
+ * atomicity §6.4 requires (a rejected op rolls back by simply never
28
+ * flushing);
29
+ * - `rollback()` drops the buffer unsent.
30
+ *
31
+ * `appendCommit` allocates the dense per-partition `commitSeq` (§2.1) by
32
+ * reading `max_commit_seq` live and buffering the `+1` write. Under a single
33
+ * Worker request this is exact. Two concurrent pushes to one partition need an
34
+ * external serialization point (normally a per-partition Durable Object); a
35
+ * realtime notifier alone does not serialize HTTP writes. Every deployment
36
+ * that accepts D1 pushes MUST front same-partition sync rounds with a
37
+ * coordinating primitive (a DO or a Queue). The adapter fails closed unless
38
+ * that coordinator explicitly sets `pushApplySerialized`, because D1 cannot
39
+ * provide the required pre-operation lock.
40
+ * This mirrors PostgreSQL's per-partition row lock, achieved by placement
41
+ * rather than a lock D1 does not expose.
42
+ */
43
+ import { type ScopeMap } from '@syncular/core';
2
44
  import type { CompiledSchema, CompiledTable } from './schema.js';
3
45
  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';
4
46
  export interface D1PreparedStatement {
@@ -73,6 +115,10 @@ export declare class D1ServerStorage implements ServerStorage {
73
115
  getReaction(partition: string, idempotencyKey: string): Promise<StoredReaction | undefined>;
74
116
  listReactions(partition: string, query: ReactionListQuery): Promise<StoredReaction[]>;
75
117
  pruneReactions(partition: string, query: ReactionPruneQuery): Promise<PrunedReactionCounts>;
118
+ latestScopedChangeSeq(partition: string, query: {
119
+ readonly table: string;
120
+ readonly scopeFilter: ScopeMap;
121
+ }): Promise<number>;
76
122
  readCommitWindow(partition: string, query: CommitWindowQuery): Promise<StoredCommit[]>;
77
123
  scanRows(partition: string, query: RowScanQuery): Promise<StoredRow[]>;
78
124
  scanRowsByIndex(partition: string, query: IndexRowScanQuery): Promise<StoredRow[]>;
@@ -45,7 +45,7 @@ import { StorageQueryError } from './storage-errors.js';
45
45
  import { decodeRow } from '@syncular/core';
46
46
  import { bindAuthoritativePartition, prepareAuthoritativeQuery, } from './authoritative-query.js';
47
47
  import { syncError } from './errors.js';
48
- import { assertAppendOnlyMigration, assertPhysicalColumns, assertStoredLayouts, commitWindowPageSql, deleteRowSql, deleteSqliteRowScopesSql, dropTableDdl, indexRowPageStatement, layoutsOf, migratePayload, parseLayouts, quoteIdent, retiredTableNames, rewritePlan, rewriteRowSql, rewriteValues, SCHEMA_META_DDL_SQLITE, scanRowPageSql, schemaDdl, selectRowScopesSql, selectRowSql, selectRowsForRewriteSql, tableColumnNames, toSqlValue, upsertSql, upsertValues, } from './relational-rows.js';
48
+ import { assertAppendOnlyMigration, assertPhysicalColumns, assertStoredLayouts, commitWindowPageSql, latestScopedChangeSeqSql, deleteRowSql, deleteSqliteRowScopesSql, dropTableDdl, indexRowPageStatement, layoutsOf, migratePayload, parseLayouts, quoteIdent, retiredTableNames, rewritePlan, rewriteRowSql, rewriteValues, SCHEMA_META_DDL_SQLITE, scanRowPageSql, schemaDdl, selectRowScopesSql, selectRowSql, selectRowsForRewriteSql, tableColumnNames, toSqlValue, upsertSql, upsertValues, } from './relational-rows.js';
49
49
  import { matchesEffective } from './scopes.js';
50
50
  import { asUint8Array, collectCommitWindowPage, deserializePushResult, placeholders, serializePushResult, sqliteDdlStatements, toStoredRow, } from './sqlite-dialect.js';
51
51
  import { isD1ConstraintError, StorageConstraintError } from './storage-errors.js';
@@ -1439,6 +1439,17 @@ export class D1ServerStorage {
1439
1439
  deadLetter: results.filter((row) => row.status === 'dead-letter').length,
1440
1440
  };
1441
1441
  }
1442
+ async latestScopedChangeSeq(partition, query) {
1443
+ const firstVariable = Object.keys(query.scopeFilter).sort()[0];
1444
+ const values = firstVariable === undefined
1445
+ ? []
1446
+ : (query.scopeFilter[firstVariable] ?? []);
1447
+ if (firstVariable === undefined || values.length === 0)
1448
+ return 0;
1449
+ // Concurrent reads of a pull share one `db.batch` round trip.
1450
+ const [row] = await this.#batchedAll(latestScopedChangeSeqSql(values.length), values.flatMap((value) => [partition, query.table, firstVariable, value]));
1451
+ return row?.seq ?? 0;
1452
+ }
1442
1453
  async readCommitWindow(partition, query) {
1443
1454
  const variables = Object.keys(query.scopeFilter).sort();
1444
1455
  const firstVariable = variables[0];
package/dist/errors.d.ts CHANGED
@@ -34,3 +34,22 @@ export declare function errorBody(error: SyncError): {
34
34
  message: string;
35
35
  details?: unknown;
36
36
  };
37
+ /** The adapter surface that caught an exception reported through `onError`. */
38
+ export type SyncularErrorRoute = 'sync' | 'operations' | 'segments' | 'blobs' | 'admin' | 'realtime';
39
+ /**
40
+ * Host error reporting: receives every exception that is not a `SyncError`
41
+ * before the adapter answers `sync.internal_error` (or the remote-operation
42
+ * `operation.*` failure code), with the surface that caught it.
43
+ */
44
+ export type SyncularErrorHandler = (error: unknown, context: {
45
+ readonly route: SyncularErrorRoute;
46
+ }) => void;
47
+ /**
48
+ * The protocol error for an exception an adapter caught. A `SyncError`
49
+ * passes through unchanged. Anything else goes to `onError` and becomes
50
+ * `sync.internal_error` (§10.2), whose message never carries the exception
51
+ * text.
52
+ */
53
+ export declare function adapterSyncError(error: unknown, onError: SyncularErrorHandler | undefined, route: SyncularErrorRoute): SyncError;
54
+ /** Hand `error` to the host's `onError`; a throwing handler is contained. */
55
+ export declare function reportError(error: unknown, onError: SyncularErrorHandler | undefined, route: SyncularErrorRoute): void;
package/dist/errors.js CHANGED
@@ -163,6 +163,14 @@ export const ERROR_CATALOG = {
163
163
  // checkpoint is incomplete or the database is newer than the running build.
164
164
  // Formerly host-only; promoted to the §10.2 catalogue so an in-flight
165
165
  // request can be refused with a distinguishable identity.
166
+ // An exception outside this catalog (host code, storage, network) reached
167
+ // an adapter; the original goes to `onError`, never onto the wire.
168
+ 'sync.internal_error': {
169
+ category: 'internal',
170
+ retryable: true,
171
+ recommendedAction: 'retryLater',
172
+ httpStatus: 500,
173
+ },
166
174
  'sync.schema_not_ready': {
167
175
  category: 'internal',
168
176
  retryable: true,
@@ -288,3 +296,24 @@ export function errorBody(error) {
288
296
  : {}),
289
297
  };
290
298
  }
299
+ /**
300
+ * The protocol error for an exception an adapter caught. A `SyncError`
301
+ * passes through unchanged. Anything else goes to `onError` and becomes
302
+ * `sync.internal_error` (§10.2), whose message never carries the exception
303
+ * text.
304
+ */
305
+ export function adapterSyncError(error, onError, route) {
306
+ if (error instanceof SyncError)
307
+ return error;
308
+ reportError(error, onError, route);
309
+ return new SyncError('sync.internal_error', 'internal server error');
310
+ }
311
+ /** Hand `error` to the host's `onError`; a throwing handler is contained. */
312
+ export function reportError(error, onError, route) {
313
+ try {
314
+ onError?.(error, { route });
315
+ }
316
+ catch {
317
+ // The report is best effort; the response stays the catalog error.
318
+ }
319
+ }
package/dist/handler.js CHANGED
@@ -353,7 +353,7 @@ async function* streamResponse(plan, ctx, schema, report) {
353
353
  const limits = clampPullLimits(plan.pull);
354
354
  const maxSeq = await ctx.storage.getMaxCommitSeq(ctx.partition);
355
355
  const horizonSeq = await ctx.storage.getHorizonSeq(ctx.partition);
356
- const prepared = await prepareSections(ctx, limits, plan.subscriptions, maxSeq, horizonSeq);
356
+ const prepared = await prepareSections(ctx, schema, limits, plan.subscriptions, maxSeq, horizonSeq, plan.logEpoch);
357
357
  for (const [index, subscription] of plan.subscriptions.entries()) {
358
358
  const trace = summaries !== undefined ? { segments: [] } : undefined;
359
359
  const reads = prepared[index];
package/dist/index.d.ts CHANGED
@@ -44,7 +44,7 @@ export * from './segment-store.js';
44
44
  export * from './signed-url.js';
45
45
  export * from './sqlite-dialect.js';
46
46
  export * from './sqlite-driver.js';
47
- export { IMAGE_METADATA_TABLE, IMAGE_VERSION_COLUMN, type SqliteImageBuilder, type SqliteImageInput, } from './sqlite-image.js';
47
+ export { IMAGE_METADATA_TABLE, IMAGE_VERSION_COLUMN, type SqliteImageBuilder, type SqliteImageInput, writeSqliteImage, } from './sqlite-image.js';
48
48
  export * from './sqlite-blob-store.js';
49
49
  export * from './sqlite-lease-store.js';
50
50
  export * from './sqlite-segment-store.js';
package/dist/index.js CHANGED
@@ -48,7 +48,7 @@ export * from './segment-store.js';
48
48
  export * from './signed-url.js';
49
49
  export * from './sqlite-dialect.js';
50
50
  export * from './sqlite-driver.js';
51
- export { IMAGE_METADATA_TABLE, IMAGE_VERSION_COLUMN, } from './sqlite-image.js';
51
+ export { IMAGE_METADATA_TABLE, IMAGE_VERSION_COLUMN, writeSqliteImage, } from './sqlite-image.js';
52
52
  export * from './sqlite-blob-store.js';
53
53
  export * from './sqlite-lease-store.js';
54
54
  export * from './sqlite-segment-store.js';
@@ -1,6 +1,6 @@
1
1
  import { decodeRemoteOperationRealtimeMessage, encodeRemoteOperationRealtimeMessage, } from '@syncular/core';
2
2
  import { REMOTE_COMMAND_CLIENT_ID_PREFIX } from './context.js';
3
- import { SyncError, syncError } from './errors.js';
3
+ import { reportError, SyncError, syncError } from './errors.js';
4
4
  class WatchSession {
5
5
  #ctx;
6
6
  #registry;
@@ -122,6 +122,9 @@ class WatchSession {
122
122
  if (this.#isClosed || this.#watches.get(state.watchId) !== state) {
123
123
  return;
124
124
  }
125
+ if (!(error instanceof SyncError)) {
126
+ reportError(error, this.#ctx.onError, 'operations');
127
+ }
125
128
  this.#sendError(state.watchId, error instanceof SyncError
126
129
  ? error
127
130
  : syncError('operation.query_failed'));
@@ -1,7 +1,7 @@
1
1
  import { validateAuthoritativeRelationPlan, } from './authoritative-query.js';
2
2
  import { decodeRow, decodeRemoteOperationRequest, encodeRemoteOperationResponse, encodeSparseRow, } from '@syncular/core';
3
3
  import { REMOTE_COMMAND_CLIENT_ID_PREFIX, RESOLVER_OUTAGE, touchAuthenticatedPartition, } from './context.js';
4
- import { SyncError, syncError } from './errors.js';
4
+ import { reportError, SyncError, syncError } from './errors.js';
5
5
  import { processPushOperationsWithTrace } from './push.js';
6
6
  import { compileSchema } from './schema.js';
7
7
  import { authorizeWrite } from './scopes.js';
@@ -206,6 +206,7 @@ export function registerRemoteQuery(descriptor, options) {
206
206
  catch (error) {
207
207
  if (error instanceof SyncError)
208
208
  throw error;
209
+ reportError(error, ctx.onError, 'operations');
209
210
  throw syncError('operation.query_failed', 'registered query execution failed');
210
211
  }
211
212
  if (result.rows.length > options.maxRows) {
@@ -218,6 +219,7 @@ export function registerRemoteQuery(descriptor, options) {
218
219
  catch (error) {
219
220
  if (error instanceof SyncError)
220
221
  throw error;
222
+ reportError(error, ctx.onError, 'operations');
221
223
  throw syncError('operation.query_failed', 'registered query result decoding failed');
222
224
  }
223
225
  return {
@@ -409,6 +411,9 @@ export async function handleRemoteOperation(bytes, ctx, registry) {
409
411
  return encodeRemoteOperationResponse(await operation.run(ctx, request.clientId, request.requestId, request.params));
410
412
  }
411
413
  catch (error) {
414
+ if (!(error instanceof SyncError)) {
415
+ reportError(error, ctx.onError, 'operations');
416
+ }
412
417
  return encodeRemoteOperationError(error, 'operation.execution_failed');
413
418
  }
414
419
  }
@@ -1,4 +1,42 @@
1
1
  import type { CommitPruneQuery, CommitPruneResult } from './storage.js';
2
+ /**
3
+ * Postgres server storage: the production database path.
4
+ *
5
+ * Semantics mirror `SqliteServerStorage` exactly (both run the shared
6
+ * storage contract in `test/storage-contract.ts`); the difference is that
7
+ * scope fanout MUST survive contact with Postgres — no scan-before-LIMIT,
8
+ * ever (performance-by-construction):
9
+ *
10
+ * - both the commit log and the current-row table carry a (table, var,
11
+ * value) inverted scope index;
12
+ * - `readCommitWindow`/`scanRows` select candidates through that index
13
+ * (`sync_change_scopes` / `sync_row_scopes`), ordered + LIMITed at the
14
+ * index, then verify the full multi-variable match against the stored
15
+ * scope map — never a log or table scan;
16
+ * - the candidate indexes are *covering* for their selection: the ordered
17
+ * column (`commit_seq` / `row_id`) sits in the index key after
18
+ * (tbl, var, value), so the planner does an index range scan and returns
19
+ * already-ordered candidates without a heap sort. `test/postgres-explain
20
+ * .test.ts` asserts an `Index` node (never `Seq Scan`) so the regression
21
+ * cannot silently return.
22
+ *
23
+ * Storage is written against the `PgExecutor` seam (zero runtime deps); the
24
+ * production driver (Bun.sql / node-postgres) and the test driver (pglite)
25
+ * are wired by the host. See `pg-executor.ts` and the server README.
26
+ *
27
+ * ## commitSeq allocation under concurrency
28
+ *
29
+ * Per-partition `commitSeq` is a dense, gap-free counter (§2.1), allocated
30
+ * inside the push transaction by `UPDATE sync_partitions SET
31
+ * max_commit_seq = max_commit_seq + 1 … RETURNING`. The `UPDATE` takes a
32
+ * row-level write lock on the partition row for the duration of the
33
+ * transaction, so two concurrent pushes to the same partition serialize on
34
+ * that row: the second blocks until the first commits, then reads the
35
+ * advanced counter. This keeps the sequence dense (a Postgres `SEQUENCE`
36
+ * would leave gaps on rollback, which the pull-window arithmetic in §4.5
37
+ * does not tolerate). Cross-partition pushes never contend.
38
+ */
39
+ import type { ScopeMap } from '@syncular/core';
2
40
  import { type PgExecutor } from './pg-executor.js';
3
41
  import type { CompiledSchema, CompiledTable } from './schema.js';
4
42
  import type { AuthoritativeQueryRequest, AuthoritativeQueryResult, CheckpointDeclaration, ClientCursorInfo, ClientRecord, CommitMetadata, CommitMetadataQuery, CommitWindowQuery, IndexRowScanQuery, PartitionRegistryEntry, PrunedReactionCounts, ReactionClaimQuery, ReactionFailureUpdate, ReactionListQuery, ReactionPruneQuery, RowScanQuery, ScopeActivityQuery, ScopeCommitActivity, ServerStorage, ServeGate, StorageTransaction, StoredCheckpoint, StoredCommit, StoredPushResult, StoredReaction, StoredRow } from './storage.js';
@@ -77,6 +115,10 @@ export declare class PostgresServerStorage implements ServerStorage {
77
115
  getReaction(partition: string, idempotencyKey: string): Promise<StoredReaction | undefined>;
78
116
  listReactions(partition: string, query: ReactionListQuery): Promise<StoredReaction[]>;
79
117
  pruneReactions(partition: string, query: ReactionPruneQuery): Promise<PrunedReactionCounts>;
118
+ latestScopedChangeSeq(partition: string, query: {
119
+ readonly table: string;
120
+ readonly scopeFilter: ScopeMap;
121
+ }): Promise<number>;
80
122
  readCommitWindow(partition: string, query: CommitWindowQuery): Promise<StoredCommit[]>;
81
123
  scanRows(partition: string, query: RowScanQuery): Promise<StoredRow[]>;
82
124
  scanRowsByIndex(partition: string, query: IndexRowScanQuery): Promise<StoredRow[]>;
@@ -5,7 +5,7 @@ import { serveNotReadyError } from './readiness.js';
5
5
  import { bindAuthoritativePartition, postgresPlaceholders, prepareAuthoritativeQuery, } from './authoritative-query.js';
6
6
  import { syncError } from './errors.js';
7
7
  import { asBytes, asNumber, } from './pg-executor.js';
8
- import { assertPhysicalColumns, assertStoredLayouts, COMMIT_WINDOW_BATCH_SQL, deleteRowSql, dropTableDdl, indexRowPageStatement, layoutsOf, migratePayload, parseLayouts, retiredTableNames, rewritePlan, rewriteRowSql, rewriteValues, SCHEMA_META_DDL_POSTGRES, scanRowBatchSql, scanRowPageSql, schemaDdl, selectRowScopesSql, selectRowSql, selectRowsForRewriteSql, upsertSql, upsertValues, } from './relational-rows.js';
8
+ import { assertPhysicalColumns, assertStoredLayouts, COMMIT_WINDOW_BATCH_SQL, LATEST_SCOPED_CHANGE_BATCH_SQL, deleteRowSql, dropTableDdl, indexRowPageStatement, layoutsOf, migratePayload, parseLayouts, retiredTableNames, rewritePlan, rewriteRowSql, rewriteValues, SCHEMA_META_DDL_POSTGRES, scanRowBatchSql, scanRowPageSql, schemaDdl, selectRowScopesSql, selectRowSql, prefetchRowsSql, selectRowsForRewriteSql, upsertSql, upsertValues, } from './relational-rows.js';
9
9
  import { matchesEffective } from './scopes.js';
10
10
  import { isPostgresConstraintError, StorageConstraintError, } from './storage-errors.js';
11
11
  import { assertScopeIndexedScan, resolveIndexRowScan } from './storage-query.js';
@@ -403,12 +403,28 @@ async function scanRowsByIndexOn(q, compiled, partition, query) {
403
403
  return rows.map(toStoredRow);
404
404
  }
405
405
  async function writeRowOn(q, compiled, partition, row) {
406
- await q.query(upsertSql(compiled, 'postgres'), upsertValues(compiled, partition, row, 'postgres'));
407
- await q.query('DELETE FROM sync_row_scopes WHERE partition=$1 AND tbl=$2 AND row_id=$3', [partition, compiled.name, row.rowId]);
408
- for (const [variable, value] of Object.entries(row.scopes)) {
409
- await q.query(`INSERT INTO sync_row_scopes(partition, tbl, var, value, row_id)
410
- VALUES ($1,$2,$3,$4,$5) ON CONFLICT DO NOTHING`, [partition, compiled.name, variable, value, row.rowId]);
411
- }
406
+ // One statement per row write: the upsert, and the scope index synced to
407
+ // the row's scope map (entries outside it deleted, missing ones inserted).
408
+ // No index entry is touched twice, so the writable CTEs cannot conflict.
409
+ // The scopes bind as text before the JSONB cast (see `appendCommit`).
410
+ const params = upsertValues(compiled, partition, row, 'postgres');
411
+ const n = params.length;
412
+ await q.query(`WITH upserted AS (${upsertSql(compiled, 'postgres')}),
413
+ scope AS (SELECT key, value FROM jsonb_each_text($${n + 1}::text::jsonb)),
414
+ removed AS (
415
+ DELETE FROM sync_row_scopes
416
+ WHERE partition=$${n + 2} AND tbl=$${n + 3} AND row_id=$${n + 4}
417
+ AND (var, value) NOT IN (SELECT key, value FROM scope)
418
+ )
419
+ INSERT INTO sync_row_scopes(partition, tbl, var, value, row_id)
420
+ SELECT $${n + 2}, $${n + 3}, key, value, $${n + 4} FROM scope
421
+ ON CONFLICT DO NOTHING`, [
422
+ ...params,
423
+ JSON.stringify(row.scopes),
424
+ partition,
425
+ compiled.name,
426
+ row.rowId,
427
+ ]);
412
428
  }
413
429
  async function getPushResultOn(q, partition, clientId, clientCommitId) {
414
430
  const { rows } = await q.query('SELECT result FROM sync_push_results WHERE partition=$1 AND client_id=$2 AND client_commit_id=$3', [partition, clientId, clientCommitId]);
@@ -442,6 +458,13 @@ class PostgresTransaction {
442
458
  #resolveTable;
443
459
  #open = true;
444
460
  #pushApplySavepoint = false;
461
+ /**
462
+ * `prefetchRows` results keyed `table\u0000rowId`; `null` records a row
463
+ * (or tombstone) read as absent. A write in this transaction drops the
464
+ * key, so the next read goes to the database again.
465
+ */
466
+ #prefetchedRows = new Map();
467
+ #prefetchedTombstones = new Map();
445
468
  /** Resolves/rejects the `transaction(fn)` wrapper (see `begin`). */
446
469
  #resolve;
447
470
  #reject;
@@ -473,16 +496,44 @@ class PostgresTransaction {
473
496
  }
474
497
  getRow(table, rowId) {
475
498
  this.#assertOpen();
499
+ const prefetched = this.#prefetchedRows.get(`${table}\u0000${rowId}`);
500
+ if (prefetched !== undefined) {
501
+ return Promise.resolve(prefetched ?? undefined);
502
+ }
476
503
  return getRowOn(this.#client, this.#resolveTable(table), this.#partition, rowId);
477
504
  }
505
+ async prefetchRows(table, rowIds) {
506
+ this.#assertOpen();
507
+ const ids = [...new Set(rowIds)].filter((rowId) => !this.#prefetchedRows.has(`${table}\u0000${rowId}`));
508
+ if (ids.length === 0)
509
+ return;
510
+ const { rows } = await this.#client.query(prefetchRowsSql(this.#resolveTable(table)), [
511
+ this.#partition,
512
+ JSON.stringify(ids),
513
+ table,
514
+ ]);
515
+ for (const record of rows) {
516
+ const key = `${table}\u0000${record.lookup_id}`;
517
+ this.#prefetchedRows.set(key, record.row_id === null
518
+ ? null
519
+ : toStoredRow({ ...record, row_id: record.row_id }));
520
+ this.#prefetchedTombstones.set(key, record.tombstone_seq === null || record.tombstone_seq === undefined
521
+ ? null
522
+ : asNumber(record.tombstone_seq));
523
+ }
524
+ }
478
525
  async getTombstoneSeq(table, rowId) {
479
526
  this.#assertOpen();
527
+ const prefetched = this.#prefetchedTombstones.get(`${table}\u0000${rowId}`);
528
+ if (prefetched !== undefined)
529
+ return prefetched ?? undefined;
480
530
  const { rows } = await this.#client.query('SELECT commit_seq FROM sync_tombstones WHERE partition=$1 AND tbl=$2 AND row_id=$3', [this.#partition, table, rowId]);
481
531
  const seq = rows[0]?.commit_seq;
482
532
  return seq === null || seq === undefined ? undefined : asNumber(seq);
483
533
  }
484
534
  async clearTombstone(table, rowId) {
485
535
  this.#assertOpen();
536
+ this.#prefetchedTombstones.delete(`${table}\u0000${rowId}`);
486
537
  await this.#client.query('DELETE FROM sync_tombstones WHERE partition=$1 AND tbl=$2 AND row_id=$3', [this.#partition, table, rowId]);
487
538
  }
488
539
  getPushResult(clientId, clientCommitId) {
@@ -576,6 +627,7 @@ class PostgresTransaction {
576
627
  }
577
628
  async upsertRow(table, row, context) {
578
629
  this.#assertOpen();
630
+ this.#prefetchedRows.delete(`${table}\u0000${row.rowId}`);
579
631
  try {
580
632
  await writeRowOn(this.#client, this.#resolveTable(table), this.#partition, row);
581
633
  }
@@ -588,10 +640,14 @@ class PostgresTransaction {
588
640
  }
589
641
  async deleteRow(table, rowId) {
590
642
  this.#assertOpen();
591
- await this.#client.query(deleteRowSql(this.#resolveTable(table), 'postgres'), [this.#partition, rowId]);
592
- await this.#client.query('DELETE FROM sync_row_scopes WHERE partition=$1 AND tbl=$2 AND row_id=$3', [this.#partition, table, rowId]);
593
- // §5.9.4: a deleted row references no blobs.
594
- await this.#client.query('DELETE FROM sync_blob_refs WHERE partition=$1 AND tbl=$2 AND row_id=$3', [this.#partition, table, rowId]);
643
+ this.#prefetchedRows.delete(`${table}\u0000${rowId}`);
644
+ // One statement: the row, its scope-index entries, and (§5.9.4: a
645
+ // deleted row references no blobs) its blob references.
646
+ await this.#client.query(`WITH deleted AS (${deleteRowSql(this.#resolveTable(table), 'postgres')}),
647
+ unscoped AS (
648
+ DELETE FROM sync_row_scopes WHERE partition=$1 AND tbl=$3 AND row_id=$2
649
+ )
650
+ DELETE FROM sync_blob_refs WHERE partition=$1 AND tbl=$3 AND row_id=$2`, [this.#partition, rowId, table]);
595
651
  }
596
652
  async setBlobRefs(table, rowId, blobIds) {
597
653
  this.#assertOpen();
@@ -604,6 +660,8 @@ class PostgresTransaction {
604
660
  }
605
661
  async appendCommit(commit) {
606
662
  this.#assertOpen();
663
+ // The commit writes its delete tombstones below.
664
+ this.#prefetchedTombstones.clear();
607
665
  const q = this.#client;
608
666
  const p = this.#partition;
609
667
  // Allocate the next dense commitSeq under a per-partition row lock: the
@@ -626,40 +684,36 @@ class PostgresTransaction {
626
684
  this.#writerVersion,
627
685
  ]);
628
686
  const commitSeq = asNumber(rows[0]?.commit_seq);
629
- for (let idx = 0; idx < commit.changes.length; idx++) {
630
- const change = commit.changes[idx];
631
- if (change === undefined)
632
- continue;
633
- // Bind serialized scopes as text before parsing JSONB. Drivers that
634
- // encode JSONB parameters would otherwise store this string as a scalar.
635
- // One statement per change: scopes AND the §5 delete tombstone ride the
636
- // same writable-CTE chain.
637
- await q.query(`WITH inserted AS (
638
- INSERT INTO sync_changes(partition, commit_seq, idx, tbl, row_id, op, row_version, scopes, payload)
639
- VALUES ($1,$2,$3,$4,$5,$6,$7,$8::text::jsonb,$9)
640
- RETURNING partition, tbl, row_id, op, commit_seq, scopes
641
- ),
642
- scoped AS (
643
- INSERT INTO sync_change_scopes(partition, tbl, var, value, commit_seq)
644
- SELECT inserted.partition, inserted.tbl, scope.key, scope.value, inserted.commit_seq
645
- FROM inserted CROSS JOIN LATERAL jsonb_each_text(inserted.scopes) AS scope
646
- ON CONFLICT DO NOTHING
647
- )
648
- INSERT INTO sync_tombstones(partition, tbl, row_id, commit_seq)
649
- SELECT partition, tbl, row_id, commit_seq FROM inserted WHERE op = 2
650
- ON CONFLICT (partition, tbl, row_id)
651
- DO UPDATE SET commit_seq = excluded.commit_seq`, [
652
- p,
653
- commitSeq,
654
- idx,
655
- change.table,
656
- change.rowId,
657
- change.op === 'upsert' ? 1 : 2,
658
- change.rowVersion ?? null,
659
- JSON.stringify(change.scopes),
660
- change.payload ?? null,
661
- ]);
662
- }
687
+ if (commit.changes.length === 0)
688
+ return commitSeq;
689
+ // One statement for the whole commit: its changes, their inverted scope
690
+ // entries, and the §5 delete tombstones ride one writable-CTE chain.
691
+ // Serialized scopes bind as text before parsing JSONB; drivers that
692
+ // encode JSONB parameters would otherwise store the string as a scalar.
693
+ // A commit can delete one row twice, so the tombstone insert keeps one
694
+ // row per key (every candidate carries the same commit_seq).
695
+ const params = [p, commitSeq];
696
+ const values = commit.changes.map((change, idx) => {
697
+ const base = params.length;
698
+ params.push(idx, change.table, change.rowId, change.op === 'upsert' ? 1 : 2, change.rowVersion ?? null, JSON.stringify(change.scopes), change.payload ?? null);
699
+ return `($1,$2::bigint,$${base + 1}::int,$${base + 2},$${base + 3},$${base + 4}::smallint,$${base + 5}::bigint,$${base + 6}::text::jsonb,$${base + 7}::bytea)`;
700
+ });
701
+ await q.query(`WITH inserted AS (
702
+ INSERT INTO sync_changes(partition, commit_seq, idx, tbl, row_id, op, row_version, scopes, payload)
703
+ VALUES ${values.join(',')}
704
+ RETURNING partition, tbl, row_id, op, commit_seq, scopes
705
+ ),
706
+ scoped AS (
707
+ INSERT INTO sync_change_scopes(partition, tbl, var, value, commit_seq)
708
+ SELECT inserted.partition, inserted.tbl, scope.key, scope.value, inserted.commit_seq
709
+ FROM inserted CROSS JOIN LATERAL jsonb_each_text(inserted.scopes) AS scope
710
+ ON CONFLICT DO NOTHING
711
+ )
712
+ INSERT INTO sync_tombstones(partition, tbl, row_id, commit_seq)
713
+ SELECT DISTINCT ON (tbl, row_id) partition, tbl, row_id, commit_seq
714
+ FROM inserted WHERE op = 2
715
+ ON CONFLICT (partition, tbl, row_id)
716
+ DO UPDATE SET commit_seq = excluded.commit_seq`, params);
663
717
  return commitSeq;
664
718
  }
665
719
  async putPushResult(clientId, clientCommitId, result) {
@@ -1348,6 +1402,17 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`);
1348
1402
  deadLetter: rows.filter((row) => row.status === 'dead-letter').length,
1349
1403
  };
1350
1404
  }
1405
+ async latestScopedChangeSeq(partition, query) {
1406
+ const firstVariable = Object.keys(query.scopeFilter).sort()[0];
1407
+ const values = firstVariable === undefined
1408
+ ? []
1409
+ : (query.scopeFilter[firstVariable] ?? []);
1410
+ if (firstVariable === undefined || values.length === 0)
1411
+ return 0;
1412
+ // Concurrent reads of one table in a pull share one statement.
1413
+ const [row] = await this.#batchedPage(LATEST_SCOPED_CHANGE_BATCH_SQL, partition, query.table, { var: firstVariable, vals: values });
1414
+ return row === undefined ? 0 : asNumber(row.seq);
1415
+ }
1351
1416
  async readCommitWindow(partition, query) {
1352
1417
  const variables = Object.keys(query.scopeFilter).sort();
1353
1418
  const firstVariable = variables[0];