@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,6 +1,6 @@
1
1
  var _a;
2
2
  /**
3
- * Cloudflare D1 server storage (TODO §4.2 — the Workers deployment rung).
3
+ * Cloudflare D1 server storage for Workers deployments.
4
4
  *
5
5
  * D1 *is* SQLite exposed over an async, statement-at-a-time API
6
6
  * (`prepare(sql).bind(...).all()` / `.first()` / `.run()`, plus `batch([…])`
@@ -41,12 +41,39 @@ var _a;
41
41
  * rather than a lock D1 does not expose.
42
42
  */
43
43
  import { decodeRow } from '@syncular/core';
44
+ import { bindAuthoritativePartition, prepareAuthoritativeQuery, } from './authoritative-query.js';
44
45
  import { syncError } from './errors.js';
45
46
  import { commitWindowPageSql, deleteRowSql, 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';
46
47
  import { matchesEffective } from './scopes.js';
47
48
  import { asUint8Array, collectCommitWindowPage, deserializePushResult, serializePushResult, sqliteDdlStatements, toStoredRow, } from './sqlite-dialect.js';
48
49
  import { isD1ConstraintError, StorageConstraintError } from './storage-errors.js';
49
50
  import { assertScopeIndexedScan, resolveIndexRowScan } from './storage-query.js';
51
+ function toStoredReaction(record) {
52
+ return {
53
+ idempotencyKey: record.idempotency_key,
54
+ type: record.type,
55
+ version: record.version,
56
+ payload: JSON.parse(record.payload),
57
+ sourceClientId: record.source_client_id,
58
+ sourceClientCommitId: record.source_client_commit_id,
59
+ sourceCommitSeq: record.source_commit_seq,
60
+ createdAtMs: record.created_at_ms,
61
+ maxAttempts: record.max_attempts,
62
+ status: record.status,
63
+ attempts: record.attempts,
64
+ availableAtMs: record.available_at_ms,
65
+ ...(record.lease_owner !== null ? { leaseOwner: record.lease_owner } : {}),
66
+ ...(record.lease_expires_at_ms !== null
67
+ ? { leaseExpiresAtMs: record.lease_expires_at_ms }
68
+ : {}),
69
+ ...(record.completed_at_ms !== null
70
+ ? { completedAtMs: record.completed_at_ms }
71
+ : {}),
72
+ ...(record.last_failure !== null
73
+ ? { lastFailure: JSON.parse(record.last_failure) }
74
+ : {}),
75
+ };
76
+ }
50
77
  function relationalValuesEqual(left, right) {
51
78
  if (left instanceof Uint8Array && right instanceof Uint8Array) {
52
79
  return (left.length === right.length &&
@@ -376,6 +403,28 @@ class D1Transaction {
376
403
  this.#assertOpen();
377
404
  this.#buffer_('INSERT OR IGNORE INTO sync_push_results(partition, client_id, client_commit_id, result) VALUES (?,?,?,?)', [this.#partition, clientId, clientCommitId, serializePushResult(result)]);
378
405
  }
406
+ async enqueueReactions(reactions) {
407
+ this.#assertOpen();
408
+ for (const reaction of reactions) {
409
+ this.#buffer_(`INSERT INTO sync_reactions(
410
+ partition, idempotency_key, type, version, payload,
411
+ source_client_id, source_client_commit_id, source_commit_seq,
412
+ created_at_ms, available_at_ms, status, attempts, max_attempts
413
+ ) VALUES (?,?,?,?,?,?,?,?,?,?,'pending',0,?)`, [
414
+ this.#partition,
415
+ reaction.idempotencyKey,
416
+ reaction.type,
417
+ reaction.version,
418
+ JSON.stringify(reaction.payload),
419
+ reaction.sourceClientId,
420
+ reaction.sourceClientCommitId,
421
+ reaction.sourceCommitSeq,
422
+ reaction.createdAtMs,
423
+ reaction.createdAtMs,
424
+ reaction.maxAttempts,
425
+ ]);
426
+ }
427
+ }
379
428
  async commit() {
380
429
  this.#assertOpen();
381
430
  if (this.#buffer.length === 0) {
@@ -448,7 +497,7 @@ export class D1ServerStorage {
448
497
  for (const table of schema.tables.values()) {
449
498
  const bindCount = tableColumnNames(table).length;
450
499
  if (bindCount > D1_MAX_BIND_PARAMS) {
451
- throw new Error(`table ${JSON.stringify(table.name)} needs ${bindCount} bound parameters per upsert D1 caps statements at ${D1_MAX_BIND_PARAMS} (DESIGN "D1 bind-parameter limit")`);
500
+ throw new Error(`table ${JSON.stringify(table.name)} needs ${bindCount} bound parameters per upsert; D1 caps statements at ${D1_MAX_BIND_PARAMS}`);
452
501
  }
453
502
  }
454
503
  await this.#db.exec(`${SCHEMA_META_DDL_SQLITE.replace(/\s+/g, ' ')};`);
@@ -555,6 +604,41 @@ export class D1ServerStorage {
555
604
  .first();
556
605
  return row?.max_commit_seq ?? 0;
557
606
  }
607
+ async queryAuthoritative(partition, query) {
608
+ if (this.#tables === undefined) {
609
+ throw new Error('ensureSchema(schema) must run before registered queries');
610
+ }
611
+ const prepared = bindAuthoritativePartition(prepareAuthoritativeQuery(query.sql, query.params, query.tables, this.#tables), partition);
612
+ const results = await this.#db.batch([
613
+ this.#db.prepare(prepared.sql).bind(...prepared.params),
614
+ this.#db
615
+ .prepare('SELECT max_commit_seq FROM sync_partitions WHERE partition=?')
616
+ .bind(partition),
617
+ ]);
618
+ const rowsResult = results[0];
619
+ const cursorResult = results[1];
620
+ if (typeof rowsResult !== 'object' ||
621
+ rowsResult === null ||
622
+ !('results' in rowsResult) ||
623
+ !Array.isArray(rowsResult.results) ||
624
+ typeof cursorResult !== 'object' ||
625
+ cursorResult === null ||
626
+ !('results' in cursorResult) ||
627
+ !Array.isArray(cursorResult.results)) {
628
+ throw new Error('D1 registered query returned an invalid batch result');
629
+ }
630
+ const cursor = cursorResult.results[0];
631
+ const maxCommitSeq = typeof cursor === 'object' &&
632
+ cursor !== null &&
633
+ 'max_commit_seq' in cursor &&
634
+ typeof cursor.max_commit_seq === 'number'
635
+ ? cursor.max_commit_seq
636
+ : 0;
637
+ return {
638
+ rows: rowsResult.results.filter((row) => typeof row === 'object' && row !== null),
639
+ maxCommitSeq,
640
+ };
641
+ }
558
642
  async getHorizonSeq(partition) {
559
643
  const row = await this.#db
560
644
  .prepare('SELECT horizon_seq FROM sync_partitions WHERE partition=?')
@@ -614,6 +698,138 @@ export class D1ServerStorage {
614
698
  throw syncError('sync.idempotency_cache_miss', 'persisted push result unreadable (§6.3)');
615
699
  }
616
700
  }
701
+ async claimReactions(partition, query) {
702
+ if (query.types.length === 0 || query.limit <= 0)
703
+ return [];
704
+ if (query.types.length + 7 > D1_MAX_BIND_PARAMS) {
705
+ throw new Error('D1 reaction claim exceeds the bound-parameter limit');
706
+ }
707
+ const typeParams = query.types.map(() => '?').join(',');
708
+ const { results } = await this.#db
709
+ .prepare(`UPDATE sync_reactions
710
+ SET status='leased', attempts=attempts+1,
711
+ lease_owner=?, lease_expires_at_ms=?, completed_at_ms=NULL
712
+ WHERE (partition, idempotency_key) IN (
713
+ SELECT partition, idempotency_key
714
+ FROM sync_reactions
715
+ WHERE partition=? AND type IN (${typeParams})
716
+ AND ((status='pending' AND available_at_ms<=?)
717
+ OR (status='leased' AND lease_expires_at_ms<=?))
718
+ ORDER BY CASE WHEN status='leased' THEN lease_expires_at_ms
719
+ ELSE available_at_ms END,
720
+ created_at_ms, idempotency_key
721
+ LIMIT ?
722
+ )
723
+ RETURNING *`)
724
+ .bind(query.leaseOwner, Math.min(Number.MAX_SAFE_INTEGER, query.nowMs + query.leaseDurationMs), partition, ...query.types, query.nowMs, query.nowMs, query.limit)
725
+ .all();
726
+ return results
727
+ .map(toStoredReaction)
728
+ .sort((a, b) => a.createdAtMs - b.createdAtMs ||
729
+ a.idempotencyKey.localeCompare(b.idempotencyKey));
730
+ }
731
+ async completeReaction(partition, idempotencyKey, leaseOwner, completedAtMs) {
732
+ const record = await this.#db
733
+ .prepare(`UPDATE sync_reactions
734
+ SET status='completed', completed_at_ms=?,
735
+ lease_owner=NULL, lease_expires_at_ms=NULL
736
+ WHERE partition=? AND idempotency_key=?
737
+ AND status='leased' AND lease_owner=?
738
+ RETURNING idempotency_key`)
739
+ .bind(completedAtMs, partition, idempotencyKey, leaseOwner)
740
+ .first();
741
+ return record !== null;
742
+ }
743
+ async extendReactionLease(partition, idempotencyKey, leaseOwner, leaseExpiresAtMs) {
744
+ const record = await this.#db
745
+ .prepare(`UPDATE sync_reactions SET lease_expires_at_ms=?
746
+ WHERE partition=? AND idempotency_key=?
747
+ AND status='leased' AND lease_owner=?
748
+ RETURNING idempotency_key`)
749
+ .bind(leaseExpiresAtMs, partition, idempotencyKey, leaseOwner)
750
+ .first();
751
+ return record !== null;
752
+ }
753
+ async failReaction(partition, idempotencyKey, update) {
754
+ const record = await this.#db
755
+ .prepare(`UPDATE sync_reactions
756
+ SET status=?, available_at_ms=?, last_failure=?,
757
+ lease_owner=NULL, lease_expires_at_ms=NULL
758
+ WHERE partition=? AND idempotency_key=?
759
+ AND status='leased' AND lease_owner=?
760
+ RETURNING idempotency_key`)
761
+ .bind(update.retryAtMs === undefined ? 'dead-letter' : 'pending', update.retryAtMs ?? update.failure.atMs, JSON.stringify(update.failure), partition, idempotencyKey, update.leaseOwner)
762
+ .first();
763
+ return record !== null;
764
+ }
765
+ async retryReaction(partition, idempotencyKey, nowMs) {
766
+ const record = await this.#db
767
+ .prepare(`UPDATE sync_reactions
768
+ SET status='pending', attempts=0, available_at_ms=?,
769
+ last_failure=NULL, lease_owner=NULL, lease_expires_at_ms=NULL,
770
+ completed_at_ms=NULL
771
+ WHERE partition=? AND idempotency_key=? AND status='dead-letter'
772
+ RETURNING idempotency_key`)
773
+ .bind(nowMs, partition, idempotencyKey)
774
+ .first();
775
+ return record !== null;
776
+ }
777
+ async getReaction(partition, idempotencyKey) {
778
+ const record = await this.#db
779
+ .prepare('SELECT * FROM sync_reactions WHERE partition=? AND idempotency_key=?')
780
+ .bind(partition, idempotencyKey)
781
+ .first();
782
+ return record === null ? undefined : toStoredReaction(record);
783
+ }
784
+ async listReactions(partition, query) {
785
+ const where = ['partition=?'];
786
+ const params = [partition];
787
+ if (query.statuses !== undefined && query.statuses.length > 0) {
788
+ where.push(`status IN (${query.statuses.map(() => '?').join(',')})`);
789
+ params.push(...query.statuses);
790
+ }
791
+ if (query.types !== undefined && query.types.length > 0) {
792
+ where.push(`type IN (${query.types.map(() => '?').join(',')})`);
793
+ params.push(...query.types);
794
+ }
795
+ if (params.length + 1 > D1_MAX_BIND_PARAMS) {
796
+ throw new Error('D1 reaction list exceeds the bound-parameter limit');
797
+ }
798
+ params.push(query.limit);
799
+ const { results } = await this.#db
800
+ .prepare(`SELECT * FROM sync_reactions WHERE ${where.join(' AND ')}
801
+ ORDER BY created_at_ms DESC, idempotency_key DESC LIMIT ?`)
802
+ .bind(...params)
803
+ .all();
804
+ return results.map(toStoredReaction);
805
+ }
806
+ async pruneReactions(partition, query) {
807
+ if (query.limit <= 0)
808
+ return { completed: 0, deadLetter: 0 };
809
+ const { results } = await this.#db
810
+ .prepare(`DELETE FROM sync_reactions
811
+ WHERE partition=? AND idempotency_key IN (
812
+ SELECT idempotency_key FROM sync_reactions
813
+ WHERE partition=?
814
+ AND ((status='completed' AND completed_at_ms IS NOT NULL
815
+ AND completed_at_ms<?)
816
+ OR (status='dead-letter' AND available_at_ms<?))
817
+ ORDER BY CASE WHEN status='completed' THEN completed_at_ms
818
+ ELSE available_at_ms END,
819
+ idempotency_key
820
+ LIMIT ?
821
+ )
822
+ AND ((status='completed' AND completed_at_ms IS NOT NULL
823
+ AND completed_at_ms<?)
824
+ OR (status='dead-letter' AND available_at_ms<?))
825
+ RETURNING status`)
826
+ .bind(partition, partition, query.completedBeforeMs, query.deadLetterBeforeMs, query.limit, query.completedBeforeMs, query.deadLetterBeforeMs)
827
+ .all();
828
+ return {
829
+ completed: results.filter((row) => row.status === 'completed').length,
830
+ deadLetter: results.filter((row) => row.status === 'dead-letter').length,
831
+ };
832
+ }
617
833
  async readCommitWindow(partition, query) {
618
834
  const variables = Object.keys(query.scopeFilter).sort();
619
835
  const firstVariable = variables[0];
@@ -757,7 +973,7 @@ export class D1ServerStorage {
757
973
  .all();
758
974
  return results.map((r) => r.blob_id);
759
975
  }
760
- // -- admin/console read surface (TODO §2.5) --------------------------------
976
+ // -- admin/console read surface --------------------------------------------
761
977
  async listClientRecords(partition) {
762
978
  const { results } = await this.#db
763
979
  .prepare('SELECT client_id, actor_id, cursor, subscriptions, updated_at_ms FROM sync_clients WHERE partition=? ORDER BY updated_at_ms DESC')
package/dist/errors.d.ts CHANGED
@@ -12,7 +12,7 @@ export interface ErrorCatalogEntry {
12
12
  readonly recommendedAction: string;
13
13
  readonly httpStatus: number;
14
14
  }
15
- /** The §10.2 wire catalog (21 sync.* + 4 blob.* codes), keyed by stable code. */
15
+ /** The §10.2 wire catalog, keyed by stable code. */
16
16
  export declare const ERROR_CATALOG: Readonly<Record<string, ErrorCatalogEntry>>;
17
17
  export declare class SyncError extends Error {
18
18
  readonly name = "SyncError";
package/dist/errors.js CHANGED
@@ -6,8 +6,50 @@
6
6
  * `recommendedAction`) plus an HTTP status for transport adapters. The
7
7
  * catalog is closed: creating a `SyncError` with an unknown code throws.
8
8
  */
9
- /** The §10.2 wire catalog (21 sync.* + 4 blob.* codes), keyed by stable code. */
9
+ /** The §10.2 wire catalog, keyed by stable code. */
10
10
  export const ERROR_CATALOG = {
11
+ 'operation.unknown': {
12
+ category: 'not-found',
13
+ retryable: false,
14
+ recommendedAction: 'regenerateClient',
15
+ httpStatus: 404,
16
+ },
17
+ 'operation.forbidden': {
18
+ category: 'forbidden',
19
+ retryable: false,
20
+ recommendedAction: 'checkPermissions',
21
+ httpStatus: 403,
22
+ },
23
+ 'operation.invalid_request': {
24
+ category: 'invalid-request',
25
+ retryable: false,
26
+ recommendedAction: 'fixRequest',
27
+ httpStatus: 400,
28
+ },
29
+ 'operation.result_too_large': {
30
+ category: 'invalid-request',
31
+ retryable: false,
32
+ recommendedAction: 'fixRequest',
33
+ httpStatus: 400,
34
+ },
35
+ 'operation.storage_unsupported': {
36
+ category: 'internal',
37
+ retryable: false,
38
+ recommendedAction: 'inspectServer',
39
+ httpStatus: 500,
40
+ },
41
+ 'operation.query_failed': {
42
+ category: 'internal',
43
+ retryable: false,
44
+ recommendedAction: 'inspectServer',
45
+ httpStatus: 500,
46
+ },
47
+ 'operation.execution_failed': {
48
+ category: 'internal',
49
+ retryable: false,
50
+ recommendedAction: 'inspectServer',
51
+ httpStatus: 500,
52
+ },
11
53
  'sync.auth_required': {
12
54
  category: 'auth-required',
13
55
  retryable: true,
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Event ring buffer + sink composition (TODO §2.5) — the "event stream"
2
+ * Event ring buffer + sink composition: the "event stream"
3
3
  * without any infrastructure dependency.
4
4
  *
5
5
  * `RingBufferEvents` is a `SyncularServerEvents` sink that retains the last
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Event ring buffer + sink composition (TODO §2.5) — the "event stream"
2
+ * Event ring buffer + sink composition: the "event stream"
3
3
  * without any infrastructure dependency.
4
4
  *
5
5
  * `RingBufferEvents` is a `SyncularServerEvents` sink that retains the last
package/dist/events.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  /**
2
- * Structured server events — the one operational seam (host surface, not
3
- * wire protocol; SPEC.md is untouched by this module).
2
+ * Structured server events — the one operational seam (host surface, outside
3
+ * the wire protocol).
4
4
  *
5
5
  * Design rules:
6
6
  * - Every event is a flat, JSON-able object with a stable `type` string:
@@ -75,6 +75,55 @@ export interface PushConflictedEvent extends PushEventBase {
75
75
  readonly recordedAtMs?: number;
76
76
  readonly cacheIdentity?: string;
77
77
  }
78
+ /** A planned reaction committed atomically with its accepted source commit. */
79
+ export interface ReactionQueuedEvent {
80
+ readonly type: 'reaction.queued';
81
+ readonly atMs: number;
82
+ readonly partition: string;
83
+ readonly actorId: string;
84
+ readonly clientId: string;
85
+ readonly clientCommitId: string;
86
+ readonly commitSeq: number;
87
+ readonly idempotencyKey: string;
88
+ readonly reactionType: string;
89
+ readonly version: number;
90
+ }
91
+ interface ReactionDeliveryEventBase {
92
+ readonly atMs: number;
93
+ readonly partition: string;
94
+ readonly workerId: string;
95
+ readonly idempotencyKey: string;
96
+ readonly reactionType: string;
97
+ readonly version: number;
98
+ readonly attempt: number;
99
+ }
100
+ export interface ReactionStartedEvent extends ReactionDeliveryEventBase {
101
+ readonly type: 'reaction.started';
102
+ }
103
+ export interface ReactionRetriedEvent extends ReactionDeliveryEventBase {
104
+ readonly type: 'reaction.retried';
105
+ readonly nextAttemptAtMs: number;
106
+ readonly errorCode: string;
107
+ }
108
+ export interface ReactionCompletedEvent extends ReactionDeliveryEventBase {
109
+ readonly type: 'reaction.completed';
110
+ }
111
+ export interface ReactionDeadLetteredEvent extends ReactionDeliveryEventBase {
112
+ readonly type: 'reaction.dead_lettered';
113
+ readonly errorCode: string;
114
+ }
115
+ /** One bounded terminal-reaction retention pass. */
116
+ export interface ReactionPruneCompletedEvent {
117
+ readonly type: 'reaction.prune_completed';
118
+ readonly atMs: number;
119
+ readonly partition: string;
120
+ readonly completedBeforeMs: number;
121
+ readonly deadLetterBeforeMs: number;
122
+ readonly limit: number;
123
+ readonly removedCompleted: number;
124
+ readonly removedDeadLetter: number;
125
+ readonly mayHaveMore: boolean;
126
+ }
78
127
  /** One emitted segment within a pull subscription section. */
79
128
  export interface PullSegmentSummary {
80
129
  readonly mediaType: 'rows' | 'sqlite';
@@ -221,7 +270,7 @@ export interface LeaseRevokedEvent {
221
270
  readonly partition: string;
222
271
  readonly leaseId: string;
223
272
  }
224
- export type SyncularServerEvent = RequestHandledEvent | PushAppliedEvent | PushRejectedEvent | PushConflictedEvent | PullServedEvent | SegmentDownloadedEvent | BlobUploadedEvent | BlobDownloadedEvent | BlobSweptEvent | RealtimeOpenedEvent | RealtimeClosedEvent | RealtimeDeltaEvent | RealtimeWakeEvent | PruneCompletedEvent | ScopesResolveFailedEvent | LeaseIssuedEvent | LeaseRevokedEvent;
273
+ export type SyncularServerEvent = RequestHandledEvent | PushAppliedEvent | PushRejectedEvent | PushConflictedEvent | ReactionQueuedEvent | ReactionStartedEvent | ReactionRetriedEvent | ReactionCompletedEvent | ReactionDeadLetteredEvent | ReactionPruneCompletedEvent | PullServedEvent | SegmentDownloadedEvent | BlobUploadedEvent | BlobDownloadedEvent | BlobSweptEvent | RealtimeOpenedEvent | RealtimeClosedEvent | RealtimeDeltaEvent | RealtimeWakeEvent | PruneCompletedEvent | ScopesResolveFailedEvent | LeaseIssuedEvent | LeaseRevokedEvent;
225
274
  /**
226
275
  * The seam. Optional on the server config — when absent, no event object
227
276
  * is ever built. Implementations receive every event synchronously and
package/dist/handler.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * `handleSyncRequest(bytes, ctx) → bytes` (SPEC.md §1, REVISE B2).
2
+ * `handleSyncRequest(bytes, ctx) → bytes` (SPEC.md §1).
3
3
  *
4
4
  * Internally streaming-friendly (§1.4): `createSyncResponseStream` returns
5
5
  * an async iterable of encoded chunks — one per frame — after performing
@@ -10,7 +10,7 @@
10
10
  * frame (§1.6).
11
11
  */
12
12
  import { DecodeError, decodeMessage, } from '@syncular/core';
13
- import { clockOf, limitsOf, RESOLVER_OUTAGE } from './context.js';
13
+ import { clockOf, limitsOf, REMOTE_COMMAND_CLIENT_ID_PREFIX, RESOLVER_OUTAGE, } from './context.js';
14
14
  import { SyncError, syncError } from './errors.js';
15
15
  import { emitEvent, } from './events.js';
16
16
  import { END_FRAME_BYTES, encodeResponseFrame, RESPONSE_ENVELOPE_HEADER, } from './frame-bytes.js';
@@ -125,6 +125,9 @@ async function planRequest(request, ctx, schema) {
125
125
  leaseToEmit: undefined,
126
126
  };
127
127
  }
128
+ if (header.clientId.startsWith(REMOTE_COMMAND_CLIENT_ID_PREFIX)) {
129
+ throw syncError('sync.invalid_client_id', 'clientId uses a reserved server-command namespace (§1.5)');
130
+ }
128
131
  // §1.5: a clientId already bound to a different actor is rejected.
129
132
  const record = await ctx.storage.getClientRecord(ctx.partition, header.clientId);
130
133
  if (record !== undefined && record.actorId !== ctx.actorId) {
@@ -455,7 +458,7 @@ async function createStreamCore(bytes, ctx, events, startedAtMs = 0) {
455
458
  throw error;
456
459
  }
457
460
  const schema = compileSchema(ctx.schema);
458
- // Relational row tables (DESIGN-relational-server-storage.md): create/
461
+ // Relational row tables: create/
459
462
  // migrate on first contact; memoized per storage instance thereafter.
460
463
  await ctx.storage.ensureSchema(schema);
461
464
  const plan = await planRequest(request, ctx, schema);
package/dist/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * @syncular/server — framework-free embeddable SSP2 protocol library
3
- * (SPEC.md is normative; REVISE.md B2 is the architectural mandate).
3
+ * (SPEC.md is normative).
4
4
  *
5
5
  * Core surface: `handleSyncRequest(bytes, ctx) → bytes` over host-provided
6
6
  * storage / scope-resolution / segment-store interfaces, plus a
@@ -8,6 +8,7 @@
8
8
  * handler (§5.5), and signed-URL token issuance/verification (§5.4).
9
9
  */
10
10
  export * from './admin.js';
11
+ export * from './authoritative-query.js';
11
12
  export * from './blob-handlers.js';
12
13
  export * from './blob-store.js';
13
14
  export * from './content-encoding.js';
@@ -20,6 +21,8 @@ export * from './events-ring.js';
20
21
  export * from './frame-bytes.js';
21
22
  export * from './handler.js';
22
23
  export * from './lease-store.js';
24
+ export * from './operations.js';
25
+ export * from './operations-realtime.js';
23
26
  export * from './pg-executor.js';
24
27
  export * from './postgres-fanout.js';
25
28
  export * from './postgres-storage.js';
@@ -27,6 +30,7 @@ export * from './prune.js';
27
30
  export * from './pull.js';
28
31
  export * from './push.js';
29
32
  export * from './readiness.js';
33
+ export { DEFAULT_REACTION_INITIAL_BACKOFF_MS, DEFAULT_REACTION_LEASE_MS, DEFAULT_REACTION_MAX_ATTEMPTS, DEFAULT_REACTION_MAX_BACKOFF_MS, DEFAULT_REACTION_RETENTION, MAX_REACTION_FAILURE_DETAILS_BYTES, MAX_REACTION_PAYLOAD_BYTES, MAX_REACTIONS_PER_COMMIT, PermanentReactionError, pruneReactions, ReactionRunner, reactionIdempotencyKey, retryDeadLetterReaction, RetryableReactionError, type PlannedReaction, type PruneReactionsOptions, type ReactionHandler, type ReactionHandlerInput, type ReactionHandlers, type ReactionPlan, type ReactionPlanner, type ReactionPlannerInput, type ReactionPruneResult, type ReactionRetentionPolicy, type ReactionRunnerOptions, type ReactionRunResult, type ReactionTypeMap, } from './reactions.js';
30
34
  export * from './realtime.js';
31
35
  export * from './relational-rows.js';
32
36
  export * from './s3-blob-store.js';
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * @syncular/server — framework-free embeddable SSP2 protocol library
3
- * (SPEC.md is normative; REVISE.md B2 is the architectural mandate).
3
+ * (SPEC.md is normative).
4
4
  *
5
5
  * Core surface: `handleSyncRequest(bytes, ctx) → bytes` over host-provided
6
6
  * storage / scope-resolution / segment-store interfaces, plus a
@@ -8,6 +8,7 @@
8
8
  * handler (§5.5), and signed-URL token issuance/verification (§5.4).
9
9
  */
10
10
  export * from './admin.js';
11
+ export * from './authoritative-query.js';
11
12
  export * from './blob-handlers.js';
12
13
  export * from './blob-store.js';
13
14
  export * from './content-encoding.js';
@@ -20,6 +21,8 @@ export * from './events-ring.js';
20
21
  export * from './frame-bytes.js';
21
22
  export * from './handler.js';
22
23
  export * from './lease-store.js';
24
+ export * from './operations.js';
25
+ export * from './operations-realtime.js';
23
26
  // The `PgExecutor` seam + Postgres storage/fanout are driver-agnostic (zero
24
27
  // runtime deps). Concrete driver adapters (pglite for tests; Bun.sql /
25
28
  // node-postgres for production, documented in the README) live in separate
@@ -31,6 +34,7 @@ export * from './prune.js';
31
34
  export * from './pull.js';
32
35
  export * from './push.js';
33
36
  export * from './readiness.js';
37
+ export { DEFAULT_REACTION_INITIAL_BACKOFF_MS, DEFAULT_REACTION_LEASE_MS, DEFAULT_REACTION_MAX_ATTEMPTS, DEFAULT_REACTION_MAX_BACKOFF_MS, DEFAULT_REACTION_RETENTION, MAX_REACTION_FAILURE_DETAILS_BYTES, MAX_REACTION_PAYLOAD_BYTES, MAX_REACTIONS_PER_COMMIT, PermanentReactionError, pruneReactions, ReactionRunner, reactionIdempotencyKey, retryDeadLetterReaction, RetryableReactionError, } from './reactions.js';
34
38
  export * from './realtime.js';
35
39
  export * from './relational-rows.js';
36
40
  export * from './s3-blob-store.js';
@@ -0,0 +1,16 @@
1
+ import type { RealtimeNotifier, SyncRequestContext } from './context.js';
2
+ import type { RemoteOperationRegistry } from './operations.js';
3
+ import type { StoredCommit } from './storage.js';
4
+ export interface RemoteOperationWatchSession {
5
+ receive(bytes: Uint8Array): Promise<void>;
6
+ close(): void;
7
+ }
8
+ /** In-memory invalidation hub. Every notification reruns affected watches. */
9
+ export declare class RemoteOperationWatchHub implements RealtimeNotifier {
10
+ #private;
11
+ constructor(registry: RemoteOperationRegistry);
12
+ connect(ctx: SyncRequestContext, send: (bytes: Uint8Array) => void): RemoteOperationWatchSession;
13
+ notifyCommit(partition: string, commit: StoredCommit): void;
14
+ }
15
+ /** Fan one applied commit into sync deltas and registered query watches. */
16
+ export declare function composeRealtimeNotifiers(...notifiers: readonly RealtimeNotifier[]): RealtimeNotifier;