@syncular/server 0.15.45 → 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 (51) hide show
  1. package/README.md +134 -4
  2. package/dist/admin.d.ts +10 -4
  3. package/dist/admin.js +10 -0
  4. package/dist/authoritative-query.d.ts +20 -0
  5. package/dist/authoritative-query.js +184 -0
  6. package/dist/context.d.ts +9 -0
  7. package/dist/context.js +2 -0
  8. package/dist/d1-storage.d.ts +10 -1
  9. package/dist/d1-storage.js +216 -0
  10. package/dist/errors.d.ts +1 -1
  11. package/dist/errors.js +43 -1
  12. package/dist/events.d.ts +52 -3
  13. package/dist/handler.js +4 -1
  14. package/dist/index.d.ts +4 -0
  15. package/dist/index.js +4 -0
  16. package/dist/operations-realtime.d.ts +16 -0
  17. package/dist/operations-realtime.js +196 -0
  18. package/dist/operations.d.ts +97 -0
  19. package/dist/operations.js +392 -0
  20. package/dist/postgres-storage.d.ts +11 -2
  21. package/dist/postgres-storage.js +220 -0
  22. package/dist/push.d.ts +8 -2
  23. package/dist/push.js +75 -21
  24. package/dist/reactions.d.ts +167 -0
  25. package/dist/reactions.js +442 -0
  26. package/dist/realtime.js +4 -1
  27. package/dist/sqlite-dialect.d.ts +1 -1
  28. package/dist/sqlite-dialect.js +20 -0
  29. package/dist/sqlite-storage.d.ts +10 -1
  30. package/dist/sqlite-storage.js +215 -0
  31. package/dist/storage.d.ts +109 -0
  32. package/dist/validate.js +1 -0
  33. package/package.json +2 -2
  34. package/src/admin.ts +27 -3
  35. package/src/authoritative-query.ts +218 -0
  36. package/src/context.ts +10 -0
  37. package/src/d1-storage.ts +352 -0
  38. package/src/errors.ts +43 -1
  39. package/src/events.ts +64 -2
  40. package/src/handler.ts +13 -1
  41. package/src/index.ts +32 -0
  42. package/src/operations-realtime.ts +272 -0
  43. package/src/operations.ts +720 -0
  44. package/src/postgres-storage.ts +351 -0
  45. package/src/push.ts +97 -29
  46. package/src/reactions.ts +741 -0
  47. package/src/realtime.ts +7 -1
  48. package/src/sqlite-dialect.ts +20 -0
  49. package/src/sqlite-storage.ts +365 -0
  50. package/src/storage.ts +165 -0
  51. package/src/validate.ts +1 -0
