@syncular/server 0.15.44 → 0.15.46

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 (93) hide show
  1. package/README.md +135 -5
  2. package/dist/admin.d.ts +12 -6
  3. package/dist/admin.js +11 -1
  4. package/dist/authoritative-query.d.ts +20 -0
  5. package/dist/authoritative-query.js +184 -0
  6. package/dist/blob-store.d.ts +2 -2
  7. package/dist/context.d.ts +11 -2
  8. package/dist/context.js +2 -0
  9. package/dist/d1-storage.d.ts +10 -1
  10. package/dist/d1-storage.js +219 -3
  11. package/dist/errors.d.ts +1 -1
  12. package/dist/errors.js +43 -1
  13. package/dist/events-ring.d.ts +1 -1
  14. package/dist/events-ring.js +1 -1
  15. package/dist/events.d.ts +52 -3
  16. package/dist/handler.js +6 -3
  17. package/dist/index.d.ts +5 -1
  18. package/dist/index.js +5 -1
  19. package/dist/operations-realtime.d.ts +16 -0
  20. package/dist/operations-realtime.js +196 -0
  21. package/dist/operations.d.ts +97 -0
  22. package/dist/operations.js +392 -0
  23. package/dist/pg-executor.d.ts +1 -1
  24. package/dist/pg-executor.js +1 -1
  25. package/dist/postgres-fanout.d.ts +1 -1
  26. package/dist/postgres-storage.d.ts +11 -2
  27. package/dist/postgres-storage.js +223 -3
  28. package/dist/pull.js +1 -1
  29. package/dist/push.d.ts +8 -2
  30. package/dist/push.js +75 -21
  31. package/dist/reactions.d.ts +167 -0
  32. package/dist/reactions.js +442 -0
  33. package/dist/realtime.js +4 -1
  34. package/dist/relational-rows.d.ts +9 -9
  35. package/dist/relational-rows.js +9 -9
  36. package/dist/s3-blob-store.js +1 -1
  37. package/dist/s3-segment-store.js +1 -1
  38. package/dist/schema.d.ts +4 -5
  39. package/dist/schema.js +3 -3
  40. package/dist/seed.js +1 -1
  41. package/dist/segment-store.d.ts +3 -3
  42. package/dist/signed-url.js +2 -2
  43. package/dist/sigv4.d.ts +2 -4
  44. package/dist/sigv4.js +3 -3
  45. package/dist/sqlite-blob-store.d.ts +1 -1
  46. package/dist/sqlite-blob-store.js +1 -1
  47. package/dist/sqlite-dialect.d.ts +3 -4
  48. package/dist/sqlite-dialect.js +21 -1
  49. package/dist/sqlite-image.d.ts +1 -1
  50. package/dist/sqlite-lease-store.d.ts +1 -1
  51. package/dist/sqlite-lease-store.js +1 -1
  52. package/dist/sqlite-segment-store.d.ts +2 -2
  53. package/dist/sqlite-segment-store.js +2 -2
  54. package/dist/sqlite-storage.d.ts +11 -2
  55. package/dist/sqlite-storage.js +219 -4
  56. package/dist/storage.d.ts +112 -3
  57. package/dist/validate.js +1 -0
  58. package/package.json +2 -2
  59. package/src/admin.ts +30 -6
  60. package/src/authoritative-query.ts +218 -0
  61. package/src/blob-store.ts +0 -0
  62. package/src/context.ts +12 -2
  63. package/src/d1-storage.ts +355 -3
  64. package/src/errors.ts +43 -1
  65. package/src/events-ring.ts +1 -1
  66. package/src/events.ts +64 -2
  67. package/src/handler.ts +15 -3
  68. package/src/index.ts +33 -1
  69. package/src/operations-realtime.ts +272 -0
  70. package/src/operations.ts +720 -0
  71. package/src/pg-executor.ts +1 -1
  72. package/src/postgres-fanout.ts +1 -1
  73. package/src/postgres-storage.ts +355 -4
  74. package/src/pull.ts +1 -1
  75. package/src/push.ts +97 -29
  76. package/src/reactions.ts +741 -0
  77. package/src/realtime.ts +7 -1
  78. package/src/relational-rows.ts +9 -9
  79. package/src/s3-blob-store.ts +1 -1
  80. package/src/s3-segment-store.ts +1 -1
  81. package/src/schema.ts +6 -7
  82. package/src/seed.ts +1 -1
  83. package/src/segment-store.ts +3 -3
  84. package/src/signed-url.ts +2 -2
  85. package/src/sigv4.ts +3 -5
  86. package/src/sqlite-blob-store.ts +1 -1
  87. package/src/sqlite-dialect.ts +22 -3
  88. package/src/sqlite-image.ts +1 -1
  89. package/src/sqlite-lease-store.ts +1 -1
  90. package/src/sqlite-segment-store.ts +2 -2
  91. package/src/sqlite-storage.ts +369 -4
  92. package/src/storage.ts +168 -3
  93. package/src/validate.ts +1 -0
