@syncular/server 0.42.2 → 0.44.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/d1-storage.js +14 -4
- package/dist/operations.d.ts +5 -0
- package/dist/operations.js +4 -4
- package/dist/postgres-storage.js +386 -104
- package/dist/push.d.ts +18 -1
- package/dist/push.js +247 -132
- package/dist/relational-rows.d.ts +9 -5
- package/dist/relational-rows.js +29 -11
- package/dist/sqlite-storage.js +14 -10
- package/dist/storage.d.ts +46 -32
- package/dist/storage.js +26 -0
- package/package.json +2 -2
- package/src/d1-storage.ts +30 -5
- package/src/operations.ts +12 -7
- package/src/postgres-storage.ts +553 -166
- package/src/push.ts +358 -151
- package/src/relational-rows.ts +31 -10
- package/src/sqlite-storage.ts +33 -18
- package/src/storage.ts +75 -36
package/dist/storage.d.ts
CHANGED
|
@@ -67,6 +67,17 @@ export interface ServeGate {
|
|
|
67
67
|
readonly name: string;
|
|
68
68
|
}[];
|
|
69
69
|
}
|
|
70
|
+
/**
|
|
71
|
+
* The push apply's admission token: the partition lock, the serve gate, and
|
|
72
|
+
* the idempotency record read in one round trip. A storage with per-statement
|
|
73
|
+
* network cost answers all three with one statement (see
|
|
74
|
+
* `PostgresServerStorage`); the lock is held from this call through commit.
|
|
75
|
+
*/
|
|
76
|
+
export interface PreparedPushApply {
|
|
77
|
+
readonly gate: ServeGate;
|
|
78
|
+
/** The already-persisted outcome for this key, or `undefined` on a miss. */
|
|
79
|
+
readonly persisted: StoredPushResult | undefined;
|
|
80
|
+
}
|
|
70
81
|
/** Why `serveGate` refuses, or `undefined` when this request may serve. */
|
|
71
82
|
export type ServeGateRefusal = {
|
|
72
83
|
readonly kind: 'schema';
|
|
@@ -128,6 +139,13 @@ export interface NewCommit {
|
|
|
128
139
|
readonly createdAtMs: number;
|
|
129
140
|
readonly changes: readonly NewChange[];
|
|
130
141
|
}
|
|
142
|
+
/**
|
|
143
|
+
* §2.2: rows whose final change in this commit is an upsert that followed a
|
|
144
|
+
* delete. That delete is one half of a scope move, so it MUST NOT leave a
|
|
145
|
+
* tombstone behind the live row. A row whose final change is a delete keeps
|
|
146
|
+
* its tombstone.
|
|
147
|
+
*/
|
|
148
|
+
export declare function rowsLiveAfterDelete(changes: readonly NewChange[]): Set<string>;
|
|
131
149
|
export interface StoredChange {
|
|
132
150
|
readonly table: string;
|
|
133
151
|
readonly rowId: string;
|
|
@@ -409,12 +427,15 @@ export interface StorageTransaction {
|
|
|
409
427
|
getRow(table: string, rowId: string): Promise<StoredRow | undefined>;
|
|
410
428
|
/**
|
|
411
429
|
* Optional read-ahead: the push layer names every row a commit's
|
|
412
|
-
* operations target,
|
|
413
|
-
* per-statement network cost reads them in one statement
|
|
414
|
-
* following `getRow` calls from that snapshot
|
|
415
|
-
* this transaction.
|
|
430
|
+
* operations target, across every table, before it applies them. A storage
|
|
431
|
+
* with per-statement network cost reads them in one statement (one branch
|
|
432
|
+
* per table) and answers the following `getRow` calls from that snapshot
|
|
433
|
+
* until the row is written in this transaction.
|
|
416
434
|
*/
|
|
417
|
-
prefetchRows?(
|
|
435
|
+
prefetchRows?(targets: readonly {
|
|
436
|
+
readonly table: string;
|
|
437
|
+
readonly rowIds: readonly string[];
|
|
438
|
+
}[]): Promise<void>;
|
|
418
439
|
/**
|
|
419
440
|
* §5 delete-precedence tombstone read: the `commit_seq` of the newest
|
|
420
441
|
* applied delete of this row still within the pruning horizon, or
|
|
@@ -429,17 +450,6 @@ export interface StorageTransaction {
|
|
|
429
450
|
* over an absent row; a no-op when no tombstone exists.
|
|
430
451
|
*/
|
|
431
452
|
clearTombstone(table: string, rowId: string): Promise<void>;
|
|
432
|
-
/**
|
|
433
|
-
* Optional transaction-scoped idempotency lookup (§2.3) with the same
|
|
434
|
-
* semantics as `ServerStorage.getPushResult`, including the
|
|
435
|
-
* `sync.idempotency_cache_miss` throw. The push layer prefers it for the
|
|
436
|
-
* post-serialization duplicate re-check so the read runs on the
|
|
437
|
-
* transaction's own connection — a pooled Postgres client that holds the
|
|
438
|
-
* partition lock must never wait for a second pool slot mid-push. In-tree
|
|
439
|
-
* SQLite/Postgres/D1 adapters implement it; a custom adapter that omits it
|
|
440
|
-
* keeps the pool-level read.
|
|
441
|
-
*/
|
|
442
|
-
getPushResult?(clientId: string, clientCommitId: string): Promise<StoredPushResult | undefined>;
|
|
443
453
|
/**
|
|
444
454
|
* Optional candidate-state scan used only by whole-commit validation.
|
|
445
455
|
* In-tree SQLite/Postgres/D1 backends implement it with read-your-own-writes
|
|
@@ -467,24 +477,28 @@ export interface StorageTransaction {
|
|
|
467
477
|
queryAuthoritative?(query: AuthoritativeQueryRequest): Promise<AuthoritativeQueryResult>;
|
|
468
478
|
/**
|
|
469
479
|
* Serialize every push apply for this partition before any operation read,
|
|
470
|
-
* validation, merge, or write
|
|
471
|
-
*
|
|
480
|
+
* validation, merge, or write, and read the admission token in the same
|
|
481
|
+
* round trip: the RFC 0007 serve gate and the §2.3 idempotency record for
|
|
482
|
+
* `clientCommitId`. The lock is retained through terminal-result commit,
|
|
483
|
+
* so the idempotency read is exact and a racing duplicate cannot rerun.
|
|
484
|
+
* `targets` are the rows the commit is known to touch before its operations
|
|
485
|
+
* are built; a storage with per-statement network cost folds that read-ahead
|
|
486
|
+
* into the admission read and answers the following `getRow` calls from it.
|
|
472
487
|
* Missing support fails closed before an app-row mutation.
|
|
473
488
|
*/
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
* the complete partition from before candidate reads through commit.
|
|
479
|
-
*/
|
|
480
|
-
lockPartitionForCommitValidation?(): Promise<void>;
|
|
489
|
+
preparePushApply(clientId: string, clientCommitId: string, runningSchemaVersion: number, targets?: readonly {
|
|
490
|
+
readonly table: string;
|
|
491
|
+
readonly rowIds: readonly string[];
|
|
492
|
+
}[]): Promise<PreparedPushApply>;
|
|
481
493
|
/**
|
|
482
494
|
* Rejection finalization while the push-apply serialization lock is still
|
|
483
495
|
* held: discard every candidate write, persist the rejected idempotency
|
|
484
|
-
* result, and finish the transaction atomically.
|
|
485
|
-
*
|
|
496
|
+
* result, and finish the transaction atomically. Returns the persisted
|
|
497
|
+
* canonical outcome, so the caller never re-reads it. Required for every
|
|
498
|
+
* push so a concurrent duplicate cannot rerun operations, validators, or
|
|
499
|
+
* merges.
|
|
486
500
|
*/
|
|
487
|
-
commitRejectedPushResult
|
|
501
|
+
commitRejectedPushResult(clientId: string, clientCommitId: string, result: StoredPushResult): Promise<StoredPushResult>;
|
|
488
502
|
upsertRow(table: string, row: StoredRow, context?: {
|
|
489
503
|
readonly opIndex: number;
|
|
490
504
|
}): Promise<void>;
|
|
@@ -566,10 +580,10 @@ export interface ServerStorage {
|
|
|
566
580
|
* same command races a push on Postgres and a SQLite-only test conceals it.
|
|
567
581
|
*
|
|
568
582
|
* A trusted command must therefore run through `registerRemoteCommand`,
|
|
569
|
-
* which builds after `
|
|
570
|
-
*
|
|
571
|
-
*
|
|
572
|
-
*
|
|
583
|
+
* which builds after `preparePushApply` and shares the committed
|
|
584
|
+
* transaction, or call `preparePushApply` before its first authority,
|
|
585
|
+
* version, or replay read. SSP2 request handling does this for you; only
|
|
586
|
+
* host-authored commands choose.
|
|
573
587
|
*/
|
|
574
588
|
begin(partition: string): Promise<StorageTransaction>;
|
|
575
589
|
getMaxCommitSeq(partition: string): Promise<number>;
|
package/dist/storage.js
CHANGED
|
@@ -38,6 +38,32 @@ export function serveGateTokenChanged(before, after) {
|
|
|
38
38
|
}
|
|
39
39
|
return undefined;
|
|
40
40
|
}
|
|
41
|
+
/**
|
|
42
|
+
* §2.2: rows whose final change in this commit is an upsert that followed a
|
|
43
|
+
* delete. That delete is one half of a scope move, so it MUST NOT leave a
|
|
44
|
+
* tombstone behind the live row. A row whose final change is a delete keeps
|
|
45
|
+
* its tombstone.
|
|
46
|
+
*/
|
|
47
|
+
export function rowsLiveAfterDelete(changes) {
|
|
48
|
+
const last = new Map();
|
|
49
|
+
const deleted = new Set();
|
|
50
|
+
const live = new Set();
|
|
51
|
+
for (const change of changes) {
|
|
52
|
+
const key = `${change.table}\u0000${change.rowId}`;
|
|
53
|
+
if (change.op === 'delete') {
|
|
54
|
+
deleted.add(key);
|
|
55
|
+
}
|
|
56
|
+
else if (deleted.has(key)) {
|
|
57
|
+
live.add(key);
|
|
58
|
+
}
|
|
59
|
+
last.set(key, change.op);
|
|
60
|
+
}
|
|
61
|
+
for (const key of live) {
|
|
62
|
+
if (last.get(key) !== 'upsert')
|
|
63
|
+
live.delete(key);
|
|
64
|
+
}
|
|
65
|
+
return live;
|
|
66
|
+
}
|
|
41
67
|
/** The round-state fields of a stored `sync_clients` row. */
|
|
42
68
|
export function clientRoundState(lastRoundAtMs, lastErrorCode, consecutiveFailures) {
|
|
43
69
|
return {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@syncular/server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.44.0",
|
|
4
4
|
"description": "Syncular server: handleSyncRequest + storage/auth interfaces for the sync protocol",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Benjamin Kniffler",
|
|
@@ -68,7 +68,7 @@
|
|
|
68
68
|
"verify:node": "cd ../.. && bun build ./packages/server/test/sqlite-runtime/verify-node.mjs --target=node --conditions=bun --outfile=./packages/server/.verify-node.built.mjs && node ./packages/server/.verify-node.built.mjs"
|
|
69
69
|
},
|
|
70
70
|
"dependencies": {
|
|
71
|
-
"@syncular/core": "0.
|
|
71
|
+
"@syncular/core": "0.44.0"
|
|
72
72
|
},
|
|
73
73
|
"devDependencies": {
|
|
74
74
|
"@electric-sql/pglite": "^0.5.4"
|
package/src/d1-storage.ts
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import { validateCommitPruneQuery } from './prune';
|
|
2
2
|
import { StorageQueryError } from './storage-errors';
|
|
3
3
|
import type { CommitPruneQuery, CommitPruneResult } from './storage';
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
clientRoundState,
|
|
6
|
+
PARTITION_TOUCH_INTERVAL_MS,
|
|
7
|
+
rowsLiveAfterDelete,
|
|
8
|
+
} from './storage';
|
|
5
9
|
/**
|
|
6
10
|
* Cloudflare D1 server storage for Workers deployments.
|
|
7
11
|
*
|
|
@@ -127,6 +131,7 @@ import type {
|
|
|
127
131
|
IndexRowScanQuery,
|
|
128
132
|
NewCommit,
|
|
129
133
|
NewReaction,
|
|
134
|
+
PreparedPushApply,
|
|
130
135
|
PartitionRegistryEntry,
|
|
131
136
|
PrunedReactionCounts,
|
|
132
137
|
ReactionClaimQuery,
|
|
@@ -546,7 +551,15 @@ class D1Transaction implements StorageTransaction {
|
|
|
546
551
|
);
|
|
547
552
|
}
|
|
548
553
|
|
|
549
|
-
async
|
|
554
|
+
async preparePushApply(
|
|
555
|
+
clientId: string,
|
|
556
|
+
clientCommitId: string,
|
|
557
|
+
runningSchemaVersion: number,
|
|
558
|
+
_targets: readonly {
|
|
559
|
+
readonly table: string;
|
|
560
|
+
readonly rowIds: readonly string[];
|
|
561
|
+
}[] = [],
|
|
562
|
+
): Promise<PreparedPushApply> {
|
|
550
563
|
this.#assertOpen();
|
|
551
564
|
if (!this.#pushApplySerialized) {
|
|
552
565
|
throw new Error(
|
|
@@ -556,6 +569,10 @@ class D1Transaction implements StorageTransaction {
|
|
|
556
569
|
// D1 has no interactive lock. The caller explicitly asserted that every
|
|
557
570
|
// write for this partition is already serialized (normally by its DO).
|
|
558
571
|
this.#pushApplyCheckpoint = this.#buffer.length;
|
|
572
|
+
return {
|
|
573
|
+
gate: await this.readServeGate(runningSchemaVersion),
|
|
574
|
+
persisted: await this.getPushResult(clientId, clientCommitId),
|
|
575
|
+
};
|
|
559
576
|
}
|
|
560
577
|
|
|
561
578
|
advanceCheckpoint(
|
|
@@ -587,7 +604,7 @@ class D1Transaction implements StorageTransaction {
|
|
|
587
604
|
clientId: string,
|
|
588
605
|
clientCommitId: string,
|
|
589
606
|
result: StoredPushResult,
|
|
590
|
-
): Promise<
|
|
607
|
+
): Promise<StoredPushResult> {
|
|
591
608
|
this.#assertOpen();
|
|
592
609
|
const checkpoint = this.#pushApplyCheckpoint;
|
|
593
610
|
if (checkpoint === undefined) {
|
|
@@ -600,6 +617,9 @@ class D1Transaction implements StorageTransaction {
|
|
|
600
617
|
this.#applicationOpIndexes.clear();
|
|
601
618
|
await this.putPushResult(clientId, clientCommitId, result);
|
|
602
619
|
await this.commit();
|
|
620
|
+
// The idempotency re-check found no record under the serialized apply, so
|
|
621
|
+
// the insert above is the canonical outcome.
|
|
622
|
+
return result;
|
|
603
623
|
}
|
|
604
624
|
|
|
605
625
|
async #assertNoUniqueCollision(
|
|
@@ -790,6 +810,7 @@ class D1Transaction implements StorageTransaction {
|
|
|
790
810
|
commit.createdAtMs,
|
|
791
811
|
],
|
|
792
812
|
);
|
|
813
|
+
const liveAfterDelete = rowsLiveAfterDelete(commit.changes);
|
|
793
814
|
commit.changes.forEach((change, idx) => {
|
|
794
815
|
this.#buffer_(
|
|
795
816
|
'INSERT INTO sync_changes(partition, commit_seq, idx, tbl, row_id, op, row_version, scopes, payload) VALUES (?,?,?,?,?,?,?,?,?)',
|
|
@@ -805,9 +826,13 @@ class D1Transaction implements StorageTransaction {
|
|
|
805
826
|
change.payload ?? null,
|
|
806
827
|
],
|
|
807
828
|
);
|
|
808
|
-
if (
|
|
829
|
+
if (
|
|
830
|
+
change.op === 'delete' &&
|
|
831
|
+
!liveAfterDelete.has(`${change.table}\u0000${change.rowId}`)
|
|
832
|
+
) {
|
|
809
833
|
// §5 delete precedence: every applied delete leaves a tombstone,
|
|
810
|
-
// pruned with the commit log (§4.6).
|
|
834
|
+
// pruned with the commit log (§4.6). A delete that the same commit
|
|
835
|
+
// follows with an upsert is a §2.2 scope move, not a deletion.
|
|
811
836
|
this.#buffer_(
|
|
812
837
|
`INSERT INTO sync_tombstones(partition, tbl, row_id, commit_seq) VALUES (?,?,?,?)
|
|
813
838
|
ON CONFLICT(partition, tbl, row_id) DO UPDATE SET commit_seq=excluded.commit_seq`,
|
package/src/operations.ts
CHANGED
|
@@ -7,7 +7,6 @@ import {
|
|
|
7
7
|
decodeRemoteOperationRequest,
|
|
8
8
|
encodeRemoteOperationResponse,
|
|
9
9
|
encodeSparseRow,
|
|
10
|
-
type PushOperation,
|
|
11
10
|
type RemoteOperationRequest,
|
|
12
11
|
type RemoteOperationResponse,
|
|
13
12
|
type RowValue,
|
|
@@ -20,7 +19,7 @@ import {
|
|
|
20
19
|
touchAuthenticatedPartition,
|
|
21
20
|
} from './context';
|
|
22
21
|
import { reportError, SyncError, syncError } from './errors';
|
|
23
|
-
import { processPushOperationsWithTrace } from './push';
|
|
22
|
+
import { processPushOperationsWithTrace, type ApplyOperation } from './push';
|
|
24
23
|
import { compileSchema } from './schema';
|
|
25
24
|
import {
|
|
26
25
|
authorizeWrite,
|
|
@@ -108,6 +107,12 @@ export type CommandMutation =
|
|
|
108
107
|
readonly op: 'upsert';
|
|
109
108
|
readonly values: Readonly<Record<string, RowValue>>;
|
|
110
109
|
}
|
|
110
|
+
| {
|
|
111
|
+
readonly table: string;
|
|
112
|
+
readonly op: 'rescope';
|
|
113
|
+
/** Full row; only its scope columns may differ from the stored row. */
|
|
114
|
+
readonly values: Readonly<Record<string, RowValue>>;
|
|
115
|
+
}
|
|
111
116
|
| {
|
|
112
117
|
readonly table: string;
|
|
113
118
|
readonly op: 'delete';
|
|
@@ -501,7 +506,7 @@ export function remoteCommand<Input = undefined>(
|
|
|
501
506
|
function commandOperations(
|
|
502
507
|
mutations: readonly CommandMutation[],
|
|
503
508
|
schema: ReturnType<typeof compileSchema>,
|
|
504
|
-
):
|
|
509
|
+
): ApplyOperation[] {
|
|
505
510
|
if (mutations.length === 0) {
|
|
506
511
|
throw syncError(
|
|
507
512
|
'operation.invalid_request',
|
|
@@ -530,7 +535,7 @@ function commandOperations(
|
|
|
530
535
|
if (!table.columnIndex.has(name)) {
|
|
531
536
|
throw syncError(
|
|
532
537
|
'operation.invalid_request',
|
|
533
|
-
'command upsert has an unknown column',
|
|
538
|
+
'command upsert or rescope has an unknown column',
|
|
534
539
|
);
|
|
535
540
|
}
|
|
536
541
|
}
|
|
@@ -538,7 +543,7 @@ function commandOperations(
|
|
|
538
543
|
if (!supplied.has(column.name)) {
|
|
539
544
|
throw syncError(
|
|
540
545
|
'operation.invalid_request',
|
|
541
|
-
'command upsert must provide a full row',
|
|
546
|
+
'command upsert or rescope must provide a full row',
|
|
542
547
|
);
|
|
543
548
|
}
|
|
544
549
|
return mutation.values[column.name] ?? null;
|
|
@@ -547,13 +552,13 @@ function commandOperations(
|
|
|
547
552
|
if (rowId === undefined) {
|
|
548
553
|
throw syncError(
|
|
549
554
|
'operation.invalid_request',
|
|
550
|
-
'command upsert requires a non-empty primary key',
|
|
555
|
+
'command upsert or rescope requires a non-empty primary key',
|
|
551
556
|
);
|
|
552
557
|
}
|
|
553
558
|
return {
|
|
554
559
|
table: table.name,
|
|
555
560
|
rowId,
|
|
556
|
-
op:
|
|
561
|
+
op: mutation.op,
|
|
557
562
|
payload: encodeSparseRow(table.columns, table.primaryKeyIndex, values),
|
|
558
563
|
};
|
|
559
564
|
});
|