@syncular/server 0.8.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 +113 -0
- package/dist/context.d.ts +8 -1
- package/dist/d1-storage.d.ts +10 -1
- package/dist/d1-storage.js +0 -0
- package/dist/frame-bytes.js +15 -0
- package/dist/handler.js +15 -0
- package/dist/postgres-storage.js +62 -0
- package/dist/push.js +191 -28
- package/dist/realtime.d.ts +5 -0
- package/dist/realtime.js +6 -0
- package/dist/sqlite-dialect.js +2 -0
- package/dist/sqlite-storage.js +22 -0
- package/dist/storage.d.ts +18 -0
- package/dist/validate.d.ts +62 -2
- package/dist/validate.js +39 -1
- package/package.json +2 -2
- package/src/context.ts +8 -1
- package/src/d1-storage.ts +0 -0
- package/src/frame-bytes.ts +16 -0
- package/src/handler.ts +20 -0
- package/src/postgres-storage.ts +79 -0
- package/src/push.ts +290 -29
- package/src/realtime.ts +11 -0
- package/src/sqlite-dialect.ts +3 -0
- package/src/sqlite-storage.ts +35 -0
- package/src/storage.ts +22 -0
- package/src/validate.ts +93 -2
package/README.md
CHANGED
|
@@ -60,6 +60,119 @@ done by a binding of the core or by in-database fanout — a relay would add a
|
|
|
60
60
|
hop, a second protocol surface, and a managed dependency for zero capability
|
|
61
61
|
the core lacks.
|
|
62
62
|
|
|
63
|
+
## Write validators and recovery metadata
|
|
64
|
+
|
|
65
|
+
`validators` is the server-authoritative seam for row business rules that
|
|
66
|
+
scope grants cannot express. A validator runs after row decode and scope
|
|
67
|
+
authorization, inside the commit transaction, for HTTP and WebSocket sync
|
|
68
|
+
rounds alike. Throw `ValidationRejection` for a deliberate host rejection:
|
|
69
|
+
|
|
70
|
+
```ts
|
|
71
|
+
import {
|
|
72
|
+
ValidationRejection,
|
|
73
|
+
type SyncServerConfig,
|
|
74
|
+
} from '@syncular/server';
|
|
75
|
+
|
|
76
|
+
const config: SyncServerConfig = {
|
|
77
|
+
schema,
|
|
78
|
+
storage,
|
|
79
|
+
segments,
|
|
80
|
+
resolveScopes,
|
|
81
|
+
validators: {
|
|
82
|
+
surgeries: ({ row }) => {
|
|
83
|
+
if (typeof row?.duration_minutes === 'number' && row.duration_minutes < 5) {
|
|
84
|
+
throw new ValidationRejection(
|
|
85
|
+
'surgery.duration_too_short',
|
|
86
|
+
'diagnostic only',
|
|
87
|
+
{
|
|
88
|
+
fieldPaths: ['duration_minutes'],
|
|
89
|
+
reason: 'below_minimum',
|
|
90
|
+
requiredAction: 'edit_fields',
|
|
91
|
+
references: { minimum_minutes: '5' },
|
|
92
|
+
},
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
The third argument is optional. When supplied, Syncular validates and
|
|
101
|
+
normalizes a bounded `RejectionDetails` object and persists it with the
|
|
102
|
+
idempotency result. Its values replicate to the authorized client, so include
|
|
103
|
+
only non-sensitive identifiers that the host explicitly approves for recovery
|
|
104
|
+
UI. Unknown members, free-form tokens, malformed paths, and over-limit data
|
|
105
|
+
fail at construction. Diagnostic prose stays in `message`; apps should map
|
|
106
|
+
the stable code/details to localized copy instead of displaying that message.
|
|
107
|
+
|
|
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.
|
|
175
|
+
|
|
63
176
|
## Structured events (the ops seam)
|
|
64
177
|
|
|
65
178
|
One optional interface, `SyncularServerEvents`, carries every
|
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/frame-bytes.js
CHANGED
|
@@ -39,6 +39,21 @@ function wrapperFor(frame) {
|
|
|
39
39
|
case 'ERROR':
|
|
40
40
|
case 'UNKNOWN':
|
|
41
41
|
return { frames: [STUB_HEADER, frame], index: 1 };
|
|
42
|
+
case 'PUSH_RESULT_DETAILS': {
|
|
43
|
+
const result = {
|
|
44
|
+
type: 'PUSH_RESULT',
|
|
45
|
+
clientCommitId: frame.clientCommitId,
|
|
46
|
+
status: 'rejected',
|
|
47
|
+
results: frame.entries.map((entry) => ({
|
|
48
|
+
opIndex: entry.opIndex,
|
|
49
|
+
status: 'error',
|
|
50
|
+
code: 'sync.constraint_violation',
|
|
51
|
+
message: '',
|
|
52
|
+
retryable: false,
|
|
53
|
+
})),
|
|
54
|
+
};
|
|
55
|
+
return { frames: [STUB_HEADER, result, frame], index: 2 };
|
|
56
|
+
}
|
|
42
57
|
case 'SUB_START':
|
|
43
58
|
return { frames: [STUB_HEADER, frame, STUB_SUB_END], index: 1 };
|
|
44
59
|
case 'SUB_END':
|
package/dist/handler.js
CHANGED
|
@@ -187,6 +187,18 @@ async function planRequest(request, ctx, schema) {
|
|
|
187
187
|
leaseToEmit,
|
|
188
188
|
};
|
|
189
189
|
}
|
|
190
|
+
function pushResultDetailsFrame(frame) {
|
|
191
|
+
const entries = frame.results.flatMap((result) => result.status === 'error' && result.details !== undefined
|
|
192
|
+
? [{ opIndex: result.opIndex, details: result.details }]
|
|
193
|
+
: []);
|
|
194
|
+
return entries.length === 0
|
|
195
|
+
? undefined
|
|
196
|
+
: {
|
|
197
|
+
type: 'PUSH_RESULT_DETAILS',
|
|
198
|
+
clientCommitId: frame.clientCommitId,
|
|
199
|
+
entries,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
190
202
|
function emitPushEvent(events, ctx, clientId, push, frame) {
|
|
191
203
|
const base = {
|
|
192
204
|
atMs: clockOf(ctx)(),
|
|
@@ -269,6 +281,9 @@ async function* streamResponse(plan, ctx, schema, report) {
|
|
|
269
281
|
emitPushEvent(events, ctx, plan.header.clientId, push, frame);
|
|
270
282
|
}
|
|
271
283
|
yield encodeResponseFrame(frame);
|
|
284
|
+
const details = pushResultDetailsFrame(frame);
|
|
285
|
+
if (details !== undefined)
|
|
286
|
+
yield encodeResponseFrame(details);
|
|
272
287
|
}
|
|
273
288
|
// Pull half (§4): subscriptions echoed in request order.
|
|
274
289
|
const cursors = [];
|
package/dist/postgres-storage.js
CHANGED
|
@@ -107,6 +107,7 @@ function serializePushResult(result) {
|
|
|
107
107
|
code: record.code,
|
|
108
108
|
message: record.message,
|
|
109
109
|
retryable: record.retryable,
|
|
110
|
+
...(record.details !== undefined ? { details: record.details } : {}),
|
|
110
111
|
};
|
|
111
112
|
}
|
|
112
113
|
return { opIndex: record.opIndex, status: record.status };
|
|
@@ -133,6 +134,7 @@ function deserializePushResult(value) {
|
|
|
133
134
|
code: record.code ?? '',
|
|
134
135
|
message: record.message ?? '',
|
|
135
136
|
retryable: record.retryable ?? false,
|
|
137
|
+
...(record.details !== undefined ? { details: record.details } : {}),
|
|
136
138
|
};
|
|
137
139
|
}
|
|
138
140
|
return { opIndex: record.opIndex, status: 'applied' };
|
|
@@ -223,6 +225,7 @@ class PostgresTransaction {
|
|
|
223
225
|
#partition;
|
|
224
226
|
#resolveTable;
|
|
225
227
|
#open = true;
|
|
228
|
+
#commitValidationSavepoint = false;
|
|
226
229
|
/** Resolves/rejects the `transaction(fn)` wrapper (see `begin`). */
|
|
227
230
|
#resolve;
|
|
228
231
|
#reject;
|
|
@@ -241,6 +244,65 @@ class PostgresTransaction {
|
|
|
241
244
|
this.#assertOpen();
|
|
242
245
|
return getRowOn(this.#client, this.#resolveTable(table), this.#partition, rowId);
|
|
243
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
|
+
}
|
|
244
306
|
async upsertRow(table, row) {
|
|
245
307
|
this.#assertOpen();
|
|
246
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
|
|
@@ -36,10 +36,17 @@ function blobIdsInRow(table, values) {
|
|
|
36
36
|
}
|
|
37
37
|
return ids;
|
|
38
38
|
}
|
|
39
|
-
function errorRecord(opIndex, code, message, retryable = false) {
|
|
39
|
+
function errorRecord(opIndex, code, message, retryable = false, details) {
|
|
40
40
|
return {
|
|
41
41
|
kind: 'terminate',
|
|
42
|
-
record: {
|
|
42
|
+
record: {
|
|
43
|
+
opIndex,
|
|
44
|
+
status: 'error',
|
|
45
|
+
code,
|
|
46
|
+
message,
|
|
47
|
+
retryable,
|
|
48
|
+
...(details !== undefined ? { details } : {}),
|
|
49
|
+
},
|
|
43
50
|
};
|
|
44
51
|
}
|
|
45
52
|
function conflictRecord(opIndex, serverVersion, serverRow) {
|
|
@@ -83,7 +90,7 @@ async function runValidator(validators, table, op, rowId, values, storedValues,
|
|
|
83
90
|
}
|
|
84
91
|
catch (error) {
|
|
85
92
|
if (error instanceof ValidationRejection) {
|
|
86
|
-
return errorRecord(opIndex, error.code, error.message);
|
|
93
|
+
return errorRecord(opIndex, error.code, error.message, false, error.details);
|
|
87
94
|
}
|
|
88
95
|
// §6.7: a non-ValidationRejection throw is still a rejection, mapped to
|
|
89
96
|
// the generic server-side constraint code (§10.2) — the validator's
|
|
@@ -142,7 +149,18 @@ async function applyOperation(tx, schema, resolved, op, opIndex, blobCtx, crdtMe
|
|
|
142
149
|
if (op.op === 'delete') {
|
|
143
150
|
if (stored === undefined) {
|
|
144
151
|
// Deleting an absent row is applied (idempotent, §6.2); no change.
|
|
145
|
-
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
|
+
};
|
|
146
164
|
}
|
|
147
165
|
if (!authorizeWrite(table, stored.scopes, resolved)) {
|
|
148
166
|
return errorRecord(opIndex, 'sync.forbidden', 'delete denied by scope authorization (§3.4)');
|
|
@@ -154,7 +172,8 @@ async function applyOperation(tx, schema, resolved, op, opIndex, blobCtx, crdtMe
|
|
|
154
172
|
// §6.7: validate the delete against the stored row (row = undefined,
|
|
155
173
|
// stored = the row about to be removed). Only reached for an existing
|
|
156
174
|
// row — an absent-row delete is an idempotent no-op above.
|
|
157
|
-
const
|
|
175
|
+
const storedValues = decodeRow(table.columns, stored.payload);
|
|
176
|
+
const deleteReject = await runValidator(validators, table, 'delete', op.rowId, undefined, storedValues, opIndex, partition, actorId);
|
|
158
177
|
if (deleteReject !== undefined)
|
|
159
178
|
return deleteReject;
|
|
160
179
|
await tx.deleteRow(op.table, op.rowId);
|
|
@@ -166,6 +185,15 @@ async function applyOperation(tx, schema, resolved, op, opIndex, blobCtx, crdtMe
|
|
|
166
185
|
op: 'delete',
|
|
167
186
|
scopes: stored.scopes,
|
|
168
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
|
+
},
|
|
169
197
|
};
|
|
170
198
|
}
|
|
171
199
|
// upsert — payload presence is enforced by the envelope codec (§6.1).
|
|
@@ -246,6 +274,16 @@ async function applyOperation(tx, schema, resolved, op, opIndex, blobCtx, crdtMe
|
|
|
246
274
|
scopes: stored.scopes,
|
|
247
275
|
payload: newPayload,
|
|
248
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
|
+
},
|
|
249
287
|
};
|
|
250
288
|
}
|
|
251
289
|
// Insert path: no stored row.
|
|
@@ -302,6 +340,15 @@ async function applyOperation(tx, schema, resolved, op, opIndex, blobCtx, crdtMe
|
|
|
302
340
|
scopes: extracted.scopes,
|
|
303
341
|
payload: insertPayload,
|
|
304
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
|
+
},
|
|
305
352
|
};
|
|
306
353
|
}
|
|
307
354
|
/**
|
|
@@ -339,6 +386,73 @@ function missingScopeVariable(table, scopes) {
|
|
|
339
386
|
}
|
|
340
387
|
return undefined;
|
|
341
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
|
+
}
|
|
342
456
|
function resultFrame(clientCommitId, stored, replay) {
|
|
343
457
|
const status = stored.status === 'applied' ? (replay ? 'cached' : 'applied') : 'rejected';
|
|
344
458
|
return {
|
|
@@ -351,6 +465,22 @@ function resultFrame(clientCommitId, stored, replay) {
|
|
|
351
465
|
results: [...stored.results],
|
|
352
466
|
};
|
|
353
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
|
+
}
|
|
354
484
|
/**
|
|
355
485
|
* Process one `PUSH_COMMIT` frame: idempotency replay (§2.3), sequential
|
|
356
486
|
* atomic apply (§6.4), realtime notification for applied commits.
|
|
@@ -366,20 +496,7 @@ export async function processPushCommit(ctx, schema, resolved, clientId, frame)
|
|
|
366
496
|
error.code === 'sync.idempotency_cache_miss') {
|
|
367
497
|
// §6.3: answer the retryable cache-miss for this commit rather than
|
|
368
498
|
// re-applying. Not persisted — a retry may find a readable record.
|
|
369
|
-
return
|
|
370
|
-
type: 'PUSH_RESULT',
|
|
371
|
-
clientCommitId: frame.clientCommitId,
|
|
372
|
-
status: 'rejected',
|
|
373
|
-
results: [
|
|
374
|
-
{
|
|
375
|
-
opIndex: 0,
|
|
376
|
-
status: 'error',
|
|
377
|
-
code: 'sync.idempotency_cache_miss',
|
|
378
|
-
message: error.message,
|
|
379
|
-
retryable: true,
|
|
380
|
-
},
|
|
381
|
-
],
|
|
382
|
-
};
|
|
499
|
+
return idempotencyCacheMissFrame(frame.clientCommitId, error);
|
|
383
500
|
}
|
|
384
501
|
throw error;
|
|
385
502
|
}
|
|
@@ -390,10 +507,38 @@ export async function processPushCommit(ctx, schema, resolved, clientId, frame)
|
|
|
390
507
|
const blobCtx = { store: ctx.blobs, partition };
|
|
391
508
|
const crdtMergers = ctx.crdtMergers;
|
|
392
509
|
const validators = ctx.validators;
|
|
510
|
+
const commitValidator = ctx.commitValidator;
|
|
393
511
|
const tx = await storage.begin(partition);
|
|
512
|
+
const commitRejectedPushResult = tx.commitRejectedPushResult?.bind(tx);
|
|
394
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
|
+
}
|
|
395
539
|
const results = [];
|
|
396
540
|
const changes = [];
|
|
541
|
+
const validatedOperations = [];
|
|
397
542
|
let terminated;
|
|
398
543
|
for (let opIndex = 0; opIndex < frame.operations.length; opIndex++) {
|
|
399
544
|
const op = frame.operations[opIndex];
|
|
@@ -405,25 +550,43 @@ export async function processPushCommit(ctx, schema, resolved, clientId, frame)
|
|
|
405
550
|
break;
|
|
406
551
|
}
|
|
407
552
|
results.push({ opIndex, status: 'applied' });
|
|
553
|
+
validatedOperations.push(outcome.operation);
|
|
408
554
|
if (outcome.change !== undefined)
|
|
409
555
|
changes.push(outcome.change);
|
|
410
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
|
+
}
|
|
411
563
|
if (terminated !== undefined) {
|
|
412
564
|
// §6.3 rejected: only the terminating operation's record; §6.4:
|
|
413
565
|
// every write of the commit rolls back.
|
|
414
|
-
await tx.rollback();
|
|
415
566
|
const stored = {
|
|
416
567
|
status: 'rejected',
|
|
417
568
|
results: [terminated],
|
|
418
569
|
};
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
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);
|
|
423
578
|
}
|
|
424
|
-
|
|
425
|
-
await
|
|
426
|
-
|
|
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
|
+
}
|
|
427
590
|
}
|
|
428
591
|
return resultFrame(frame.clientCommitId, stored, false);
|
|
429
592
|
}
|
package/dist/realtime.d.ts
CHANGED
|
@@ -20,10 +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 { CommitValidator, ValidatorRegistry } from './validate.js';
|
|
23
24
|
export interface RealtimeHubConfig {
|
|
24
25
|
readonly schema: ServerSchema;
|
|
25
26
|
readonly storage: ServerStorage;
|
|
26
27
|
readonly resolveScopes: ResolveScopes;
|
|
28
|
+
/** §6.7 validators used by sync rounds carried over this socket. */
|
|
29
|
+
readonly validators?: ValidatorRegistry;
|
|
30
|
+
/** §6.8 whole-commit validator shared with HTTP sync rounds. */
|
|
31
|
+
readonly commitValidator?: CommitValidator;
|
|
27
32
|
readonly clock?: () => number;
|
|
28
33
|
/** Deltas larger than this become `delta-too-large` wake-ups (§8.2). */
|
|
29
34
|
readonly maxDeltaBytes?: number;
|
package/dist/realtime.js
CHANGED
|
@@ -716,6 +716,12 @@ export class RealtimeHub {
|
|
|
716
716
|
storage: this.#config.storage,
|
|
717
717
|
segments,
|
|
718
718
|
resolveScopes: this.#config.resolveScopes,
|
|
719
|
+
...(this.#config.validators !== undefined
|
|
720
|
+
? { validators: this.#config.validators }
|
|
721
|
+
: {}),
|
|
722
|
+
...(this.#config.commitValidator !== undefined
|
|
723
|
+
? { commitValidator: this.#config.commitValidator }
|
|
724
|
+
: {}),
|
|
719
725
|
...(this.#config.clock !== undefined
|
|
720
726
|
? { clock: this.#config.clock }
|
|
721
727
|
: {}),
|
package/dist/sqlite-dialect.js
CHANGED
|
@@ -111,6 +111,7 @@ export function serializePushResult(result) {
|
|
|
111
111
|
code: record.code,
|
|
112
112
|
message: record.message,
|
|
113
113
|
retryable: record.retryable,
|
|
114
|
+
...(record.details !== undefined ? { details: record.details } : {}),
|
|
114
115
|
};
|
|
115
116
|
}
|
|
116
117
|
return { opIndex: record.opIndex, status: record.status };
|
|
@@ -137,6 +138,7 @@ export function deserializePushResult(text) {
|
|
|
137
138
|
code: record.code ?? '',
|
|
138
139
|
message: record.message ?? '',
|
|
139
140
|
retryable: record.retryable ?? false,
|
|
141
|
+
...(record.details !== undefined ? { details: record.details } : {}),
|
|
140
142
|
};
|
|
141
143
|
}
|
|
142
144
|
return { opIndex: record.opIndex, status: 'applied' };
|