@@ -1,5 +1,5 @@
1
1
  /**
2
- * The minimal Postgres executor seam (REVISE B2: zero runtime deps).
2
+ * The minimal Postgres executor seam has zero runtime dependencies.
3
3
  *
4
4
  * `PostgresServerStorage` is written entirely against this interface so the
5
5
  * server library never imports a specific driver. Production wires one of:
@@ -1,5 +1,5 @@
1
1
  /**
2
- * The minimal Postgres executor seam (REVISE B2: zero runtime deps).
2
+ * The minimal Postgres executor seam has zero runtime dependencies.
3
3
  *
4
4
  * `PostgresServerStorage` is written entirely against this interface so the
5
5
  * server library never imports a specific driver. Production wires one of:
@@ -1,5 +1,5 @@
1
1
  /**
2
- * LISTEN/NOTIFY multi-instance fanout (TODO §4.1) — the primitive that lets
2
+ * LISTEN/NOTIFY multi-instance fanout lets
3
3
  * N server instances behind a load balancer wake each other's realtime
4
4
  * sessions when a commit lands on any one of them.
5
5
  *
@@ -1,6 +1,6 @@
1
1
  import { type PgExecutor } from './pg-executor.js';
2
2
  import type { CompiledSchema, CompiledTable } from './schema.js';
3
- import type { ClientCursorInfo, ClientRecord, CommitMetadata, CommitMetadataQuery, CommitWindowQuery, IndexRowScanQuery, RowScanQuery, ScopeActivityQuery, ScopeCommitActivity, ServerStorage, StorageTransaction, StoredCommit, StoredPushResult, StoredRow } from './storage.js';
3
+ 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';
4
4
  /**
5
5
  * Schema DDL. Applied by `PostgresServerStorage.migrate()` (idempotent).
6
6
  *
@@ -23,7 +23,7 @@ import type { ClientCursorInfo, ClientRecord, CommitMetadata, CommitMetadataQuer
23
23
  * set, §5.9.5) as an index range, never a scan. `postgres-explain
24
24
  * .test.ts` asserts an `Index` node here too.
25
25
  */