@@ -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.
@@ -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];
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
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Durable post-commit reactions. Planning runs inside the authoritative push
3
+ * transaction and may only produce bounded data. Delivery runs later under a
4
+ * lease and is at-least-once, so handlers receive the stable idempotency key.
5
+ */
6
+ import type { SyncularServerEvents } from './events.js';
7
+ import type { DurableJsonValue, NewReaction, ServerStorage } from './storage.js';
8
+ import type { CommitValidationReader, ValidateCommitOperation } from './validate.js';
9
+ export declare const MAX_REACTIONS_PER_COMMIT = 100;
10
+ export declare const MAX_REACTION_PAYLOAD_BYTES: number;
11
+ export declare const MAX_REACTION_FAILURE_DETAILS_BYTES: number;
12
+ export declare const DEFAULT_REACTION_MAX_ATTEMPTS = 10;
13
+ export declare const DEFAULT_REACTION_LEASE_MS = 30000;
14
+ export declare const DEFAULT_REACTION_INITIAL_BACKOFF_MS = 1000;
15
+ export declare const DEFAULT_REACTION_MAX_BACKOFF_MS: number;
16
+ export interface ReactionRetentionPolicy {
17
+ /** Keep completed rows for at least this duration (default 30 days). */
18
+ readonly completedRetentionMs: number;
19
+ /** Keep dead letters for at least this duration (default 90 days). */
20
+ readonly deadLetterRetentionMs: number;
21
+ /** Maximum terminal rows removed by one pass (default 1000). */
22
+ readonly batchSize: number;
23
+ }
24
+ export declare const DEFAULT_REACTION_RETENTION: ReactionRetentionPolicy;
25
+ export type ReactionTypeMap = Readonly<Record<string, DurableJsonValue>>;
26
+ export interface ReactionPlan {
27
+ /** Unique within the source client commit. */
28
+ readonly key: string;
29
+ readonly type: string;
30
+ readonly version: number;
31
+ readonly payload: DurableJsonValue;
32
+ readonly maxAttempts?: number;
33
+ }
34
+ export type PlannedReaction<Reactions extends ReactionTypeMap = ReactionTypeMap> = {
35
+ [Type in keyof Reactions & string]: {
36
+ /** Unique within the source client commit. */
37
+ readonly key: string;
38
+ readonly type: Type;
39
+ readonly version: number;
40
+ readonly payload: Reactions[Type];
41
+ readonly maxAttempts?: number;
42
+ };
43
+ }[keyof Reactions & string];
44
+ export interface ReactionPlannerInput {
45
+ readonly clientId: string;
46
+ readonly clientCommitId: string;
47
+ readonly actorId: string;
48
+ readonly partition: string;
49
+ readonly operations: readonly ValidateCommitOperation[];
50
+ /** Candidate-state reads from the still-open authoritative transaction. */
51
+ readonly read: CommitValidationReader;
52
+ }
53
+ /**
54
+ * A pure planner over an accepted candidate commit. It may read candidate
55
+ * state and return durable data. It must not execute user-visible side effects.
56
+ */
57
+ export type ReactionPlanner<Reactions extends ReactionTypeMap = ReactionTypeMap> = (input: ReactionPlannerInput) => readonly PlannedReaction<Reactions>[] | Promise<readonly PlannedReaction<Reactions>[]>;
58
+ /** Erased planner shape stored on non-generic server configuration. */
59
+ export type AnyReactionPlanner = (input: ReactionPlannerInput) => readonly ReactionPlan[] | Promise<readonly ReactionPlan[]>;
60
+ export interface ReactionHandlerInput<Payload extends DurableJsonValue> {
61
+ readonly partition: string;
62
+ readonly idempotencyKey: string;
63
+ readonly type: string;
64
+ readonly version: number;
65
+ readonly payload: Payload;
66
+ readonly attempt: number;
67
+ readonly maxAttempts: number;
68
+ readonly sourceClientId: string;
69
+ readonly sourceClientCommitId: string;
70
+ readonly sourceCommitSeq: number;
71
+ /** Extend a long-running handler's lease; throws after ownership is lost. */
72
+ readonly extendLease: () => Promise<void>;
73
+ }
74
+ export type ReactionHandler<Payload extends DurableJsonValue> = (input: ReactionHandlerInput<Payload>) => void | Promise<void>;
75
+ export type ReactionHandlers<Reactions extends ReactionTypeMap = ReactionTypeMap> = {
76
+ readonly [Type in keyof Reactions & string]: ReactionHandler<Reactions[Type]>;
77
+ };
78
+ /** Stable handler idempotency key for one planned item in a client commit. */
79
+ export declare function reactionIdempotencyKey(partition: string, clientId: string, clientCommitId: string, plannerKey: string): string;
80
+ export interface PreparedReaction {
81
+ readonly idempotencyKey: string;
82
+ readonly type: string;
83
+ readonly version: number;
84
+ readonly payload: DurableJsonValue;
85
+ readonly maxAttempts: number;
86
+ }
87
+ /** Internal push seam, exported for focused planner tests and custom hosts. */
88
+ export declare function prepareReactions(planner: AnyReactionPlanner, input: ReactionPlannerInput): Promise<PreparedReaction[]>;
89
+ declare class ReactionDeliveryError extends Error {
90
+ readonly code: string;
91
+ readonly details?: {
92
+ readonly [key: string]: DurableJsonValue;
93
+ };
94
+ constructor(name: string, code: string, details?: {
95
+ readonly [key: string]: DurableJsonValue;
96
+ });
97
+ }
98
+ /** A handler failure that should be retried until its attempt limit. */
99
+ export declare class RetryableReactionError extends ReactionDeliveryError {
100
+ constructor(code: string, details?: {
101
+ readonly [key: string]: DurableJsonValue;
102
+ });
103
+ }
104
+ /** A handler failure that should be dead-lettered immediately. */
105
+ export declare class PermanentReactionError extends ReactionDeliveryError {
106
+ constructor(code: string, details?: {
107
+ readonly [key: string]: DurableJsonValue;
108
+ });
109
+ }
110
+ export interface ReactionRunnerOptions<Reactions extends ReactionTypeMap = ReactionTypeMap> {
111
+ readonly storage: ServerStorage;
112
+ readonly partition: string;
113
+ readonly workerId: string;
114
+ readonly handlers: ReactionHandlers<Reactions>;
115
+ readonly events?: SyncularServerEvents;
116
+ readonly clock?: () => number;
117
+ readonly leaseDurationMs?: number;
118
+ readonly batchSize?: number;
119
+ readonly initialBackoffMs?: number;
120
+ readonly maxBackoffMs?: number;
121
+ }
122
+ export interface ReactionRunResult {
123
+ readonly claimed: number;
124
+ readonly completed: number;
125
+ readonly retried: number;
126
+ readonly deadLettered: number;
127
+ /** Lease ownership changed before this worker could persist its outcome. */
128
+ readonly lostLeases: number;
129
+ }
130
+ export interface PruneReactionsOptions {
131
+ readonly storage: ServerStorage;
132
+ readonly partition: string;
133
+ readonly nowMs: number;
134
+ readonly retention?: Partial<ReactionRetentionPolicy>;
135
+ readonly events?: SyncularServerEvents;
136
+ }
137
+ export interface ReactionPruneResult {
138
+ readonly completedBeforeMs: number;
139
+ readonly deadLetterBeforeMs: number;
140
+ readonly removedCompleted: number;
141
+ readonly removedDeadLetter: number;
142
+ /** True when the bounded pass filled its batch and another pass may help. */
143
+ readonly mayHaveMore: boolean;
144
+ }
145
+ /** Host-driven worker. Call `runOnce` from the host scheduler or queue wake. */
146
+ export declare class ReactionRunner<Reactions extends ReactionTypeMap = ReactionTypeMap> {
147
+ #private;
148
+ constructor(options: ReactionRunnerOptions<Reactions>);
149
+ runOnce(): Promise<ReactionRunResult>;
150
+ }
151
+ /** Explicit operator action for a dead-lettered reaction. */
152
+ export declare function retryDeadLetterReaction(options: {
153
+ readonly storage: ServerStorage;
154
+ readonly partition: string;
155
+ readonly idempotencyKey: string;
156
+ readonly nowMs?: number;
157
+ }): Promise<boolean>;
158
+ /** Delete one bounded batch of aged completed and dead-lettered rows. */
159
+ export declare function pruneReactions(options: PruneReactionsOptions): Promise<ReactionPruneResult>;
160
+ /** Helper used by the push path after commit sequence allocation. */
161
+ export declare function toNewReactions(prepared: readonly PreparedReaction[], source: {
162
+ readonly clientId: string;
163
+ readonly clientCommitId: string;
164
+ readonly commitSeq: number;
165
+ readonly createdAtMs: number;
166
+ }): NewReaction[];
167
+ export {};