@syncular/server 0.32.0 → 0.32.1
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 -2
- package/dist/handler.js +17 -7
- package/dist/operations.js +4 -4
- package/dist/push.js +28 -7
- package/dist/realtime.js +22 -7
- package/dist/seed.js +4 -3
- package/package.json +2 -2
- package/src/d1-storage.ts +12 -2
- package/src/handler.ts +27 -10
- package/src/operations.ts +8 -4
- package/src/push.ts +40 -9
- package/src/realtime.ts +25 -6
- package/src/seed.ts +4 -3
package/dist/d1-storage.js
CHANGED
|
@@ -144,6 +144,7 @@ class D1Transaction {
|
|
|
144
144
|
* `tbl\u0000rowId` → the pending state, consulted by `getRow` before D1.
|
|
145
145
|
*/
|
|
146
146
|
#pending = new Map();
|
|
147
|
+
#clearedTombstones = new Set();
|
|
147
148
|
constructor(db, partition, resolveTable, prepareQuery, pushApplySerialized) {
|
|
148
149
|
this.#db = db;
|
|
149
150
|
this.#partition = partition;
|
|
@@ -175,6 +176,9 @@ class D1Transaction {
|
|
|
175
176
|
}
|
|
176
177
|
async getTombstoneSeq(table, rowId) {
|
|
177
178
|
this.#assertOpen();
|
|
179
|
+
if (this.#clearedTombstones.has(_a.#key(table, rowId))) {
|
|
180
|
+
return undefined;
|
|
181
|
+
}
|
|
178
182
|
// Persisted read, no #pending overlay: a delete buffered by this commit
|
|
179
183
|
// records its tombstone at appendCommit, matching the SQLite/Postgres
|
|
180
184
|
// adapters where the tombstone insert also runs at commit append.
|
|
@@ -186,6 +190,7 @@ class D1Transaction {
|
|
|
186
190
|
}
|
|
187
191
|
async clearTombstone(table, rowId) {
|
|
188
192
|
this.#assertOpen();
|
|
193
|
+
this.#clearedTombstones.add(_a.#key(table, rowId));
|
|
189
194
|
this.#buffer_('DELETE FROM sync_tombstones WHERE partition=? AND tbl=? AND row_id=?', [this.#partition, table, rowId]);
|
|
190
195
|
}
|
|
191
196
|
async getPushResult(clientId, clientCommitId) {
|
|
@@ -250,7 +255,7 @@ class D1Transaction {
|
|
|
250
255
|
}
|
|
251
256
|
}
|
|
252
257
|
return [...rows.values()]
|
|
253
|
-
.sort((left, right) => left.rowId.
|
|
258
|
+
.sort((left, right) => left.rowId < right.rowId ? -1 : left.rowId > right.rowId ? 1 : 0)
|
|
254
259
|
.slice(0, query.limit);
|
|
255
260
|
}
|
|
256
261
|
async scanRowsByIndex(query) {
|
|
@@ -287,7 +292,7 @@ class D1Transaction {
|
|
|
287
292
|
rows.set(rowId, pending.row);
|
|
288
293
|
}
|
|
289
294
|
return [...rows.values()]
|
|
290
|
-
.sort((left, right) => left.rowId.
|
|
295
|
+
.sort((left, right) => left.rowId < right.rowId ? -1 : left.rowId > right.rowId ? 1 : 0)
|
|
291
296
|
.slice(0, query.limit);
|
|
292
297
|
}
|
|
293
298
|
async queryAuthoritative(query) {
|
|
@@ -334,6 +339,7 @@ class D1Transaction {
|
|
|
334
339
|
}
|
|
335
340
|
this.#buffer.length = checkpoint;
|
|
336
341
|
this.#pending.clear();
|
|
342
|
+
this.#clearedTombstones.clear();
|
|
337
343
|
this.#maxCommitSeq = undefined;
|
|
338
344
|
this.#applicationOpIndexes.clear();
|
|
339
345
|
await this.putPushResult(clientId, clientCommitId, result);
|
package/dist/handler.js
CHANGED
|
@@ -341,25 +341,35 @@ async function* streamResponse(plan, ctx, schema, report) {
|
|
|
341
341
|
}
|
|
342
342
|
try {
|
|
343
343
|
// Push half (§6): one PUSH_RESULT per PUSH_COMMIT, in request order.
|
|
344
|
+
const currentSchema = compileSchema(ctx.schema);
|
|
344
345
|
let acceptedThrough = 0;
|
|
345
346
|
for (const push of plan.pushes) {
|
|
346
|
-
const processed = await processPushCommitWithTrace(ctx,
|
|
347
|
+
const processed = await processPushCommitWithTrace(ctx, currentSchema, plan.resolved, plan.header.clientId, push, schema);
|
|
347
348
|
acceptedThrough = Math.max(acceptedThrough, processed.frame.commitSeq ?? 0);
|
|
348
349
|
const frame = {
|
|
349
350
|
...processed.frame,
|
|
350
351
|
results: processed.frame.results.map((result) => {
|
|
351
|
-
if (result.status !== 'conflict')
|
|
352
|
+
if (result.status !== 'conflict' || schema === currentSchema) {
|
|
352
353
|
return result;
|
|
354
|
+
}
|
|
353
355
|
const operation = push.operations[result.opIndex];
|
|
356
|
+
// §2.3: a replay returns the persisted result without rebuilding
|
|
357
|
+
// operations, so a reused clientCommitId with changed operations
|
|
358
|
+
// leaves no table to project the stored conflict row through.
|
|
359
|
+
if (operation === undefined) {
|
|
360
|
+
throw syncError('sync.invalid_request', 'replayed clientCommitId carries changed operations', JSON.stringify({
|
|
361
|
+
clientCommitId: push.clientCommitId,
|
|
362
|
+
opIndex: result.opIndex,
|
|
363
|
+
}));
|
|
364
|
+
}
|
|
354
365
|
const target = schema.tables.get(operation.table);
|
|
355
|
-
const
|
|
366
|
+
const bitmapBytes = Math.ceil(target.columns.length / 8);
|
|
356
367
|
return {
|
|
357
368
|
...result,
|
|
358
|
-
serverRow: projectRowPayload(
|
|
369
|
+
serverRow: projectRowPayload(currentSchema.tables.get(operation.table), target, result.serverRow),
|
|
359
370
|
conflictColumns: result.conflictColumns
|
|
360
|
-
.slice(0,
|
|
361
|
-
.map((byte, i) => i ===
|
|
362
|
-
target.columns.length % 8 !== 0
|
|
371
|
+
.slice(0, bitmapBytes)
|
|
372
|
+
.map((byte, i) => i === bitmapBytes - 1 && target.columns.length % 8 !== 0
|
|
363
373
|
? byte & ((1 << (target.columns.length % 8)) - 1)
|
|
364
374
|
: byte),
|
|
365
375
|
};
|
package/dist/operations.js
CHANGED
|
@@ -4,7 +4,7 @@ import { REMOTE_COMMAND_CLIENT_ID_PREFIX, RESOLVER_OUTAGE, touchAuthenticatedPar
|
|
|
4
4
|
import { reportError, SyncError, syncError } from './errors.js';
|
|
5
5
|
import { processPushOperationsWithTrace } from './push.js';
|
|
6
6
|
import { compileSchema } from './schema.js';
|
|
7
|
-
import { authorizeWrite } from './scopes.js';
|
|
7
|
+
import { authorizeWrite, renderScopeValue, } from './scopes.js';
|
|
8
8
|
import { transactionQuery } from './storage-query.js';
|
|
9
9
|
import { toValidateRow } from './validate.js';
|
|
10
10
|
function scopeAllowed(allowed, variable, value) {
|
|
@@ -265,9 +265,9 @@ function commandOperations(mutations, schema) {
|
|
|
265
265
|
}
|
|
266
266
|
return mutation.values[column.name] ?? null;
|
|
267
267
|
});
|
|
268
|
-
const rowId = values[table.primaryKeyIndex];
|
|
269
|
-
if (
|
|
270
|
-
throw syncError('operation.invalid_request', 'command upsert requires a non-empty
|
|
268
|
+
const rowId = renderScopeValue(values[table.primaryKeyIndex]);
|
|
269
|
+
if (rowId === undefined) {
|
|
270
|
+
throw syncError('operation.invalid_request', 'command upsert requires a non-empty primary key');
|
|
271
271
|
}
|
|
272
272
|
return {
|
|
273
273
|
table: table.name,
|
package/dist/push.js
CHANGED
|
@@ -298,6 +298,7 @@ async function applyOperation(tx, schema, wireSchema, resolved, op, opIndex, blo
|
|
|
298
298
|
for (let i = 0; i < table.columns.length; i++) {
|
|
299
299
|
if (present[i] &&
|
|
300
300
|
!crdtIndexes.has(i) &&
|
|
301
|
+
i !== table.primaryKeyIndex &&
|
|
301
302
|
effectiveVersion(i) > op.baseVersion) {
|
|
302
303
|
marked.push(i);
|
|
303
304
|
}
|
|
@@ -602,7 +603,16 @@ async function enforceReferences(tx, schema, staged, clientOpCount, cascadeLimit
|
|
|
602
603
|
const changes = [];
|
|
603
604
|
const operations = [];
|
|
604
605
|
const originByIndex = [];
|
|
606
|
+
const finalByRow = new Map();
|
|
605
607
|
for (const write of staged) {
|
|
608
|
+
const rowKey = `${write.table.name}\u0000${write.rowId}`;
|
|
609
|
+
const existing = finalByRow.get(rowKey);
|
|
610
|
+
finalByRow.set(rowKey, {
|
|
611
|
+
first: existing?.first ?? write,
|
|
612
|
+
last: write,
|
|
613
|
+
});
|
|
614
|
+
}
|
|
615
|
+
for (const { last: write } of finalByRow.values()) {
|
|
606
616
|
if (write.op !== 'upsert' || write.values === undefined)
|
|
607
617
|
continue;
|
|
608
618
|
for (const reference of write.table.references) {
|
|
@@ -636,9 +646,9 @@ async function enforceReferences(tx, schema, staged, clientOpCount, cascadeLimit
|
|
|
636
646
|
const scheduled = new Set();
|
|
637
647
|
const processed = new Set();
|
|
638
648
|
const queue = [];
|
|
639
|
-
for (const
|
|
640
|
-
if (
|
|
641
|
-
schedule(
|
|
649
|
+
for (const { first, last } of finalByRow.values()) {
|
|
650
|
+
if (last.op === 'delete' && first.stored !== undefined) {
|
|
651
|
+
schedule(last.table, last.rowId, last.opIndex, scheduled, queue);
|
|
642
652
|
}
|
|
643
653
|
}
|
|
644
654
|
for (let cursor = 0; cursor < queue.length; cursor++) {
|
|
@@ -649,6 +659,12 @@ async function enforceReferences(tx, schema, staged, clientOpCount, cascadeLimit
|
|
|
649
659
|
if (processed.has(key))
|
|
650
660
|
continue;
|
|
651
661
|
processed.add(key);
|
|
662
|
+
const parentPkColumn = item.table.columns[item.table.primaryKeyIndex];
|
|
663
|
+
const parentKeyValue = parentPkColumn?.type === 'integer'
|
|
664
|
+
? Number(item.rowId)
|
|
665
|
+
: parentPkColumn?.type === 'boolean'
|
|
666
|
+
? item.rowId === 'true'
|
|
667
|
+
: item.rowId;
|
|
652
668
|
for (const reference of item.table.referencedBy) {
|
|
653
669
|
if (tx.scanRowsByIndex === undefined) {
|
|
654
670
|
throw new Error('storage transaction does not support declared-reference enforcement (scanRowsByIndex)');
|
|
@@ -659,7 +675,7 @@ async function enforceReferences(tx, schema, staged, clientOpCount, cascadeLimit
|
|
|
659
675
|
const page = await tx.scanRowsByIndex({
|
|
660
676
|
table: reference.table,
|
|
661
677
|
index: reference.index,
|
|
662
|
-
values: [
|
|
678
|
+
values: [parentKeyValue],
|
|
663
679
|
afterRowId,
|
|
664
680
|
limit: 1_000,
|
|
665
681
|
});
|
|
@@ -684,15 +700,20 @@ async function enforceReferences(tx, schema, staged, clientOpCount, cascadeLimit
|
|
|
684
700
|
throw new Error(`unreachable: reference child table ${reference.table} is not compiled`);
|
|
685
701
|
}
|
|
686
702
|
for (const childRow of children) {
|
|
703
|
+
if (reference.onDelete === 'CASCADE') {
|
|
704
|
+
if (!schedule(child, childRow.rowId, item.originOpIndex, scheduled, queue)) {
|
|
705
|
+
continue;
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
else if (scheduled.has(`${child.name}\u0000${childRow.rowId}`)) {
|
|
709
|
+
continue;
|
|
710
|
+
}
|
|
687
711
|
if (originByIndex.length >= cascadeLimit) {
|
|
688
712
|
return {
|
|
689
713
|
kind: 'terminate',
|
|
690
714
|
record: referenceRejection(item.originOpIndex, 'declared-reference cascade exceeded the per-commit operation cap (§6.11)', { reason: 'cascade_limit' }),
|
|
691
715
|
};
|
|
692
716
|
}
|
|
693
|
-
if (!schedule(child, childRow.rowId, item.originOpIndex, scheduled, queue)) {
|
|
694
|
-
continue;
|
|
695
|
-
}
|
|
696
717
|
const syntheticIndex = clientOpCount + originByIndex.length;
|
|
697
718
|
const storedValues = decodeRow(child.columns, childRow.payload);
|
|
698
719
|
if (reference.onDelete === 'CASCADE') {
|
package/dist/realtime.js
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* one handler, two framings); the only JSON data-plane server event is
|
|
17
17
|
* the `sync` wake-up (§8.3).
|
|
18
18
|
*/
|
|
19
|
-
import { DecodeError, decodeMessage, encodeMessage, encodePresenceError, encodePresenceFanout, MessageStreamScanner, PROTOCOL_WIRE_VERSION, parseRealtimePresencePublish, REALTIME_TAG_DELTA, REALTIME_TAG_ROUND, } from '@syncular/core';
|
|
19
|
+
import { DecodeError, decodeMessage, encodeMessage, encodePresenceError, encodePresenceFanout, isSupportedProtocolWireVersion, MessageStreamScanner, PROTOCOL_WIRE_VERSION, parseRealtimePresencePublish, REALTIME_TAG_DELTA, REALTIME_TAG_ROUND, } from '@syncular/core';
|
|
20
20
|
import { REMOTE_COMMAND_CLIENT_ID_PREFIX, RESOLVER_OUTAGE, touchAuthenticatedPartition, } from './context.js';
|
|
21
21
|
import { adapterSyncError, reportError, SyncError, syncError, } from './errors.js';
|
|
22
22
|
import { emitEvent } from './events.js';
|
|
@@ -160,6 +160,8 @@ export class RealtimeSession {
|
|
|
160
160
|
#registrationGeneration = 0;
|
|
161
161
|
/** Generation of the most recent refresh whose result was applied. */
|
|
162
162
|
#registrationApplied = 0;
|
|
163
|
+
/** Highest sequence that required a catch-up wake for this session. */
|
|
164
|
+
#wakeRequiredThroughSeq;
|
|
163
165
|
constructor(hub, options, registrations, cursor, latestSeq, clock, maxDeltaBytes, storage, logEpoch, wireVersion, events) {
|
|
164
166
|
this.sessionId = crypto.randomUUID();
|
|
165
167
|
this.partition = options.partition;
|
|
@@ -170,6 +172,7 @@ export class RealtimeSession {
|
|
|
170
172
|
this.cursor = cursor;
|
|
171
173
|
this.lastKnownSeq = latestSeq;
|
|
172
174
|
this.wakePending = cursor < latestSeq;
|
|
175
|
+
this.#wakeRequiredThroughSeq = cursor < latestSeq ? latestSeq : 0;
|
|
173
176
|
this.registrations = registrations;
|
|
174
177
|
this.openedAtMs = clock();
|
|
175
178
|
this.#send = options.send;
|
|
@@ -208,10 +211,10 @@ export class RealtimeSession {
|
|
|
208
211
|
// proves those commits no longer need socket notifications, so the next
|
|
209
212
|
// notification must be adjacent to the acknowledged cursor.
|
|
210
213
|
this.lastKnownSeq = Math.max(this.lastKnownSeq, this.cursor);
|
|
211
|
-
if (this.cursor >= this
|
|
214
|
+
if (this.cursor >= this.#wakeRequiredThroughSeq)
|
|
212
215
|
this.wakePending = false;
|
|
213
216
|
// §8.2: acks update the client cursor record without an HTTP pull.
|
|
214
|
-
this.#persistCursor();
|
|
217
|
+
this.#persistCursor(cursor);
|
|
215
218
|
}
|
|
216
219
|
/**
|
|
217
220
|
* Resolve once every queued control-plane write has settled (§8.2). A host
|
|
@@ -413,7 +416,7 @@ export class RealtimeSession {
|
|
|
413
416
|
};
|
|
414
417
|
try {
|
|
415
418
|
const requestedWireVersion = (requestBytes[4] ?? 0) | ((requestBytes[5] ?? 0) << 8);
|
|
416
|
-
if (requestedWireVersion
|
|
419
|
+
if (isSupportedProtocolWireVersion(requestedWireVersion)) {
|
|
417
420
|
this.wireVersion = requestedWireVersion;
|
|
418
421
|
}
|
|
419
422
|
let stream;
|
|
@@ -537,12 +540,12 @@ export class RealtimeSession {
|
|
|
537
540
|
this.#closeSocket?.();
|
|
538
541
|
this.#hub.disconnect(this);
|
|
539
542
|
}
|
|
540
|
-
#persistCursor() {
|
|
543
|
+
#persistCursor(cursor) {
|
|
541
544
|
// §8.2: start the write now and keep it on the control tail so `drain()`
|
|
542
545
|
// can await it. Two acks may write concurrently: storage folds them with
|
|
543
546
|
// `MAX(cursor, ?)`, so neither order loses the later cursor.
|
|
544
547
|
const write = (async () => {
|
|
545
|
-
await this.#storage.advanceClientCursor(this.partition, this.clientId, this.actorId, this.logEpoch,
|
|
548
|
+
await this.#storage.advanceClientCursor(this.partition, this.clientId, this.actorId, this.logEpoch, cursor, this.#clock());
|
|
546
549
|
})();
|
|
547
550
|
write.catch((error) => {
|
|
548
551
|
// Best-effort for the connection (the next pull repairs the cursor);
|
|
@@ -571,6 +574,7 @@ export class RealtimeSession {
|
|
|
571
574
|
}
|
|
572
575
|
sendWake(reason) {
|
|
573
576
|
this.wakePending = true;
|
|
577
|
+
this.#wakeRequiredThroughSeq = Math.max(this.#wakeRequiredThroughSeq, this.lastKnownSeq);
|
|
574
578
|
this.#sendSafe(JSON.stringify({
|
|
575
579
|
event: 'sync',
|
|
576
580
|
data: {
|
|
@@ -866,6 +870,12 @@ export class RealtimeHub {
|
|
|
866
870
|
...(this.#config.commitValidator !== undefined
|
|
867
871
|
? { commitValidator: this.#config.commitValidator }
|
|
868
872
|
: {}),
|
|
873
|
+
...(this.#config.reactionPlanner !== undefined
|
|
874
|
+
? { reactionPlanner: this.#config.reactionPlanner }
|
|
875
|
+
: {}),
|
|
876
|
+
...(this.#config.checkpoints !== undefined
|
|
877
|
+
? { checkpoints: this.#config.checkpoints }
|
|
878
|
+
: {}),
|
|
869
879
|
...(this.#config.clock !== undefined
|
|
870
880
|
? { clock: this.#config.clock }
|
|
871
881
|
: {}),
|
|
@@ -893,6 +903,9 @@ export class RealtimeHub {
|
|
|
893
903
|
...(this.#config.onError !== undefined
|
|
894
904
|
? { onError: this.#config.onError }
|
|
895
905
|
: {}),
|
|
906
|
+
...(this.#config.mapError !== undefined
|
|
907
|
+
? { mapError: this.#config.mapError }
|
|
908
|
+
: {}),
|
|
896
909
|
realtime: this,
|
|
897
910
|
};
|
|
898
911
|
}
|
|
@@ -929,7 +942,9 @@ export class RealtimeHub {
|
|
|
929
942
|
const registrations = await this.loadRegistrations(options.partition, options.actorId, options.clientId);
|
|
930
943
|
const latestSeq = await storage.getMaxCommitSeq(options.partition);
|
|
931
944
|
const cursor = record?.cursor ?? -1;
|
|
932
|
-
const session = new RealtimeSession(this, options, registrations, cursor, latestSeq, clock, this.#config.maxDeltaBytes ?? DEFAULT_MAX_DELTA_BYTES, storage, registry.logEpoch, record
|
|
945
|
+
const session = new RealtimeSession(this, options, registrations, cursor, latestSeq, clock, this.#config.maxDeltaBytes ?? DEFAULT_MAX_DELTA_BYTES, storage, registry.logEpoch, record !== undefined && isSupportedProtocolWireVersion(record.wireVersion)
|
|
946
|
+
? record.wireVersion
|
|
947
|
+
: PROTOCOL_WIRE_VERSION, this.#config.events);
|
|
933
948
|
this.#sessions.add(session);
|
|
934
949
|
const helloResult = options.send(JSON.stringify({
|
|
935
950
|
event: 'hello',
|
package/dist/seed.js
CHANGED
|
@@ -13,6 +13,7 @@ import { decodeMessage, encodeMessage, encodeSparseRow, PROTOCOL_WIRE_VERSION, }
|
|
|
13
13
|
import { SyncError } from './errors.js';
|
|
14
14
|
import { composeEvents } from './events-ring.js';
|
|
15
15
|
import { handleSyncRequest } from './handler.js';
|
|
16
|
+
import { renderScopeValue } from './scopes.js';
|
|
16
17
|
/** Structured terminal failure from the real push path used by a seed. */
|
|
17
18
|
export class SeedMutationError extends Error {
|
|
18
19
|
name = 'SeedMutationError';
|
|
@@ -94,9 +95,9 @@ export async function seedMutations(config, target, mutations) {
|
|
|
94
95
|
}
|
|
95
96
|
const values = table.columns.map((column) => (byColumn.get(column.name) ?? null));
|
|
96
97
|
const pkIndex = table.columns.findIndex((c) => c.name === table.primaryKey);
|
|
97
|
-
const rowId = values[pkIndex];
|
|
98
|
-
if (
|
|
99
|
-
throw new SyncError('sync.invalid_request', `seedMutations: table ${table.name}: upsert requires a non-empty
|
|
98
|
+
const rowId = renderScopeValue(values[pkIndex]);
|
|
99
|
+
if (rowId === undefined) {
|
|
100
|
+
throw new SyncError('sync.invalid_request', `seedMutations: table ${table.name}: upsert requires a non-empty primary key`);
|
|
100
101
|
}
|
|
101
102
|
return {
|
|
102
103
|
table: mutation.table,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@syncular/server",
|
|
3
|
-
"version": "0.32.
|
|
3
|
+
"version": "0.32.1",
|
|
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.32.
|
|
71
|
+
"@syncular/core": "0.32.1"
|
|
72
72
|
},
|
|
73
73
|
"devDependencies": {
|
|
74
74
|
"@electric-sql/pglite": "^0.5.4"
|
package/src/d1-storage.ts
CHANGED
|
@@ -302,6 +302,7 @@ class D1Transaction implements StorageTransaction {
|
|
|
302
302
|
* `tbl\u0000rowId` → the pending state, consulted by `getRow` before D1.
|
|
303
303
|
*/
|
|
304
304
|
readonly #pending = new Map<string, PendingRow>();
|
|
305
|
+
readonly #clearedTombstones = new Set<string>();
|
|
305
306
|
|
|
306
307
|
constructor(
|
|
307
308
|
db: Pick<D1Database, 'prepare' | 'batch'>,
|
|
@@ -347,6 +348,9 @@ class D1Transaction implements StorageTransaction {
|
|
|
347
348
|
rowId: string,
|
|
348
349
|
): Promise<number | undefined> {
|
|
349
350
|
this.#assertOpen();
|
|
351
|
+
if (this.#clearedTombstones.has(D1Transaction.#key(table, rowId))) {
|
|
352
|
+
return undefined;
|
|
353
|
+
}
|
|
350
354
|
// Persisted read, no #pending overlay: a delete buffered by this commit
|
|
351
355
|
// records its tombstone at appendCommit, matching the SQLite/Postgres
|
|
352
356
|
// adapters where the tombstone insert also runs at commit append.
|
|
@@ -361,6 +365,7 @@ class D1Transaction implements StorageTransaction {
|
|
|
361
365
|
|
|
362
366
|
async clearTombstone(table: string, rowId: string): Promise<void> {
|
|
363
367
|
this.#assertOpen();
|
|
368
|
+
this.#clearedTombstones.add(D1Transaction.#key(table, rowId));
|
|
364
369
|
this.#buffer_(
|
|
365
370
|
'DELETE FROM sync_tombstones WHERE partition=? AND tbl=? AND row_id=?',
|
|
366
371
|
[this.#partition, table, rowId],
|
|
@@ -448,7 +453,9 @@ class D1Transaction implements StorageTransaction {
|
|
|
448
453
|
}
|
|
449
454
|
}
|
|
450
455
|
return [...rows.values()]
|
|
451
|
-
.sort((left, right) =>
|
|
456
|
+
.sort((left, right) =>
|
|
457
|
+
left.rowId < right.rowId ? -1 : left.rowId > right.rowId ? 1 : 0,
|
|
458
|
+
)
|
|
452
459
|
.slice(0, query.limit);
|
|
453
460
|
}
|
|
454
461
|
|
|
@@ -502,7 +509,9 @@ class D1Transaction implements StorageTransaction {
|
|
|
502
509
|
if (matches) rows.set(rowId, pending.row);
|
|
503
510
|
}
|
|
504
511
|
return [...rows.values()]
|
|
505
|
-
.sort((left, right) =>
|
|
512
|
+
.sort((left, right) =>
|
|
513
|
+
left.rowId < right.rowId ? -1 : left.rowId > right.rowId ? 1 : 0,
|
|
514
|
+
)
|
|
506
515
|
.slice(0, query.limit);
|
|
507
516
|
}
|
|
508
517
|
|
|
@@ -574,6 +583,7 @@ class D1Transaction implements StorageTransaction {
|
|
|
574
583
|
}
|
|
575
584
|
this.#buffer.length = checkpoint;
|
|
576
585
|
this.#pending.clear();
|
|
586
|
+
this.#clearedTombstones.clear();
|
|
577
587
|
this.#maxCommitSeq = undefined;
|
|
578
588
|
this.#applicationOpIndexes.clear();
|
|
579
589
|
await this.putPushResult(clientId, clientCommitId, result);
|
package/src/handler.ts
CHANGED
|
@@ -526,11 +526,12 @@ async function* streamResponse(
|
|
|
526
526
|
}
|
|
527
527
|
try {
|
|
528
528
|
// Push half (§6): one PUSH_RESULT per PUSH_COMMIT, in request order.
|
|
529
|
+
const currentSchema = compileSchema(ctx.schema);
|
|
529
530
|
let acceptedThrough = 0;
|
|
530
531
|
for (const push of plan.pushes) {
|
|
531
532
|
const processed = await processPushCommitWithTrace(
|
|
532
533
|
ctx,
|
|
533
|
-
|
|
534
|
+
currentSchema,
|
|
534
535
|
plan.resolved,
|
|
535
536
|
plan.header.clientId,
|
|
536
537
|
push,
|
|
@@ -543,20 +544,36 @@ async function* streamResponse(
|
|
|
543
544
|
const frame = {
|
|
544
545
|
...processed.frame,
|
|
545
546
|
results: processed.frame.results.map((result) => {
|
|
546
|
-
if (result.status !== 'conflict')
|
|
547
|
-
|
|
547
|
+
if (result.status !== 'conflict' || schema === currentSchema) {
|
|
548
|
+
return result;
|
|
549
|
+
}
|
|
550
|
+
const operation = push.operations[result.opIndex];
|
|
551
|
+
// §2.3: a replay returns the persisted result without rebuilding
|
|
552
|
+
// operations, so a reused clientCommitId with changed operations
|
|
553
|
+
// leaves no table to project the stored conflict row through.
|
|
554
|
+
if (operation === undefined) {
|
|
555
|
+
throw syncError(
|
|
556
|
+
'sync.invalid_request',
|
|
557
|
+
'replayed clientCommitId carries changed operations',
|
|
558
|
+
JSON.stringify({
|
|
559
|
+
clientCommitId: push.clientCommitId,
|
|
560
|
+
opIndex: result.opIndex,
|
|
561
|
+
}),
|
|
562
|
+
);
|
|
563
|
+
}
|
|
548
564
|
const target = schema.tables.get(operation.table)!;
|
|
549
|
-
const
|
|
550
|
-
operation.table,
|
|
551
|
-
)!;
|
|
565
|
+
const bitmapBytes = Math.ceil(target.columns.length / 8);
|
|
552
566
|
return {
|
|
553
567
|
...result,
|
|
554
|
-
serverRow: projectRowPayload(
|
|
568
|
+
serverRow: projectRowPayload(
|
|
569
|
+
currentSchema.tables.get(operation.table)!,
|
|
570
|
+
target,
|
|
571
|
+
result.serverRow,
|
|
572
|
+
),
|
|
555
573
|
conflictColumns: result.conflictColumns
|
|
556
|
-
.slice(0,
|
|
574
|
+
.slice(0, bitmapBytes)
|
|
557
575
|
.map((byte, i) =>
|
|
558
|
-
i ===
|
|
559
|
-
target.columns.length % 8 !== 0
|
|
576
|
+
i === bitmapBytes - 1 && target.columns.length % 8 !== 0
|
|
560
577
|
? byte & ((1 << (target.columns.length % 8)) - 1)
|
|
561
578
|
: byte,
|
|
562
579
|
),
|
package/src/operations.ts
CHANGED
|
@@ -21,7 +21,11 @@ import {
|
|
|
21
21
|
import { reportError, SyncError, syncError } from './errors';
|
|
22
22
|
import { processPushOperationsWithTrace } from './push';
|
|
23
23
|
import { compileSchema } from './schema';
|
|
24
|
-
import {
|
|
24
|
+
import {
|
|
25
|
+
authorizeWrite,
|
|
26
|
+
renderScopeValue,
|
|
27
|
+
type ResolvedScopes,
|
|
28
|
+
} from './scopes';
|
|
25
29
|
import type {
|
|
26
30
|
AuthoritativeQueryRequest,
|
|
27
31
|
AuthoritativeQueryResult,
|
|
@@ -534,11 +538,11 @@ function commandOperations(
|
|
|
534
538
|
}
|
|
535
539
|
return mutation.values[column.name] ?? null;
|
|
536
540
|
});
|
|
537
|
-
const rowId = values[table.primaryKeyIndex];
|
|
538
|
-
if (
|
|
541
|
+
const rowId = renderScopeValue(values[table.primaryKeyIndex]);
|
|
542
|
+
if (rowId === undefined) {
|
|
539
543
|
throw syncError(
|
|
540
544
|
'operation.invalid_request',
|
|
541
|
-
'command upsert requires a non-empty
|
|
545
|
+
'command upsert requires a non-empty primary key',
|
|
542
546
|
);
|
|
543
547
|
}
|
|
544
548
|
return {
|
package/src/push.ts
CHANGED
|
@@ -503,6 +503,7 @@ async function applyOperation(
|
|
|
503
503
|
if (
|
|
504
504
|
present[i] &&
|
|
505
505
|
!crdtIndexes.has(i) &&
|
|
506
|
+
i !== table.primaryKeyIndex &&
|
|
506
507
|
effectiveVersion(i) > op.baseVersion
|
|
507
508
|
) {
|
|
508
509
|
marked.push(i);
|
|
@@ -978,7 +979,20 @@ async function enforceReferences(
|
|
|
978
979
|
const operations: ValidateCommitOperation[] = [];
|
|
979
980
|
const originByIndex: number[] = [];
|
|
980
981
|
|
|
982
|
+
const finalByRow = new Map<
|
|
983
|
+
string,
|
|
984
|
+
{ readonly first: StagedWrite; readonly last: StagedWrite }
|
|
985
|
+
>();
|
|
981
986
|
for (const write of staged) {
|
|
987
|
+
const rowKey = `${write.table.name}\u0000${write.rowId}`;
|
|
988
|
+
const existing = finalByRow.get(rowKey);
|
|
989
|
+
finalByRow.set(rowKey, {
|
|
990
|
+
first: existing?.first ?? write,
|
|
991
|
+
last: write,
|
|
992
|
+
});
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
for (const { last: write } of finalByRow.values()) {
|
|
982
996
|
if (write.op !== 'upsert' || write.values === undefined) continue;
|
|
983
997
|
for (const reference of write.table.references) {
|
|
984
998
|
const value = write.values[reference.columnIndex];
|
|
@@ -1028,9 +1042,9 @@ async function enforceReferences(
|
|
|
1028
1042
|
readonly rowId: string;
|
|
1029
1043
|
readonly originOpIndex: number;
|
|
1030
1044
|
}> = [];
|
|
1031
|
-
for (const
|
|
1032
|
-
if (
|
|
1033
|
-
schedule(
|
|
1045
|
+
for (const { first, last } of finalByRow.values()) {
|
|
1046
|
+
if (last.op === 'delete' && first.stored !== undefined) {
|
|
1047
|
+
schedule(last.table, last.rowId, last.opIndex, scheduled, queue);
|
|
1034
1048
|
}
|
|
1035
1049
|
}
|
|
1036
1050
|
|
|
@@ -1040,6 +1054,13 @@ async function enforceReferences(
|
|
|
1040
1054
|
const key = `${item.table.name}\u0000${item.rowId}`;
|
|
1041
1055
|
if (processed.has(key)) continue;
|
|
1042
1056
|
processed.add(key);
|
|
1057
|
+
const parentPkColumn = item.table.columns[item.table.primaryKeyIndex];
|
|
1058
|
+
const parentKeyValue: RowValue =
|
|
1059
|
+
parentPkColumn?.type === 'integer'
|
|
1060
|
+
? Number(item.rowId)
|
|
1061
|
+
: parentPkColumn?.type === 'boolean'
|
|
1062
|
+
? item.rowId === 'true'
|
|
1063
|
+
: item.rowId;
|
|
1043
1064
|
for (const reference of item.table.referencedBy) {
|
|
1044
1065
|
if (tx.scanRowsByIndex === undefined) {
|
|
1045
1066
|
throw new Error(
|
|
@@ -1052,7 +1073,7 @@ async function enforceReferences(
|
|
|
1052
1073
|
const page = await tx.scanRowsByIndex({
|
|
1053
1074
|
table: reference.table,
|
|
1054
1075
|
index: reference.index,
|
|
1055
|
-
values: [
|
|
1076
|
+
values: [parentKeyValue],
|
|
1056
1077
|
afterRowId,
|
|
1057
1078
|
limit: 1_000,
|
|
1058
1079
|
});
|
|
@@ -1081,6 +1102,21 @@ async function enforceReferences(
|
|
|
1081
1102
|
);
|
|
1082
1103
|
}
|
|
1083
1104
|
for (const childRow of children) {
|
|
1105
|
+
if (reference.onDelete === 'CASCADE') {
|
|
1106
|
+
if (
|
|
1107
|
+
!schedule(
|
|
1108
|
+
child,
|
|
1109
|
+
childRow.rowId,
|
|
1110
|
+
item.originOpIndex,
|
|
1111
|
+
scheduled,
|
|
1112
|
+
queue,
|
|
1113
|
+
)
|
|
1114
|
+
) {
|
|
1115
|
+
continue;
|
|
1116
|
+
}
|
|
1117
|
+
} else if (scheduled.has(`${child.name}\u0000${childRow.rowId}`)) {
|
|
1118
|
+
continue;
|
|
1119
|
+
}
|
|
1084
1120
|
if (originByIndex.length >= cascadeLimit) {
|
|
1085
1121
|
return {
|
|
1086
1122
|
kind: 'terminate',
|
|
@@ -1091,11 +1127,6 @@ async function enforceReferences(
|
|
|
1091
1127
|
),
|
|
1092
1128
|
};
|
|
1093
1129
|
}
|
|
1094
|
-
if (
|
|
1095
|
-
!schedule(child, childRow.rowId, item.originOpIndex, scheduled, queue)
|
|
1096
|
-
) {
|
|
1097
|
-
continue;
|
|
1098
|
-
}
|
|
1099
1130
|
const syntheticIndex = clientOpCount + originByIndex.length;
|
|
1100
1131
|
const storedValues = decodeRow(child.columns, childRow.payload);
|
|
1101
1132
|
if (reference.onDelete === 'CASCADE') {
|
package/src/realtime.ts
CHANGED
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
encodeMessage,
|
|
24
24
|
encodePresenceError,
|
|
25
25
|
encodePresenceFanout,
|
|
26
|
+
isSupportedProtocolWireVersion,
|
|
26
27
|
MessageStreamScanner,
|
|
27
28
|
PROTOCOL_WIRE_VERSION,
|
|
28
29
|
type PresenceKind,
|
|
@@ -278,6 +279,8 @@ export class RealtimeSession {
|
|
|
278
279
|
#registrationGeneration = 0;
|
|
279
280
|
/** Generation of the most recent refresh whose result was applied. */
|
|
280
281
|
#registrationApplied = 0;
|
|
282
|
+
/** Highest sequence that required a catch-up wake for this session. */
|
|
283
|
+
#wakeRequiredThroughSeq: number;
|
|
281
284
|
|
|
282
285
|
constructor(
|
|
283
286
|
hub: RealtimeHub,
|
|
@@ -301,6 +304,7 @@ export class RealtimeSession {
|
|
|
301
304
|
this.cursor = cursor;
|
|
302
305
|
this.lastKnownSeq = latestSeq;
|
|
303
306
|
this.wakePending = cursor < latestSeq;
|
|
307
|
+
this.#wakeRequiredThroughSeq = cursor < latestSeq ? latestSeq : 0;
|
|
304
308
|
this.registrations = registrations;
|
|
305
309
|
this.openedAtMs = clock();
|
|
306
310
|
this.#send = options.send;
|
|
@@ -336,9 +340,9 @@ export class RealtimeSession {
|
|
|
336
340
|
// proves those commits no longer need socket notifications, so the next
|
|
337
341
|
// notification must be adjacent to the acknowledged cursor.
|
|
338
342
|
this.lastKnownSeq = Math.max(this.lastKnownSeq, this.cursor);
|
|
339
|
-
if (this.cursor >= this
|
|
343
|
+
if (this.cursor >= this.#wakeRequiredThroughSeq) this.wakePending = false;
|
|
340
344
|
// §8.2: acks update the client cursor record without an HTTP pull.
|
|
341
|
-
this.#persistCursor();
|
|
345
|
+
this.#persistCursor(cursor);
|
|
342
346
|
}
|
|
343
347
|
|
|
344
348
|
/**
|
|
@@ -571,7 +575,7 @@ export class RealtimeSession {
|
|
|
571
575
|
try {
|
|
572
576
|
const requestedWireVersion =
|
|
573
577
|
(requestBytes[4] ?? 0) | ((requestBytes[5] ?? 0) << 8);
|
|
574
|
-
if (requestedWireVersion
|
|
578
|
+
if (isSupportedProtocolWireVersion(requestedWireVersion)) {
|
|
575
579
|
this.wireVersion = requestedWireVersion;
|
|
576
580
|
}
|
|
577
581
|
let stream: AsyncIterable<Uint8Array> | undefined;
|
|
@@ -721,7 +725,7 @@ export class RealtimeSession {
|
|
|
721
725
|
this.#hub.disconnect(this);
|
|
722
726
|
}
|
|
723
727
|
|
|
724
|
-
#persistCursor(): void {
|
|
728
|
+
#persistCursor(cursor: number): void {
|
|
725
729
|
// §8.2: start the write now and keep it on the control tail so `drain()`
|
|
726
730
|
// can await it. Two acks may write concurrently: storage folds them with
|
|
727
731
|
// `MAX(cursor, ?)`, so neither order loses the later cursor.
|
|
@@ -731,7 +735,7 @@ export class RealtimeSession {
|
|
|
731
735
|
this.clientId,
|
|
732
736
|
this.actorId,
|
|
733
737
|
this.logEpoch,
|
|
734
|
-
|
|
738
|
+
cursor,
|
|
735
739
|
this.#clock(),
|
|
736
740
|
);
|
|
737
741
|
})();
|
|
@@ -767,6 +771,10 @@ export class RealtimeSession {
|
|
|
767
771
|
|
|
768
772
|
sendWake(reason: WakeReason): void {
|
|
769
773
|
this.wakePending = true;
|
|
774
|
+
this.#wakeRequiredThroughSeq = Math.max(
|
|
775
|
+
this.#wakeRequiredThroughSeq,
|
|
776
|
+
this.lastKnownSeq,
|
|
777
|
+
);
|
|
770
778
|
this.#sendSafe(
|
|
771
779
|
JSON.stringify({
|
|
772
780
|
event: 'sync',
|
|
@@ -1121,6 +1129,12 @@ export class RealtimeHub {
|
|
|
1121
1129
|
...(this.#config.commitValidator !== undefined
|
|
1122
1130
|
? { commitValidator: this.#config.commitValidator }
|
|
1123
1131
|
: {}),
|
|
1132
|
+
...(this.#config.reactionPlanner !== undefined
|
|
1133
|
+
? { reactionPlanner: this.#config.reactionPlanner }
|
|
1134
|
+
: {}),
|
|
1135
|
+
...(this.#config.checkpoints !== undefined
|
|
1136
|
+
? { checkpoints: this.#config.checkpoints }
|
|
1137
|
+
: {}),
|
|
1124
1138
|
...(this.#config.clock !== undefined
|
|
1125
1139
|
? { clock: this.#config.clock }
|
|
1126
1140
|
: {}),
|
|
@@ -1148,6 +1162,9 @@ export class RealtimeHub {
|
|
|
1148
1162
|
...(this.#config.onError !== undefined
|
|
1149
1163
|
? { onError: this.#config.onError }
|
|
1150
1164
|
: {}),
|
|
1165
|
+
...(this.#config.mapError !== undefined
|
|
1166
|
+
? { mapError: this.#config.mapError }
|
|
1167
|
+
: {}),
|
|
1151
1168
|
realtime: this,
|
|
1152
1169
|
};
|
|
1153
1170
|
}
|
|
@@ -1211,7 +1228,9 @@ export class RealtimeHub {
|
|
|
1211
1228
|
this.#config.maxDeltaBytes ?? DEFAULT_MAX_DELTA_BYTES,
|
|
1212
1229
|
storage,
|
|
1213
1230
|
registry.logEpoch,
|
|
1214
|
-
record
|
|
1231
|
+
record !== undefined && isSupportedProtocolWireVersion(record.wireVersion)
|
|
1232
|
+
? record.wireVersion
|
|
1233
|
+
: PROTOCOL_WIRE_VERSION,
|
|
1215
1234
|
this.#config.events,
|
|
1216
1235
|
);
|
|
1217
1236
|
this.#sessions.add(session);
|
package/src/seed.ts
CHANGED
|
@@ -24,6 +24,7 @@ import { SyncError } from './errors';
|
|
|
24
24
|
import type { SyncularServerEvent, SyncularServerEvents } from './events';
|
|
25
25
|
import { composeEvents } from './events-ring';
|
|
26
26
|
import { handleSyncRequest } from './handler';
|
|
27
|
+
import { renderScopeValue } from './scopes';
|
|
27
28
|
|
|
28
29
|
/** One app-shaped seed mutation — the same vocabulary as client mutations. */
|
|
29
30
|
export type SeedMutation =
|
|
@@ -171,11 +172,11 @@ export async function seedMutations(
|
|
|
171
172
|
(column) => (byColumn.get(column.name) ?? null) as RowValue,
|
|
172
173
|
);
|
|
173
174
|
const pkIndex = table.columns.findIndex((c) => c.name === table.primaryKey);
|
|
174
|
-
const rowId = values[pkIndex];
|
|
175
|
-
if (
|
|
175
|
+
const rowId = renderScopeValue(values[pkIndex]);
|
|
176
|
+
if (rowId === undefined) {
|
|
176
177
|
throw new SyncError(
|
|
177
178
|
'sync.invalid_request',
|
|
178
|
-
`seedMutations: table ${table.name}: upsert requires a non-empty
|
|
179
|
+
`seedMutations: table ${table.name}: upsert requires a non-empty primary key`,
|
|
179
180
|
);
|
|
180
181
|
}
|
|
181
182
|
return {
|