26
- export declare const POSTGRES_DDL = "\nCREATE TABLE IF NOT EXISTS sync_partitions(\n partition TEXT PRIMARY KEY,\n max_commit_seq BIGINT NOT NULL DEFAULT 0,\n horizon_seq BIGINT NOT NULL DEFAULT 0\n);\nCREATE TABLE IF NOT EXISTS sync_row_scopes(\n partition TEXT NOT NULL, tbl TEXT NOT NULL,\n var TEXT NOT NULL, value TEXT NOT NULL, row_id TEXT NOT NULL,\n PRIMARY KEY(partition, tbl, var, value, row_id)\n);\nCREATE TABLE IF NOT EXISTS sync_commits(\n partition TEXT NOT NULL, commit_seq BIGINT NOT NULL,\n client_id TEXT NOT NULL, client_commit_id TEXT NOT NULL,\n actor_id TEXT NOT NULL, created_at_ms BIGINT NOT NULL,\n PRIMARY KEY(partition, commit_seq)\n);\nCREATE INDEX IF NOT EXISTS sync_commits_by_time\n ON sync_commits(partition, created_at_ms);\nCREATE TABLE IF NOT EXISTS sync_changes(\n partition TEXT NOT NULL, commit_seq BIGINT NOT NULL, idx INTEGER NOT NULL,\n tbl TEXT NOT NULL, row_id TEXT NOT NULL, op SMALLINT NOT NULL,\n row_version BIGINT, scopes JSONB NOT NULL, payload BYTEA,\n PRIMARY KEY(partition, commit_seq, idx)\n);\nCREATE INDEX IF NOT EXISTS sync_changes_by_table\n ON sync_changes(partition, commit_seq, tbl, idx);\nCREATE TABLE IF NOT EXISTS sync_change_scopes(\n partition TEXT NOT NULL, tbl TEXT NOT NULL,\n var TEXT NOT NULL, value TEXT NOT NULL, commit_seq BIGINT NOT NULL,\n PRIMARY KEY(partition, tbl, var, value, commit_seq)\n);\nCREATE TABLE IF NOT EXISTS sync_push_results(\n partition TEXT NOT NULL, client_id TEXT NOT NULL,\n client_commit_id TEXT NOT NULL, result JSONB NOT NULL,\n PRIMARY KEY(partition, client_id, client_commit_id)\n);\nCREATE TABLE IF NOT EXISTS sync_clients(\n partition TEXT NOT NULL, client_id TEXT NOT NULL, actor_id TEXT NOT NULL,\n cursor BIGINT NOT NULL, subscriptions JSONB NOT NULL,\n updated_at_ms BIGINT NOT NULL,\n PRIMARY KEY(partition, client_id)\n);\nCREATE TABLE IF NOT EXISTS sync_blob_refs(\n partition TEXT NOT NULL, tbl TEXT NOT NULL, row_id TEXT NOT NULL,\n blob_id TEXT NOT NULL,\n PRIMARY KEY(partition, tbl, row_id, blob_id)\n);\nCREATE INDEX IF NOT EXISTS sync_blob_refs_by_blob\n ON sync_blob_refs(partition, blob_id);\n";
26
+ export declare const POSTGRES_DDL = "\nCREATE TABLE IF NOT EXISTS sync_partitions(\n partition TEXT PRIMARY KEY,\n max_commit_seq BIGINT NOT NULL DEFAULT 0,\n horizon_seq BIGINT NOT NULL DEFAULT 0\n);\nCREATE TABLE IF NOT EXISTS sync_row_scopes(\n partition TEXT NOT NULL, tbl TEXT NOT NULL,\n var TEXT NOT NULL, value TEXT NOT NULL, row_id TEXT NOT NULL,\n PRIMARY KEY(partition, tbl, var, value, row_id)\n);\nCREATE TABLE IF NOT EXISTS sync_commits(\n partition TEXT NOT NULL, commit_seq BIGINT NOT NULL,\n client_id TEXT NOT NULL, client_commit_id TEXT NOT NULL,\n actor_id TEXT NOT NULL, created_at_ms BIGINT NOT NULL,\n PRIMARY KEY(partition, commit_seq)\n);\nCREATE INDEX IF NOT EXISTS sync_commits_by_time\n ON sync_commits(partition, created_at_ms);\nCREATE TABLE IF NOT EXISTS sync_changes(\n partition TEXT NOT NULL, commit_seq BIGINT NOT NULL, idx INTEGER NOT NULL,\n tbl TEXT NOT NULL, row_id TEXT NOT NULL, op SMALLINT NOT NULL,\n row_version BIGINT, scopes JSONB NOT NULL, payload BYTEA,\n PRIMARY KEY(partition, commit_seq, idx)\n);\nCREATE INDEX IF NOT EXISTS sync_changes_by_table\n ON sync_changes(partition, commit_seq, tbl, idx);\nCREATE TABLE IF NOT EXISTS sync_change_scopes(\n partition TEXT NOT NULL, tbl TEXT NOT NULL,\n var TEXT NOT NULL, value TEXT NOT NULL, commit_seq BIGINT NOT NULL,\n PRIMARY KEY(partition, tbl, var, value, commit_seq)\n);\nCREATE TABLE IF NOT EXISTS sync_push_results(\n partition TEXT NOT NULL, client_id TEXT NOT NULL,\n client_commit_id TEXT NOT NULL, result JSONB NOT NULL,\n PRIMARY KEY(partition, client_id, client_commit_id)\n);\nCREATE TABLE IF NOT EXISTS sync_reactions(\n partition TEXT NOT NULL, idempotency_key TEXT NOT NULL,\n type TEXT NOT NULL, version INTEGER NOT NULL, payload JSONB NOT NULL,\n source_client_id TEXT NOT NULL, source_client_commit_id TEXT NOT NULL,\n source_commit_seq BIGINT NOT NULL, created_at_ms BIGINT NOT NULL,\n available_at_ms BIGINT NOT NULL, status TEXT NOT NULL,\n attempts INTEGER NOT NULL, max_attempts INTEGER NOT NULL,\n lease_owner TEXT, lease_expires_at_ms BIGINT, completed_at_ms BIGINT,\n last_failure JSONB,\n PRIMARY KEY(partition, idempotency_key),\n CHECK(status IN ('pending', 'leased', 'completed', 'dead-letter'))\n);\nCREATE INDEX IF NOT EXISTS sync_reactions_due\n ON sync_reactions(partition, status, available_at_ms, created_at_ms, idempotency_key);\nCREATE INDEX IF NOT EXISTS sync_reactions_lease\n ON sync_reactions(partition, status, lease_expires_at_ms);\nCREATE INDEX IF NOT EXISTS sync_reactions_completed\n ON sync_reactions(partition, status, completed_at_ms, idempotency_key);\nCREATE INDEX IF NOT EXISTS sync_reactions_dead_letter\n ON sync_reactions(partition, status, available_at_ms, idempotency_key);\nCREATE TABLE IF NOT EXISTS sync_clients(\n partition TEXT NOT NULL, client_id TEXT NOT NULL, actor_id TEXT NOT NULL,\n cursor BIGINT NOT NULL, subscriptions JSONB NOT NULL,\n updated_at_ms BIGINT NOT NULL,\n PRIMARY KEY(partition, client_id)\n);\nCREATE TABLE IF NOT EXISTS sync_blob_refs(\n partition TEXT NOT NULL, tbl TEXT NOT NULL, row_id TEXT NOT NULL,\n blob_id TEXT NOT NULL,\n PRIMARY KEY(partition, tbl, row_id, blob_id)\n);\nCREATE INDEX IF NOT EXISTS sync_blob_refs_by_blob\n ON sync_blob_refs(partition, blob_id);\n";
27
27
  export declare class PostgresServerStorage implements ServerStorage {
28
28
  #private;
29
29
  constructor(exec: PgExecutor);
@@ -42,12 +42,21 @@ export declare class PostgresServerStorage implements ServerStorage {
42
42
  */
43
43
  begin(partition: string): Promise<StorageTransaction>;
44
44
  getMaxCommitSeq(partition: string): Promise<number>;
45
+ queryAuthoritative(partition: string, query: AuthoritativeQueryRequest): Promise<AuthoritativeQueryResult>;
45
46
  getHorizonSeq(partition: string): Promise<number>;
46
47
  setHorizonSeq(partition: string, seq: number): Promise<void>;
47
48
  pruneCommitsThrough(partition: string, seq: number): Promise<number>;
48
49
  getCommitSeqBefore(partition: string, createdBeforeMs: number): Promise<number>;
49
50
  getRow(partition: string, table: string, rowId: string): Promise<StoredRow | undefined>;
50
51
  getPushResult(partition: string, clientId: string, clientCommitId: string): Promise<StoredPushResult | undefined>;
52
+ claimReactions(partition: string, query: ReactionClaimQuery): Promise<StoredReaction[]>;
53
+ completeReaction(partition: string, idempotencyKey: string, leaseOwner: string, completedAtMs: number): Promise<boolean>;
54
+ extendReactionLease(partition: string, idempotencyKey: string, leaseOwner: string, leaseExpiresAtMs: number): Promise<boolean>;
55
+ failReaction(partition: string, idempotencyKey: string, update: ReactionFailureUpdate): Promise<boolean>;
56
+ retryReaction(partition: string, idempotencyKey: string, nowMs: number): Promise<boolean>;
57
+ getReaction(partition: string, idempotencyKey: string): Promise<StoredReaction | undefined>;
58
+ listReactions(partition: string, query: ReactionListQuery): Promise<StoredReaction[]>;
59
+ pruneReactions(partition: string, query: ReactionPruneQuery): Promise<PrunedReactionCounts>;
51
60
  readCommitWindow(partition: string, query: CommitWindowQuery): Promise<StoredCommit[]>;
52
61
  scanRows(partition: string, query: RowScanQuery): Promise<StoredRow[]>;
53
62
  scanRowsByIndex(partition: string, query: IndexRowScanQuery): Promise<StoredRow[]>;
@@ -1,3 +1,4 @@
1
+ import { bindAuthoritativePartition, postgresPlaceholders, prepareAuthoritativeQuery, } from './authoritative-query.js';
1
2
  import { syncError } from './errors.js';
2
3
  import { asBytes, asNumber, } from './pg-executor.js';
3
4
  import { commitWindowPageSql, deleteRowSql, dropTableDdl, indexRowPageStatement, layoutsOf, migratePayload, parseLayouts, retiredTableNames, rewritePlan, rewriteRowSql, rewriteValues, SCHEMA_META_DDL_POSTGRES, scanRowPageSql, schemaDdl, selectRowScopesSql, selectRowSql, selectRowsForRewriteSql, upsertSql, upsertValues, } from './relational-rows.js';
@@ -63,6 +64,26 @@ CREATE TABLE IF NOT EXISTS sync_push_results(
63
64
  client_commit_id TEXT NOT NULL, result JSONB NOT NULL,
64
65
  PRIMARY KEY(partition, client_id, client_commit_id)
65
66
  );
67
+ CREATE TABLE IF NOT EXISTS sync_reactions(
68
+ partition TEXT NOT NULL, idempotency_key TEXT NOT NULL,
69
+ type TEXT NOT NULL, version INTEGER NOT NULL, payload JSONB NOT NULL,
70
+ source_client_id TEXT NOT NULL, source_client_commit_id TEXT NOT NULL,
71
+ source_commit_seq BIGINT NOT NULL, created_at_ms BIGINT NOT NULL,
72
+ available_at_ms BIGINT NOT NULL, status TEXT NOT NULL,
73
+ attempts INTEGER NOT NULL, max_attempts INTEGER NOT NULL,
74
+ lease_owner TEXT, lease_expires_at_ms BIGINT, completed_at_ms BIGINT,
75
+ last_failure JSONB,
76
+ PRIMARY KEY(partition, idempotency_key),
77
+ CHECK(status IN ('pending', 'leased', 'completed', 'dead-letter'))
78
+ );
79
+ CREATE INDEX IF NOT EXISTS sync_reactions_due
80
+ ON sync_reactions(partition, status, available_at_ms, created_at_ms, idempotency_key);
81
+ CREATE INDEX IF NOT EXISTS sync_reactions_lease
82
+ ON sync_reactions(partition, status, lease_expires_at_ms);
83
+ CREATE INDEX IF NOT EXISTS sync_reactions_completed
84
+ ON sync_reactions(partition, status, completed_at_ms, idempotency_key);
85
+ CREATE INDEX IF NOT EXISTS sync_reactions_dead_letter
86
+ ON sync_reactions(partition, status, available_at_ms, idempotency_key);
66
87
  CREATE TABLE IF NOT EXISTS sync_clients(
67
88
  partition TEXT NOT NULL, client_id TEXT NOT NULL, actor_id TEXT NOT NULL,
68
89
  cursor BIGINT NOT NULL, subscriptions JSONB NOT NULL,
@@ -83,6 +104,32 @@ function toBase64(bytes) {
83
104
  function fromBase64(text) {
84
105
  return new Uint8Array(Buffer.from(text, 'base64'));
85
106
  }
107
+ function toStoredReaction(record) {
108
+ return {
109
+ idempotencyKey: record.idempotency_key,
110
+ type: record.type,
111
+ version: asNumber(record.version),
112
+ payload: asJson(record.payload),
113
+ sourceClientId: record.source_client_id,
114
+ sourceClientCommitId: record.source_client_commit_id,
115
+ sourceCommitSeq: asNumber(record.source_commit_seq),
116
+ createdAtMs: asNumber(record.created_at_ms),
117
+ maxAttempts: asNumber(record.max_attempts),
118
+ status: record.status,
119
+ attempts: asNumber(record.attempts),
120
+ availableAtMs: asNumber(record.available_at_ms),
121
+ ...(record.lease_owner !== null ? { leaseOwner: record.lease_owner } : {}),
122
+ ...(record.lease_expires_at_ms !== null
123
+ ? { leaseExpiresAtMs: asNumber(record.lease_expires_at_ms) }
124
+ : {}),
125
+ ...(record.completed_at_ms !== null
126
+ ? { completedAtMs: asNumber(record.completed_at_ms) }
127
+ : {}),
128
+ ...(record.last_failure !== null
129
+ ? { lastFailure: asJson(record.last_failure) }
130
+ : {}),
131
+ };
132
+ }
86
133
  /**
87
134
  * Serialize a push result to a JSON-able object (stored in a JSONB column).
88
135
  * `serverRow` bytes are base64-encoded — JSONB cannot hold raw bytes.
@@ -191,8 +238,8 @@ function toStoredChange(record) {
191
238
  };
192
239
  }
193
240
  /**
194
- * Migration rewrite (DESIGN "optional materialization"): keyset-paged walk
195
- * of a row table inside the migration transaction; when `oldLayout` is
241
+ * Migration rewrite: keyset-paged walk of a row table inside the migration
242
+ * transaction. When `oldLayout` is
196
243
  * given every payload re-encodes under the current columns, and the
197
244
  * projection (when materialized) refreshes from the payload either way.
198
245
  */
@@ -425,6 +472,27 @@ class PostgresTransaction {
425
472
  JSON.stringify(serializePushResult(result)),
426
473
  ]);
427
474
  }
475
+ async enqueueReactions(reactions) {
476
+ this.#assertOpen();
477
+ for (const reaction of reactions) {
478
+ await this.#client.query(`INSERT INTO sync_reactions(
479
+ partition, idempotency_key, type, version, payload,
480
+ source_client_id, source_client_commit_id, source_commit_seq,
481
+ created_at_ms, available_at_ms, status, attempts, max_attempts
482
+ ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$9,'pending',0,$10)`, [
483
+ this.#partition,
484
+ reaction.idempotencyKey,
485
+ reaction.type,
486
+ reaction.version,
487
+ JSON.stringify(reaction.payload),
488
+ reaction.sourceClientId,
489
+ reaction.sourceClientCommitId,
490
+ reaction.sourceCommitSeq,
491
+ reaction.createdAtMs,
492
+ reaction.maxAttempts,
493
+ ]);
494
+ }
495
+ }
428
496
  async commit() {
429
497
  this.#assertOpen();
430
498
  this.#open = false;
@@ -599,6 +667,23 @@ export class PostgresServerStorage {
599
667
  const { rows } = await this.#exec.query('SELECT max_commit_seq FROM sync_partitions WHERE partition=$1', [partition]);
600
668
  return rows[0] === undefined ? 0 : asNumber(rows[0].max_commit_seq);
601
669
  }
670
+ async queryAuthoritative(partition, query) {
671
+ if (this.#tables === undefined) {
672
+ throw new Error('ensureSchema(schema) must run before registered queries');
673
+ }
674
+ const prepared = bindAuthoritativePartition(prepareAuthoritativeQuery(query.sql, query.params, query.tables, this.#tables), partition);
675
+ return this.#exec.transaction(async (client) => {
676
+ await client.query('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');
677
+ const result = await client.query(postgresPlaceholders(prepared.sql), prepared.params);
678
+ const cursor = await client.query('SELECT max_commit_seq FROM sync_partitions WHERE partition=$1', [partition]);
679
+ return {
680
+ rows: result.rows,
681
+ maxCommitSeq: cursor.rows[0] === undefined
682
+ ? 0
683
+ : asNumber(cursor.rows[0].max_commit_seq),
684
+ };
685
+ });
686
+ }
602
687
  async getHorizonSeq(partition) {
603
688
  const { rows } = await this.#exec.query('SELECT horizon_seq FROM sync_partitions WHERE partition=$1', [partition]);
604
689
  return rows[0] === undefined ? 0 : asNumber(rows[0].horizon_seq);
@@ -624,6 +709,141 @@ export class PostgresServerStorage {
624
709
  getPushResult(partition, clientId, clientCommitId) {
625
710
  return getPushResultOn(this.#exec, partition, clientId, clientCommitId);
626
711
  }
712
+ async claimReactions(partition, query) {
713
+ if (query.types.length === 0 || query.limit <= 0)
714
+ return [];
715
+ const typeParams = query.types.map((_, index) => `$${index + 2}`).join(',');
716
+ const nowParam = query.types.length + 2;
717
+ const limitParam = nowParam + 1;
718
+ const workerParam = limitParam + 1;
719
+ const expiresParam = workerParam + 1;
720
+ const { rows } = await this.#exec.query(`WITH due AS (
721
+ SELECT partition, idempotency_key
722
+ FROM sync_reactions
723
+ WHERE partition=$1 AND type IN (${typeParams})
724
+ AND ((status='pending' AND available_at_ms<=$${nowParam})
725
+ OR (status='leased' AND lease_expires_at_ms<=$${nowParam}))
726
+ ORDER BY CASE WHEN status='leased' THEN lease_expires_at_ms
727
+ ELSE available_at_ms END,
728
+ created_at_ms, idempotency_key
729
+ FOR UPDATE SKIP LOCKED
730
+ LIMIT $${limitParam}
731
+ )
732
+ UPDATE sync_reactions AS reaction
733
+ SET status='leased', attempts=reaction.attempts+1,
734
+ lease_owner=$${workerParam}, lease_expires_at_ms=$${expiresParam},
735
+ completed_at_ms=NULL
736
+ FROM due
737
+ WHERE reaction.partition=due.partition
738
+ AND reaction.idempotency_key=due.idempotency_key
739
+ RETURNING reaction.*`, [
740
+ partition,
741
+ ...query.types,
742
+ query.nowMs,
743
+ query.limit,
744
+ query.leaseOwner,
745
+ Math.min(Number.MAX_SAFE_INTEGER, query.nowMs + query.leaseDurationMs),
746
+ ]);
747
+ return rows
748
+ .map(toStoredReaction)
749
+ .sort((a, b) => a.createdAtMs - b.createdAtMs ||
750
+ a.idempotencyKey.localeCompare(b.idempotencyKey));
751
+ }
752
+ async completeReaction(partition, idempotencyKey, leaseOwner, completedAtMs) {
753
+ const result = await this.#exec.query(`UPDATE sync_reactions
754
+ SET status='completed', completed_at_ms=$4,
755
+ lease_owner=NULL, lease_expires_at_ms=NULL
756
+ WHERE partition=$1 AND idempotency_key=$2
757
+ AND status='leased' AND lease_owner=$3`, [partition, idempotencyKey, leaseOwner, completedAtMs]);
758
+ return result.rowCount === 1;
759
+ }
760
+ async extendReactionLease(partition, idempotencyKey, leaseOwner, leaseExpiresAtMs) {
761
+ const result = await this.#exec.query(`UPDATE sync_reactions SET lease_expires_at_ms=$4
762
+ WHERE partition=$1 AND idempotency_key=$2
763
+ AND status='leased' AND lease_owner=$3`, [partition, idempotencyKey, leaseOwner, leaseExpiresAtMs]);
764
+ return result.rowCount === 1;
765
+ }
766
+ async failReaction(partition, idempotencyKey, update) {
767
+ const result = await this.#exec.query(`UPDATE sync_reactions
768
+ SET status=$4, available_at_ms=$5, last_failure=$6,
769
+ lease_owner=NULL, lease_expires_at_ms=NULL
770
+ WHERE partition=$1 AND idempotency_key=$2
771
+ AND status='leased' AND lease_owner=$3`, [
772
+ partition,
773
+ idempotencyKey,
774
+ update.leaseOwner,
775
+ update.retryAtMs === undefined ? 'dead-letter' : 'pending',
776
+ update.retryAtMs ?? update.failure.atMs,
777
+ JSON.stringify(update.failure),
778
+ ]);
779
+ return result.rowCount === 1;
780
+ }
781
+ async retryReaction(partition, idempotencyKey, nowMs) {
782
+ const result = await this.#exec.query(`UPDATE sync_reactions
783
+ SET status='pending', attempts=0, available_at_ms=$3,
784
+ last_failure=NULL, lease_owner=NULL, lease_expires_at_ms=NULL,
785
+ completed_at_ms=NULL
786
+ WHERE partition=$1 AND idempotency_key=$2 AND status='dead-letter'`, [partition, idempotencyKey, nowMs]);
787
+ return result.rowCount === 1;
788
+ }
789
+ async getReaction(partition, idempotencyKey) {
790
+ const { rows } = await this.#exec.query('SELECT * FROM sync_reactions WHERE partition=$1 AND idempotency_key=$2', [partition, idempotencyKey]);
791
+ return rows[0] === undefined ? undefined : toStoredReaction(rows[0]);
792
+ }
793
+ async listReactions(partition, query) {
794
+ const where = ['partition=$1'];
795
+ const params = [partition];
796
+ if (query.statuses !== undefined && query.statuses.length > 0) {
797
+ const placeholders = query.statuses.map((_, index) => `$${params.length + index + 1}`);
798
+ where.push(`status IN (${placeholders.join(',')})`);
799
+ params.push(...query.statuses);
800
+ }
801
+ if (query.types !== undefined && query.types.length > 0) {
802
+ const placeholders = query.types.map((_, index) => `$${params.length + index + 1}`);
803
+ where.push(`type IN (${placeholders.join(',')})`);
804
+ params.push(...query.types);
805
+ }
806
+ params.push(query.limit);
807
+ const { rows } = await this.#exec.query(`SELECT * FROM sync_reactions WHERE ${where.join(' AND ')}
808
+ ORDER BY created_at_ms DESC, idempotency_key DESC LIMIT $${params.length}`, params);
809
+ return rows.map(toStoredReaction);
810
+ }
811
+ async pruneReactions(partition, query) {
812
+ if (query.limit <= 0)
813
+ return { completed: 0, deadLetter: 0 };
814
+ const { rows } = await this.#exec.query(`WITH targets AS (
815
+ SELECT partition, idempotency_key
816
+ FROM sync_reactions
817
+ WHERE partition=$1
818
+ AND ((status='completed' AND completed_at_ms IS NOT NULL
819
+ AND completed_at_ms<$2)
820
+ OR (status='dead-letter' AND available_at_ms<$3))
821
+ ORDER BY CASE WHEN status='completed' THEN completed_at_ms
822
+ ELSE available_at_ms END,
823
+ idempotency_key
824
+ FOR UPDATE SKIP LOCKED
825
+ LIMIT $4
826
+ )
827
+ DELETE FROM sync_reactions AS reaction
828
+ USING targets
829
+ WHERE reaction.partition=targets.partition
830
+ AND reaction.idempotency_key=targets.idempotency_key
831
+ AND ((reaction.status='completed'
832
+ AND reaction.completed_at_ms IS NOT NULL
833
+ AND reaction.completed_at_ms<$2)
834
+ OR (reaction.status='dead-letter'
835
+ AND reaction.available_at_ms<$3))
836
+ RETURNING reaction.status`, [
837
+ partition,
838
+ query.completedBeforeMs,
839
+ query.deadLetterBeforeMs,
840
+ query.limit,
841
+ ]);
842
+ return {
843
+ completed: rows.filter((row) => row.status === 'completed').length,
844
+ deadLetter: rows.filter((row) => row.status === 'dead-letter').length,
845
+ };
846
+ }
627
847
  async readCommitWindow(partition, query) {
628
848
  const variables = Object.keys(query.scopeFilter).sort();
629
849
  const firstVariable = variables[0];
@@ -818,7 +1038,7 @@ export class PostgresServerStorage {
818
1038
  const { rows } = await this.#exec.query('SELECT DISTINCT blob_id FROM sync_blob_refs WHERE partition=$1', [partition]);
819
1039
  return rows.map((r) => r.blob_id);
820
1040
  }
821
- // -- admin/console read surface (TODO §2.5) --------------------------------
1041
+ // -- admin/console read surface --------------------------------------------
822
1042
  async listClientRecords(partition) {
823
1043
  const { rows } = await this.#exec.query('SELECT client_id, actor_id, cursor, subscriptions, updated_at_ms FROM sync_clients WHERE partition=$1 ORDER BY updated_at_ms DESC', [partition]);
824
1044
  return rows.map((r) => ({
package/dist/pull.js CHANGED
@@ -170,7 +170,7 @@ async function* sqliteImageSegment(ctx, schema, limits, plan, asOf, digest, trac
170
170
  // builder through `sqliteImageBuilder`; when omitted we default to the
171
171
  // in-tree `buildSqliteImage` ONLY on a Bun runtime, reached by a *dynamic*
172
172
  // import so `bun:sqlite` is never a static dependency of the pull path
173
- // (TODO §4.2 neutrality enforced by test/runtime-neutrality.test.ts). On
173
+ // (runtime neutrality is enforced by test/runtime-neutrality.test.ts). On
174
174
  // Workers/edge (no `Bun`) this yields the rows lane for a bit-2 client — a
175
175
  // support floor, not a fallback (§5.3: sqlite is an *accept*, not a demand).
176
176
  const buildImage = await resolveImageBuilder(ctx);
package/dist/push.d.ts CHANGED
@@ -17,11 +17,11 @@
17
17
  * merge); a validator throw rejects the whole commit atomically with a
18
18
  * host code.
19
19
  */
20
- import { type PushCommitFrame, type PushResultFrame } from '@syncular/core';
20
+ import { type PushCommitFrame, type PushOperation, type PushResultFrame } from '@syncular/core';
21
21
  import type { SyncRequestContext } from './context.js';
22
22
  import type { CompiledSchema } from './schema.js';
23
23
  import type { ResolvedScopes } from './scopes.js';
24
- import type { StoredCommit } from './storage.js';
24
+ import type { StorageTransaction, StoredCommit } from './storage.js';
25
25
  export interface ProcessedPushCommit {
26
26
  readonly frame: PushResultFrame;
27
27
  /** True when this request observed an already-recorded idempotency outcome. */
@@ -43,3 +43,9 @@ export declare function processPushCommit(ctx: SyncRequestContext, schema: Compi
43
43
  * cache provenance needed by structured events and server helpers.
44
44
  */
45
45
  export declare function processPushCommitWithTrace(ctx: SyncRequestContext, schema: CompiledSchema, resolved: ResolvedScopes, clientId: string, frame: PushCommitFrame): Promise<ProcessedPushCommit>;
46
+ /**
47
+ * Shared serialized apply path for SSP2 commits and authoritative commands.
48
+ * The builder runs after the partition lock and idempotency re-check, so its
49
+ * reads and the returned operations share the transaction that is committed.
50
+ */
51
+ export declare function processPushOperationsWithTrace(ctx: SyncRequestContext, schema: CompiledSchema, resolved: ResolvedScopes, clientId: string, clientCommitId: string, buildOperations: (tx: StorageTransaction) => Promise<readonly PushOperation[]>): Promise<ProcessedPushCommit>;
package/dist/push.js CHANGED
@@ -20,6 +20,8 @@
20
20
  import { decodeRow, encodeRow, parseBlobRef, } from '@syncular/core';
21
21
  import { clockOf } from './context.js';
22
22
  import { SyncError } from './errors.js';
23
+ import { emitEvent } from './events.js';
24
+ import { prepareReactions, toNewReactions, } from './reactions.js';
23
25
  import { authorizeWrite, renderScopeValue, storedScopesForRow } from './scopes.js';
24
26
  import { StorageConstraintError } from './storage-errors.js';
25
27
  import { CommitValidationRejection, toValidateRow, ValidationRejection, } from './validate.js';
@@ -514,10 +516,18 @@ export async function processPushCommit(ctx, schema, resolved, clientId, frame)
514
516
  * cache provenance needed by structured events and server helpers.
515
517
  */
516
518
  export async function processPushCommitWithTrace(ctx, schema, resolved, clientId, frame) {
519
+ return processPushOperationsWithTrace(ctx, schema, resolved, clientId, frame.clientCommitId, async () => frame.operations);
520
+ }
521
+ /**
522
+ * Shared serialized apply path for SSP2 commits and authoritative commands.
523
+ * The builder runs after the partition lock and idempotency re-check, so its
524
+ * reads and the returned operations share the transaction that is committed.
525
+ */
526
+ export async function processPushOperationsWithTrace(ctx, schema, resolved, clientId, clientCommitId, buildOperations) {
517
527
  const { storage, partition } = ctx;
518
528
  let persisted;
519
529
  try {
520
- persisted = await storage.getPushResult(partition, clientId, frame.clientCommitId);
530
+ persisted = await storage.getPushResult(partition, clientId, clientCommitId);
521
531
  }
522
532
  catch (error) {
523
533
  if (error instanceof SyncError &&
@@ -525,28 +535,30 @@ export async function processPushCommitWithTrace(ctx, schema, resolved, clientId
525
535
  // §6.3: answer the retryable cache-miss for this commit rather than
526
536
  // re-applying. Not persisted — a retry may find a readable record.
527
537
  return {
528
- frame: idempotencyCacheMissFrame(frame.clientCommitId, error),
538
+ frame: idempotencyCacheMissFrame(clientCommitId, error),
529
539
  replayed: false,
530
540
  };
531
541
  }
532
542
  throw error;
533
543
  }
534
544
  if (persisted !== undefined) {
535
- return processedPushCommit(frame.clientCommitId, persisted, true);
545
+ return processedPushCommit(clientCommitId, persisted, true);
536
546
  }
537
547
  const createdAtMs = clockOf(ctx)();
538
548
  const blobCtx = { store: ctx.blobs, partition };
539
549
  const crdtMergers = ctx.crdtMergers;
540
550
  const validators = ctx.validators;
541
551
  const commitValidator = ctx.commitValidator;
552
+ const reactionPlanner = ctx.reactionPlanner;
542
553
  const tx = await storage.begin(partition);
543
554
  const lockPartitionForPush = tx.lockPartitionForPush?.bind(tx) ??
544
555
  tx.lockPartitionForCommitValidation?.bind(tx);
545
556
  const commitRejectedPushResult = tx.commitRejectedPushResult?.bind(tx);
546
557
  try {
547
558
  if (lockPartitionForPush === undefined ||
548
- commitRejectedPushResult === undefined) {
549
- throw new Error('storage transaction does not support serialized push apply and atomic rejection finalization');
559
+ commitRejectedPushResult === undefined ||
560
+ (reactionPlanner !== undefined && tx.enqueueReactions === undefined)) {
561
+ throw new Error('storage transaction does not support serialized push apply, atomic rejection finalization, and configured durable reactions');
550
562
  }
551
563
  await lockPartitionForPush();
552
564
  // The optimistic lookup above may have raced another delivery. Re-check
@@ -559,11 +571,11 @@ export async function processPushCommitWithTrace(ctx, schema, resolved, clientId
559
571
  // transaction-scoped read observes it.
560
572
  try {
561
573
  const serializedPersisted = tx.getPushResult !== undefined
562
- ? await tx.getPushResult(clientId, frame.clientCommitId)
563
- : await storage.getPushResult(partition, clientId, frame.clientCommitId);
574
+ ? await tx.getPushResult(clientId, clientCommitId)
575
+ : await storage.getPushResult(partition, clientId, clientCommitId);
564
576
  if (serializedPersisted !== undefined) {
565
577
  await tx.rollback();
566
- return processedPushCommit(frame.clientCommitId, serializedPersisted, true);
578
+ return processedPushCommit(clientCommitId, serializedPersisted, true);
567
579
  }
568
580
  }
569
581
  catch (error) {
@@ -571,7 +583,7 @@ export async function processPushCommitWithTrace(ctx, schema, resolved, clientId
571
583
  error.code === 'sync.idempotency_cache_miss') {
572
584
  await tx.rollback();
573
585
  return {
574
- frame: idempotencyCacheMissFrame(frame.clientCommitId, error),
586
+ frame: idempotencyCacheMissFrame(clientCommitId, error),
575
587
  replayed: false,
576
588
  };
577
589
  }
@@ -581,8 +593,9 @@ export async function processPushCommitWithTrace(ctx, schema, resolved, clientId
581
593
  const changes = [];
582
594
  const validatedOperations = [];
583
595
  let terminated;
584
- for (let opIndex = 0; opIndex < frame.operations.length; opIndex++) {
585
- const op = frame.operations[opIndex];
596
+ const operations = await buildOperations(tx);
597
+ for (let opIndex = 0; opIndex < operations.length; opIndex++) {
598
+ const op = operations[opIndex];
586
599
  if (op === undefined)
587
600
  continue;
588
601
  const outcome = await applyOperation(tx, schema, resolved, op, opIndex, blobCtx, crdtMergers, validators, partition, ctx.actorId);
@@ -596,11 +609,22 @@ export async function processPushCommitWithTrace(ctx, schema, resolved, clientId
596
609
  changes.push(outcome.change);
597
610
  }
598
611
  if (terminated === undefined) {
599
- const commitReject = await runCommitValidator(commitValidator, tx, schema, clientId, frame.clientCommitId, ctx.actorId, partition, validatedOperations);
612
+ const commitReject = await runCommitValidator(commitValidator, tx, schema, clientId, clientCommitId, ctx.actorId, partition, validatedOperations);
600
613
  if (commitReject?.kind === 'terminate') {
601
614
  terminated = commitReject.record;
602
615
  }
603
616
  }
617
+ let preparedReactions = [];
618
+ if (terminated === undefined && reactionPlanner !== undefined) {
619
+ preparedReactions = await prepareReactions(reactionPlanner, {
620
+ clientId,
621
+ clientCommitId,
622
+ actorId: ctx.actorId,
623
+ partition,
624
+ operations: validatedOperations,
625
+ read: commitValidationReader(tx, schema),
626
+ });
627
+ }
604
628
  if (terminated !== undefined) {
605
629
  // §6.3 rejected: only the terminating operation's record; §6.4:
606
630
  // every write of the commit rolls back.
@@ -610,27 +634,57 @@ export async function processPushCommitWithTrace(ctx, schema, resolved, clientId
610
634
  });
611
635
  // Discard candidates and persist the rejection while retaining the same
612
636
  // partition lock. There is no unlock gap in which a duplicate can rerun.
613
- await commitRejectedPushResult(clientId, frame.clientCommitId, stored);
614
- const canonical = await storage.getPushResult(partition, clientId, frame.clientCommitId);
637
+ await commitRejectedPushResult(clientId, clientCommitId, stored);
638
+ const canonical = await storage.getPushResult(partition, clientId, clientCommitId);
615
639
  if (canonical === undefined) {
616
640
  throw new Error('push rejection finalization did not persist an outcome');
617
641
  }
618
- return processedPushCommit(frame.clientCommitId, canonical, canonical.cacheIdentity !== stored.cacheIdentity);
642
+ return processedPushCommit(clientCommitId, canonical, canonical.cacheIdentity !== stored.cacheIdentity);
619
643
  }
620
644
  const commitSeq = await tx.appendCommit({
621
645
  clientId,
622
- clientCommitId: frame.clientCommitId,
646
+ clientCommitId,
623
647
  actorId: ctx.actorId,
624
648
  createdAtMs,
625
649
  changes,
626
650
  });
651
+ const newReactions = toNewReactions(preparedReactions, {
652
+ clientId,
653
+ clientCommitId,
654
+ commitSeq,
655
+ createdAtMs,
656
+ });
657
+ if (newReactions.length > 0) {
658
+ const enqueueReactions = tx.enqueueReactions;
659
+ if (enqueueReactions === undefined) {
660
+ throw new Error('storage lost durable reaction enqueue support');
661
+ }
662
+ await enqueueReactions.call(tx, newReactions);
663
+ }
627
664
  const stored = newStoredPushResult(createdAtMs, {
628
665
  status: 'applied',
629
666
  commitSeq,
630
667
  results,
631
668
  });
632
- await tx.putPushResult(clientId, frame.clientCommitId, stored);
669
+ await tx.putPushResult(clientId, clientCommitId, stored);
633
670
  await tx.commit();
671
+ if (ctx.events !== undefined) {
672
+ const atMs = clockOf(ctx)();
673
+ for (const reaction of newReactions) {
674
+ emitEvent(ctx.events, {
675
+ type: 'reaction.queued',
676
+ atMs,
677
+ partition,
678
+ actorId: ctx.actorId,
679
+ clientId,
680
+ clientCommitId,
681
+ commitSeq,
682
+ idempotencyKey: reaction.idempotencyKey,
683
+ reactionType: reaction.type,
684
+ version: reaction.version,
685
+ });
686
+ }
687
+ }
634
688
  if (ctx.realtime !== undefined && changes.length > 0) {
635
689
  await ctx.realtime.notifyCommit(partition, {
636
690
  commitSeq,
@@ -639,7 +693,7 @@ export async function processPushCommitWithTrace(ctx, schema, resolved, clientId
639
693
  changes,
640
694
  });
641
695
  }
642
- return processedPushCommit(frame.clientCommitId, stored, false);
696
+ return processedPushCommit(clientCommitId, stored, false);
643
697
  }
644
698
  catch (error) {
645
699
  if (error instanceof StorageConstraintError) {
@@ -660,12 +714,12 @@ export async function processPushCommitWithTrace(ctx, schema, resolved, clientId
660
714
  throw new Error('storage transaction lost atomic push rejection finalization support');
661
715
  }
662
716
  try {
663
- await commitRejectedPushResult(clientId, frame.clientCommitId, stored);
664
- const canonical = await storage.getPushResult(partition, clientId, frame.clientCommitId);
717
+ await commitRejectedPushResult(clientId, clientCommitId, stored);
718
+ const canonical = await storage.getPushResult(partition, clientId, clientCommitId);
665
719
  if (canonical === undefined) {
666
720
  throw new Error('push rejection finalization did not persist an outcome');
667
721
  }
668
- return processedPushCommit(frame.clientCommitId, canonical, canonical.cacheIdentity !== stored.cacheIdentity);
722
+ return processedPushCommit(clientCommitId, canonical, canonical.cacheIdentity !== stored.cacheIdentity);
669
723
  }
670
724
  catch (finalizationError) {
671
725
  // A failed finalization must still release the transaction: on