@syncular/server 0.43.0 → 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 +8 -1
- package/dist/postgres-storage.js +385 -119
- package/dist/push.d.ts +4 -1
- package/dist/push.js +47 -72
- package/dist/relational-rows.d.ts +9 -5
- package/dist/relational-rows.js +29 -11
- package/dist/sqlite-storage.js +8 -7
- package/dist/storage.d.ts +39 -32
- package/package.json +2 -2
- package/src/d1-storage.ts +18 -2
- package/src/postgres-storage.ts +552 -187
- package/src/push.ts +63 -88
- package/src/relational-rows.ts +31 -10
- package/src/sqlite-storage.ts +25 -16
- package/src/storage.ts +48 -36
package/dist/push.d.ts
CHANGED
|
@@ -68,4 +68,7 @@ export declare function processPushCommitWithTrace(ctx: SyncRequestContext, sche
|
|
|
68
68
|
* The builder runs after the partition lock and idempotency re-check, so its
|
|
69
69
|
* reads and the returned operations share the transaction that is committed.
|
|
70
70
|
*/
|
|
71
|
-
export declare function processPushOperationsWithTrace(ctx: SyncRequestContext, schema: CompiledSchema, resolved: ResolvedScopes, clientId: string, clientCommitId: string, buildOperations: (tx: StorageTransaction) => Promise<readonly ApplyOperation[]>, wireSchema?: CompiledSchema
|
|
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
|
@@ -997,80 +997,54 @@ export async function processPushCommit(ctx, schema, resolved, clientId, frame)
|
|
|
997
997
|
* cache provenance needed by structured events and server helpers.
|
|
998
998
|
*/
|
|
999
999
|
export async function processPushCommitWithTrace(ctx, schema, resolved, clientId, frame, wireSchema = schema) {
|
|
1000
|
-
|
|
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 })));
|
|
1001
1014
|
}
|
|
1002
1015
|
/**
|
|
1003
1016
|
* Shared serialized apply path for SSP2 commits and authoritative commands.
|
|
1004
1017
|
* The builder runs after the partition lock and idempotency re-check, so its
|
|
1005
1018
|
* reads and the returned operations share the transaction that is committed.
|
|
1006
1019
|
*/
|
|
1007
|
-
export async function processPushOperationsWithTrace(ctx, schema, resolved, clientId, clientCommitId, buildOperations, wireSchema = schema) {
|
|
1008
|
-
const {
|
|
1009
|
-
let persisted;
|
|
1010
|
-
try {
|
|
1011
|
-
persisted = await storage.getPushResult(partition, clientId, clientCommitId);
|
|
1012
|
-
}
|
|
1013
|
-
catch (error) {
|
|
1014
|
-
if (error instanceof SyncError &&
|
|
1015
|
-
error.code === 'sync.idempotency_cache_miss') {
|
|
1016
|
-
// §6.3: answer the retryable cache-miss for this commit rather than
|
|
1017
|
-
// re-applying. Not persisted — a retry may find a readable record.
|
|
1018
|
-
return {
|
|
1019
|
-
frame: idempotencyCacheMissFrame(clientCommitId, error),
|
|
1020
|
-
replayed: false,
|
|
1021
|
-
};
|
|
1022
|
-
}
|
|
1023
|
-
throw error;
|
|
1024
|
-
}
|
|
1025
|
-
if (persisted !== undefined) {
|
|
1026
|
-
return processedPushCommit(clientCommitId, persisted, true);
|
|
1027
|
-
}
|
|
1020
|
+
export async function processPushOperationsWithTrace(ctx, schema, resolved, clientId, clientCommitId, buildOperations, wireSchema = schema, prefetchTargets) {
|
|
1021
|
+
const { partition } = ctx;
|
|
1028
1022
|
const createdAtMs = clockOf(ctx)();
|
|
1029
1023
|
const blobCtx = { store: ctx.blobs, partition };
|
|
1030
1024
|
const crdtMergers = ctx.crdtMergers;
|
|
1031
1025
|
const validators = ctx.validators;
|
|
1032
1026
|
const commitValidator = ctx.commitValidator;
|
|
1033
1027
|
const reactionPlanner = ctx.reactionPlanner;
|
|
1034
|
-
const tx = await storage.begin(partition);
|
|
1035
|
-
const lockPartitionForPush = tx.lockPartitionForPush?.bind(tx) ??
|
|
1036
|
-
tx.lockPartitionForCommitValidation?.bind(tx);
|
|
1037
|
-
const commitRejectedPushResult = tx.commitRejectedPushResult?.bind(tx);
|
|
1028
|
+
const tx = await ctx.storage.begin(partition);
|
|
1038
1029
|
try {
|
|
1039
|
-
if (
|
|
1040
|
-
|
|
1041
|
-
(reactionPlanner !== undefined && tx.enqueueReactions === undefined)) {
|
|
1042
|
-
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');
|
|
1043
1032
|
}
|
|
1044
|
-
|
|
1045
|
-
//
|
|
1046
|
-
//
|
|
1047
|
-
//
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
await tx.rollback();
|
|
1052
|
-
throw serveNotReadyError(refusal);
|
|
1053
|
-
}
|
|
1054
|
-
// The optimistic lookup above may have raced another delivery. Re-check
|
|
1055
|
-
// only after acquiring partition serialization and before any operation
|
|
1056
|
-
// read, validation, merge, or staged write. The re-check runs on the
|
|
1057
|
-
// transaction's own connection when the backend provides one: a pooled
|
|
1058
|
-
// Postgres client holding the partition lock would otherwise wait for a
|
|
1059
|
-
// second pool slot and deadlock against pushes waiting on the lock. A
|
|
1060
|
-
// racing duplicate's result commits before the lock releases, so the
|
|
1061
|
-
// 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;
|
|
1062
1040
|
try {
|
|
1063
|
-
|
|
1064
|
-
? await tx.getPushResult(clientId, clientCommitId)
|
|
1065
|
-
: await storage.getPushResult(partition, clientId, clientCommitId);
|
|
1066
|
-
if (serializedPersisted !== undefined) {
|
|
1067
|
-
await tx.rollback();
|
|
1068
|
-
return processedPushCommit(clientCommitId, serializedPersisted, true);
|
|
1069
|
-
}
|
|
1041
|
+
prepared = await tx.preparePushApply(clientId, clientCommitId, schema.version, prefetchTargets);
|
|
1070
1042
|
}
|
|
1071
1043
|
catch (error) {
|
|
1072
1044
|
if (error instanceof SyncError &&
|
|
1073
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.
|
|
1074
1048
|
await tx.rollback();
|
|
1075
1049
|
return {
|
|
1076
1050
|
frame: idempotencyCacheMissFrame(clientCommitId, error),
|
|
@@ -1079,6 +1053,18 @@ export async function processPushOperationsWithTrace(ctx, schema, resolved, clie
|
|
|
1079
1053
|
}
|
|
1080
1054
|
throw error;
|
|
1081
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
|
+
}
|
|
1082
1068
|
const results = [];
|
|
1083
1069
|
const changes = [];
|
|
1084
1070
|
const validatedOperations = [];
|
|
@@ -1094,8 +1080,8 @@ export async function processPushOperationsWithTrace(ctx, schema, resolved, clie
|
|
|
1094
1080
|
ids.push(op.rowId);
|
|
1095
1081
|
targets.set(op.table, ids);
|
|
1096
1082
|
}
|
|
1097
|
-
|
|
1098
|
-
await tx.prefetchRows(table, rowIds);
|
|
1083
|
+
if (targets.size > 0) {
|
|
1084
|
+
await tx.prefetchRows([...targets].map(([table, rowIds]) => ({ table, rowIds })));
|
|
1099
1085
|
}
|
|
1100
1086
|
}
|
|
1101
1087
|
for (let opIndex = 0; opIndex < operations.length; opIndex++) {
|
|
@@ -1161,12 +1147,9 @@ export async function processPushOperationsWithTrace(ctx, schema, resolved, clie
|
|
|
1161
1147
|
results: [terminated],
|
|
1162
1148
|
});
|
|
1163
1149
|
// Discard candidates and persist the rejection while retaining the same
|
|
1164
|
-
// partition lock. There is no unlock gap in which a duplicate can rerun
|
|
1165
|
-
|
|
1166
|
-
const canonical = await
|
|
1167
|
-
if (canonical === undefined) {
|
|
1168
|
-
throw new Error('push rejection finalization did not persist an outcome');
|
|
1169
|
-
}
|
|
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);
|
|
1170
1153
|
return processedPushCommit(clientCommitId, canonical, canonical.cacheIdentity !== stored.cacheIdentity);
|
|
1171
1154
|
}
|
|
1172
1155
|
const commitSeq = await tx.appendCommit({
|
|
@@ -1244,16 +1227,8 @@ export async function processPushOperationsWithTrace(ctx, schema, resolved, clie
|
|
|
1244
1227
|
},
|
|
1245
1228
|
],
|
|
1246
1229
|
});
|
|
1247
|
-
if (commitRejectedPushResult === undefined) {
|
|
1248
|
-
await tx.rollback();
|
|
1249
|
-
throw new Error('storage transaction lost atomic push rejection finalization support');
|
|
1250
|
-
}
|
|
1251
1230
|
try {
|
|
1252
|
-
await commitRejectedPushResult(clientId, clientCommitId, stored);
|
|
1253
|
-
const canonical = await storage.getPushResult(partition, clientId, clientCommitId);
|
|
1254
|
-
if (canonical === undefined) {
|
|
1255
|
-
throw new Error('push rejection finalization did not persist an outcome');
|
|
1256
|
-
}
|
|
1231
|
+
const canonical = await tx.commitRejectedPushResult(clientId, clientCommitId, stored);
|
|
1257
1232
|
return processedPushCommit(clientCommitId, canonical, canonical.cacheIdentity !== stored.cacheIdentity);
|
|
1258
1233
|
}
|
|
1259
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
|
@@ -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();
|
package/dist/storage.d.ts
CHANGED
|
@@ -67,6 +67,17 @@ export interface ServeGate {
|
|
|
67
67
|
readonly name: string;
|
|
68
68
|
}[];
|
|
69
69
|
}
|
|
70
|
+
/**
|
|
71
|
+
* The push apply's admission token: the partition lock, the serve gate, and
|
|
72
|
+
* the idempotency record read in one round trip. A storage with per-statement
|
|
73
|
+
* network cost answers all three with one statement (see
|
|
74
|
+
* `PostgresServerStorage`); the lock is held from this call through commit.
|
|
75
|
+
*/
|
|
76
|
+
export interface PreparedPushApply {
|
|
77
|
+
readonly gate: ServeGate;
|
|
78
|
+
/** The already-persisted outcome for this key, or `undefined` on a miss. */
|
|
79
|
+
readonly persisted: StoredPushResult | undefined;
|
|
80
|
+
}
|
|
70
81
|
/** Why `serveGate` refuses, or `undefined` when this request may serve. */
|
|
71
82
|
export type ServeGateRefusal = {
|
|
72
83
|
readonly kind: 'schema';
|
|
@@ -416,12 +427,15 @@ export interface StorageTransaction {
|
|
|
416
427
|
getRow(table: string, rowId: string): Promise<StoredRow | undefined>;
|
|
417
428
|
/**
|
|
418
429
|
* Optional read-ahead: the push layer names every row a commit's
|
|
419
|
-
* operations target,
|
|
420
|
-
* per-statement network cost reads them in one statement
|
|
421
|
-
* following `getRow` calls from that snapshot
|
|
422
|
-
* this transaction.
|
|
430
|
+
* operations target, across every table, before it applies them. A storage
|
|
431
|
+
* with per-statement network cost reads them in one statement (one branch
|
|
432
|
+
* per table) and answers the following `getRow` calls from that snapshot
|
|
433
|
+
* until the row is written in this transaction.
|
|
423
434
|
*/
|
|
424
|
-
prefetchRows?(
|
|
435
|
+
prefetchRows?(targets: readonly {
|
|
436
|
+
readonly table: string;
|
|
437
|
+
readonly rowIds: readonly string[];
|
|
438
|
+
}[]): Promise<void>;
|
|
425
439
|
/**
|
|
426
440
|
* §5 delete-precedence tombstone read: the `commit_seq` of the newest
|
|
427
441
|
* applied delete of this row still within the pruning horizon, or
|
|
@@ -436,17 +450,6 @@ export interface StorageTransaction {
|
|
|
436
450
|
* over an absent row; a no-op when no tombstone exists.
|
|
437
451
|
*/
|
|
438
452
|
clearTombstone(table: string, rowId: string): Promise<void>;
|
|
439
|
-
/**
|
|
440
|
-
* Optional transaction-scoped idempotency lookup (§2.3) with the same
|
|
441
|
-
* semantics as `ServerStorage.getPushResult`, including the
|
|
442
|
-
* `sync.idempotency_cache_miss` throw. The push layer prefers it for the
|
|
443
|
-
* post-serialization duplicate re-check so the read runs on the
|
|
444
|
-
* transaction's own connection — a pooled Postgres client that holds the
|
|
445
|
-
* partition lock must never wait for a second pool slot mid-push. In-tree
|
|
446
|
-
* SQLite/Postgres/D1 adapters implement it; a custom adapter that omits it
|
|
447
|
-
* keeps the pool-level read.
|
|
448
|
-
*/
|
|
449
|
-
getPushResult?(clientId: string, clientCommitId: string): Promise<StoredPushResult | undefined>;
|
|
450
453
|
/**
|
|
451
454
|
* Optional candidate-state scan used only by whole-commit validation.
|
|
452
455
|
* In-tree SQLite/Postgres/D1 backends implement it with read-your-own-writes
|
|
@@ -474,24 +477,28 @@ export interface StorageTransaction {
|
|
|
474
477
|
queryAuthoritative?(query: AuthoritativeQueryRequest): Promise<AuthoritativeQueryResult>;
|
|
475
478
|
/**
|
|
476
479
|
* Serialize every push apply for this partition before any operation read,
|
|
477
|
-
* validation, merge, or write
|
|
478
|
-
*
|
|
480
|
+
* validation, merge, or write, and read the admission token in the same
|
|
481
|
+
* round trip: the RFC 0007 serve gate and the §2.3 idempotency record for
|
|
482
|
+
* `clientCommitId`. The lock is retained through terminal-result commit,
|
|
483
|
+
* so the idempotency read is exact and a racing duplicate cannot rerun.
|
|
484
|
+
* `targets` are the rows the commit is known to touch before its operations
|
|
485
|
+
* are built; a storage with per-statement network cost folds that read-ahead
|
|
486
|
+
* into the admission read and answers the following `getRow` calls from it.
|
|
479
487
|
* Missing support fails closed before an app-row mutation.
|
|
480
488
|
*/
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
* the complete partition from before candidate reads through commit.
|
|
486
|
-
*/
|
|
487
|
-
lockPartitionForCommitValidation?(): Promise<void>;
|
|
489
|
+
preparePushApply(clientId: string, clientCommitId: string, runningSchemaVersion: number, targets?: readonly {
|
|
490
|
+
readonly table: string;
|
|
491
|
+
readonly rowIds: readonly string[];
|
|
492
|
+
}[]): Promise<PreparedPushApply>;
|
|
488
493
|
/**
|
|
489
494
|
* Rejection finalization while the push-apply serialization lock is still
|
|
490
495
|
* held: discard every candidate write, persist the rejected idempotency
|
|
491
|
-
* result, and finish the transaction atomically.
|
|
492
|
-
*
|
|
496
|
+
* result, and finish the transaction atomically. Returns the persisted
|
|
497
|
+
* canonical outcome, so the caller never re-reads it. Required for every
|
|
498
|
+
* push so a concurrent duplicate cannot rerun operations, validators, or
|
|
499
|
+
* merges.
|
|
493
500
|
*/
|
|
494
|
-
commitRejectedPushResult
|
|
501
|
+
commitRejectedPushResult(clientId: string, clientCommitId: string, result: StoredPushResult): Promise<StoredPushResult>;
|
|
495
502
|
upsertRow(table: string, row: StoredRow, context?: {
|
|
496
503
|
readonly opIndex: number;
|
|
497
504
|
}): Promise<void>;
|
|
@@ -573,10 +580,10 @@ export interface ServerStorage {
|
|
|
573
580
|
* same command races a push on Postgres and a SQLite-only test conceals it.
|
|
574
581
|
*
|
|
575
582
|
* A trusted command must therefore run through `registerRemoteCommand`,
|
|
576
|
-
* which builds after `
|
|
577
|
-
*
|
|
578
|
-
*
|
|
579
|
-
*
|
|
583
|
+
* which builds after `preparePushApply` and shares the committed
|
|
584
|
+
* transaction, or call `preparePushApply` before its first authority,
|
|
585
|
+
* version, or replay read. SSP2 request handling does this for you; only
|
|
586
|
+
* host-authored commands choose.
|
|
580
587
|
*/
|
|
581
588
|
begin(partition: string): Promise<StorageTransaction>;
|
|
582
589
|
getMaxCommitSeq(partition: string): Promise<number>;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@syncular/server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.44.0",
|
|
4
4
|
"description": "Syncular server: handleSyncRequest + storage/auth interfaces for the sync protocol",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Benjamin Kniffler",
|
|
@@ -68,7 +68,7 @@
|
|
|
68
68
|
"verify:node": "cd ../.. && bun build ./packages/server/test/sqlite-runtime/verify-node.mjs --target=node --conditions=bun --outfile=./packages/server/.verify-node.built.mjs && node ./packages/server/.verify-node.built.mjs"
|
|
69
69
|
},
|
|
70
70
|
"dependencies": {
|
|
71
|
-
"@syncular/core": "0.
|
|
71
|
+
"@syncular/core": "0.44.0"
|
|
72
72
|
},
|
|
73
73
|
"devDependencies": {
|
|
74
74
|
"@electric-sql/pglite": "^0.5.4"
|
package/src/d1-storage.ts
CHANGED
|
@@ -131,6 +131,7 @@ import type {
|
|
|
131
131
|
IndexRowScanQuery,
|
|
132
132
|
NewCommit,
|
|
133
133
|
NewReaction,
|
|
134
|
+
PreparedPushApply,
|
|
134
135
|
PartitionRegistryEntry,
|
|
135
136
|
PrunedReactionCounts,
|
|
136
137
|
ReactionClaimQuery,
|
|
@@ -550,7 +551,15 @@ class D1Transaction implements StorageTransaction {
|
|
|
550
551
|
);
|
|
551
552
|
}
|
|
552
553
|
|
|
553
|
-
async
|
|
554
|
+
async preparePushApply(
|
|
555
|
+
clientId: string,
|
|
556
|
+
clientCommitId: string,
|
|
557
|
+
runningSchemaVersion: number,
|
|
558
|
+
_targets: readonly {
|
|
559
|
+
readonly table: string;
|
|
560
|
+
readonly rowIds: readonly string[];
|
|
561
|
+
}[] = [],
|
|
562
|
+
): Promise<PreparedPushApply> {
|
|
554
563
|
this.#assertOpen();
|
|
555
564
|
if (!this.#pushApplySerialized) {
|
|
556
565
|
throw new Error(
|
|
@@ -560,6 +569,10 @@ class D1Transaction implements StorageTransaction {
|
|
|
560
569
|
// D1 has no interactive lock. The caller explicitly asserted that every
|
|
561
570
|
// write for this partition is already serialized (normally by its DO).
|
|
562
571
|
this.#pushApplyCheckpoint = this.#buffer.length;
|
|
572
|
+
return {
|
|
573
|
+
gate: await this.readServeGate(runningSchemaVersion),
|
|
574
|
+
persisted: await this.getPushResult(clientId, clientCommitId),
|
|
575
|
+
};
|
|
563
576
|
}
|
|
564
577
|
|
|
565
578
|
advanceCheckpoint(
|
|
@@ -591,7 +604,7 @@ class D1Transaction implements StorageTransaction {
|
|
|
591
604
|
clientId: string,
|
|
592
605
|
clientCommitId: string,
|
|
593
606
|
result: StoredPushResult,
|
|
594
|
-
): Promise<
|
|
607
|
+
): Promise<StoredPushResult> {
|
|
595
608
|
this.#assertOpen();
|
|
596
609
|
const checkpoint = this.#pushApplyCheckpoint;
|
|
597
610
|
if (checkpoint === undefined) {
|
|
@@ -604,6 +617,9 @@ class D1Transaction implements StorageTransaction {
|
|
|
604
617
|
this.#applicationOpIndexes.clear();
|
|
605
618
|
await this.putPushResult(clientId, clientCommitId, result);
|
|
606
619
|
await this.commit();
|
|
620
|
+
// The idempotency re-check found no record under the serialized apply, so
|
|
621
|
+
// the insert above is the canonical outcome.
|
|
622
|
+
return result;
|
|
607
623
|
}
|
|
608
624
|
|
|
609
625
|
async #assertNoUniqueCollision(
|