@syncular/server 0.42.2 → 0.44.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.
- package/dist/d1-storage.js +14 -4
- package/dist/operations.d.ts +5 -0
- package/dist/operations.js +4 -4
- package/dist/postgres-storage.js +386 -104
- package/dist/push.d.ts +18 -1
- package/dist/push.js +247 -132
- package/dist/relational-rows.d.ts +9 -5
- package/dist/relational-rows.js +29 -11
- package/dist/sqlite-storage.js +14 -10
- package/dist/storage.d.ts +46 -32
- package/dist/storage.js +26 -0
- package/package.json +2 -2
- package/src/d1-storage.ts +30 -5
- package/src/operations.ts +12 -7
- package/src/postgres-storage.ts +553 -166
- package/src/push.ts +358 -151
- package/src/relational-rows.ts +31 -10
- package/src/sqlite-storage.ts +33 -18
- package/src/storage.ts +75 -36
package/dist/push.d.ts
CHANGED
|
@@ -28,6 +28,20 @@ import type { StorageTransaction, StoredCommit } from './storage.js';
|
|
|
28
28
|
* row-codec decode (§5.9.1), so `parseBlobRef` here is total.
|
|
29
29
|
*/
|
|
30
30
|
export declare function blobIdsInRow(table: CompiledTable, values: readonly RowValue[]): string[];
|
|
31
|
+
/**
|
|
32
|
+
* A server-authoritative scope move (§2.2). Commands emit it through
|
|
33
|
+
* `CommandMutation`; it is not an SSP2 wire operation, so a client cannot
|
|
34
|
+
* request one. Apply rewrites the stored row in place under the new scopes
|
|
35
|
+
* and emits the delete/upsert change pair in one commit.
|
|
36
|
+
*/
|
|
37
|
+
export interface RescopeOperation {
|
|
38
|
+
readonly table: string;
|
|
39
|
+
readonly rowId: string;
|
|
40
|
+
readonly op: 'rescope';
|
|
41
|
+
/** Full-row payload (the row's values under the new scopes). */
|
|
42
|
+
readonly payload: Uint8Array;
|
|
43
|
+
}
|
|
44
|
+
export type ApplyOperation = PushOperation | RescopeOperation;
|
|
31
45
|
export interface ProcessedPushCommit {
|
|
32
46
|
readonly frame: PushResultFrame;
|
|
33
47
|
/** True when this request observed an already-recorded idempotency outcome. */
|
|
@@ -54,4 +68,7 @@ export declare function processPushCommitWithTrace(ctx: SyncRequestContext, sche
|
|
|
54
68
|
* The builder runs after the partition lock and idempotency re-check, so its
|
|
55
69
|
* reads and the returned operations share the transaction that is committed.
|
|
56
70
|
*/
|
|
57
|
-
export declare function processPushOperationsWithTrace(ctx: SyncRequestContext, schema: CompiledSchema, resolved: ResolvedScopes, clientId: string, clientCommitId: string, buildOperations: (tx: StorageTransaction) => Promise<readonly
|
|
71
|
+
export declare function processPushOperationsWithTrace(ctx: SyncRequestContext, schema: CompiledSchema, resolved: ResolvedScopes, clientId: string, clientCommitId: string, buildOperations: (tx: StorageTransaction) => Promise<readonly ApplyOperation[]>, wireSchema?: CompiledSchema, prefetchTargets?: readonly {
|
|
72
|
+
readonly table: string;
|
|
73
|
+
readonly rowIds: readonly string[];
|
|
74
|
+
}[]): Promise<ProcessedPushCommit>;
|
package/dist/push.js
CHANGED
|
@@ -172,20 +172,149 @@ async function applyOperation(tx, schema, wireSchema, resolved, op, opIndex, blo
|
|
|
172
172
|
return errorRecord(opIndex, 'sync.forbidden', 'scope resolution failed (§3.4 step 4)');
|
|
173
173
|
}
|
|
174
174
|
const stored = await tx.getRow(op.table, op.rowId);
|
|
175
|
-
if (op.op === '
|
|
175
|
+
if (op.op === 'rescope') {
|
|
176
|
+
// §2.2 explicit scope move. One stored row changes its scope values;
|
|
177
|
+
// apply rewrites it in place, keeps `server_version`, and emits the
|
|
178
|
+
// delete under the old scopes plus the upsert under the new ones.
|
|
176
179
|
if (stored === undefined) {
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
180
|
+
return errorRecord(opIndex, 'sync.row_missing', 'rescope targets an absent row (§2.2)');
|
|
181
|
+
}
|
|
182
|
+
if (!authorizeWrite(table, stored.scopes, resolved)) {
|
|
183
|
+
return errorRecord(opIndex, 'sync.forbidden', 'rescope denied by scope authorization (§3.4)');
|
|
184
|
+
}
|
|
185
|
+
const missing = missingScopeVariable(table, stored.scopes);
|
|
186
|
+
if (missing !== undefined) {
|
|
187
|
+
return errorRecord(opIndex, 'sync.missing_scopes', `stored row lacks scope variable ${JSON.stringify(missing)} (§3.1)`);
|
|
188
|
+
}
|
|
189
|
+
let sparse;
|
|
190
|
+
try {
|
|
191
|
+
sparse = decodeSparseRow(wireTable.columns, wireTable.primaryKeyIndex, op.payload);
|
|
192
|
+
}
|
|
193
|
+
catch (error) {
|
|
194
|
+
return errorRecord(opIndex, 'sync.invalid_request', `rescope payload failed row-codec decode (§1.7): ${error instanceof Error ? error.message : String(error)}`);
|
|
195
|
+
}
|
|
196
|
+
if (sparse.length < table.columns.length ||
|
|
197
|
+
sparse.some((value) => value === undefined)) {
|
|
198
|
+
return errorRecord(opIndex, 'sync.invalid_request', 'rescope requires a full-row payload (§2.2)');
|
|
199
|
+
}
|
|
200
|
+
const values = sparse.map((value) => value ?? null);
|
|
201
|
+
const pkValue = renderScopeValue(values[table.primaryKeyIndex] ?? null);
|
|
202
|
+
if (pkValue !== op.rowId) {
|
|
203
|
+
return errorRecord(opIndex, 'sync.invalid_request', 'payload primary key does not match rowId');
|
|
204
|
+
}
|
|
205
|
+
const storedValues = decodeRow(table.columns, stored.payload);
|
|
206
|
+
const scopeIndexes = new Set(table.scopePatterns.map((pattern) => pattern.columnIndex));
|
|
207
|
+
const sameValue = (left, right) => {
|
|
208
|
+
if (left instanceof Uint8Array || right instanceof Uint8Array) {
|
|
209
|
+
return (left instanceof Uint8Array &&
|
|
210
|
+
right instanceof Uint8Array &&
|
|
211
|
+
left.length === right.length &&
|
|
212
|
+
left.every((byte, index) => byte === right[index]));
|
|
213
|
+
}
|
|
214
|
+
return left === right;
|
|
215
|
+
};
|
|
216
|
+
for (let index = 0; index < table.columns.length; index++) {
|
|
217
|
+
if (index === table.primaryKeyIndex || scopeIndexes.has(index))
|
|
218
|
+
continue;
|
|
219
|
+
if (!sameValue(values[index] ?? null, storedValues[index] ?? null)) {
|
|
220
|
+
return errorRecord(opIndex, 'sync.invalid_request', 'rescope may change only scope columns (§2.2)');
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
const extracted = storedScopesForRow(table, values);
|
|
224
|
+
if ('missing' in extracted) {
|
|
225
|
+
return errorRecord(opIndex, 'sync.forbidden', `rescope missing scope column value for ${JSON.stringify(extracted.missing)} (§3.4)`);
|
|
226
|
+
}
|
|
227
|
+
if (!authorizeWrite(table, extracted.scopes, resolved)) {
|
|
228
|
+
return errorRecord(opIndex, 'sync.forbidden', 'rescope denied by scope authorization of the new scopes (§3.4)');
|
|
229
|
+
}
|
|
230
|
+
const deleteReject = await runValidator(validators, table, 'delete', op.rowId, undefined, storedValues, opIndex, tx, partition, actorId);
|
|
231
|
+
if (deleteReject !== undefined)
|
|
232
|
+
return deleteReject;
|
|
233
|
+
const upsertReject = await runValidator(validators, table, 'upsert', op.rowId, values, storedValues, opIndex, tx, partition, actorId);
|
|
234
|
+
if (upsertReject !== undefined)
|
|
235
|
+
return upsertReject;
|
|
236
|
+
const blobCheck = await checkAndRecordBlobs(tx, table, op.rowId, values, opIndex, blobCtx);
|
|
237
|
+
if (blobCheck !== undefined)
|
|
238
|
+
return blobCheck;
|
|
239
|
+
const payload = encodeRow(table.columns, values);
|
|
240
|
+
// The move clears any tombstone from an earlier incarnation: the row is
|
|
241
|
+
// live at the end of this commit, so the next move must not hit §5.2.
|
|
242
|
+
await tx.clearTombstone(op.table, op.rowId);
|
|
243
|
+
await tx.upsertRow(op.table, {
|
|
244
|
+
rowId: op.rowId,
|
|
245
|
+
serverVersion: stored.serverVersion,
|
|
246
|
+
scopes: extracted.scopes,
|
|
247
|
+
payload,
|
|
248
|
+
...(stored.columnVersions !== undefined
|
|
249
|
+
? { columnVersions: stored.columnVersions }
|
|
250
|
+
: {}),
|
|
251
|
+
}, { opIndex });
|
|
252
|
+
const storedRow = toValidateRow(table.columns, storedValues);
|
|
253
|
+
return {
|
|
254
|
+
kind: 'applied',
|
|
255
|
+
changes: [
|
|
256
|
+
{
|
|
257
|
+
table: op.table,
|
|
258
|
+
rowId: op.rowId,
|
|
259
|
+
op: 'delete',
|
|
260
|
+
scopes: stored.scopes,
|
|
261
|
+
},
|
|
262
|
+
{
|
|
263
|
+
table: op.table,
|
|
264
|
+
rowId: op.rowId,
|
|
265
|
+
op: 'upsert',
|
|
266
|
+
rowVersion: stored.serverVersion,
|
|
267
|
+
scopes: extracted.scopes,
|
|
268
|
+
payload,
|
|
269
|
+
},
|
|
270
|
+
],
|
|
271
|
+
operations: [
|
|
272
|
+
{
|
|
182
273
|
opIndex,
|
|
183
274
|
op: 'delete',
|
|
184
275
|
table: table.name,
|
|
185
276
|
rowId: op.rowId,
|
|
186
277
|
row: undefined,
|
|
187
|
-
stored:
|
|
278
|
+
stored: storedRow,
|
|
279
|
+
storedServerVersion: stored.serverVersion,
|
|
280
|
+
},
|
|
281
|
+
{
|
|
282
|
+
opIndex,
|
|
283
|
+
op: 'upsert',
|
|
284
|
+
table: table.name,
|
|
285
|
+
rowId: op.rowId,
|
|
286
|
+
row: toValidateRow(table.columns, values),
|
|
287
|
+
stored: storedRow,
|
|
288
|
+
storedServerVersion: stored.serverVersion,
|
|
289
|
+
nextServerVersion: stored.serverVersion,
|
|
188
290
|
},
|
|
291
|
+
],
|
|
292
|
+
staged: {
|
|
293
|
+
table,
|
|
294
|
+
op: 'upsert',
|
|
295
|
+
rowId: op.rowId,
|
|
296
|
+
opIndex,
|
|
297
|
+
values,
|
|
298
|
+
stored,
|
|
299
|
+
},
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
if (op.op === 'delete') {
|
|
303
|
+
if (stored === undefined) {
|
|
304
|
+
// Deleting an absent row is applied (idempotent, §6.2); no change.
|
|
305
|
+
return {
|
|
306
|
+
kind: 'applied',
|
|
307
|
+
changes: [],
|
|
308
|
+
operations: [
|
|
309
|
+
{
|
|
310
|
+
opIndex,
|
|
311
|
+
op: 'delete',
|
|
312
|
+
table: table.name,
|
|
313
|
+
rowId: op.rowId,
|
|
314
|
+
row: undefined,
|
|
315
|
+
stored: undefined,
|
|
316
|
+
},
|
|
317
|
+
],
|
|
189
318
|
staged: {
|
|
190
319
|
table,
|
|
191
320
|
op: 'delete',
|
|
@@ -212,21 +341,25 @@ async function applyOperation(tx, schema, wireSchema, resolved, op, opIndex, blo
|
|
|
212
341
|
await tx.deleteRow(op.table, op.rowId);
|
|
213
342
|
return {
|
|
214
343
|
kind: 'applied',
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
344
|
+
changes: [
|
|
345
|
+
{
|
|
346
|
+
table: op.table,
|
|
347
|
+
rowId: op.rowId,
|
|
348
|
+
op: 'delete',
|
|
349
|
+
scopes: stored.scopes,
|
|
350
|
+
},
|
|
351
|
+
],
|
|
352
|
+
operations: [
|
|
353
|
+
{
|
|
354
|
+
opIndex,
|
|
355
|
+
op: 'delete',
|
|
356
|
+
table: table.name,
|
|
357
|
+
rowId: op.rowId,
|
|
358
|
+
row: undefined,
|
|
359
|
+
stored: toValidateRow(table.columns, storedValues),
|
|
360
|
+
storedServerVersion: stored.serverVersion,
|
|
361
|
+
},
|
|
362
|
+
],
|
|
230
363
|
staged: { table, op: 'delete', rowId: op.rowId, opIndex, stored },
|
|
231
364
|
};
|
|
232
365
|
}
|
|
@@ -354,24 +487,28 @@ async function applyOperation(tx, schema, wireSchema, resolved, op, opIndex, blo
|
|
|
354
487
|
await tx.upsertRow(op.table, newRow, { opIndex });
|
|
355
488
|
return {
|
|
356
489
|
kind: 'applied',
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
490
|
+
changes: [
|
|
491
|
+
{
|
|
492
|
+
table: op.table,
|
|
493
|
+
rowId: op.rowId,
|
|
494
|
+
op: 'upsert',
|
|
495
|
+
rowVersion: newVersion,
|
|
496
|
+
scopes: stored.scopes,
|
|
497
|
+
payload: newPayload,
|
|
498
|
+
},
|
|
499
|
+
],
|
|
500
|
+
operations: [
|
|
501
|
+
{
|
|
502
|
+
opIndex,
|
|
503
|
+
op: 'upsert',
|
|
504
|
+
table: table.name,
|
|
505
|
+
rowId: op.rowId,
|
|
506
|
+
row: toValidateRow(table.columns, values),
|
|
507
|
+
stored: toValidateRow(table.columns, storedValues),
|
|
508
|
+
storedServerVersion: stored.serverVersion,
|
|
509
|
+
nextServerVersion: newVersion,
|
|
510
|
+
},
|
|
511
|
+
],
|
|
375
512
|
staged: { table, op: 'upsert', rowId: op.rowId, opIndex, values, stored },
|
|
376
513
|
};
|
|
377
514
|
}
|
|
@@ -440,23 +577,27 @@ async function applyOperation(tx, schema, wireSchema, resolved, op, opIndex, blo
|
|
|
440
577
|
await tx.upsertRow(op.table, newRow, { opIndex });
|
|
441
578
|
return {
|
|
442
579
|
kind: 'applied',
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
580
|
+
changes: [
|
|
581
|
+
{
|
|
582
|
+
table: op.table,
|
|
583
|
+
rowId: op.rowId,
|
|
584
|
+
op: 'upsert',
|
|
585
|
+
rowVersion: 1,
|
|
586
|
+
scopes: extracted.scopes,
|
|
587
|
+
payload: insertPayload,
|
|
588
|
+
},
|
|
589
|
+
],
|
|
590
|
+
operations: [
|
|
591
|
+
{
|
|
592
|
+
opIndex,
|
|
593
|
+
op: 'upsert',
|
|
594
|
+
table: table.name,
|
|
595
|
+
rowId: op.rowId,
|
|
596
|
+
row: toValidateRow(table.columns, values),
|
|
597
|
+
stored: undefined,
|
|
598
|
+
nextServerVersion: 1,
|
|
599
|
+
},
|
|
600
|
+
],
|
|
460
601
|
staged: {
|
|
461
602
|
table,
|
|
462
603
|
op: 'upsert',
|
|
@@ -856,80 +997,54 @@ export async function processPushCommit(ctx, schema, resolved, clientId, frame)
|
|
|
856
997
|
* cache provenance needed by structured events and server helpers.
|
|
857
998
|
*/
|
|
858
999
|
export async function processPushCommitWithTrace(ctx, schema, resolved, clientId, frame, wireSchema = schema) {
|
|
859
|
-
|
|
1000
|
+
// The frame names its rows before the transaction opens, so the admission
|
|
1001
|
+
// read carries the commit's read-ahead and the apply issues no separate
|
|
1002
|
+
// prefetch statement.
|
|
1003
|
+
const targets = new Map();
|
|
1004
|
+
for (const op of frame.operations) {
|
|
1005
|
+
if (!schema.tables.has(op.table))
|
|
1006
|
+
continue;
|
|
1007
|
+
const ids = targets.get(op.table) ?? [];
|
|
1008
|
+
ids.push(op.rowId);
|
|
1009
|
+
targets.set(op.table, ids);
|
|
1010
|
+
}
|
|
1011
|
+
return processPushOperationsWithTrace(ctx, schema, resolved, clientId, frame.clientCommitId, async () => frame.operations, wireSchema, targets.size === 0
|
|
1012
|
+
? undefined
|
|
1013
|
+
: [...targets].map(([table, rowIds]) => ({ table, rowIds })));
|
|
860
1014
|
}
|
|
861
1015
|
/**
|
|
862
1016
|
* Shared serialized apply path for SSP2 commits and authoritative commands.
|
|
863
1017
|
* The builder runs after the partition lock and idempotency re-check, so its
|
|
864
1018
|
* reads and the returned operations share the transaction that is committed.
|
|
865
1019
|
*/
|
|
866
|
-
export async function processPushOperationsWithTrace(ctx, schema, resolved, clientId, clientCommitId, buildOperations, wireSchema = schema) {
|
|
867
|
-
const {
|
|
868
|
-
let persisted;
|
|
869
|
-
try {
|
|
870
|
-
persisted = await storage.getPushResult(partition, clientId, clientCommitId);
|
|
871
|
-
}
|
|
872
|
-
catch (error) {
|
|
873
|
-
if (error instanceof SyncError &&
|
|
874
|
-
error.code === 'sync.idempotency_cache_miss') {
|
|
875
|
-
// §6.3: answer the retryable cache-miss for this commit rather than
|
|
876
|
-
// re-applying. Not persisted — a retry may find a readable record.
|
|
877
|
-
return {
|
|
878
|
-
frame: idempotencyCacheMissFrame(clientCommitId, error),
|
|
879
|
-
replayed: false,
|
|
880
|
-
};
|
|
881
|
-
}
|
|
882
|
-
throw error;
|
|
883
|
-
}
|
|
884
|
-
if (persisted !== undefined) {
|
|
885
|
-
return processedPushCommit(clientCommitId, persisted, true);
|
|
886
|
-
}
|
|
1020
|
+
export async function processPushOperationsWithTrace(ctx, schema, resolved, clientId, clientCommitId, buildOperations, wireSchema = schema, prefetchTargets) {
|
|
1021
|
+
const { partition } = ctx;
|
|
887
1022
|
const createdAtMs = clockOf(ctx)();
|
|
888
1023
|
const blobCtx = { store: ctx.blobs, partition };
|
|
889
1024
|
const crdtMergers = ctx.crdtMergers;
|
|
890
1025
|
const validators = ctx.validators;
|
|
891
1026
|
const commitValidator = ctx.commitValidator;
|
|
892
1027
|
const reactionPlanner = ctx.reactionPlanner;
|
|
893
|
-
const tx = await storage.begin(partition);
|
|
894
|
-
const lockPartitionForPush = tx.lockPartitionForPush?.bind(tx) ??
|
|
895
|
-
tx.lockPartitionForCommitValidation?.bind(tx);
|
|
896
|
-
const commitRejectedPushResult = tx.commitRejectedPushResult?.bind(tx);
|
|
1028
|
+
const tx = await ctx.storage.begin(partition);
|
|
897
1029
|
try {
|
|
898
|
-
if (
|
|
899
|
-
|
|
900
|
-
(reactionPlanner !== undefined && tx.enqueueReactions === undefined)) {
|
|
901
|
-
throw new Error('storage transaction does not support serialized push apply, atomic rejection finalization, and configured durable reactions');
|
|
1030
|
+
if (reactionPlanner !== undefined && tx.enqueueReactions === undefined) {
|
|
1031
|
+
throw new Error('storage transaction does not support configured durable reactions');
|
|
902
1032
|
}
|
|
903
|
-
|
|
904
|
-
//
|
|
905
|
-
//
|
|
906
|
-
//
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
await tx.rollback();
|
|
911
|
-
throw serveNotReadyError(refusal);
|
|
912
|
-
}
|
|
913
|
-
// The optimistic lookup above may have raced another delivery. Re-check
|
|
914
|
-
// only after acquiring partition serialization and before any operation
|
|
915
|
-
// read, validation, merge, or staged write. The re-check runs on the
|
|
916
|
-
// transaction's own connection when the backend provides one: a pooled
|
|
917
|
-
// Postgres client holding the partition lock would otherwise wait for a
|
|
918
|
-
// second pool slot and deadlock against pushes waiting on the lock. A
|
|
919
|
-
// racing duplicate's result commits before the lock releases, so the
|
|
920
|
-
// transaction-scoped read observes it.
|
|
1033
|
+
// The storage takes the partition lock and then reads the admission token
|
|
1034
|
+
// on the transaction's own connection: the RFC 0007 serve gate and the
|
|
1035
|
+
// §2.3 idempotency record, plus any read-ahead the caller already knows.
|
|
1036
|
+
// A racing duplicate's result commits before the lock releases, so the
|
|
1037
|
+
// later read is exact and a duplicate never reruns an operation,
|
|
1038
|
+
// validator, or merge.
|
|
1039
|
+
let prepared;
|
|
921
1040
|
try {
|
|
922
|
-
|
|
923
|
-
? await tx.getPushResult(clientId, clientCommitId)
|
|
924
|
-
: await storage.getPushResult(partition, clientId, clientCommitId);
|
|
925
|
-
if (serializedPersisted !== undefined) {
|
|
926
|
-
await tx.rollback();
|
|
927
|
-
return processedPushCommit(clientCommitId, serializedPersisted, true);
|
|
928
|
-
}
|
|
1041
|
+
prepared = await tx.preparePushApply(clientId, clientCommitId, schema.version, prefetchTargets);
|
|
929
1042
|
}
|
|
930
1043
|
catch (error) {
|
|
931
1044
|
if (error instanceof SyncError &&
|
|
932
1045
|
error.code === 'sync.idempotency_cache_miss') {
|
|
1046
|
+
// §6.3: answer the retryable cache-miss for this commit rather than
|
|
1047
|
+
// re-applying. Not persisted — a retry may find a readable record.
|
|
933
1048
|
await tx.rollback();
|
|
934
1049
|
return {
|
|
935
1050
|
frame: idempotencyCacheMissFrame(clientCommitId, error),
|
|
@@ -938,6 +1053,18 @@ export async function processPushOperationsWithTrace(ctx, schema, resolved, clie
|
|
|
938
1053
|
}
|
|
939
1054
|
throw error;
|
|
940
1055
|
}
|
|
1056
|
+
// RFC 0007: the gate is evaluated while the partition lock is held, so
|
|
1057
|
+
// the migration check and the write share one transaction and no
|
|
1058
|
+
// migration can interleave in between.
|
|
1059
|
+
const refusal = serveGateRefusal(prepared.gate, schema.version, ctx.checkpoints);
|
|
1060
|
+
if (refusal !== undefined) {
|
|
1061
|
+
await tx.rollback();
|
|
1062
|
+
throw serveNotReadyError(refusal);
|
|
1063
|
+
}
|
|
1064
|
+
if (prepared.persisted !== undefined) {
|
|
1065
|
+
await tx.rollback();
|
|
1066
|
+
return processedPushCommit(clientCommitId, prepared.persisted, true);
|
|
1067
|
+
}
|
|
941
1068
|
const results = [];
|
|
942
1069
|
const changes = [];
|
|
943
1070
|
const validatedOperations = [];
|
|
@@ -953,8 +1080,8 @@ export async function processPushOperationsWithTrace(ctx, schema, resolved, clie
|
|
|
953
1080
|
ids.push(op.rowId);
|
|
954
1081
|
targets.set(op.table, ids);
|
|
955
1082
|
}
|
|
956
|
-
|
|
957
|
-
await tx.prefetchRows(table, rowIds);
|
|
1083
|
+
if (targets.size > 0) {
|
|
1084
|
+
await tx.prefetchRows([...targets].map(([table, rowIds]) => ({ table, rowIds })));
|
|
958
1085
|
}
|
|
959
1086
|
}
|
|
960
1087
|
for (let opIndex = 0; opIndex < operations.length; opIndex++) {
|
|
@@ -967,10 +1094,9 @@ export async function processPushOperationsWithTrace(ctx, schema, resolved, clie
|
|
|
967
1094
|
break;
|
|
968
1095
|
}
|
|
969
1096
|
results.push({ opIndex, status: 'applied' });
|
|
970
|
-
validatedOperations.push(outcome.
|
|
1097
|
+
validatedOperations.push(...outcome.operations);
|
|
971
1098
|
stagedWrites.push(outcome.staged);
|
|
972
|
-
|
|
973
|
-
changes.push(outcome.change);
|
|
1099
|
+
changes.push(...outcome.changes);
|
|
974
1100
|
}
|
|
975
1101
|
// §6.11: reference enforcement runs after every client operation is
|
|
976
1102
|
// staged (so the candidate state is final for the client's own writes)
|
|
@@ -1021,12 +1147,9 @@ export async function processPushOperationsWithTrace(ctx, schema, resolved, clie
|
|
|
1021
1147
|
results: [terminated],
|
|
1022
1148
|
});
|
|
1023
1149
|
// Discard candidates and persist the rejection while retaining the same
|
|
1024
|
-
// partition lock. There is no unlock gap in which a duplicate can rerun
|
|
1025
|
-
|
|
1026
|
-
const canonical = await
|
|
1027
|
-
if (canonical === undefined) {
|
|
1028
|
-
throw new Error('push rejection finalization did not persist an outcome');
|
|
1029
|
-
}
|
|
1150
|
+
// partition lock. There is no unlock gap in which a duplicate can rerun,
|
|
1151
|
+
// and the finalization returns the canonical outcome it persisted.
|
|
1152
|
+
const canonical = await tx.commitRejectedPushResult(clientId, clientCommitId, stored);
|
|
1030
1153
|
return processedPushCommit(clientCommitId, canonical, canonical.cacheIdentity !== stored.cacheIdentity);
|
|
1031
1154
|
}
|
|
1032
1155
|
const commitSeq = await tx.appendCommit({
|
|
@@ -1104,16 +1227,8 @@ export async function processPushOperationsWithTrace(ctx, schema, resolved, clie
|
|
|
1104
1227
|
},
|
|
1105
1228
|
],
|
|
1106
1229
|
});
|
|
1107
|
-
if (commitRejectedPushResult === undefined) {
|
|
1108
|
-
await tx.rollback();
|
|
1109
|
-
throw new Error('storage transaction lost atomic push rejection finalization support');
|
|
1110
|
-
}
|
|
1111
1230
|
try {
|
|
1112
|
-
await commitRejectedPushResult(clientId, clientCommitId, stored);
|
|
1113
|
-
const canonical = await storage.getPushResult(partition, clientId, clientCommitId);
|
|
1114
|
-
if (canonical === undefined) {
|
|
1115
|
-
throw new Error('push rejection finalization did not persist an outcome');
|
|
1116
|
-
}
|
|
1231
|
+
const canonical = await tx.commitRejectedPushResult(clientId, clientCommitId, stored);
|
|
1117
1232
|
return processedPushCommit(clientCommitId, canonical, canonical.cacheIdentity !== stored.cacheIdentity);
|
|
1118
1233
|
}
|
|
1119
1234
|
catch (finalizationError) {
|
|
@@ -275,11 +275,15 @@ export declare function scanRowBatchSql(tables: readonly CompiledTable[]): strin
|
|
|
275
275
|
*/
|
|
276
276
|
export declare const COMMIT_WINDOW_BATCH_SQL = "SELECT s.i AS sub, c.commit_seq AS commit_seq, m.actor_id AS actor_id, m.created_at_ms AS created_at_ms,\n ch.tbl AS tbl, ch.row_id AS row_id, ch.op AS op, ch.row_version AS row_version, ch.scopes AS scopes, ch.payload AS payload\n FROM jsonb_to_recordset($2::text::jsonb) AS s(i int, tbl text, var text, vals text[], after bigint, through bigint, lim int)\n CROSS JOIN LATERAL (SELECT DISTINCT commit_seq FROM sync_change_scopes\n WHERE partition=$1 AND tbl=s.tbl AND var=s.var AND value=ANY(s.vals)\n AND commit_seq>s.after AND commit_seq<=s.through\n ORDER BY commit_seq LIMIT s.lim) c\n LEFT JOIN sync_commits m\n ON m.partition=$1 AND m.commit_seq=c.commit_seq\n LEFT JOIN sync_changes ch\n ON ch.partition=$1 AND ch.commit_seq=c.commit_seq AND ch.tbl=s.tbl\n ORDER BY s.i, c.commit_seq, ch.idx";
|
|
277
277
|
/**
|
|
278
|
-
* PostgreSQL push read-ahead
|
|
279
|
-
*
|
|
280
|
-
*
|
|
281
|
-
|
|
282
|
-
|
|
278
|
+
* PostgreSQL push read-ahead across every table a commit targets, in one
|
|
279
|
+
* statement: each requested row id with its stored row (NULL columns when
|
|
280
|
+
* absent, `selectRowSql` shape) and its §5 delete tombstone. Params:
|
|
281
|
+
* [partition, specs as JSON text], `specs` = `{tbl, ids: string[]}` records.
|
|
282
|
+
*/
|
|
283
|
+
export declare function prefetchRowsBatchSql(tables: readonly CompiledTable[], params?: {
|
|
284
|
+
readonly partition: string;
|
|
285
|
+
readonly specs: string;
|
|
286
|
+
}): string;
|
|
283
287
|
/**
|
|
284
288
|
* SQLite/D1: the newest `commit_seq` in the inverted change-scope index for
|
|
285
289
|
* one variable's values, as one index-only max per value (a `(partition,
|
package/dist/relational-rows.js
CHANGED
|
@@ -667,17 +667,35 @@ export const COMMIT_WINDOW_BATCH_SQL = `SELECT s.i AS sub, c.commit_seq AS commi
|
|
|
667
667
|
ON ch.partition=$1 AND ch.commit_seq=c.commit_seq AND ch.tbl=s.tbl
|
|
668
668
|
ORDER BY s.i, c.commit_seq, ch.idx`;
|
|
669
669
|
/**
|
|
670
|
-
* PostgreSQL push read-ahead
|
|
671
|
-
*
|
|
672
|
-
*
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
670
|
+
* PostgreSQL push read-ahead across every table a commit targets, in one
|
|
671
|
+
* statement: each requested row id with its stored row (NULL columns when
|
|
672
|
+
* absent, `selectRowSql` shape) and its §5 delete tombstone. Params:
|
|
673
|
+
* [partition, specs as JSON text], `specs` = `{tbl, ids: string[]}` records.
|
|
674
|
+
*/
|
|
675
|
+
export function prefetchRowsBatchSql(tables, params = {
|
|
676
|
+
partition: '$1',
|
|
677
|
+
specs: '$2',
|
|
678
|
+
}) {
|
|
679
|
+
// One branch per table: a lateral join cannot switch its relation per
|
|
680
|
+
// record, and the union keeps the whole read-ahead one round trip.
|
|
681
|
+
const projection = tables
|
|
682
|
+
.map((table) => {
|
|
683
|
+
const quoted = quoteIdent(table.name);
|
|
684
|
+
return `SELECT ids.tbl AS tbl, ids.id AS lookup_id, r.${quoteIdent(SYNC_ROW_ID_COLUMN)} AS row_id, r.${quoteIdent(SYNC_VERSION_COLUMN)} AS server_version, r.${quoteIdent(SYNC_SCOPES_COLUMN)} AS scopes, r.${quoteIdent(SYNC_PAYLOAD_COLUMN)} AS payload, r.${quoteIdent(SYNC_COLUMN_VERSIONS_COLUMN)} AS column_versions, t.commit_seq AS tombstone_seq
|
|
685
|
+
FROM ids
|
|
686
|
+
LEFT JOIN ${quoted} r
|
|
687
|
+
ON r.${quoteIdent(SYNC_PARTITION_COLUMN)}=${params.partition} AND r.${quoteIdent(SYNC_ROW_ID_COLUMN)}=ids.id
|
|
688
|
+
LEFT JOIN sync_tombstones t
|
|
689
|
+
ON t.partition=${params.partition} AND t.tbl=ids.tbl AND t.row_id=ids.id
|
|
690
|
+
WHERE ids.tbl='${table.name.replaceAll("'", "''")}'`;
|
|
691
|
+
})
|
|
692
|
+
.join('\n UNION ALL ');
|
|
693
|
+
return `WITH ids AS (
|
|
694
|
+
SELECT spec.tbl AS tbl, entry.id AS id
|
|
695
|
+
FROM jsonb_to_recordset(${params.specs}::text::jsonb) AS spec(tbl text, ids jsonb)
|
|
696
|
+
CROSS JOIN LATERAL jsonb_array_elements_text(spec.ids) AS entry(id)
|
|
697
|
+
)
|
|
698
|
+
${projection}`;
|
|
681
699
|
}
|
|
682
700
|
/**
|
|
683
701
|
* SQLite/D1: the newest `commit_seq` in the inverted change-scope index for
|
package/dist/sqlite-storage.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { retainRollbackFailure, rollbackAfterError } from './storage-errors.js';
|
|
2
2
|
import { validateCommitPruneQuery } from './prune.js';
|
|
3
3
|
import { StorageQueryError } from './storage-errors.js';
|
|
4
|
-
import { clientRoundState, PARTITION_TOUCH_INTERVAL_MS, serveGateRefusal, } from './storage.js';
|
|
4
|
+
import { clientRoundState, PARTITION_TOUCH_INTERVAL_MS, rowsLiveAfterDelete, serveGateRefusal, } from './storage.js';
|
|
5
5
|
import { serveNotReadyError } from './readiness.js';
|
|
6
6
|
/**
|
|
7
7
|
* SQLite server storage over the shared synchronous driver.
|
|
@@ -93,12 +93,6 @@ class SqliteTransaction {
|
|
|
93
93
|
.query('DELETE FROM sync_tombstones WHERE partition=? AND tbl=? AND row_id=?')
|
|
94
94
|
.run(this.#partition, table, rowId);
|
|
95
95
|
}
|
|
96
|
-
getPushResult(clientId, clientCommitId) {
|
|
97
|
-
this.#assertOpen();
|
|
98
|
-
// One shared SQLite connection: this read runs inside this
|
|
99
|
-
// transaction's BEGIN IMMEDIATE.
|
|
100
|
-
return this.#storage.getPushResult(this.#partition, clientId, clientCommitId);
|
|
101
|
-
}
|
|
102
96
|
scanRows(query) {
|
|
103
97
|
this.#assertOpen();
|
|
104
98
|
return this.#storage.scanRows(this.#partition, query);
|
|
@@ -113,11 +107,15 @@ class SqliteTransaction {
|
|
|
113
107
|
// BEGIN IMMEDIATE and observes every row staged so far.
|
|
114
108
|
return this.#storage.runAuthoritativeQuery(this.#partition, query);
|
|
115
109
|
}
|
|
116
|
-
async
|
|
110
|
+
async preparePushApply(clientId, clientCommitId, runningSchemaVersion, _targets = []) {
|
|
117
111
|
this.#assertOpen();
|
|
118
112
|
// BEGIN IMMEDIATE in the constructor already owns SQLite's writer lock.
|
|
119
113
|
this.#storage.db.exec('SAVEPOINT syncular_push_candidate');
|
|
120
114
|
this.#pushApplySavepoint = true;
|
|
115
|
+
return {
|
|
116
|
+
gate: await this.#storage.readServeGate(this.#partition, runningSchemaVersion),
|
|
117
|
+
persisted: await this.#storage.getPushResult(this.#partition, clientId, clientCommitId),
|
|
118
|
+
};
|
|
121
119
|
}
|
|
122
120
|
async advanceCheckpoint(name, ownerEpoch, watermark, observedRows, nowMs) {
|
|
123
121
|
this.#assertOpen();
|
|
@@ -144,6 +142,9 @@ class SqliteTransaction {
|
|
|
144
142
|
this.#pushApplySavepoint = false;
|
|
145
143
|
await this.putPushResult(clientId, clientCommitId, result);
|
|
146
144
|
await this.commit();
|
|
145
|
+
// The idempotency re-check found no record under the writer lock, so the
|
|
146
|
+
// insert above is the canonical outcome.
|
|
147
|
+
return result;
|
|
147
148
|
}
|
|
148
149
|
async upsertRow(table, row, context) {
|
|
149
150
|
this.#assertOpen();
|
|
@@ -186,11 +187,14 @@ class SqliteTransaction {
|
|
|
186
187
|
const commitSeq = (row?.max_commit_seq ?? 0) + 1;
|
|
187
188
|
db.query('UPDATE sync_partitions SET max_commit_seq=? WHERE partition=?').run(commitSeq, p);
|
|
188
189
|
db.query('INSERT INTO sync_commits(partition, commit_seq, client_id, client_commit_id, actor_id, created_at_ms, writer_version) VALUES (?,?,?,?,?,?,?)').run(p, commitSeq, commit.clientId, commit.clientCommitId, commit.actorId, commit.createdAtMs, this.#writerVersion);
|
|
190
|
+
const liveAfterDelete = rowsLiveAfterDelete(commit.changes);
|
|
189
191
|
commit.changes.forEach((change, idx) => {
|
|
190
192
|
db.query('INSERT INTO sync_changes(partition, commit_seq, idx, tbl, row_id, op, row_version, scopes, payload) VALUES (?,?,?,?,?,?,?,?,?)').run(p, commitSeq, idx, change.table, change.rowId, change.op === 'upsert' ? 1 : 2, change.rowVersion ?? null, JSON.stringify(change.scopes), change.payload ?? null);
|
|
191
|
-
if (change.op === 'delete'
|
|
193
|
+
if (change.op === 'delete' &&
|
|
194
|
+
!liveAfterDelete.has(`${change.table}\u0000${change.rowId}`)) {
|
|
192
195
|
// §5 delete precedence: every applied delete leaves a tombstone,
|
|
193
|
-
// pruned with the commit log (§4.6).
|
|
196
|
+
// pruned with the commit log (§4.6). A delete that the same commit
|
|
197
|
+
// follows with an upsert is a §2.2 scope move, not a deletion.
|
|
194
198
|
db.query(`INSERT INTO sync_tombstones(partition, tbl, row_id, commit_seq) VALUES (?,?,?,?)
|
|
195
199
|
ON CONFLICT(partition, tbl, row_id) DO UPDATE SET commit_seq=excluded.commit_seq`).run(p, change.table, change.rowId, commitSeq);
|
|
196
200
|
}
|