@syncular/server 0.22.0 → 0.24.0

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 +14 -2
  2. package/dist/admin.d.ts +3 -1
  3. package/dist/admin.js +7 -1
  4. package/dist/blob-handlers.js +1 -1
  5. package/dist/context.d.ts +8 -1
  6. package/dist/d1-storage.d.ts +11 -3
  7. package/dist/d1-storage.js +186 -36
  8. package/dist/errors.js +10 -0
  9. package/dist/handler.js +50 -4
  10. package/dist/operations.d.ts +7 -1
  11. package/dist/operations.js +5 -3
  12. package/dist/postgres-storage.d.ts +12 -4
  13. package/dist/postgres-storage.js +310 -59
  14. package/dist/push.js +31 -7
  15. package/dist/readiness.d.ts +9 -2
  16. package/dist/readiness.js +27 -2
  17. package/dist/realtime.js +20 -1
  18. package/dist/relational-rows.d.ts +31 -12
  19. package/dist/relational-rows.js +121 -26
  20. package/dist/segment-download.js +8 -0
  21. package/dist/sqlite-dialect.d.ts +1 -1
  22. package/dist/sqlite-dialect.js +15 -0
  23. package/dist/sqlite-storage.d.ts +18 -3
  24. package/dist/sqlite-storage.js +327 -82
  25. package/dist/storage-errors.d.ts +5 -3
  26. package/dist/storage-errors.js +13 -2
  27. package/dist/storage-query.d.ts +8 -1
  28. package/dist/storage-query.js +12 -0
  29. package/dist/storage.d.ts +153 -14
  30. package/dist/storage.js +40 -1
  31. package/dist/validate.d.ts +33 -15
  32. package/package.json +2 -2
  33. package/src/admin.ts +13 -1
  34. package/src/blob-handlers.ts +1 -1
  35. package/src/context.ts +8 -0
  36. package/src/d1-storage.ts +282 -37
  37. package/src/errors.ts +10 -0
  38. package/src/handler.ts +62 -4
  39. package/src/operations.ts +32 -14
  40. package/src/postgres-storage.ts +515 -68
  41. package/src/push.ts +31 -1
  42. package/src/readiness.ts +39 -4
  43. package/src/realtime.ts +22 -1
  44. package/src/relational-rows.ts +145 -30
  45. package/src/segment-download.ts +16 -0
  46. package/src/sqlite-dialect.ts +15 -0
  47. package/src/sqlite-storage.ts +491 -96
  48. package/src/storage-errors.ts +33 -3
  49. package/src/storage-query.ts +23 -1
  50. package/src/storage.ts +245 -12
  51. package/src/validate.ts +40 -15
@@ -26,7 +26,15 @@ export type StorageQueryErrorCode =
26
26
  | 'sync.storage.invalid_limit'
27
27
  | 'sync.storage.prune_epoch_mismatch'
28
28
  | 'sync.storage.partition_unregistered'
29
- | 'sync.storage.invalid_prune_cursor';
29
+ | 'sync.storage.invalid_prune_cursor'
30
+ | 'sync.storage.checkpoint_not_declared'
31
+ | 'sync.storage.checkpoint_unsupported'
32
+ | 'sync.storage.checkpoint_fence_missing'
33
+ | 'sync.storage.checkpoint_incomplete'
34
+ | 'sync.storage.stored_layout_mismatch'
35
+ | 'sync.storage.physical_layout_mismatch'
36
+ | 'sync.storage.transaction_query_unsupported'
37
+ | 'sync.storage.query_over_staged_writes';
30
38
 
31
39
  const STORAGE_QUERY_MESSAGES: Readonly<Record<StorageQueryErrorCode, string>> =
