@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
@@ -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) {
@@ -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];
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,
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
@@ -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) {
package/dist/index.d.ts CHANGED
@@ -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
@@ -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;
@@ -0,0 +1,196 @@
1
+ import { decodeRemoteOperationRealtimeMessage, encodeRemoteOperationRealtimeMessage, } from '@syncular/core';
2
+ import { REMOTE_COMMAND_CLIENT_ID_PREFIX } from './context.js';
3
+ import { SyncError, syncError } from './errors.js';
4
+ class WatchSession {
5
+ #ctx;
6
+ #registry;
7
+ #send;
8
+ #closed;
9
+ #watches = new Map();
10
+ #isClosed = false;
11
+ get partition() {
12
+ return this.#ctx.partition;
13
+ }
14
+ constructor(ctx, registry, send, closed) {
15
+ this.#ctx = ctx;
16
+ this.#registry = registry;
17
+ this.#send = send;
18
+ this.#closed = closed;
19
+ }
20
+ async receive(bytes) {
21
+ if (this.#isClosed)
22
+ return;
23
+ let message;
24
+ try {
25
+ message = decodeRemoteOperationRealtimeMessage(bytes);
26
+ if (typeof message !== 'object' ||
27
+ message === null ||
28
+ message.revision !== 1 ||
29
+ (message.kind !== 'watch' && message.kind !== 'unwatch') ||
30
+ typeof message.watchId !== 'string' ||
31
+ message.watchId.length === 0) {
32
+ throw syncError('operation.invalid_request');
33
+ }
34
+ if (message.kind === 'watch') {
35
+ if (typeof message.clientId !== 'string' ||
36
+ message.clientId.length === 0 ||
37
+ typeof message.operationId !== 'string' ||
38
+ message.operationId.length === 0) {
39
+ throw syncError('operation.invalid_request');
40
+ }
41
+ }
42
+ }
43
+ catch (error) {
44
+ if (error instanceof SyncError)
45
+ throw error;
46
+ throw syncError('operation.invalid_request');
47
+ }
48
+ if (message.kind === 'unwatch') {
49
+ this.#watches.delete(message.watchId);
50
+ return;
51
+ }
52
+ if (this.#watches.has(message.watchId)) {
53
+ this.#sendError(message.watchId, syncError('operation.invalid_request', 'watchId is already active'));
54
+ return;
55
+ }
56
+ if (message.clientId.startsWith(REMOTE_COMMAND_CLIENT_ID_PREFIX)) {
57
+ this.#sendError(message.watchId, syncError('sync.invalid_client_id', 'clientId uses a reserved server-command namespace (§1.5)'));
58
+ return;
59
+ }
60
+ const clientRecord = await this.#ctx.storage.getClientRecord(this.#ctx.partition, message.clientId);
61
+ if (clientRecord !== undefined &&
62
+ clientRecord.actorId !== this.#ctx.actorId) {
63
+ this.#sendError(message.watchId, syncError('sync.invalid_client_id', 'clientId is bound to a different actor in this partition (§1.5)'));
64
+ return;
65
+ }
66
+ const operation = this.#registry.get(message.operationId);
67
+ if (operation === undefined || operation.kind !== 'query') {
68
+ this.#sendError(message.watchId, syncError('operation.unknown'));
69
+ return;
70
+ }
71
+ const state = {
72
+ watchId: message.watchId,
73
+ clientId: message.clientId,
74
+ operation,
75
+ params: message.params,
76
+ running: false,
77
+ dirty: false,
78
+ };
79
+ this.#watches.set(message.watchId, state);
80
+ await this.#refresh(state);
81
+ }
82
+ notify(tables) {
83
+ for (const state of this.#watches.values()) {
84
+ if (!state.operation.tables.some((table) => tables.has(table)))
85
+ continue;
86
+ if (state.running)
87
+ state.dirty = true;
88
+ else
89
+ void this.#refresh(state);
90
+ }
91
+ }
92
+ async #refresh(state) {
93
+ if (this.#isClosed || this.#watches.get(state.watchId) !== state)
94
+ return;
95
+ if (state.running) {
96
+ state.dirty = true;
97
+ return;
98
+ }
99
+ state.running = true;
100
+ try {
101
+ do {
102
+ state.dirty = false;
103
+ try {
104
+ const response = await state.operation.run(this.#ctx, state.clientId, state.params);
105
+ if (this.#isClosed || this.#watches.get(state.watchId) !== state) {
106
+ return;
107
+ }
108
+ if (response.kind !== 'query') {
109
+ throw syncError('operation.query_failed');
110
+ }
111
+ if (!this.#emit(encodeRemoteOperationRealtimeMessage({
112
+ revision: 1,
113
+ kind: 'snapshot',
114
+ watchId: state.watchId,
115
+ operationId: response.operationId,
116
+ rows: response.rows,
117
+ maxCommitSeq: response.maxCommitSeq,
118
+ })))
119
+ return;
120
+ }
121
+ catch (error) {
122
+ if (this.#isClosed || this.#watches.get(state.watchId) !== state) {
123
+ return;
124
+ }
125
+ this.#sendError(state.watchId, error instanceof SyncError
126
+ ? error
127
+ : syncError('operation.query_failed'));
128
+ }
129
+ } while (state.dirty &&
130
+ !this.#isClosed &&
131
+ this.#watches.get(state.watchId) === state);
132
+ }
133
+ finally {
134
+ state.running = false;
135
+ }
136
+ }
137
+ #sendError(watchId, error) {
138
+ this.#emit(encodeRemoteOperationRealtimeMessage({
139
+ revision: 1,
140
+ kind: 'watch_error',
141
+ watchId,
142
+ code: error.code,
143
+ message: error.message,
144
+ retryable: error.retryable,
145
+ }));
146
+ }
147
+ #emit(bytes) {
148
+ if (this.#isClosed)
149
+ return false;
150
+ try {
151
+ this.#send(bytes);
152
+ return true;
153
+ }
154
+ catch {
155
+ this.close();
156
+ return false;
157
+ }
158
+ }
159
+ close() {
160
+ if (this.#isClosed)
161
+ return;
162
+ this.#isClosed = true;
163
+ this.#watches.clear();
164
+ this.#closed();
165
+ }
166
+ }
167
+ /** In-memory invalidation hub. Every notification reruns affected watches. */
168
+ export class RemoteOperationWatchHub {
169
+ #registry;
170
+ #sessions = new Set();
171
+ constructor(registry) {
172
+ this.#registry = registry;
173
+ }
174
+ connect(ctx, send) {
175
+ const session = new WatchSession(ctx, this.#registry, send, () => {
176
+ this.#sessions.delete(session);
177
+ });
178
+ this.#sessions.add(session);
179
+ return session;
180
+ }
181
+ notifyCommit(partition, commit) {
182
+ const tables = new Set(commit.changes.map((change) => change.table));
183
+ for (const session of this.#sessions) {
184
+ if (session.partition === partition)
185
+ session.notify(tables);
186
+ }
187
+ }
188
+ }
189
+ /** Fan one applied commit into sync deltas and registered query watches. */
190
+ export function composeRealtimeNotifiers(...notifiers) {
191
+ return {
192
+ notifyCommit: async (partition, commit) => {
193
+ await Promise.all(notifiers.map((notifier) => notifier.notifyCommit(partition, commit)));
194
+ },
195
+ };
196
+ }