@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.
package/src/seed.ts CHANGED
@@ -21,6 +21,8 @@ import {
21
21
  } from '@syncular/core';
22
22
  import type { SyncServerConfig } from './context';
23
23
  import { SyncError } from './errors';
24
+ import type { SyncularServerEvent, SyncularServerEvents } from './events';
25
+ import { composeEvents } from './events-ring';
24
26
  import { handleSyncRequest } from './handler';
25
27
 
26
28
  /** One app-shaped seed mutation — the same vocabulary as client mutations. */
@@ -54,6 +56,48 @@ export interface SeedTarget {
54
56
  readonly commitId?: string;
55
57
  }
56
58
 
59
+ export interface SeedMutationErrorOptions {
60
+ readonly clientId: string;
61
+ readonly clientCommitId: string;
62
+ readonly opIndex: number;
63
+ readonly code: string;
64
+ readonly replayed: boolean;
65
+ readonly retryable: boolean;
66
+ readonly message: string;
67
+ readonly recordedAtMs?: number;
68
+ readonly cacheIdentity?: string;
69
+ }
70
+
71
+ /** Structured terminal failure from the real push path used by a seed. */
72
+ export class SeedMutationError extends Error {
73
+ override readonly name = 'SeedMutationError';
74
+ readonly clientId: string;
75
+ readonly clientCommitId: string;
76
+ readonly opIndex: number;
77
+ /** Exact protocol or host-validator rejection code. */
78
+ readonly code: string;
79
+ readonly replayed: boolean;
80
+ readonly retryable: boolean;
81
+ readonly recordedAtMs?: number;
82
+ readonly cacheIdentity?: string;
83
+
84
+ constructor(options: SeedMutationErrorOptions) {
85
+ super(options.message);
86
+ this.clientId = options.clientId;
87
+ this.clientCommitId = options.clientCommitId;
88
+ this.opIndex = options.opIndex;
89
+ this.code = options.code;
90
+ this.replayed = options.replayed;
91
+ this.retryable = options.retryable;
92
+ if (options.recordedAtMs !== undefined) {
93
+ this.recordedAtMs = options.recordedAtMs;
94
+ }
95
+ if (options.cacheIdentity !== undefined) {
96
+ this.cacheIdentity = options.cacheIdentity;
97
+ }
98
+ }
99
+ }
100
+
57
101
  const MAPPABLE_RE = /^_*[A-Za-z][A-Za-z0-9_]*$/;
58
102
 
59
103
  /** Pinned §12 schema alias used by generated row types and every client host. */
@@ -79,8 +123,8 @@ function snakeToCamel(name: string): string {
79
123
 
80
124
  /**
81
125
  * Seed `mutations` into a partition through the real push path. Throws a
82
- * `SyncError` when the push is rejected or any operation fails, so a broken
83
- * seed fails loud at boot instead of silently serving an empty database.
126
+ * `SeedMutationError` when the push is rejected and `SyncError` for malformed
127
+ * helper input, so a broken seed fails loud instead of serving an empty store.
84
128
  */
85
129
  export async function seedMutations(
86
130
  config: SyncServerConfig,
@@ -154,13 +198,37 @@ export async function seedMutations(
154
198
  accept: 0b0011,
155
199
  },
156
200
  ];
201
+ type SeedTerminalEvent = Extract<
202
+ SyncularServerEvent,
203
+ { type: 'push.rejected' | 'push.conflicted' }
204
+ >;
205
+ let terminalEvent: SeedTerminalEvent | undefined;
206
+ const capture: SyncularServerEvents = {
207
+ emit(event) {
208
+ if (
209
+ (event.type === 'push.rejected' || event.type === 'push.conflicted') &&
210
+ event.clientId === clientId &&
211
+ event.clientCommitId === clientCommitId
212
+ ) {
213
+ terminalEvent = event;
214
+ }
215
+ },
216
+ };
157
217
  const response = await handleSyncRequest(
158
218
  encodeMessage({
159
219
  wireVersion: PROTOCOL_WIRE_VERSION,
160
220
  msgKind: 'request',
161
221
  frames,
162
222
  }),
163
- { ...config, partition: target.partition, actorId: target.actorId },
223
+ {
224
+ ...config,
225
+ partition: target.partition,
226
+ actorId: target.actorId,
227
+ events:
228
+ config.events === undefined
229
+ ? capture
230
+ : composeEvents(config.events, capture),
231
+ },
164
232
  );
165
233
 
166
234
  // Fail loud: surface the first rejected/failed operation.
@@ -177,13 +245,30 @@ export async function seedMutations(
177
245
  }
178
246
  if (result.status === 'rejected') {
179
247
  const failed = result.results.find((r) => r.status !== 'applied');
248
+ const code = failed?.code ?? 'sync.invalid_request';
249
+ const opIndex = failed?.opIndex ?? 0;
250
+ const retryable = failed?.status === 'error' ? failed.retryable : false;
180
251
  const detail =
181
- failed !== undefined && 'code' in failed
182
- ? ` (op ${failed.opIndex}: ${failed.code} — ${failed.message})`
183
- : '';
184
- throw new SyncError(
185
- 'sync.invalid_request',
186
- `seedMutations: the seed commit was rejected${detail}`,
187
- );
252
+ failed === undefined
253
+ ? ''
254
+ : ` (op ${opIndex}: ${code} — ${failed.message})`;
255
+ const replayed = terminalEvent?.replay ?? false;
256
+ throw new SeedMutationError({
257
+ clientId,
258
+ clientCommitId,
259
+ opIndex,
260
+ code,
261
+ replayed,
262
+ retryable,
263
+ message: `seedMutations: the seed commit was rejected${
264
+ replayed ? ' (cached replay)' : ''
265
+ }${detail}`,
266
+ ...(terminalEvent?.recordedAtMs !== undefined
267
+ ? { recordedAtMs: terminalEvent.recordedAtMs }
268
+ : {}),
269
+ ...(terminalEvent?.cacheIdentity !== undefined
270
+ ? { cacheIdentity: terminalEvent.cacheIdentity }
271
+ : {}),
272
+ });
188
273
  }
189
274
  }