32
40
  {
@@ -44,6 +52,22 @@ const STORAGE_QUERY_MESSAGES: Readonly<Record<StorageQueryErrorCode, string>> =
44
52
  'pruning requires a registered partition',
45
53
  'sync.storage.invalid_prune_cursor':
46
54
  'pruning requires a non-negative safe integer cursor and a non-empty log epoch',
55
+ 'sync.storage.checkpoint_not_declared':
56
+ 'checkpoint claim requires a declared, not yet activated row',
57
+ 'sync.storage.checkpoint_unsupported':
58
+ 'this storage backend cannot declare backfill checkpoints',
59
+ 'sync.storage.checkpoint_fence_missing':
60
+ 'checkpoint activation requires the writer fence raised at declaration',
61
+ 'sync.storage.checkpoint_incomplete':
62
+ 'a declared backfill checkpoint is not activated and this caller did not declare it',
63
+ 'sync.storage.stored_layout_mismatch':
64
+ 'stored schema layouts disagree with the configured schema at the same version',
65
+ 'sync.storage.physical_layout_mismatch':
66
+ 'a synced table does not match the storage layout the running code reads and writes',
67
+ 'sync.storage.transaction_query_unsupported':
68
+ 'this storage transaction cannot run registered queries',
69
+ 'sync.storage.query_over_staged_writes':
70
+ 'registered query reads a table this transaction has buffered writes for',
47
71
  'sync.storage.scan_requires_scope':
48
72
  'scope-indexed row scans require at least one scope variable',
49
73
  'sync.storage.index_not_found':
@@ -58,15 +82,21 @@ const STORAGE_QUERY_MESSAGES: Readonly<Record<StorageQueryErrorCode, string>> =
58
82
 
59
83
  /**
60
84
  * Host-only query error. Messages never include identifiers, values, SQL,
61
- * paths, or row data; callers branch on `code`, never message text.
85
+ * paths, or row data; callers branch on `code`, never message text. The
86
+ * schema-readiness codes name the offending table and column in `details`.
62
87
  */
63
88
  export class StorageQueryError extends Error {
64
89
  override readonly name = 'StorageQueryError';
65
90
  readonly code: StorageQueryErrorCode;
91
+ readonly details: Readonly<Record<string, string>> | undefined;
66
92
 
67
- constructor(code: StorageQueryErrorCode) {
93
+ constructor(
94
+ code: StorageQueryErrorCode,
95
+ details?: Readonly<Record<string, string>>,
96
+ ) {
68
97
  super(STORAGE_QUERY_MESSAGES[code]);
69
98
  this.code = code;
99
+ this.details = details;
70
100
  }
71
101
  }
72
102
 
@@ -1,6 +1,12 @@
1
1
  import type { ScopeMap } from '@syncular/core';
2
2
  import type { CompiledTable, IndexSchema } from './schema';
3
- import type { IndexRowScanQuery, RowScanQuery } from './storage';
3
+ import type {
4
+ AuthoritativeQueryRequest,
5
+ AuthoritativeQueryResult,
6
+ IndexRowScanQuery,
7
+ RowScanQuery,
8
+ StorageTransaction,
9
+ } from './storage';
4
10
  import { StorageQueryError } from './storage-errors';
5
11
 
6
12
  /**
@@ -51,3 +57,19 @@ export function resolveIndexRowScan(
51
57
  }
52
58
  return index;
53
59
  }
60
+
61
+ /**
62
+ * §6.7/§6.8 transaction-bound registered query for every host hook that runs
63
+ * inside a push transaction. A storage transaction without the capability
64
+ * fails; the call never falls back to `ServerStorage.queryAuthoritative`,
65
+ * which reads outside the transaction.
66
+ */
67
+ export async function transactionQuery(
68
+ tx: StorageTransaction,
69
+ query: AuthoritativeQueryRequest,
70
+ ): Promise<AuthoritativeQueryResult> {
71
+ if (tx.queryAuthoritative === undefined) {
72
+ throw new StorageQueryError('sync.storage.transaction_query_unsupported');
73
+ }
74
+ return tx.queryAuthoritative(query);
75
+ }
package/src/storage.ts CHANGED
@@ -31,6 +31,120 @@ export interface CommitPruneResult {
31
31
  readonly removedCommits: number;
32
32
  }
33
33
 
34
+ /** RFC 0007 backfill lifecycle. `activated` is terminal within a schema version. */
35
+ export type CheckpointState = 'declared' | 'backfilling' | 'activated';
36
+
37
+ /**
38
+ * Host-owned checkpoint installation carried on `ensureSchema`: the schema
39
+ * bump, the `declared` row, and the raised writer fence commit together.
40
+ */
41
+ export interface CheckpointDeclaration {
42
+ readonly partition: string;
43
+ readonly name: string;
44
+ readonly schemaVersion: number;
45
+ }
46
+
47
+ /** One host-storage backfill checkpoint row. Never a synced application row. */
48
+ export interface StoredCheckpoint {
49
+ readonly partition: string;
50
+ readonly name: string;
51
+ readonly schemaVersion: number;
52
+ readonly state: CheckpointState;
53
+ /** Highest source `commit_seq` the projection was derived from. */
54
+ readonly watermark: number;
55
+ /** Monotonic claim counter; a batch is stale when it does not match. */
56
+ readonly ownerEpoch: number;
57
+ readonly observedRows: number;
58
+ readonly updatedAtMs: number;
59
+ }
60
+
61
+ /**
62
+ * RFC 0007 serve-gate token, read inside the transaction that produces the
63
+ * result. `storedSchemaVersion` is the published marker; `logEpoch` is the
64
+ * partition's log generation, so a restore that presents V -> other -> V is
65
+ * still distinguishable. `pending` names every incomplete declared checkpoint
66
+ * at the running schema version, across partitions.
67
+ */
68
+ export interface ServeGate {
69
+ readonly storedSchemaVersion: number;
70
+ readonly logEpoch: string | undefined;
71
+ readonly pending: readonly {
72
+ readonly partition: string;
73
+ readonly name: string;
74
+ }[];
75
+ }
76
+
77
+ /** Why `serveGate` refuses, or `undefined` when this request may serve. */
78
+ export type ServeGateRefusal =
79
+ | {
80
+ readonly kind: 'schema';
81
+ readonly stored: number;
82
+ readonly running: number;
83
+ }
84
+ | {
85
+ readonly kind: 'checkpoint';
86
+ readonly partition: string;
87
+ readonly name: string;
88
+ }
89
+ | {
90
+ /** The token changed between two reads: a restore rotated the epoch. */
91
+ readonly kind: 'epoch';
92
+ };
93
+
94
+ /**
95
+ * Evaluate the RFC 0007 predicate against a gate token. A process that
96
+ * declares the pending checkpoint may serve while it backfills; any other
97
+ * process is refused, and a database newer than the running build is always
98
+ * refused.
99
+ */
100
+ export function serveGateRefusal(
101
+ gate: ServeGate,
102
+ runningSchemaVersion: number,
103
+ checkpoints: readonly CheckpointDeclaration[] | undefined,
104
+ ): ServeGateRefusal | undefined {
105
+ if (gate.storedSchemaVersion > runningSchemaVersion) {
106
+ return {
107
+ kind: 'schema',
108
+ stored: gate.storedSchemaVersion,
109
+ running: runningSchemaVersion,
110
+ };
111
+ }
112
+ for (const pending of gate.pending) {
113
+ const declared = (checkpoints ?? []).some(
114
+ (declaration) =>
115
+ declaration.partition === pending.partition &&
116
+ declaration.name === pending.name,
117
+ );
118
+ if (!declared) {
119
+ return {
120
+ kind: 'checkpoint',
121
+ partition: pending.partition,
122
+ name: pending.name,
123
+ };
124
+ }
125
+ }
126
+ return undefined;
127
+ }
128
+
129
+ /**
130
+ * RFC 0007: compare two gate tokens read around a data read. The token binds
131
+ * the published schema version and the partition log epoch, so a restore that
132
+ * presents V → other → V is distinguishable. Returns a refusal when either
133
+ * changed across the read.
134
+ */
135
+ export function serveGateTokenChanged(
136
+ before: ServeGate,
137
+ after: ServeGate,
138
+ ): ServeGateRefusal | undefined {
139
+ if (
140
+ before.storedSchemaVersion !== after.storedSchemaVersion ||
141
+ before.logEpoch !== after.logEpoch
142
+ ) {
143
+ return { kind: 'epoch' };
144
+ }
145
+ return undefined;
146
+ }
147
+
34
148
  /** The current stored state of a synced row. */
35
149
  export interface StoredRow {
36
150
  readonly rowId: string;
@@ -359,6 +473,21 @@ export interface StorageTransaction {
359
473
  * read-your-own-writes semantics. It is not reachable from SSP2 requests.
360
474
  */
361
475
  scanRowsByIndex?(query: IndexRowScanQuery): Promise<StoredRow[]>;
476
+ /**
477
+ * Optional transaction-bound registered query (§6.7, §6.8): the relation
478
+ * rewrite of `ServerStorage.queryAuthoritative`, bound to this
479
+ * transaction's partition and executed on this transaction's connection.
480
+ * A hook inside the push transaction therefore reads the transaction's
481
+ * snapshot and never waits for a second connection. The push layer has
482
+ * already evaluated the serve gate on this transaction, so the call takes
483
+ * no checkpoint declarations. SQLite and PostgreSQL return the staged
484
+ * writes of this transaction. D1 buffers writes until commit and fails
485
+ * with `sync.storage.query_over_staged_writes` when the query reads a
486
+ * table this transaction has written.
487
+ */
488
+ queryAuthoritative?(
489
+ query: AuthoritativeQueryRequest,
490
+ ): Promise<AuthoritativeQueryResult>;
362
491
  /**
363
492
  * Serialize every push apply for this partition before any operation read,
364
493
  * validation, merge, or write. The push layer re-checks idempotency only
@@ -406,6 +535,27 @@ export interface StorageTransaction {
406
535
  clientCommitId: string,
407
536
  result: StoredPushResult,
408
537
  ): Promise<void>;
538
+ /**
539
+ * Advance this partition's checkpoint only while `owner_epoch` still
540
+ * matches, so the owner-epoch CAS rides inside the caller's transaction.
541
+ * The host calls this from `processPushOperationsWithTrace`'s operation
542
+ * builder: the backfill is an ordinary server-authoritative write, and this
543
+ * CAS commits with that write's row versions and commit-log append. False
544
+ * means abort and write nothing, rather than a throw for a superseded owner.
545
+ */
546
+ advanceCheckpoint(
547
+ name: string,
548
+ ownerEpoch: number,
549
+ watermark: number,
550
+ observedRows: number,
551
+ nowMs: number,
552
+ ): Promise<boolean>;
553
+ /**
554
+ * RFC 0007 serve gate on the caller's own connection, for write paths that
555
+ * already hold the partition lock: the gate and the write share one
556
+ * transaction, so a migration cannot interleave between them.
557
+ */
558
+ readServeGate(runningSchemaVersion: number): Promise<ServeGate>;
409
559
  /**
410
560
  * Blob reference index (§5.9.4) — ADDITIVE, optional. Set the blobIds a
411
561
  * row currently references (empty = clear), replacing any prior entries
@@ -438,7 +588,10 @@ export interface ServerStorage {
438
588
  * before binding a public port; protocol handlers still call this method
439
589
  * lazily as a defensive backstop.
440
590
  */
441
- ensureSchema(schema: CompiledSchema): Promise<void>;
591
+ ensureSchema(
592
+ schema: CompiledSchema,
593
+ checkpoints?: readonly CheckpointDeclaration[],
594
+ ): Promise<void>;
442
595
 
443
596
  /** Create or refresh the authenticated partition registry row (§2.1). */
444
597
  touchPartition(
@@ -491,6 +644,84 @@ export interface ServerStorage {
491
644
  createdBeforeMs: number,
492
645
  ): Promise<number>;
493
646
 
647
+ /** Every declared backfill checkpoint for the partition, ordered by name. */
648
+ readCheckpoints(partition: string): Promise<StoredCheckpoint[]>;
649
+ /**
650
+ * RFC 0007 serve gate: read the published schema version, the partition's
651
+ * log epoch, and every incomplete declared checkpoint at
652
+ * `runningSchemaVersion`. Never memoized; the serve paths call it per
653
+ * request so an already-serving process notices a migration.
654
+ */
655
+ readServeGate(
656
+ partition: string,
657
+ runningSchemaVersion: number,
658
+ ): Promise<ServeGate>;
659
+ /**
660
+ * Atomically take ownership of a declared or backfilling checkpoint. Increments
661
+ * `owner_epoch` and moves `declared` to `backfilling`. Throws
662
+ * `sync.storage.checkpoint_not_declared` when no claimable row exists (absent,
663
+ * or already `activated`).
664
+ */
665
+ claimCheckpoint(
666
+ partition: string,
667
+ name: string,
668
+ schemaVersion: number,
669
+ nowMs: number,
670
+ ): Promise<StoredCheckpoint>;
671
+ /**
672
+ * Advance the watermark only while `owner_epoch` still matches. Returns false
673
+ * for a superseded owner; the stale case never throws.
674
+ */
675
+ advanceCheckpoint(
676
+ partition: string,
677
+ name: string,
678
+ ownerEpoch: number,
679
+ watermark: number,
680
+ observedRows: number,
681
+ nowMs: number,
682
+ ): Promise<boolean>;
683
+ /** Highest commitSeq carrying a change to one of `tables`; 0 when none. */
684
+ sourceCoverageSeq(
685
+ partition: string,
686
+ tables: readonly string[],
687
+ ): Promise<number>;
688
+ /**
689
+ * Whether any source change sits above `seq`. `unverifiable` when the pruning
690
+ * horizon has passed `seq`, so the history that would answer the question is
691
+ * gone; a pruned window is never reported as `clean`.
692
+ */
693
+ hasSourceChangesAbove(
694
+ partition: string,
695
+ tables: readonly string[],
696
+ seq: number,
697
+ ): Promise<'clean' | 'changed' | 'unverifiable'>;
698
+ /**
699
+ * Install the barrier: insert the checkpoint in `declared` and raise
700
+ * `required_writer_version` for the partition in one transaction. The fence
701
+ * is raised at declaration, before any backfill work, so old writers are
702
+ * rejected for the whole backfill window. Idempotent on an existing declared
703
+ * or backfilling row.
704
+ */
705
+ declareCheckpoint(
706
+ partition: string,
707
+ name: string,
708
+ schemaVersion: number,
709
+ nowMs: number,
710
+ ): Promise<StoredCheckpoint>;
711
+ /**
712
+ * Activate a declared checkpoint under the partition write lock. Refuses when
713
+ * the fence is not already raised at the checkpoint's schema version, when a
714
+ * source change sits above `watermark` (`stale`), or when the pruning horizon
715
+ * has passed `watermark` (`unverifiable`). `activated` is terminal.
716
+ */
717
+ activateCheckpoint(
718
+ partition: string,
719
+ name: string,
720
+ ownerEpoch: number,
721
+ watermark: number,
722
+ sources: readonly string[],
723
+ nowMs: number,
724
+ ): Promise<'activated' | 'stale' | 'unverifiable'>;
494
725
  getRow(
495
726
  partition: string,
496
727
  table: string,
@@ -574,21 +805,23 @@ export interface ServerStorage {
574
805
  * every generated app-table relation with a partition-filtered relation and
575
806
  * return rows plus maxCommitSeq from one consistent database snapshot.
576
807
  *
577
- * The storage serializes this call behind an open transaction on a
578
- * single-connection executor (SQLite, PGlite). A validator runs inside the
579
- * push transaction, so on that executor a validator that calls
580
- * `queryAuthoritative` waits on the transaction it is already inside and
581
- * the push never completes. A pool-backed executor (`pg`, `Bun.sql`) hands
582
- * the call a second connection instead: the validator does not deadlock,
583
- * but it reads committed state without the candidate write. On either
584
- * executor read candidate state through the whole-commit `CommitValidator`
585
- * reader (§6.8). A call made from any other context waits for the open
586
- * transaction to finish on a single-connection executor, and runs
587
- * concurrently on a pool-backed one.
808
+ * Do not call this method from a hook that runs inside the push
809
+ * transaction (a row validator, the whole-commit validator, or a reaction
810
+ * planner). On a single-connection executor (SQLite, PGlite) the storage
811
+ * serializes this call behind the open transaction, so the hook waits on
812
+ * the transaction it is inside and the push never completes. A pool-backed
813
+ * executor (`pg`, `Bun.sql`) runs the call on a second connection: it reads
814
+ * committed state without the transaction's staged writes, and it waits
815
+ * forever when the push transactions hold every pool connection. Those
816
+ * hooks call the transaction-bound `queryAuthoritative` on
817
+ * `ValidateContext` or on the candidate-state reader instead (§6.7, §6.8).
818
+ * A call from any other context waits for the open transaction on a
819
+ * single-connection executor and runs concurrently on a pool-backed one.
588
820
  */
589
821
  queryAuthoritative?(
590
822
  partition: string,
591
823
  query: AuthoritativeQueryRequest,
824
+ checkpoints?: readonly CheckpointDeclaration[],
592
825
  ): Promise<AuthoritativeQueryResult>;
593
826
 
594
827
  /**
package/src/validate.ts CHANGED
@@ -20,6 +20,10 @@ import {
20
20
  type RowValue,
21
21
  type ScopeMap,
22
22
  } from '@syncular/core';
23
+ import type {
24
+ AuthoritativeQueryRequest,
25
+ AuthoritativeQueryResult,
26
+ } from './storage';
23
27
 
24
28
  /**
25
29
  * §6.7 reserved code prefixes. A host validator code MUST NOT start with
@@ -104,6 +108,17 @@ export interface CommitValidationReader {
104
108
  rowId: string,
105
109
  ): Promise<CommitValidationRow | undefined>;
106
110
  scanRows(input: CommitValidationScanInput): Promise<CommitValidationRow[]>;
111
+ /**
112
+ * Run a generated registered query on the commit transaction (§6.8). The
113
+ * request has the shape `ServerStorage.queryAuthoritative` takes; the
114
+ * storage binds every relation to the commit's partition. SQLite and
115
+ * PostgreSQL return the final candidate state; D1 fails with
116
+ * `sync.storage.query_over_staged_writes` when the query reads a table the
117
+ * commit has written.
118
+ */
119
+ queryAuthoritative(
120
+ query: AuthoritativeQueryRequest,
121
+ ): Promise<AuthoritativeQueryResult>;
107
122
  }
108
123
 
109
124
  export interface ValidateCommitInput {
@@ -126,21 +141,31 @@ export type CommitValidator = (
126
141
  /**
127
142
  * Ambient context a validator may consult (§6.7).
128
143
  *
129
- * A validator runs inside the push transaction. On a single-connection
130
- * executor (SQLite, PGlite) the storage serializes authoritative queries
131
- * behind that transaction, so a validator that calls `queryAuthoritative`
132
- * waits for the transaction it is already inside and the push never
133
- * completes. On a pool-backed executor (`pg`, `Bun.sql`) the query takes a
134
- * second connection and returns committed state without the candidate write.
135
- * Either way a rule that must read rows belongs in the whole-commit
136
- * `CommitValidator`, whose reader runs get and scan operations on the same
137
- * transaction (§6.8).
144
+ * A validator runs inside the push transaction. It MUST NOT call
145
+ * `ServerStorage.queryAuthoritative`: on a single-connection executor
146
+ * (SQLite, PGlite) that call waits for the transaction the validator is
147
+ * inside and the push never completes, and on a pool-backed executor (`pg`,
148
+ * `Bun.sql`) it reads committed state without the staged writes, or waits
149
+ * forever once push transactions hold every pool connection. An authority
150
+ * read calls `queryAuthoritative` on this context instead.
138
151
  */
139
152
  export interface ValidateContext {
140
153
  /** Host-authenticated actor (§1.1) performing the write. */
141
154
  readonly actorId: string;
142
155
  /** The partition (§1.1) the commit targets. */
143
156
  readonly partition: string;
157
+ /**
158
+ * Run a generated registered query on the push transaction (§6.7). The
159
+ * request has the shape `ServerStorage.queryAuthoritative` takes; the
160
+ * storage binds every relation to the commit's partition. SQLite and
161
+ * PostgreSQL return the transaction's snapshot, including the operations
162
+ * staged before this one; D1 fails with
163
+ * `sync.storage.query_over_staged_writes` when the query reads a table the
164
+ * commit has written.
165
+ */
166
+ readonly queryAuthoritative: (
167
+ query: AuthoritativeQueryRequest,
168
+ ) => Promise<AuthoritativeQueryResult>;
144
169
  }
145
170
 
146
171
  /**
@@ -152,12 +177,12 @@ export interface ValidateContext {
152
177
  * `sync.constraint_violation` code (a validator SHOULD throw
153
178
  * `ValidationRejection` to control the code). MUST NOT mutate the row.
154
179
  *
155
- * The hook runs inside the push transaction and cannot read candidate rows
156
- * from an authoritative query: on a single-connection executor
157
- * `queryAuthoritative` serializes behind that transaction and the push never
158
- * completes, and on a pool-backed executor it reads committed state without
159
- * the candidate write. Read rows with the whole-commit `CommitValidator`
160
- * reader instead (§6.8).
180
+ * The hook runs inside the push transaction. An authority read uses
181
+ * `ctx.queryAuthoritative`, which executes on that transaction; calling
182
+ * `ServerStorage.queryAuthoritative` from here deadlocks on a
183
+ * single-connection executor and reads committed state on a pool-backed one.
184
+ * A rule over sibling operations belongs in the whole-commit
185
+ * `CommitValidator` (§6.8).
161
186
  */
162
187
  export type Validator = (
163
188
  op: ValidateOperation,