@syncular/server 0.9.0 → 0.10.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/README.md +67 -3
- package/dist/context.d.ts +8 -1
- package/dist/d1-storage.d.ts +10 -1
- package/dist/d1-storage.js +0 -0
- package/dist/postgres-storage.js +60 -0
- package/dist/push.js +181 -25
- package/dist/realtime.d.ts +3 -1
- package/dist/realtime.js +3 -0
- package/dist/sqlite-storage.js +22 -0
- package/dist/storage.d.ts +18 -0
- package/dist/validate.d.ts +56 -1
- package/dist/validate.js +15 -0
- package/package.json +2 -2
- package/src/context.ts +8 -1
- package/src/d1-storage.ts +0 -0
- package/src/postgres-storage.ts +76 -0
- package/src/push.ts +273 -27
- package/src/realtime.ts +6 -1
- package/src/sqlite-storage.ts +35 -0
- package/src/storage.ts +22 -0
- package/src/validate.ts +79 -0
package/README.md
CHANGED
|
@@ -105,9 +105,73 @@ UI. Unknown members, free-form tokens, malformed paths, and over-limit data
|
|
|
105
105
|
fail at construction. Diagnostic prose stays in `message`; apps should map
|
|
106
106
|
the stable code/details to localized copy instead of displaying that message.
|
|
107
107
|
|
|
108
|
-
Validators are per-operation.
|
|
109
|
-
|
|
110
|
-
|
|
108
|
+
Validators are per-operation. A validator must not mutate the row it receives.
|
|
109
|
+
|
|
110
|
+
For a multi-row or multi-table invariant, install `commitValidator`. It runs
|
|
111
|
+
once after every operation is staged in the same transaction, and can inspect
|
|
112
|
+
both the decoded sibling operations and final candidate state:
|
|
113
|
+
|
|
114
|
+
```ts
|
|
115
|
+
import {
|
|
116
|
+
CommitValidationRejection,
|
|
117
|
+
type SyncServerConfig,
|
|
118
|
+
} from '@syncular/server';
|
|
119
|
+
|
|
120
|
+
const config: SyncServerConfig = {
|
|
121
|
+
schema,
|
|
122
|
+
storage,
|
|
123
|
+
segments,
|
|
124
|
+
resolveScopes,
|
|
125
|
+
commitValidator: ({ operations }) => {
|
|
126
|
+
const transition = operations.find(
|
|
127
|
+
(operation) =>
|
|
128
|
+
operation.table === 'surgeries' &&
|
|
129
|
+
operation.row !== undefined &&
|
|
130
|
+
operation.stored !== undefined &&
|
|
131
|
+
operation.row.status !== operation.stored.status,
|
|
132
|
+
);
|
|
133
|
+
if (transition === undefined) return;
|
|
134
|
+
|
|
135
|
+
const hasEvent = operations.some(
|
|
136
|
+
(operation) =>
|
|
137
|
+
operation.table === 'surgery_status_events' &&
|
|
138
|
+
operation.row?.surgery_id === transition.rowId &&
|
|
139
|
+
operation.row?.status === transition.row?.status,
|
|
140
|
+
);
|
|
141
|
+
if (!hasEvent) {
|
|
142
|
+
throw new CommitValidationRejection(
|
|
143
|
+
transition.opIndex,
|
|
144
|
+
'surgery.status_event_required',
|
|
145
|
+
'diagnostic only',
|
|
146
|
+
{
|
|
147
|
+
fieldPaths: ['status'],
|
|
148
|
+
reason: 'missing_sibling_operation',
|
|
149
|
+
requiredAction: 'repair_aggregate',
|
|
150
|
+
},
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
},
|
|
154
|
+
};
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
The callback also receives `read.getRow()` and bounded `read.scanRows()` APIs.
|
|
158
|
+
Those reads see the final candidate state, including staged sibling upserts and
|
|
159
|
+
deletes. Syncular serializes the partition before any operation read so two
|
|
160
|
+
aggregate validators cannot both accept mutually invalid candidates. It also
|
|
161
|
+
re-checks idempotency after taking that lock and persists a rejected outcome
|
|
162
|
+
while the lock is retained, so overlapping duplicate deliveries cannot rerun
|
|
163
|
+
the callback.
|
|
164
|
+
|
|
165
|
+
SQLite and PostgreSQL provide that serialization directly. D1 does not expose
|
|
166
|
+
an interactive lock: `D1ServerStorage` fails closed unless it is constructed
|
|
167
|
+
inside an external per-partition coordinator with
|
|
168
|
+
`{ commitValidationSerialized: true }` (normally one Durable Object per
|
|
169
|
+
partition). Do not set that assertion on a stateless Worker. Custom storages
|
|
170
|
+
must implement both the transaction lock and candidate scan seam.
|
|
171
|
+
|
|
172
|
+
Whole-commit validation checks a client-proposed commit; it does not grant
|
|
173
|
+
authority. Privileged operations such as connecting facilities still belong in
|
|
174
|
+
explicit server-authoritative commands.
|
|
111
175
|
|
|
112
176
|
## Structured events (the ops seam)
|
|
113
177
|
|
package/dist/context.d.ts
CHANGED
|
@@ -14,7 +14,7 @@ import type { SegmentStore } from './segment-store.js';
|
|
|
14
14
|
import type { BlobPresignConfig, BlobUploadPresignConfig, SegmentUrlConfig } from './signed-url.js';
|
|
15
15
|
import type { SqliteImageBuilder } from './sqlite-image.js';
|
|
16
16
|
import type { ServerStorage, StoredCommit } from './storage.js';
|
|
17
|
-
import type { ValidatorRegistry } from './validate.js';
|
|
17
|
+
import type { CommitValidator, ValidatorRegistry } from './validate.js';
|
|
18
18
|
/** SSP2 body content type (§1.1). */
|
|
19
19
|
export declare const SSP2_CONTENT_TYPE = "application/vnd.syncular.sync.v2";
|
|
20
20
|
export interface ResolveScopesArgs {
|
|
@@ -94,6 +94,13 @@ export interface SyncServerConfig {
|
|
|
94
94
|
* process, never on the wire.
|
|
95
95
|
*/
|
|
96
96
|
readonly validators?: ValidatorRegistry;
|
|
97
|
+
/**
|
|
98
|
+
* §6.8 whole-commit validator. When present, the storage serializes this
|
|
99
|
+
* partition before operation reads/writes, then invokes the callback once
|
|
100
|
+
* over every staged decoded operation and candidate-state reader before
|
|
101
|
+
* commit-log/idempotency append. A throw rolls back the complete commit.
|
|
102
|
+
*/
|
|
103
|
+
readonly commitValidator?: CommitValidator;
|
|
97
104
|
readonly resolveScopes: ResolveScopes;
|
|
98
105
|
/**
|
|
99
106
|
* §7.3 auth leases. Absent ⇒ the feature is off: no `LEASE` frame is
|
package/dist/d1-storage.d.ts
CHANGED
|
@@ -13,9 +13,18 @@ export interface D1Database {
|
|
|
13
13
|
batch(statements: D1PreparedStatement[]): Promise<unknown[]>;
|
|
14
14
|
exec(query: string): Promise<unknown>;
|
|
15
15
|
}
|
|
16
|
+
export interface D1ServerStorageOptions {
|
|
17
|
+
/**
|
|
18
|
+
* Assert that all writes for a partition reach this storage serially.
|
|
19
|
+
* Required for §6.8 because D1 exposes no interactive transaction lock.
|
|
20
|
+
* Set this only inside a per-partition Durable Object or equivalent
|
|
21
|
+
* coordinator; the default fails closed when a commit validator is used.
|
|
22
|
+
*/
|
|
23
|
+
readonly commitValidationSerialized?: boolean;
|
|
24
|
+
}
|
|
16
25
|
export declare class D1ServerStorage implements ServerStorage {
|
|
17
26
|
#private;
|
|
18
|
-
constructor(db: D1Database);
|
|
27
|
+
constructor(db: D1Database, options?: D1ServerStorageOptions);
|
|
19
28
|
/** Apply the schema DDL (idempotent). Call once before use. */
|
|
20
29
|
migrate(): Promise<void>;
|
|
21
30
|
/** Resolve a table's compiled schema; row operations require `ensureSchema`. */
|
package/dist/d1-storage.js
CHANGED
|
Binary file
|
package/dist/postgres-storage.js
CHANGED
|
@@ -225,6 +225,7 @@ class PostgresTransaction {
|
|
|
225
225
|
#partition;
|
|
226
226
|
#resolveTable;
|
|
227
227
|
#open = true;
|
|
228
|
+
#commitValidationSavepoint = false;
|
|
228
229
|
/** Resolves/rejects the `transaction(fn)` wrapper (see `begin`). */
|
|
229
230
|
#resolve;
|
|
230
231
|
#reject;
|
|
@@ -243,6 +244,65 @@ class PostgresTransaction {
|
|
|
243
244
|
this.#assertOpen();
|
|
244
245
|
return getRowOn(this.#client, this.#resolveTable(table), this.#partition, rowId);
|
|
245
246
|
}
|
|
247
|
+
async scanRows(query) {
|
|
248
|
+
this.#assertOpen();
|
|
249
|
+
const variables = Object.keys(query.scopeFilter).sort();
|
|
250
|
+
const firstVariable = variables[0];
|
|
251
|
+
if (firstVariable === undefined)
|
|
252
|
+
return [];
|
|
253
|
+
const firstValues = query.scopeFilter[firstVariable] ?? [];
|
|
254
|
+
if (firstValues.length === 0)
|
|
255
|
+
return [];
|
|
256
|
+
const sql = scanRowPageSql(this.#resolveTable(query.table), firstValues.length, 'postgres');
|
|
257
|
+
const rows = [];
|
|
258
|
+
let afterRowId = query.afterRowId ?? '';
|
|
259
|
+
const batchSize = Math.max(64, query.limit);
|
|
260
|
+
while (rows.length < query.limit) {
|
|
261
|
+
const { rows: records } = await this.#client.query(sql, [
|
|
262
|
+
this.#partition,
|
|
263
|
+
query.table,
|
|
264
|
+
firstVariable,
|
|
265
|
+
...firstValues,
|
|
266
|
+
afterRowId,
|
|
267
|
+
batchSize,
|
|
268
|
+
]);
|
|
269
|
+
if (records.length === 0)
|
|
270
|
+
break;
|
|
271
|
+
for (const record of records) {
|
|
272
|
+
afterRowId = record.row_id;
|
|
273
|
+
if (record.payload === null || record.payload === undefined)
|
|
274
|
+
continue;
|
|
275
|
+
const stored = toStoredRow(record);
|
|
276
|
+
if (!matchesEffective(stored.scopes, query.scopeFilter))
|
|
277
|
+
continue;
|
|
278
|
+
rows.push(stored);
|
|
279
|
+
if (rows.length >= query.limit)
|
|
280
|
+
break;
|
|
281
|
+
}
|
|
282
|
+
if (records.length < batchSize)
|
|
283
|
+
break;
|
|
284
|
+
}
|
|
285
|
+
return rows;
|
|
286
|
+
}
|
|
287
|
+
async lockPartitionForCommitValidation() {
|
|
288
|
+
this.#assertOpen();
|
|
289
|
+
await this.#client.query(`INSERT INTO sync_partitions(partition, max_commit_seq) VALUES ($1, 0)
|
|
290
|
+
ON CONFLICT (partition) DO NOTHING`, [this.#partition]);
|
|
291
|
+
await this.#client.query('SELECT max_commit_seq FROM sync_partitions WHERE partition=$1 FOR UPDATE', [this.#partition]);
|
|
292
|
+
await this.#client.query('SAVEPOINT syncular_commit_validation_candidate');
|
|
293
|
+
this.#commitValidationSavepoint = true;
|
|
294
|
+
}
|
|
295
|
+
async commitRejectedPushResult(clientId, clientCommitId, result) {
|
|
296
|
+
this.#assertOpen();
|
|
297
|
+
if (!this.#commitValidationSavepoint) {
|
|
298
|
+
throw new Error('whole-commit rejection requires its validation savepoint');
|
|
299
|
+
}
|
|
300
|
+
await this.#client.query('ROLLBACK TO SAVEPOINT syncular_commit_validation_candidate');
|
|
301
|
+
await this.#client.query('RELEASE SAVEPOINT syncular_commit_validation_candidate');
|
|
302
|
+
this.#commitValidationSavepoint = false;
|
|
303
|
+
await this.putPushResult(clientId, clientCommitId, result);
|
|
304
|
+
await this.commit();
|
|
305
|
+
}
|
|
246
306
|
async upsertRow(table, row) {
|
|
247
307
|
this.#assertOpen();
|
|
248
308
|
await writeRowOn(this.#client, this.#resolveTable(table), this.#partition, row);
|
package/dist/push.js
CHANGED
|
@@ -21,7 +21,7 @@ import { decodeRow, encodeRow, parseBlobRef, } from '@syncular/core';
|
|
|
21
21
|
import { clockOf } from './context.js';
|
|
22
22
|
import { SyncError } from './errors.js';
|
|
23
23
|
import { authorizeWrite, renderScopeValue, storedScopesForRow } from './scopes.js';
|
|
24
|
-
import { toValidateRow, ValidationRejection } from './validate.js';
|
|
24
|
+
import { CommitValidationRejection, toValidateRow, ValidationRejection, } from './validate.js';
|
|
25
25
|
/**
|
|
26
26
|
* Extract the blobIds a decoded row references through its `blob_ref`
|
|
27
27
|
* columns (§5.9.4), skipping NULLs. Malformed BlobRefs already failed at
|
|
@@ -149,7 +149,18 @@ async function applyOperation(tx, schema, resolved, op, opIndex, blobCtx, crdtMe
|
|
|
149
149
|
if (op.op === 'delete') {
|
|
150
150
|
if (stored === undefined) {
|
|
151
151
|
// Deleting an absent row is applied (idempotent, §6.2); no change.
|
|
152
|
-
return {
|
|
152
|
+
return {
|
|
153
|
+
kind: 'applied',
|
|
154
|
+
change: undefined,
|
|
155
|
+
operation: {
|
|
156
|
+
opIndex,
|
|
157
|
+
op: 'delete',
|
|
158
|
+
table: table.name,
|
|
159
|
+
rowId: op.rowId,
|
|
160
|
+
row: undefined,
|
|
161
|
+
stored: undefined,
|
|
162
|
+
},
|
|
163
|
+
};
|
|
153
164
|
}
|
|
154
165
|
if (!authorizeWrite(table, stored.scopes, resolved)) {
|
|
155
166
|
return errorRecord(opIndex, 'sync.forbidden', 'delete denied by scope authorization (§3.4)');
|
|
@@ -161,7 +172,8 @@ async function applyOperation(tx, schema, resolved, op, opIndex, blobCtx, crdtMe
|
|
|
161
172
|
// §6.7: validate the delete against the stored row (row = undefined,
|
|
162
173
|
// stored = the row about to be removed). Only reached for an existing
|
|
163
174
|
// row — an absent-row delete is an idempotent no-op above.
|
|
164
|
-
const
|
|
175
|
+
const storedValues = decodeRow(table.columns, stored.payload);
|
|
176
|
+
const deleteReject = await runValidator(validators, table, 'delete', op.rowId, undefined, storedValues, opIndex, partition, actorId);
|
|
165
177
|
if (deleteReject !== undefined)
|
|
166
178
|
return deleteReject;
|
|
167
179
|
await tx.deleteRow(op.table, op.rowId);
|
|
@@ -173,6 +185,15 @@ async function applyOperation(tx, schema, resolved, op, opIndex, blobCtx, crdtMe
|
|
|
173
185
|
op: 'delete',
|
|
174
186
|
scopes: stored.scopes,
|
|
175
187
|
},
|
|
188
|
+
operation: {
|
|
189
|
+
opIndex,
|
|
190
|
+
op: 'delete',
|
|
191
|
+
table: table.name,
|
|
192
|
+
rowId: op.rowId,
|
|
193
|
+
row: undefined,
|
|
194
|
+
stored: toValidateRow(table.columns, storedValues),
|
|
195
|
+
storedServerVersion: stored.serverVersion,
|
|
196
|
+
},
|
|
176
197
|
};
|
|
177
198
|
}
|
|
178
199
|
// upsert — payload presence is enforced by the envelope codec (§6.1).
|
|
@@ -253,6 +274,16 @@ async function applyOperation(tx, schema, resolved, op, opIndex, blobCtx, crdtMe
|
|
|
253
274
|
scopes: stored.scopes,
|
|
254
275
|
payload: newPayload,
|
|
255
276
|
},
|
|
277
|
+
operation: {
|
|
278
|
+
opIndex,
|
|
279
|
+
op: 'upsert',
|
|
280
|
+
table: table.name,
|
|
281
|
+
rowId: op.rowId,
|
|
282
|
+
row: toValidateRow(table.columns, values),
|
|
283
|
+
stored: toValidateRow(table.columns, storedValues),
|
|
284
|
+
storedServerVersion: stored.serverVersion,
|
|
285
|
+
nextServerVersion: newVersion,
|
|
286
|
+
},
|
|
256
287
|
};
|
|
257
288
|
}
|
|
258
289
|
// Insert path: no stored row.
|
|
@@ -309,6 +340,15 @@ async function applyOperation(tx, schema, resolved, op, opIndex, blobCtx, crdtMe
|
|
|
309
340
|
scopes: extracted.scopes,
|
|
310
341
|
payload: insertPayload,
|
|
311
342
|
},
|
|
343
|
+
operation: {
|
|
344
|
+
opIndex,
|
|
345
|
+
op: 'upsert',
|
|
346
|
+
table: table.name,
|
|
347
|
+
rowId: op.rowId,
|
|
348
|
+
row: toValidateRow(table.columns, values),
|
|
349
|
+
stored: undefined,
|
|
350
|
+
nextServerVersion: 1,
|
|
351
|
+
},
|
|
312
352
|
};
|
|
313
353
|
}
|
|
314
354
|
/**
|
|
@@ -346,6 +386,73 @@ function missingScopeVariable(table, scopes) {
|
|
|
346
386
|
}
|
|
347
387
|
return undefined;
|
|
348
388
|
}
|
|
389
|
+
function commitValidationReader(tx, schema) {
|
|
390
|
+
const tableFor = (name) => {
|
|
391
|
+
const table = schema.tables.get(name);
|
|
392
|
+
if (table === undefined) {
|
|
393
|
+
throw new Error(`commit validator requested unknown table ${JSON.stringify(name)}`);
|
|
394
|
+
}
|
|
395
|
+
return table;
|
|
396
|
+
};
|
|
397
|
+
return {
|
|
398
|
+
getRow: async (tableName, rowId) => {
|
|
399
|
+
const table = tableFor(tableName);
|
|
400
|
+
const stored = await tx.getRow(tableName, rowId);
|
|
401
|
+
if (stored === undefined)
|
|
402
|
+
return undefined;
|
|
403
|
+
return {
|
|
404
|
+
row: toValidateRow(table.columns, decodeRow(table.columns, stored.payload)),
|
|
405
|
+
serverVersion: stored.serverVersion,
|
|
406
|
+
};
|
|
407
|
+
},
|
|
408
|
+
scanRows: async ({ table: tableName, scopeFilter, afterRowId = null, limit = 100, }) => {
|
|
409
|
+
const table = tableFor(tableName);
|
|
410
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 1_000) {
|
|
411
|
+
throw new Error('commit validator scan limit must be an integer from 1 to 1,000');
|
|
412
|
+
}
|
|
413
|
+
if (tx.scanRows === undefined) {
|
|
414
|
+
throw new Error('storage transaction does not support commit-validator scans');
|
|
415
|
+
}
|
|
416
|
+
const rows = await tx.scanRows({
|
|
417
|
+
table: tableName,
|
|
418
|
+
scopeFilter,
|
|
419
|
+
afterRowId,
|
|
420
|
+
limit,
|
|
421
|
+
});
|
|
422
|
+
return rows.map((stored) => ({
|
|
423
|
+
row: toValidateRow(table.columns, decodeRow(table.columns, stored.payload)),
|
|
424
|
+
serverVersion: stored.serverVersion,
|
|
425
|
+
}));
|
|
426
|
+
},
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
async function runCommitValidator(validator, tx, schema, clientId, clientCommitId, actorId, partition, operations) {
|
|
430
|
+
if (validator === undefined)
|
|
431
|
+
return undefined;
|
|
432
|
+
try {
|
|
433
|
+
await validator({
|
|
434
|
+
clientId,
|
|
435
|
+
clientCommitId,
|
|
436
|
+
actorId,
|
|
437
|
+
partition,
|
|
438
|
+
operations,
|
|
439
|
+
read: commitValidationReader(tx, schema),
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
catch (error) {
|
|
443
|
+
if (error instanceof CommitValidationRejection) {
|
|
444
|
+
if (error.opIndex >= operations.length) {
|
|
445
|
+
return errorRecord(0, 'sync.constraint_violation', `commit validator rejection names unavailable opIndex ${error.opIndex}`);
|
|
446
|
+
}
|
|
447
|
+
return errorRecord(error.opIndex, error.code, error.message, false, error.details);
|
|
448
|
+
}
|
|
449
|
+
if (error instanceof ValidationRejection) {
|
|
450
|
+
return errorRecord(operations[0]?.opIndex ?? 0, error.code, error.message, false, error.details);
|
|
451
|
+
}
|
|
452
|
+
return errorRecord(operations[0]?.opIndex ?? 0, 'sync.constraint_violation', `whole-commit validator threw: ${error instanceof Error ? error.message : String(error)}`);
|
|
453
|
+
}
|
|
454
|
+
return undefined;
|
|
455
|
+
}
|
|
349
456
|
function resultFrame(clientCommitId, stored, replay) {
|
|
350
457
|
const status = stored.status === 'applied' ? (replay ? 'cached' : 'applied') : 'rejected';
|
|
351
458
|
return {
|
|
@@ -358,6 +465,22 @@ function resultFrame(clientCommitId, stored, replay) {
|
|
|
358
465
|
results: [...stored.results],
|
|
359
466
|
};
|
|
360
467
|
}
|
|
468
|
+
function idempotencyCacheMissFrame(clientCommitId, error) {
|
|
469
|
+
return {
|
|
470
|
+
type: 'PUSH_RESULT',
|
|
471
|
+
clientCommitId,
|
|
472
|
+
status: 'rejected',
|
|
473
|
+
results: [
|
|
474
|
+
{
|
|
475
|
+
opIndex: 0,
|
|
476
|
+
status: 'error',
|
|
477
|
+
code: 'sync.idempotency_cache_miss',
|
|
478
|
+
message: error.message,
|
|
479
|
+
retryable: true,
|
|
480
|
+
},
|
|
481
|
+
],
|
|
482
|
+
};
|
|
483
|
+
}
|
|
361
484
|
/**
|
|
362
485
|
* Process one `PUSH_COMMIT` frame: idempotency replay (§2.3), sequential
|
|
363
486
|
* atomic apply (§6.4), realtime notification for applied commits.
|
|
@@ -373,20 +496,7 @@ export async function processPushCommit(ctx, schema, resolved, clientId, frame)
|
|
|
373
496
|
error.code === 'sync.idempotency_cache_miss') {
|
|
374
497
|
// §6.3: answer the retryable cache-miss for this commit rather than
|
|
375
498
|
// re-applying. Not persisted — a retry may find a readable record.
|
|
376
|
-
return
|
|
377
|
-
type: 'PUSH_RESULT',
|
|
378
|
-
clientCommitId: frame.clientCommitId,
|
|
379
|
-
status: 'rejected',
|
|
380
|
-
results: [
|
|
381
|
-
{
|
|
382
|
-
opIndex: 0,
|
|
383
|
-
status: 'error',
|
|
384
|
-
code: 'sync.idempotency_cache_miss',
|
|
385
|
-
message: error.message,
|
|
386
|
-
retryable: true,
|
|
387
|
-
},
|
|
388
|
-
],
|
|
389
|
-
};
|
|
499
|
+
return idempotencyCacheMissFrame(frame.clientCommitId, error);
|
|
390
500
|
}
|
|
391
501
|
throw error;
|
|
392
502
|
}
|
|
@@ -397,10 +507,38 @@ export async function processPushCommit(ctx, schema, resolved, clientId, frame)
|
|
|
397
507
|
const blobCtx = { store: ctx.blobs, partition };
|
|
398
508
|
const crdtMergers = ctx.crdtMergers;
|
|
399
509
|
const validators = ctx.validators;
|
|
510
|
+
const commitValidator = ctx.commitValidator;
|
|
400
511
|
const tx = await storage.begin(partition);
|
|
512
|
+
const commitRejectedPushResult = tx.commitRejectedPushResult?.bind(tx);
|
|
401
513
|
try {
|
|
514
|
+
if (commitValidator !== undefined) {
|
|
515
|
+
if (tx.lockPartitionForCommitValidation === undefined ||
|
|
516
|
+
commitRejectedPushResult === undefined) {
|
|
517
|
+
throw new Error('storage transaction does not support atomic whole-commit validation finalization');
|
|
518
|
+
}
|
|
519
|
+
await tx.lockPartitionForCommitValidation();
|
|
520
|
+
// The optimistic lookup above may have raced another request for the
|
|
521
|
+
// same idempotency key. Re-check after acquiring partition serialization
|
|
522
|
+
// so a concurrent duplicate never reruns the aggregate validator.
|
|
523
|
+
try {
|
|
524
|
+
const serializedPersisted = await storage.getPushResult(partition, clientId, frame.clientCommitId);
|
|
525
|
+
if (serializedPersisted !== undefined) {
|
|
526
|
+
await tx.rollback();
|
|
527
|
+
return resultFrame(frame.clientCommitId, serializedPersisted, true);
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
catch (error) {
|
|
531
|
+
if (error instanceof SyncError &&
|
|
532
|
+
error.code === 'sync.idempotency_cache_miss') {
|
|
533
|
+
await tx.rollback();
|
|
534
|
+
return idempotencyCacheMissFrame(frame.clientCommitId, error);
|
|
535
|
+
}
|
|
536
|
+
throw error;
|
|
537
|
+
}
|
|
538
|
+
}
|
|
402
539
|
const results = [];
|
|
403
540
|
const changes = [];
|
|
541
|
+
const validatedOperations = [];
|
|
404
542
|
let terminated;
|
|
405
543
|
for (let opIndex = 0; opIndex < frame.operations.length; opIndex++) {
|
|
406
544
|
const op = frame.operations[opIndex];
|
|
@@ -412,25 +550,43 @@ export async function processPushCommit(ctx, schema, resolved, clientId, frame)
|
|
|
412
550
|
break;
|
|
413
551
|
}
|
|
414
552
|
results.push({ opIndex, status: 'applied' });
|
|
553
|
+
validatedOperations.push(outcome.operation);
|
|
415
554
|
if (outcome.change !== undefined)
|
|
416
555
|
changes.push(outcome.change);
|
|
417
556
|
}
|
|
557
|
+
if (terminated === undefined) {
|
|
558
|
+
const commitReject = await runCommitValidator(commitValidator, tx, schema, clientId, frame.clientCommitId, ctx.actorId, partition, validatedOperations);
|
|
559
|
+
if (commitReject?.kind === 'terminate') {
|
|
560
|
+
terminated = commitReject.record;
|
|
561
|
+
}
|
|
562
|
+
}
|
|
418
563
|
if (terminated !== undefined) {
|
|
419
564
|
// §6.3 rejected: only the terminating operation's record; §6.4:
|
|
420
565
|
// every write of the commit rolls back.
|
|
421
|
-
await tx.rollback();
|
|
422
566
|
const stored = {
|
|
423
567
|
status: 'rejected',
|
|
424
568
|
results: [terminated],
|
|
425
569
|
};
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
570
|
+
if (commitValidator !== undefined) {
|
|
571
|
+
// Discard candidate rows and persist the rejection while retaining the
|
|
572
|
+
// same partition lock. This closes the duplicate-request race between
|
|
573
|
+
// rollback and the durable idempotency outcome.
|
|
574
|
+
if (commitRejectedPushResult === undefined) {
|
|
575
|
+
throw new Error('storage transaction lost whole-commit rejection finalization support');
|
|
576
|
+
}
|
|
577
|
+
await commitRejectedPushResult(clientId, frame.clientCommitId, stored);
|
|
430
578
|
}
|
|
431
|
-
|
|
432
|
-
await
|
|
433
|
-
|
|
579
|
+
else {
|
|
580
|
+
await tx.rollback();
|
|
581
|
+
const rejectionTx = await storage.begin(partition);
|
|
582
|
+
try {
|
|
583
|
+
await rejectionTx.putPushResult(clientId, frame.clientCommitId, stored);
|
|
584
|
+
await rejectionTx.commit();
|
|
585
|
+
}
|
|
586
|
+
catch (error) {
|
|
587
|
+
await rejectionTx.rollback();
|
|
588
|
+
throw error;
|
|
589
|
+
}
|
|
434
590
|
}
|
|
435
591
|
return resultFrame(frame.clientCommitId, stored, false);
|
|
436
592
|
}
|
package/dist/realtime.d.ts
CHANGED
|
@@ -20,13 +20,15 @@ import type { ServerSchema } from './schema.js';
|
|
|
20
20
|
import type { SegmentStore } from './segment-store.js';
|
|
21
21
|
import type { SegmentUrlConfig } from './signed-url.js';
|
|
22
22
|
import type { ServerStorage, StoredCommit } from './storage.js';
|
|
23
|
-
import type { ValidatorRegistry } from './validate.js';
|
|
23
|
+
import type { CommitValidator, ValidatorRegistry } from './validate.js';
|
|
24
24
|
export interface RealtimeHubConfig {
|
|
25
25
|
readonly schema: ServerSchema;
|
|
26
26
|
readonly storage: ServerStorage;
|
|
27
27
|
readonly resolveScopes: ResolveScopes;
|
|
28
28
|
/** §6.7 validators used by sync rounds carried over this socket. */
|
|
29
29
|
readonly validators?: ValidatorRegistry;
|
|
30
|
+
/** §6.8 whole-commit validator shared with HTTP sync rounds. */
|
|
31
|
+
readonly commitValidator?: CommitValidator;
|
|
30
32
|
readonly clock?: () => number;
|
|
31
33
|
/** Deltas larger than this become `delta-too-large` wake-ups (§8.2). */
|
|
32
34
|
readonly maxDeltaBytes?: number;
|
package/dist/realtime.js
CHANGED
|
@@ -719,6 +719,9 @@ export class RealtimeHub {
|
|
|
719
719
|
...(this.#config.validators !== undefined
|
|
720
720
|
? { validators: this.#config.validators }
|
|
721
721
|
: {}),
|
|
722
|
+
...(this.#config.commitValidator !== undefined
|
|
723
|
+
? { commitValidator: this.#config.commitValidator }
|
|
724
|
+
: {}),
|
|
722
725
|
...(this.#config.clock !== undefined
|
|
723
726
|
? { clock: this.#config.clock }
|
|
724
727
|
: {}),
|
package/dist/sqlite-storage.js
CHANGED
|
@@ -15,6 +15,7 @@ class SqliteTransaction {
|
|
|
15
15
|
#storage;
|
|
16
16
|
#partition;
|
|
17
17
|
#open = true;
|
|
18
|
+
#commitValidationSavepoint = false;
|
|
18
19
|
constructor(storage, partition) {
|
|
19
20
|
this.#storage = storage;
|
|
20
21
|
this.#partition = partition;
|
|
@@ -28,6 +29,27 @@ class SqliteTransaction {
|
|
|
28
29
|
this.#assertOpen();
|
|
29
30
|
return this.#storage.getRow(this.#partition, table, rowId);
|
|
30
31
|
}
|
|
32
|
+
scanRows(query) {
|
|
33
|
+
this.#assertOpen();
|
|
34
|
+
return this.#storage.scanRows(this.#partition, query);
|
|
35
|
+
}
|
|
36
|
+
async lockPartitionForCommitValidation() {
|
|
37
|
+
this.#assertOpen();
|
|
38
|
+
// BEGIN IMMEDIATE in the constructor already owns SQLite's writer lock.
|
|
39
|
+
this.#storage.db.exec('SAVEPOINT syncular_commit_validation_candidate');
|
|
40
|
+
this.#commitValidationSavepoint = true;
|
|
41
|
+
}
|
|
42
|
+
async commitRejectedPushResult(clientId, clientCommitId, result) {
|
|
43
|
+
this.#assertOpen();
|
|
44
|
+
if (!this.#commitValidationSavepoint) {
|
|
45
|
+
throw new Error('whole-commit rejection requires its validation savepoint');
|
|
46
|
+
}
|
|
47
|
+
this.#storage.db.exec('ROLLBACK TO SAVEPOINT syncular_commit_validation_candidate');
|
|
48
|
+
this.#storage.db.exec('RELEASE SAVEPOINT syncular_commit_validation_candidate');
|
|
49
|
+
this.#commitValidationSavepoint = false;
|
|
50
|
+
await this.putPushResult(clientId, clientCommitId, result);
|
|
51
|
+
await this.commit();
|
|
52
|
+
}
|
|
31
53
|
async upsertRow(table, row) {
|
|
32
54
|
this.#assertOpen();
|
|
33
55
|
this.#storage.writeRow(this.#partition, table, row);
|
package/dist/storage.d.ts
CHANGED
|
@@ -150,6 +150,24 @@ export interface ScopeActivityQuery {
|
|
|
150
150
|
*/
|
|
151
151
|
export interface StorageTransaction {
|
|
152
152
|
getRow(table: string, rowId: string): Promise<StoredRow | undefined>;
|
|
153
|
+
/**
|
|
154
|
+
* Optional candidate-state scan used only by whole-commit validation.
|
|
155
|
+
* In-tree SQLite/Postgres/D1 backends implement it with read-your-own-writes
|
|
156
|
+
* semantics. A custom backend may omit it until `commitValidator` is used.
|
|
157
|
+
*/
|
|
158
|
+
scanRows?(query: RowScanQuery): Promise<StoredRow[]>;
|
|
159
|
+
/**
|
|
160
|
+
* Serialize candidate-state validation for this partition before any row
|
|
161
|
+
* read/write. Required at runtime when `commitValidator` is configured.
|
|
162
|
+
*/
|
|
163
|
+
lockPartitionForCommitValidation?(): Promise<void>;
|
|
164
|
+
/**
|
|
165
|
+
* §6.8 rejection finalization while the validation serialization lock is
|
|
166
|
+
* still held: discard every candidate write, persist the rejected
|
|
167
|
+
* idempotency result, and finish the transaction atomically. Required when
|
|
168
|
+
* `commitValidator` is configured so a concurrent duplicate cannot rerun it.
|
|
169
|
+
*/
|
|
170
|
+
commitRejectedPushResult?(clientId: string, clientCommitId: string, result: StoredPushResult): Promise<void>;
|
|
153
171
|
upsertRow(table: string, row: StoredRow): Promise<void>;
|
|
154
172
|
deleteRow(table: string, rowId: string): Promise<void>;
|
|
155
173
|
/** Allocates the next per-partition commitSeq and appends the commit. */
|
package/dist/validate.d.ts
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* path pays only an `undefined` check per operation and builds no context
|
|
14
14
|
* object — zero cost, the events-seam discipline.
|
|
15
15
|
*/
|
|
16
|
-
import { type RejectionDetails, type RowColumn, type RowValue } from '@syncular/core';
|
|
16
|
+
import { type RejectionDetails, type RowColumn, type RowValue, type ScopeMap } from '@syncular/core';
|
|
17
17
|
/**
|
|
18
18
|
* §6.7 reserved code prefixes. A host validator code MUST NOT start with
|
|
19
19
|
* any of these: they namespace the protocol's own error families (§10.2)
|
|
@@ -49,6 +49,52 @@ export interface ValidateOperation {
|
|
|
49
49
|
/** The currently-stored row (update/delete), keyed by column name; undefined on insert. */
|
|
50
50
|
readonly stored: ValidateRow | undefined;
|
|
51
51
|
}
|
|
52
|
+
/**
|
|
53
|
+
* One authorized, decoded operation presented to the whole-commit validator.
|
|
54
|
+
* `row` is the final candidate row after scope stripping and CRDT merge;
|
|
55
|
+
* `stored` is the state observed immediately before this operation. Multiple
|
|
56
|
+
* operations targeting one row therefore retain their sequential evidence.
|
|
57
|
+
*/
|
|
58
|
+
export interface ValidateCommitOperation extends ValidateOperation {
|
|
59
|
+
readonly opIndex: number;
|
|
60
|
+
readonly storedServerVersion?: number;
|
|
61
|
+
readonly nextServerVersion?: number;
|
|
62
|
+
}
|
|
63
|
+
/** One candidate-state row read from inside the still-open commit transaction. */
|
|
64
|
+
export interface CommitValidationRow {
|
|
65
|
+
readonly row: ValidateRow;
|
|
66
|
+
readonly serverVersion: number;
|
|
67
|
+
}
|
|
68
|
+
export interface CommitValidationScanInput {
|
|
69
|
+
readonly table: string;
|
|
70
|
+
/** Exact scope filter, using the same AND-across-keys semantics as sync. */
|
|
71
|
+
readonly scopeFilter: ScopeMap;
|
|
72
|
+
readonly afterRowId?: string | null;
|
|
73
|
+
/** Bounded per call by the server to 1..1,000; defaults to 100. */
|
|
74
|
+
readonly limit?: number;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Candidate-state reads bound to the same storage transaction as the commit.
|
|
78
|
+
* Reads observe every staged sibling operation and no uncommitted competing
|
|
79
|
+
* transaction when the storage's commit-validation lock contract is honored.
|
|
80
|
+
*/
|
|
81
|
+
export interface CommitValidationReader {
|
|
82
|
+
getRow(table: string, rowId: string): Promise<CommitValidationRow | undefined>;
|
|
83
|
+
scanRows(input: CommitValidationScanInput): Promise<CommitValidationRow[]>;
|
|
84
|
+
}
|
|
85
|
+
export interface ValidateCommitInput {
|
|
86
|
+
readonly clientId: string;
|
|
87
|
+
readonly clientCommitId: string;
|
|
88
|
+
readonly actorId: string;
|
|
89
|
+
readonly partition: string;
|
|
90
|
+
readonly operations: readonly ValidateCommitOperation[];
|
|
91
|
+
readonly read: CommitValidationReader;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Runs once after every operation passed protocol/scope/row validation and was
|
|
95
|
+
* staged, but before commit-log/idempotency append and transaction commit.
|
|
96
|
+
*/
|
|
97
|
+
export type CommitValidator = (input: ValidateCommitInput) => void | Promise<void>;
|
|
52
98
|
/** Ambient context a validator may consult (§6.7). */
|
|
53
99
|
export interface ValidateContext {
|
|
54
100
|
/** Host-authenticated actor (§1.1) performing the write. */
|
|
@@ -84,5 +130,14 @@ export declare class ValidationRejection extends Error {
|
|
|
84
130
|
readonly details: RejectionDetails | undefined;
|
|
85
131
|
constructor(code: string, message?: string, details?: RejectionDetails);
|
|
86
132
|
}
|
|
133
|
+
/**
|
|
134
|
+
* A whole-commit rejection attributed to one operation for the existing
|
|
135
|
+
* per-operation PUSH_RESULT envelope. The validator may still describe
|
|
136
|
+
* multiple affected fields in `details.fieldPaths`.
|
|
137
|
+
*/
|
|
138
|
+
export declare class CommitValidationRejection extends ValidationRejection {
|
|
139
|
+
readonly opIndex: number;
|
|
140
|
+
constructor(opIndex: number, code: string, message?: string, details?: RejectionDetails);
|
|
141
|
+
}
|
|
87
142
|
/** Build the column-keyed row object a validator inspects (§6.7). */
|
|
88
143
|
export declare function toValidateRow(columns: readonly RowColumn[], values: readonly RowValue[]): ValidateRow;
|