@@ -134,6 +134,12 @@ export function serializePushResult(result: StoredPushResult): string {
134
134
  return JSON.stringify({
135
135
  status: result.status,
136
136
  ...(result.commitSeq !== undefined ? { commitSeq: result.commitSeq } : {}),
137
+ ...(result.recordedAtMs !== undefined
138
+ ? { recordedAtMs: result.recordedAtMs }
139
+ : {}),
140
+ ...(result.cacheIdentity !== undefined
141
+ ? { cacheIdentity: result.cacheIdentity }
142
+ : {}),
137
143
  results: result.results.map((record) => {
138
144
  if (record.status === 'conflict') {
139
145
  return {
@@ -164,6 +170,8 @@ export function deserializePushResult(text: string): StoredPushResult {
164
170
  const parsed = JSON.parse(text) as {
165
171
  status: 'applied' | 'rejected';
166
172
  commitSeq?: number;
173
+ recordedAtMs?: number;
174
+ cacheIdentity?: string;
167
175
  results: SerializedResult[];
168
176
  };
169
177
  const results: PushOperationResult[] = parsed.results.map((record) => {
@@ -192,6 +200,12 @@ export function deserializePushResult(text: string): StoredPushResult {
192
200
  return {
193
201
  status: parsed.status,
194
202
  ...(parsed.commitSeq !== undefined ? { commitSeq: parsed.commitSeq } : {}),
203
+ ...(parsed.recordedAtMs !== undefined
204
+ ? { recordedAtMs: parsed.recordedAtMs }
205
+ : {}),
206
+ ...(parsed.cacheIdentity !== undefined
207
+ ? { cacheIdentity: parsed.cacheIdentity }
208
+ : {}),
195
209
  results,
196
210
  };
197
211
  }
@@ -69,11 +69,17 @@ class SqliteTransaction implements StorageTransaction {
69
69
  #storage: SqliteServerStorage;
70
70
  #partition: string;
71
71
  #open = true;
72
- #commitValidationSavepoint = false;
72
+ #pushApplySavepoint = false;
73
+ readonly #release: () => void;
73
74
 
74
- constructor(storage: SqliteServerStorage, partition: string) {
75
+ constructor(
76
+ storage: SqliteServerStorage,
77
+ partition: string,
78
+ release: () => void,
79
+ ) {
75
80
  this.#storage = storage;
76
81
  this.#partition = partition;
82
+ this.#release = release;
77
83
  storage.db.exec('BEGIN IMMEDIATE');
78
84
  }
79
85
 
@@ -96,11 +102,11 @@ class SqliteTransaction implements StorageTransaction {
96
102
  return this.#storage.scanRowsByIndex(this.#partition, query);
97
103
  }
98
104
 
99
- async lockPartitionForCommitValidation(): Promise<void> {
105
+ async lockPartitionForPush(): Promise<void> {
100
106
  this.#assertOpen();
101
107
  // BEGIN IMMEDIATE in the constructor already owns SQLite's writer lock.
102
- this.#storage.db.exec('SAVEPOINT syncular_commit_validation_candidate');
103
- this.#commitValidationSavepoint = true;
108
+ this.#storage.db.exec('SAVEPOINT syncular_push_candidate');
109
+ this.#pushApplySavepoint = true;
104
110
  }
105
111
 
106
112
  async commitRejectedPushResult(
@@ -109,18 +115,12 @@ class SqliteTransaction implements StorageTransaction {
109
115
  result: StoredPushResult,
110
116
  ): Promise<void> {
111
117
  this.#assertOpen();
112
- if (!this.#commitValidationSavepoint) {
113
- throw new Error(
114
- 'whole-commit rejection requires its validation savepoint',
115
- );
118
+ if (!this.#pushApplySavepoint) {
119
+ throw new Error('push rejection requires its apply savepoint');
116
120
  }
117
- this.#storage.db.exec(
118
- 'ROLLBACK TO SAVEPOINT syncular_commit_validation_candidate',
119
- );
120
- this.#storage.db.exec(
121
- 'RELEASE SAVEPOINT syncular_commit_validation_candidate',
122
- );
123
- this.#commitValidationSavepoint = false;
121
+ this.#storage.db.exec('ROLLBACK TO SAVEPOINT syncular_push_candidate');
122
+ this.#storage.db.exec('RELEASE SAVEPOINT syncular_push_candidate');
123
+ this.#pushApplySavepoint = false;
124
124
  await this.putPushResult(clientId, clientCommitId, result);
125
125
  await this.commit();
126
126
  }
@@ -245,18 +245,28 @@ class SqliteTransaction implements StorageTransaction {
245
245
  async commit(): Promise<void> {
246
246
  this.#assertOpen();
247
247
  this.#open = false;
248
- this.#storage.db.exec('COMMIT');
248
+ try {
249
+ this.#storage.db.exec('COMMIT');
250
+ } finally {
251
+ this.#release();
252
+ }
249
253
  }
250
254
 
251
255
  async rollback(): Promise<void> {
252
256
  if (!this.#open) return;
253
257
  this.#open = false;
254
- this.#storage.db.exec('ROLLBACK');
258
+ try {
259
+ this.#storage.db.exec('ROLLBACK');
260
+ } finally {
261
+ this.#release();
262
+ }
255
263
  }
256
264
  }
257
265
 
258
266
  export class SqliteServerStorage implements ServerStorage {
259
267
  readonly db: Database;
268
+ /** One bun:sqlite connection can own only one transaction at a time. */
269
+ #transactionTail: Promise<void> = Promise.resolve();
260
270
  /** Set by `ensureSchema`: app-table lookup for the relational row store. */
261
271
  #tables: ReadonlyMap<string, CompiledTable> | undefined;
262
272
  #schemaVersion: number | undefined;
@@ -405,7 +415,18 @@ export class SqliteServerStorage implements ServerStorage {
405
415
  }
406
416
 
407
417
  async begin(partition: string): Promise<StorageTransaction> {
408
- return new SqliteTransaction(this, partition);
418
+ const previous = this.#transactionTail;
419
+ let release!: () => void;
420
+ this.#transactionTail = new Promise<void>((resolve) => {
421
+ release = resolve;
422
+ });
423
+ await previous;
424
+ try {
425
+ return new SqliteTransaction(this, partition, release);
426
+ } catch (error) {
427
+ release();
428
+ throw error;
429
+ }
409
430
  }
410
431
 
411
432
  /** Internal: write a row + refresh its scope-index entries. */
package/src/storage.ts CHANGED
@@ -70,6 +70,10 @@ export interface StoredPushResult {
70
70
  readonly status: 'applied' | 'rejected';
71
71
  /** Present iff `status` is `applied`. */
72
72
  readonly commitSeq?: number;
73
+ /** Host clock when this terminal idempotency outcome was first recorded. */
74
+ readonly recordedAtMs?: number;
75
+ /** Privacy-safe identity used to distinguish this stored outcome from a race. */
76
+ readonly cacheIdentity?: string;
73
77
  readonly results: readonly PushOperationResult[];
74
78
  }
75
79
 
@@ -199,15 +203,23 @@ export interface StorageTransaction {
199
203
  */
200
204
  scanRowsByIndex?(query: IndexRowScanQuery): Promise<StoredRow[]>;
201
205
  /**
202
- * Serialize candidate-state validation for this partition before any row
203
- * read/write. Required at runtime when `commitValidator` is configured.
206
+ * Serialize every push apply for this partition before any operation read,
207
+ * validation, merge, or write. The push layer re-checks idempotency only
208
+ * after this resolves and retains the lock through terminal-result commit.
209
+ * Missing support fails closed before an app-row mutation.
210
+ */
211
+ lockPartitionForPush?(): Promise<void>;
212
+ /**
213
+ * @deprecated Implement `lockPartitionForPush`. Kept as a compatibility
214
+ * bridge for custom adapters whose existing implementation already locks
215
+ * the complete partition from before candidate reads through commit.
204
216
  */
205
217
  lockPartitionForCommitValidation?(): Promise<void>;
206
218
  /**
207
- * §6.8 rejection finalization while the validation serialization lock is
208
- * still held: discard every candidate write, persist the rejected
209
- * idempotency result, and finish the transaction atomically. Required when
210
- * `commitValidator` is configured so a concurrent duplicate cannot rerun it.
219
+ * Rejection finalization while the push-apply serialization lock is still
220
+ * held: discard every candidate write, persist the rejected idempotency
221
+ * result, and finish the transaction atomically. Required for every push so
222
+ * a concurrent duplicate cannot rerun operations, validators, or merges.
211
223
  */
212
224
  commitRejectedPushResult?(
213
225
  clientId: string,