@syncular/server 0.15.22 → 0.15.24

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.
@@ -14,38 +14,16 @@
14
14
  * the `sync` wake-up (§8.3).
15
15
  */
16
16
  import { type PresenceKind, type ScopeMap, type WakeReason } from '@syncular/core';
17
- import type { LeaseConfig, ResolveScopes, ServerLimits, SyncRequestContext } from './context.js';
17
+ import type { SyncRequestContext, SyncServerConfig } from './context.js';
18
18
  import { type SyncularServerEvents } from './events.js';
19
- import type { ServerSchema } from './schema.js';
20
- import type { SegmentStore } from './segment-store.js';
21
- import type { SegmentUrlConfig } from './signed-url.js';
22
19
  import type { ServerStorage, StoredCommit } from './storage.js';
23
- import type { CommitValidator, ValidatorRegistry } from './validate.js';
24
- export interface RealtimeHubConfig {
25
- readonly schema: ServerSchema;
26
- readonly storage: ServerStorage;
27
- readonly resolveScopes: ResolveScopes;
28
- /** §6.7 validators used by sync rounds carried over this socket. */
29
- readonly validators?: ValidatorRegistry;
30
- /** §6.8 whole-commit validator shared with HTTP sync rounds. */
31
- readonly commitValidator?: CommitValidator;
32
- readonly clock?: () => number;
20
+ /**
21
+ * Realtime adds fanout/presence tuning to the canonical sync-server config;
22
+ * socket rounds must never have a narrower push/pull capability set than HTTP.
23
+ */
24
+ export interface RealtimeHubConfig extends Omit<SyncServerConfig, 'realtime'> {
33
25
  /** Deltas larger than this become `delta-too-large` wake-ups (§8.2). */
34
26
  readonly maxDeltaBytes?: number;
35
- /** Optional structured-events sink (`realtime.*` events). */
36
- readonly events?: SyncularServerEvents;
37
- /**
38
- * Segment store for sync rounds over the socket (§8.7). Without it a
39
- * socket round fails loudly with an in-band ERROR — provide the same
40
- * store the HTTP binding uses (one handler, two framings).
41
- */
42
- readonly segments?: SegmentStore;
43
- /** Request limits for socket rounds; defaults match the HTTP binding. */
44
- readonly limits?: Partial<ServerLimits>;
45
- readonly signedUrls?: SegmentUrlConfig;
46
- /** §7.3 auth leases for socket sync rounds (§8.7) — same config the
47
- * HTTP binding uses, so rounds over the socket are lease-aware too. */
48
- readonly leases?: LeaseConfig;
49
27
  /**
50
28
  * §8.6 presence: cap on the serialized size (bytes) of a published
51
29
  * presence document. An over-cap publish is rejected loudly to the
@@ -149,9 +127,11 @@ export declare class RealtimeSession {
149
127
  * round's request byte stream (§8.7). Synchronous entry — assembly and
150
128
  * violation detection happen inline so a pipelined chunk arriving
151
129
  * while a response streams is caught deterministically; the round
152
- * itself runs async once the request is complete.
130
+ * itself runs async once the request is complete. The returned promise, when
131
+ * present, resolves only after response streaming and registration refresh;
132
+ * coordinated hosts await it to retain their partition FIFO through commit.
153
133
  */
154
- handleBinary(bytes: Uint8Array): void;
134
+ handleBinary(bytes: Uint8Array): Promise<void> | undefined;
155
135
  sendHeartbeat(): void;
156
136
  sendWake(reason: WakeReason): void;
157
137
  /** Called by the hub for every applied commit, in commitSeq order. */
@@ -186,6 +166,10 @@ export declare class RealtimeHub {
186
166
  * the same shape the HTTP adapter builds, so the round drives the
187
167
  * SAME handler with zero semantic divergence.
188
168
  */
169
+ requestContextFor(identity: {
170
+ readonly partition: string;
171
+ readonly actorId: string;
172
+ }): SyncRequestContext;
189
173
  requestContext(session: RealtimeSession): SyncRequestContext;
190
174
  /**
191
175
  * Register a connected socket (§8.1): load the client's last pull's
package/dist/realtime.js CHANGED
@@ -327,7 +327,9 @@ export class RealtimeSession {
327
327
  * round's request byte stream (§8.7). Synchronous entry — assembly and
328
328
  * violation detection happen inline so a pipelined chunk arriving
329
329
  * while a response streams is caught deterministically; the round
330
- * itself runs async once the request is complete.
330
+ * itself runs async once the request is complete. The returned promise, when
331
+ * present, resolves only after response streaming and registration refresh;
332
+ * coordinated hosts await it to retain their partition FIFO through commit.
331
333
  */
332
334
  handleBinary(bytes) {
333
335
  if (bytes.length === 0)
@@ -365,7 +367,7 @@ export class RealtimeSession {
365
367
  }
366
368
  const token = Symbol('round');
367
369
  this.#activeRound = token;
368
- void this.#runRound(done.message.slice(), token);
370
+ return this.#runRound(done.message.slice(), token);
369
371
  }
370
372
  /** Drive the shared handler and stream the response back (§8.7). */
371
373
  async #runRound(requestBytes, token) {
@@ -702,7 +704,7 @@ export class RealtimeHub {
702
704
  * the same shape the HTTP adapter builds, so the round drives the
703
705
  * SAME handler with zero semantic divergence.
704
706
  */
705
- requestContext(session) {
707
+ requestContextFor(identity) {
706
708
  const segments = this.#config.segments;
707
709
  if (segments === undefined) {
708
710
  // Fail loud (§8.7): a hub serving socket rounds needs the same
@@ -710,12 +712,21 @@ export class RealtimeHub {
710
712
  throw syncError('sync.invalid_request', 'socket sync rounds require a segment store on the realtime hub (§8.7)');
711
713
  }
712
714
  return {
713
- partition: session.partition,
714
- actorId: session.actorId,
715
+ partition: identity.partition,
716
+ actorId: identity.actorId,
715
717
  schema: this.#config.schema,
716
718
  storage: this.#config.storage,
717
719
  segments,
718
720
  resolveScopes: this.#config.resolveScopes,
721
+ ...(this.#config.blobs !== undefined
722
+ ? { blobs: this.#config.blobs }
723
+ : {}),
724
+ ...(this.#config.maxBlobBytes !== undefined
725
+ ? { maxBlobBytes: this.#config.maxBlobBytes }
726
+ : {}),
727
+ ...(this.#config.crdtMergers !== undefined
728
+ ? { crdtMergers: this.#config.crdtMergers }
729
+ : {}),
719
730
  ...(this.#config.validators !== undefined
720
731
  ? { validators: this.#config.validators }
721
732
  : {}),
@@ -731,6 +742,15 @@ export class RealtimeHub {
731
742
  ...(this.#config.signedUrls !== undefined
732
743
  ? { signedUrls: this.#config.signedUrls }
733
744
  : {}),
745
+ ...(this.#config.blobSignedUrls !== undefined
746
+ ? { blobSignedUrls: this.#config.blobSignedUrls }
747
+ : {}),
748
+ ...(this.#config.blobUploadUrls !== undefined
749
+ ? { blobUploadUrls: this.#config.blobUploadUrls }
750
+ : {}),
751
+ ...(this.#config.sqliteImageBuilder !== undefined
752
+ ? { sqliteImageBuilder: this.#config.sqliteImageBuilder }
753
+ : {}),
734
754
  ...(this.#config.leases !== undefined
735
755
  ? { leases: this.#config.leases }
736
756
  : {}),
@@ -740,6 +760,9 @@ export class RealtimeHub {
740
760
  realtime: this,
741
761
  };
742
762
  }
763
+ requestContext(session) {
764
+ return this.requestContextFor(session);
765
+ }
743
766
  /**
744
767
  * Register a connected socket (§8.1): load the client's last pull's
745
768
  * subscription list, resolve + intersect scopes, send `hello`.
package/dist/seed.d.ts CHANGED
@@ -26,9 +26,34 @@ export interface SeedTarget {
26
26
  /** The client commit id (default `'seed-commit-1'`). */
27
27
  readonly commitId?: string;
28
28
  }
29
+ export interface SeedMutationErrorOptions {
30
+ readonly clientId: string;
31
+ readonly clientCommitId: string;
32
+ readonly opIndex: number;
33
+ readonly code: string;
34
+ readonly replayed: boolean;
35
+ readonly retryable: boolean;
36
+ readonly message: string;
37
+ readonly recordedAtMs?: number;
38
+ readonly cacheIdentity?: string;
39
+ }
40
+ /** Structured terminal failure from the real push path used by a seed. */
41
+ export declare class SeedMutationError extends Error {
42
+ readonly name = "SeedMutationError";
43
+ readonly clientId: string;
44
+ readonly clientCommitId: string;
45
+ readonly opIndex: number;
46
+ /** Exact protocol or host-validator rejection code. */
47
+ readonly code: string;
48
+ readonly replayed: boolean;
49
+ readonly retryable: boolean;
50
+ readonly recordedAtMs?: number;
51
+ readonly cacheIdentity?: string;
52
+ constructor(options: SeedMutationErrorOptions);
53
+ }
29
54
  /**
30
55
  * Seed `mutations` into a partition through the real push path. Throws a
31
- * `SyncError` when the push is rejected or any operation fails, so a broken
32
- * seed fails loud at boot instead of silently serving an empty database.
56
+ * `SeedMutationError` when the push is rejected and `SyncError` for malformed
57
+ * helper input, so a broken seed fails loud instead of serving an empty store.
33
58
  */
34
59
  export declare function seedMutations(config: SyncServerConfig, target: SeedTarget, mutations: readonly SeedMutation[]): Promise<void>;
package/dist/seed.js CHANGED
@@ -11,7 +11,36 @@
11
11
  */
12
12
  import { decodeMessage, encodeMessage, encodeRow, PROTOCOL_WIRE_VERSION, } from '@syncular/core';
13
13
  import { SyncError } from './errors.js';
14
+ import { composeEvents } from './events-ring.js';
14
15
  import { handleSyncRequest } from './handler.js';
16
+ /** Structured terminal failure from the real push path used by a seed. */
17
+ export class SeedMutationError extends Error {
18
+ name = 'SeedMutationError';
19
+ clientId;
20
+ clientCommitId;
21
+ opIndex;
22
+ /** Exact protocol or host-validator rejection code. */
23
+ code;
24
+ replayed;
25
+ retryable;
26
+ recordedAtMs;
27
+ cacheIdentity;
28
+ constructor(options) {
29
+ super(options.message);
30
+ this.clientId = options.clientId;
31
+ this.clientCommitId = options.clientCommitId;
32
+ this.opIndex = options.opIndex;
33
+ this.code = options.code;
34
+ this.replayed = options.replayed;
35
+ this.retryable = options.retryable;
36
+ if (options.recordedAtMs !== undefined) {
37
+ this.recordedAtMs = options.recordedAtMs;
38
+ }
39
+ if (options.cacheIdentity !== undefined) {
40
+ this.cacheIdentity = options.cacheIdentity;
41
+ }
42
+ }
43
+ }
15
44
  const MAPPABLE_RE = /^_*[A-Za-z][A-Za-z0-9_]*$/;
16
45
  /** Pinned §12 schema alias used by generated row types and every client host. */
17
46
  function snakeToCamel(name) {
@@ -35,8 +64,8 @@ function snakeToCamel(name) {
35
64
  }
36
65
  /**
37
66
  * Seed `mutations` into a partition through the real push path. Throws a
38
- * `SyncError` when the push is rejected or any operation fails, so a broken
39
- * seed fails loud at boot instead of silently serving an empty database.
67
+ * `SeedMutationError` when the push is rejected and `SyncError` for malformed
68
+ * helper input, so a broken seed fails loud instead of serving an empty store.
40
69
  */
41
70
  export async function seedMutations(config, target, mutations) {
42
71
  const clientId = target.clientId ?? 'seed';
@@ -88,11 +117,28 @@ export async function seedMutations(config, target, mutations) {
88
117
  accept: 0b0011,
89
118
  },
90
119
  ];
120
+ let terminalEvent;
121
+ const capture = {
122
+ emit(event) {
123
+ if ((event.type === 'push.rejected' || event.type === 'push.conflicted') &&
124
+ event.clientId === clientId &&
125
+ event.clientCommitId === clientCommitId) {
126
+ terminalEvent = event;
127
+ }
128
+ },
129
+ };
91
130
  const response = await handleSyncRequest(encodeMessage({
92
131
  wireVersion: PROTOCOL_WIRE_VERSION,
93
132
  msgKind: 'request',
94
133
  frames,
95
- }), { ...config, partition: target.partition, actorId: target.actorId });
134
+ }), {
135
+ ...config,
136
+ partition: target.partition,
137
+ actorId: target.actorId,
138
+ events: config.events === undefined
139
+ ? capture
140
+ : composeEvents(config.events, capture),
141
+ });
96
142
  // Fail loud: surface the first rejected/failed operation.
97
143
  const message = decodeMessage(response);
98
144
  const result = message.frames.find((frame) => frame.type === 'PUSH_RESULT' && frame.clientCommitId === clientCommitId);
@@ -101,9 +147,27 @@ export async function seedMutations(config, target, mutations) {
101
147
  }
102
148
  if (result.status === 'rejected') {
103
149
  const failed = result.results.find((r) => r.status !== 'applied');
104
- const detail = failed !== undefined && 'code' in failed
105
- ? ` (op ${failed.opIndex}: ${failed.code} — ${failed.message})`
106
- : '';
107
- throw new SyncError('sync.invalid_request', `seedMutations: the seed commit was rejected${detail}`);
150
+ const code = failed?.code ?? 'sync.invalid_request';
151
+ const opIndex = failed?.opIndex ?? 0;
152
+ const retryable = failed?.status === 'error' ? failed.retryable : false;
153
+ const detail = failed === undefined
154
+ ? ''
155
+ : ` (op ${opIndex}: ${code} — ${failed.message})`;
156
+ const replayed = terminalEvent?.replay ?? false;
157
+ throw new SeedMutationError({
158
+ clientId,
159
+ clientCommitId,
160
+ opIndex,
161
+ code,
162
+ replayed,
163
+ retryable,
164
+ message: `seedMutations: the seed commit was rejected${replayed ? ' (cached replay)' : ''}${detail}`,
165
+ ...(terminalEvent?.recordedAtMs !== undefined
166
+ ? { recordedAtMs: terminalEvent.recordedAtMs }
167
+ : {}),
168
+ ...(terminalEvent?.cacheIdentity !== undefined
169
+ ? { cacheIdentity: terminalEvent.cacheIdentity }
170
+ : {}),
171
+ });
108
172
  }
109
173
  }
@@ -93,6 +93,12 @@ export function serializePushResult(result) {
93
93
  return JSON.stringify({
94
94
  status: result.status,
95
95
  ...(result.commitSeq !== undefined ? { commitSeq: result.commitSeq } : {}),
96
+ ...(result.recordedAtMs !== undefined
97
+ ? { recordedAtMs: result.recordedAtMs }
98
+ : {}),
99
+ ...(result.cacheIdentity !== undefined
100
+ ? { cacheIdentity: result.cacheIdentity }
101
+ : {}),
96
102
  results: result.results.map((record) => {
97
103
  if (record.status === 'conflict') {
98
104
  return {
@@ -146,6 +152,12 @@ export function deserializePushResult(text) {
146
152
  return {
147
153
  status: parsed.status,
148
154
  ...(parsed.commitSeq !== undefined ? { commitSeq: parsed.commitSeq } : {}),
155
+ ...(parsed.recordedAtMs !== undefined
156
+ ? { recordedAtMs: parsed.recordedAtMs }
157
+ : {}),
158
+ ...(parsed.cacheIdentity !== undefined
159
+ ? { cacheIdentity: parsed.cacheIdentity }
160
+ : {}),
149
161
  results,
150
162
  };
151
163
  }
@@ -17,10 +17,12 @@ class SqliteTransaction {
17
17
  #storage;
18
18
  #partition;
19
19
  #open = true;
20
- #commitValidationSavepoint = false;
21
- constructor(storage, partition) {
20
+ #pushApplySavepoint = false;
21
+ #release;
22
+ constructor(storage, partition, release) {
22
23
  this.#storage = storage;
23
24
  this.#partition = partition;
25
+ this.#release = release;
24
26
  storage.db.exec('BEGIN IMMEDIATE');
25
27
  }
26
28
  #assertOpen() {
@@ -39,20 +41,20 @@ class SqliteTransaction {
39
41
  this.#assertOpen();
40
42
  return this.#storage.scanRowsByIndex(this.#partition, query);
41
43
  }
42
- async lockPartitionForCommitValidation() {
44
+ async lockPartitionForPush() {
43
45
  this.#assertOpen();
44
46
  // BEGIN IMMEDIATE in the constructor already owns SQLite's writer lock.
45
- this.#storage.db.exec('SAVEPOINT syncular_commit_validation_candidate');
46
- this.#commitValidationSavepoint = true;
47
+ this.#storage.db.exec('SAVEPOINT syncular_push_candidate');
48
+ this.#pushApplySavepoint = true;
47
49
  }
48
50
  async commitRejectedPushResult(clientId, clientCommitId, result) {
49
51
  this.#assertOpen();
50
- if (!this.#commitValidationSavepoint) {
51
- throw new Error('whole-commit rejection requires its validation savepoint');
52
+ if (!this.#pushApplySavepoint) {
53
+ throw new Error('push rejection requires its apply savepoint');
52
54
  }
53
- this.#storage.db.exec('ROLLBACK TO SAVEPOINT syncular_commit_validation_candidate');
54
- this.#storage.db.exec('RELEASE SAVEPOINT syncular_commit_validation_candidate');
55
- this.#commitValidationSavepoint = false;
55
+ this.#storage.db.exec('ROLLBACK TO SAVEPOINT syncular_push_candidate');
56
+ this.#storage.db.exec('RELEASE SAVEPOINT syncular_push_candidate');
57
+ this.#pushApplySavepoint = false;
56
58
  await this.putPushResult(clientId, clientCommitId, result);
57
59
  await this.commit();
58
60
  }
@@ -113,17 +115,29 @@ class SqliteTransaction {
113
115
  async commit() {
114
116
  this.#assertOpen();
115
117
  this.#open = false;
116
- this.#storage.db.exec('COMMIT');
118
+ try {
119
+ this.#storage.db.exec('COMMIT');
120
+ }
121
+ finally {
122
+ this.#release();
123
+ }
117
124
  }
118
125
  async rollback() {
119
126
  if (!this.#open)
120
127
  return;
121
128
  this.#open = false;
122
- this.#storage.db.exec('ROLLBACK');
129
+ try {
130
+ this.#storage.db.exec('ROLLBACK');
131
+ }
132
+ finally {
133
+ this.#release();
134
+ }
123
135
  }
124
136
  }
125
137
  export class SqliteServerStorage {
126
138
  db;
139
+ /** One bun:sqlite connection can own only one transaction at a time. */
140
+ #transactionTail = Promise.resolve();
127
141
  /** Set by `ensureSchema`: app-table lookup for the relational row store. */
128
142
  #tables;
129
143
  #schemaVersion;
@@ -237,7 +251,19 @@ export class SqliteServerStorage {
237
251
  }
238
252
  }
239
253
  async begin(partition) {
240
- return new SqliteTransaction(this, partition);
254
+ const previous = this.#transactionTail;
255
+ let release;
256
+ this.#transactionTail = new Promise((resolve) => {
257
+ release = resolve;
258
+ });
259
+ await previous;
260
+ try {
261
+ return new SqliteTransaction(this, partition, release);
262
+ }
263
+ catch (error) {
264
+ release();
265
+ throw error;
266
+ }
241
267
  }
242
268
  /** Internal: write a row + refresh its scope-index entries. */
243
269
  writeRow(partition, table, row) {
package/dist/storage.d.ts CHANGED
@@ -64,6 +64,10 @@ export interface StoredPushResult {
64
64
  readonly status: 'applied' | 'rejected';
65
65
  /** Present iff `status` is `applied`. */
66
66
  readonly commitSeq?: number;
67
+ /** Host clock when this terminal idempotency outcome was first recorded. */
68
+ readonly recordedAtMs?: number;
69
+ /** Privacy-safe identity used to distinguish this stored outcome from a race. */
70
+ readonly cacheIdentity?: string;
67
71
  readonly results: readonly PushOperationResult[];
68
72
  }
69
73
  export interface ClientSubscription {
@@ -182,15 +186,23 @@ export interface StorageTransaction {
182
186
  */
183
187
  scanRowsByIndex?(query: IndexRowScanQuery): Promise<StoredRow[]>;
184
188
  /**
185
- * Serialize candidate-state validation for this partition before any row
186
- * read/write. Required at runtime when `commitValidator` is configured.
189
+ * Serialize every push apply for this partition before any operation read,
190
+ * validation, merge, or write. The push layer re-checks idempotency only
191
+ * after this resolves and retains the lock through terminal-result commit.
192
+ * Missing support fails closed before an app-row mutation.
193
+ */
194
+ lockPartitionForPush?(): Promise<void>;
195
+ /**
196
+ * @deprecated Implement `lockPartitionForPush`. Kept as a compatibility
197
+ * bridge for custom adapters whose existing implementation already locks
198
+ * the complete partition from before candidate reads through commit.
187
199
  */
188
200
  lockPartitionForCommitValidation?(): Promise<void>;
189
201
  /**
190
- * §6.8 rejection finalization while the validation serialization lock is
191
- * still held: discard every candidate write, persist the rejected
192
- * idempotency result, and finish the transaction atomically. Required when
193
- * `commitValidator` is configured so a concurrent duplicate cannot rerun it.
202
+ * Rejection finalization while the push-apply serialization lock is still
203
+ * held: discard every candidate write, persist the rejected idempotency
204
+ * result, and finish the transaction atomically. Required for every push so
205
+ * a concurrent duplicate cannot rerun operations, validators, or merges.
194
206
  */
195
207
  commitRejectedPushResult?(clientId: string, clientCommitId: string, result: StoredPushResult): Promise<void>;
196
208
  upsertRow(table: string, row: StoredRow, context?: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syncular/server",
3
- "version": "0.15.22",
3
+ "version": "0.15.24",
4
4
  "description": "Syncular server: handleSyncRequest + storage/auth interfaces for the sync protocol",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Benjamin Kniffler",
@@ -53,7 +53,7 @@
53
53
  "!dist/**/*.test.d.ts"
54
54
  ],
55
55
  "dependencies": {
56
- "@syncular/core": "0.15.22"
56
+ "@syncular/core": "0.15.24"
57
57
  },
58
58
  "devDependencies": {
59
59
  "@electric-sql/pglite": "^0.5.4"
package/src/d1-storage.ts CHANGED
@@ -31,11 +31,11 @@
31
31
  * reading `max_commit_seq` live and buffering the `+1` write. Under a single
32
32
  * Worker request this is exact. Two concurrent pushes to one partition need an
33
33
  * external serialization point (normally a per-partition Durable Object); a
34
- * realtime notifier alone does not serialize HTTP writes. A stateless
35
- * HTTP-only deployment SHOULD front same-partition writes with a coordinating
36
- * primitive (a DO or a Queue). For §6.8 whole-commit validation this becomes
37
- * mandatory and fail-closed: the coordinator must explicitly set
38
- * `commitValidationSerialized`, because D1 cannot provide the required lock.
34
+ * realtime notifier alone does not serialize HTTP writes. Every deployment
35
+ * that accepts D1 pushes MUST front same-partition sync rounds with a
36
+ * coordinating primitive (a DO or a Queue). The adapter fails closed unless
37
+ * that coordinator explicitly sets `pushApplySerialized`, because D1 cannot
38
+ * provide the required pre-operation lock.
39
39
  * This mirrors PostgreSQL's per-partition row lock, achieved by placement
40
40
  * rather than a lock D1 does not expose.
41
41
  */
@@ -143,12 +143,12 @@ class D1Transaction implements StorageTransaction {
143
143
  readonly #db: D1Database;
144
144
  readonly #partition: string;
145
145
  readonly #resolveTable: (name: string) => CompiledTable;
146
- readonly #commitValidationSerialized: boolean;
146
+ readonly #pushApplySerialized: boolean;
147
147
  readonly #buffer: BufferedStatement[] = [];
148
148
  #open = true;
149
149
  /** Live snapshot of `max_commit_seq`, advanced within this transaction. */
150
150
  #maxCommitSeq: number | undefined;
151
- #commitValidationCheckpoint: number | undefined;
151
+ #pushApplyCheckpoint: number | undefined;
152
152
  #lastApplicationOpIndex: number | undefined;
153
153
  /**
154
154
  * Read-your-own-writes overlay (§6.2 needs `getRow` to see buffered writes
@@ -161,12 +161,12 @@ class D1Transaction implements StorageTransaction {
161
161
  db: D1Database,
162
162
  partition: string,
163
163
  resolveTable: (name: string) => CompiledTable,
164
- commitValidationSerialized: boolean,
164
+ pushApplySerialized: boolean,
165
165
  ) {
166
166
  this.#db = db;
167
167
  this.#partition = partition;
168
168
  this.#resolveTable = resolveTable;
169
- this.#commitValidationSerialized = commitValidationSerialized;
169
+ this.#pushApplySerialized = pushApplySerialized;
170
170
  }
171
171
 
172
172
  #assertOpen(): void {
@@ -312,16 +312,16 @@ class D1Transaction implements StorageTransaction {
312
312
  .slice(0, query.limit);
313
313
  }
314
314
 
315
- async lockPartitionForCommitValidation(): Promise<void> {
315
+ async lockPartitionForPush(): Promise<void> {
316
316
  this.#assertOpen();
317
- if (!this.#commitValidationSerialized) {
317
+ if (!this.#pushApplySerialized) {
318
318
  throw new Error(
319
- 'D1 whole-commit validation requires externally serialized partition writes',
319
+ 'D1 push apply requires externally serialized partition writes',
320
320
  );
321
321
  }
322
322
  // D1 has no interactive lock. The caller explicitly asserted that every
323
323
  // write for this partition is already serialized (normally by its DO).
324
- this.#commitValidationCheckpoint = this.#buffer.length;
324
+ this.#pushApplyCheckpoint = this.#buffer.length;
325
325
  }
326
326
 
327
327
  async commitRejectedPushResult(
@@ -330,11 +330,9 @@ class D1Transaction implements StorageTransaction {
330
330
  result: StoredPushResult,
331
331
  ): Promise<void> {
332
332
  this.#assertOpen();
333
- const checkpoint = this.#commitValidationCheckpoint;
333
+ const checkpoint = this.#pushApplyCheckpoint;
334
334
  if (checkpoint === undefined) {
335
- throw new Error(
336
- 'whole-commit rejection requires its validation checkpoint',
337
- );
335
+ throw new Error('push rejection requires its apply checkpoint');
338
336
  }
339
337
  this.#buffer.length = checkpoint;
340
338
  this.#pending.clear();
@@ -558,16 +556,22 @@ class D1Transaction implements StorageTransaction {
558
556
 
559
557
  async commit(): Promise<void> {
560
558
  this.#assertOpen();
561
- this.#open = false;
562
- if (this.#buffer.length === 0) return;
559
+ if (this.#buffer.length === 0) {
560
+ this.#open = false;
561
+ return;
562
+ }
563
563
  const statements = this.#buffer.map((entry) =>
564
564
  this.#db.prepare(entry.sql).bind(...entry.params),
565
565
  );
566
566
  // One atomic D1 batch — the §6.4 all-or-nothing commit.
567
567
  try {
568
568
  await this.#db.batch(statements);
569
+ this.#open = false;
569
570
  } catch (error) {
570
571
  if (isD1ConstraintError(error)) {
572
+ // D1 batches are atomic. Keep this logical transaction open so the
573
+ // push layer can discard its buffered candidates and persist the
574
+ // terminal rejection while the external partition queue is retained.
571
575
  throw new StorageConstraintError(error, this.#lastApplicationOpIndex);
572
576
  }
573
577
  throw error;
@@ -590,23 +594,30 @@ const D1_MAX_BIND_PARAMS = 100;
590
594
  export interface D1ServerStorageOptions {
591
595
  /**
592
596
  * Assert that all writes for a partition reach this storage serially.
593
- * Required for §6.8 because D1 exposes no interactive transaction lock.
594
- * Set this only inside a per-partition Durable Object or equivalent
595
- * coordinator; the default fails closed when a commit validator is used.
597
+ * Required for every push because D1 exposes no interactive transaction
598
+ * lock. Set this only inside an explicit per-partition request queue,
599
+ * Durable Object, or equivalent coordinator; the default fails closed.
600
+ */
601
+ readonly pushApplySerialized?: boolean;
602
+ /**
603
+ * @deprecated Use `pushApplySerialized`. This alias remains valid only
604
+ * because the old assertion already promised that every partition write,
605
+ * not merely validator callbacks, was externally serialized.
596
606
  */
597
607
  readonly commitValidationSerialized?: boolean;
598
608
  }
599
609
 
600
610
  export class D1ServerStorage implements ServerStorage {
601
611
  readonly #db: D1Database;
602
- readonly #commitValidationSerialized: boolean;
612
+ readonly #pushApplySerialized: boolean;
603
613
  /** Set by `ensureSchema`: app-table lookup for the relational row store. */
604
614
  #tables: ReadonlyMap<string, CompiledTable> | undefined;
605
615
  #schemaVersion: number | undefined;
606
616
 
607
617
  constructor(db: D1Database, options: D1ServerStorageOptions = {}) {
608
618
  this.#db = db;
609
- this.#commitValidationSerialized =
619
+ this.#pushApplySerialized =
620
+ options.pushApplySerialized === true ||
610
621
  options.commitValidationSerialized === true;
611
622
  }
612
623
 
@@ -769,7 +780,7 @@ export class D1ServerStorage implements ServerStorage {
769
780
  this.#db,
770
781
  partition,
771
782
  (name) => this.table(name),
772
- this.#commitValidationSerialized,
783
+ this.#pushApplySerialized,
773
784
  );
774
785
  }
775
786
 
package/src/events.ts CHANGED
@@ -63,12 +63,22 @@ export interface PushRejectedEvent extends PushEventBase {
63
63
  readonly type: 'push.rejected';
64
64
  readonly code: string;
65
65
  readonly opIndex: number;
66
+ /** True when the rejection was replayed from the idempotency cache. */
67
+ readonly replay: boolean;
68
+ /** Original host time for outcomes recorded by a metadata-aware server. */
69
+ readonly recordedAtMs?: number;
70
+ /** Privacy-safe identity of the stored outcome, when available. */
71
+ readonly cacheIdentity?: string;
66
72
  }
67
73
 
68
74
  /** A push commit terminated by a version conflict (§6.2). */
69
75
  export interface PushConflictedEvent extends PushEventBase {
70
76
  readonly type: 'push.conflicted';
71
77
  readonly opIndex: number;
78
+ /** True when the conflict was replayed from the idempotency cache. */
79
+ readonly replay: boolean;
80
+ readonly recordedAtMs?: number;
81
+ readonly cacheIdentity?: string;
72
82
  }
73
83
 
74
84
  /** One emitted segment within a pull subscription section. */