@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/src/relational-rows.ts
CHANGED
|
@@ -807,17 +807,38 @@ export const COMMIT_WINDOW_BATCH_SQL = `SELECT s.i AS sub, c.commit_seq AS commi
|
|
|
807
807
|
ORDER BY s.i, c.commit_seq, ch.idx`;
|
|
808
808
|
|
|
809
809
|
/**
|
|
810
|
-
* PostgreSQL push read-ahead
|
|
811
|
-
*
|
|
812
|
-
*
|
|
810
|
+
* PostgreSQL push read-ahead across every table a commit targets, in one
|
|
811
|
+
* statement: each requested row id with its stored row (NULL columns when
|
|
812
|
+
* absent, `selectRowSql` shape) and its §5 delete tombstone. Params:
|
|
813
|
+
* [partition, specs as JSON text], `specs` = `{tbl, ids: string[]}` records.
|
|
813
814
|
*/
|
|
814
|
-
export function
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
815
|
+
export function prefetchRowsBatchSql(
|
|
816
|
+
tables: readonly CompiledTable[],
|
|
817
|
+
params: { readonly partition: string; readonly specs: string } = {
|
|
818
|
+
partition: '$1',
|
|
819
|
+
specs: '$2',
|
|
820
|
+
},
|
|
821
|
+
): string {
|
|
822
|
+
// One branch per table: a lateral join cannot switch its relation per
|
|
823
|
+
// record, and the union keeps the whole read-ahead one round trip.
|
|
824
|
+
const projection = tables
|
|
825
|
+
.map((table) => {
|
|
826
|
+
const quoted = quoteIdent(table.name);
|
|
827
|
+
return `SELECT ids.tbl AS tbl, ids.id AS lookup_id, r.${quoteIdent(SYNC_ROW_ID_COLUMN)} AS row_id, r.${quoteIdent(SYNC_VERSION_COLUMN)} AS server_version, r.${quoteIdent(SYNC_SCOPES_COLUMN)} AS scopes, r.${quoteIdent(SYNC_PAYLOAD_COLUMN)} AS payload, r.${quoteIdent(SYNC_COLUMN_VERSIONS_COLUMN)} AS column_versions, t.commit_seq AS tombstone_seq
|
|
828
|
+
FROM ids
|
|
829
|
+
LEFT JOIN ${quoted} r
|
|
830
|
+
ON r.${quoteIdent(SYNC_PARTITION_COLUMN)}=${params.partition} AND r.${quoteIdent(SYNC_ROW_ID_COLUMN)}=ids.id
|
|
831
|
+
LEFT JOIN sync_tombstones t
|
|
832
|
+
ON t.partition=${params.partition} AND t.tbl=ids.tbl AND t.row_id=ids.id
|
|
833
|
+
WHERE ids.tbl='${table.name.replaceAll("'", "''")}'`;
|
|
834
|
+
})
|
|
835
|
+
.join('\n UNION ALL ');
|
|
836
|
+
return `WITH ids AS (
|
|
837
|
+
SELECT spec.tbl AS tbl, entry.id AS id
|
|
838
|
+
FROM jsonb_to_recordset(${params.specs}::text::jsonb) AS spec(tbl text, ids jsonb)
|
|
839
|
+
CROSS JOIN LATERAL jsonb_array_elements_text(spec.ids) AS entry(id)
|
|
840
|
+
)
|
|
841
|
+
${projection}`;
|
|
821
842
|
}
|
|
822
843
|
|
|
823
844
|
/**
|
package/src/sqlite-storage.ts
CHANGED
|
@@ -5,6 +5,7 @@ import type { CommitPruneQuery, CommitPruneResult } from './storage';
|
|
|
5
5
|
import {
|
|
6
6
|
clientRoundState,
|
|
7
7
|
PARTITION_TOUCH_INTERVAL_MS,
|
|
8
|
+
rowsLiveAfterDelete,
|
|
8
9
|
serveGateRefusal,
|
|
9
10
|
} from './storage';
|
|
10
11
|
import { serveNotReadyError } from './readiness';
|
|
@@ -100,6 +101,7 @@ import type {
|
|
|
100
101
|
IndexRowScanQuery,
|
|
101
102
|
NewCommit,
|
|
102
103
|
NewReaction,
|
|
104
|
+
PreparedPushApply,
|
|
103
105
|
PartitionRegistryEntry,
|
|
104
106
|
PrunedReactionCounts,
|
|
105
107
|
ReactionClaimQuery,
|
|
@@ -254,20 +256,6 @@ class SqliteTransaction implements StorageTransaction {
|
|
|
254
256
|
.run(this.#partition, table, rowId);
|
|
255
257
|
}
|
|
256
258
|
|
|
257
|
-
getPushResult(
|
|
258
|
-
clientId: string,
|
|
259
|
-
clientCommitId: string,
|
|
260
|
-
): Promise<StoredPushResult | undefined> {
|
|
261
|
-
this.#assertOpen();
|
|
262
|
-
// One shared SQLite connection: this read runs inside this
|
|
263
|
-
// transaction's BEGIN IMMEDIATE.
|
|
264
|
-
return this.#storage.getPushResult(
|
|
265
|
-
this.#partition,
|
|
266
|
-
clientId,
|
|
267
|
-
clientCommitId,
|
|
268
|
-
);
|
|
269
|
-
}
|
|
270
|
-
|
|
271
259
|
scanRows(query: RowScanQuery): Promise<StoredRow[]> {
|
|
272
260
|
this.#assertOpen();
|
|
273
261
|
return this.#storage.scanRows(this.#partition, query);
|
|
@@ -287,11 +275,30 @@ class SqliteTransaction implements StorageTransaction {
|
|
|
287
275
|
return this.#storage.runAuthoritativeQuery(this.#partition, query);
|
|
288
276
|
}
|
|
289
277
|
|
|
290
|
-
async
|
|
278
|
+
async preparePushApply(
|
|
279
|
+
clientId: string,
|
|
280
|
+
clientCommitId: string,
|
|
281
|
+
runningSchemaVersion: number,
|
|
282
|
+
_targets: readonly {
|
|
283
|
+
readonly table: string;
|
|
284
|
+
readonly rowIds: readonly string[];
|
|
285
|
+
}[] = [],
|
|
286
|
+
): Promise<PreparedPushApply> {
|
|
291
287
|
this.#assertOpen();
|
|
292
288
|
// BEGIN IMMEDIATE in the constructor already owns SQLite's writer lock.
|
|
293
289
|
this.#storage.db.exec('SAVEPOINT syncular_push_candidate');
|
|
294
290
|
this.#pushApplySavepoint = true;
|
|
291
|
+
return {
|
|
292
|
+
gate: await this.#storage.readServeGate(
|
|
293
|
+
this.#partition,
|
|
294
|
+
runningSchemaVersion,
|
|
295
|
+
),
|
|
296
|
+
persisted: await this.#storage.getPushResult(
|
|
297
|
+
this.#partition,
|
|
298
|
+
clientId,
|
|
299
|
+
clientCommitId,
|
|
300
|
+
),
|
|
301
|
+
};
|
|
295
302
|
}
|
|
296
303
|
|
|
297
304
|
async advanceCheckpoint(
|
|
@@ -323,7 +330,7 @@ class SqliteTransaction implements StorageTransaction {
|
|
|
323
330
|
clientId: string,
|
|
324
331
|
clientCommitId: string,
|
|
325
332
|
result: StoredPushResult,
|
|
326
|
-
): Promise<
|
|
333
|
+
): Promise<StoredPushResult> {
|
|
327
334
|
this.#assertOpen();
|
|
328
335
|
if (!this.#pushApplySavepoint) {
|
|
329
336
|
throw new Error('push rejection requires its apply savepoint');
|
|
@@ -333,6 +340,9 @@ class SqliteTransaction implements StorageTransaction {
|
|
|
333
340
|
this.#pushApplySavepoint = false;
|
|
334
341
|
await this.putPushResult(clientId, clientCommitId, result);
|
|
335
342
|
await this.commit();
|
|
343
|
+
// The idempotency re-check found no record under the writer lock, so the
|
|
344
|
+
// insert above is the canonical outcome.
|
|
345
|
+
return result;
|
|
336
346
|
}
|
|
337
347
|
|
|
338
348
|
async upsertRow(
|
|
@@ -414,6 +424,7 @@ class SqliteTransaction implements StorageTransaction {
|
|
|
414
424
|
commit.createdAtMs,
|
|
415
425
|
this.#writerVersion,
|
|
416
426
|
);
|
|
427
|
+
const liveAfterDelete = rowsLiveAfterDelete(commit.changes);
|
|
417
428
|
commit.changes.forEach((change, idx) => {
|
|
418
429
|
db.query(
|
|
419
430
|
'INSERT INTO sync_changes(partition, commit_seq, idx, tbl, row_id, op, row_version, scopes, payload) VALUES (?,?,?,?,?,?,?,?,?)',
|
|
@@ -428,9 +439,13 @@ class SqliteTransaction implements StorageTransaction {
|
|
|
428
439
|
JSON.stringify(change.scopes),
|
|
429
440
|
change.payload ?? null,
|
|
430
441
|
);
|
|
431
|
-
if (
|
|
442
|
+
if (
|
|
443
|
+
change.op === 'delete' &&
|
|
444
|
+
!liveAfterDelete.has(`${change.table}\u0000${change.rowId}`)
|
|
445
|
+
) {
|
|
432
446
|
// §5 delete precedence: every applied delete leaves a tombstone,
|
|
433
|
-
// pruned with the commit log (§4.6).
|
|
447
|
+
// pruned with the commit log (§4.6). A delete that the same commit
|
|
448
|
+
// follows with an upsert is a §2.2 scope move, not a deletion.
|
|
434
449
|
db.query(
|
|
435
450
|
`INSERT INTO sync_tombstones(partition, tbl, row_id, commit_seq) VALUES (?,?,?,?)
|
|
436
451
|
ON CONFLICT(partition, tbl, row_id) DO UPDATE SET commit_seq=excluded.commit_seq`,
|
package/src/storage.ts
CHANGED
|
@@ -74,6 +74,18 @@ export interface ServeGate {
|
|
|
74
74
|
}[];
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
+
/**
|
|
78
|
+
* The push apply's admission token: the partition lock, the serve gate, and
|
|
79
|
+
* the idempotency record read in one round trip. A storage with per-statement
|
|
80
|
+
* network cost answers all three with one statement (see
|
|
81
|
+
* `PostgresServerStorage`); the lock is held from this call through commit.
|
|
82
|
+
*/
|
|
83
|
+
export interface PreparedPushApply {
|
|
84
|
+
readonly gate: ServeGate;
|
|
85
|
+
/** The already-persisted outcome for this key, or `undefined` on a miss. */
|
|
86
|
+
readonly persisted: StoredPushResult | undefined;
|
|
87
|
+
}
|
|
88
|
+
|
|
77
89
|
/** Why `serveGate` refuses, or `undefined` when this request may serve. */
|
|
78
90
|
export type ServeGateRefusal =
|
|
79
91
|
| {
|
|
@@ -182,6 +194,33 @@ export interface NewCommit {
|
|
|
182
194
|
readonly changes: readonly NewChange[];
|
|
183
195
|
}
|
|
184
196
|
|
|
197
|
+
/**
|
|
198
|
+
* §2.2: rows whose final change in this commit is an upsert that followed a
|
|
199
|
+
* delete. That delete is one half of a scope move, so it MUST NOT leave a
|
|
200
|
+
* tombstone behind the live row. A row whose final change is a delete keeps
|
|
201
|
+
* its tombstone.
|
|
202
|
+
*/
|
|
203
|
+
export function rowsLiveAfterDelete(
|
|
204
|
+
changes: readonly NewChange[],
|
|
205
|
+
): Set<string> {
|
|
206
|
+
const last = new Map<string, NewChange['op']>();
|
|
207
|
+
const deleted = new Set<string>();
|
|
208
|
+
const live = new Set<string>();
|
|
209
|
+
for (const change of changes) {
|
|
210
|
+
const key = `${change.table}\u0000${change.rowId}`;
|
|
211
|
+
if (change.op === 'delete') {
|
|
212
|
+
deleted.add(key);
|
|
213
|
+
} else if (deleted.has(key)) {
|
|
214
|
+
live.add(key);
|
|
215
|
+
}
|
|
216
|
+
last.set(key, change.op);
|
|
217
|
+
}
|
|
218
|
+
for (const key of live) {
|
|
219
|
+
if (last.get(key) !== 'upsert') live.delete(key);
|
|
220
|
+
}
|
|
221
|
+
return live;
|
|
222
|
+
}
|
|
223
|
+
|
|
185
224
|
export interface StoredChange {
|
|
186
225
|
readonly table: string;
|
|
187
226
|
readonly rowId: string;
|
|
@@ -520,12 +559,17 @@ export interface StorageTransaction {
|
|
|
520
559
|
getRow(table: string, rowId: string): Promise<StoredRow | undefined>;
|
|
521
560
|
/**
|
|
522
561
|
* Optional read-ahead: the push layer names every row a commit's
|
|
523
|
-
* operations target,
|
|
524
|
-
* per-statement network cost reads them in one statement
|
|
525
|
-
* following `getRow` calls from that snapshot
|
|
526
|
-
* this transaction.
|
|
562
|
+
* operations target, across every table, before it applies them. A storage
|
|
563
|
+
* with per-statement network cost reads them in one statement (one branch
|
|
564
|
+
* per table) and answers the following `getRow` calls from that snapshot
|
|
565
|
+
* until the row is written in this transaction.
|
|
527
566
|
*/
|
|
528
|
-
prefetchRows?(
|
|
567
|
+
prefetchRows?(
|
|
568
|
+
targets: readonly {
|
|
569
|
+
readonly table: string;
|
|
570
|
+
readonly rowIds: readonly string[];
|
|
571
|
+
}[],
|
|
572
|
+
): Promise<void>;
|
|
529
573
|
/**
|
|
530
574
|
* §5 delete-precedence tombstone read: the `commit_seq` of the newest
|
|
531
575
|
* applied delete of this row still within the pruning horizon, or
|
|
@@ -540,20 +584,6 @@ export interface StorageTransaction {
|
|
|
540
584
|
* over an absent row; a no-op when no tombstone exists.
|
|
541
585
|
*/
|
|
542
586
|
clearTombstone(table: string, rowId: string): Promise<void>;
|
|
543
|
-
/**
|
|
544
|
-
* Optional transaction-scoped idempotency lookup (§2.3) with the same
|
|
545
|
-
* semantics as `ServerStorage.getPushResult`, including the
|
|
546
|
-
* `sync.idempotency_cache_miss` throw. The push layer prefers it for the
|
|
547
|
-
* post-serialization duplicate re-check so the read runs on the
|
|
548
|
-
* transaction's own connection — a pooled Postgres client that holds the
|
|
549
|
-
* partition lock must never wait for a second pool slot mid-push. In-tree
|
|
550
|
-
* SQLite/Postgres/D1 adapters implement it; a custom adapter that omits it
|
|
551
|
-
* keeps the pool-level read.
|
|
552
|
-
*/
|
|
553
|
-
getPushResult?(
|
|
554
|
-
clientId: string,
|
|
555
|
-
clientCommitId: string,
|
|
556
|
-
): Promise<StoredPushResult | undefined>;
|
|
557
587
|
/**
|
|
558
588
|
* Optional candidate-state scan used only by whole-commit validation.
|
|
559
589
|
* In-tree SQLite/Postgres/D1 backends implement it with read-your-own-writes
|
|
@@ -583,28 +613,37 @@ export interface StorageTransaction {
|
|
|
583
613
|
): Promise<AuthoritativeQueryResult>;
|
|
584
614
|
/**
|
|
585
615
|
* Serialize every push apply for this partition before any operation read,
|
|
586
|
-
* validation, merge, or write
|
|
587
|
-
*
|
|
616
|
+
* validation, merge, or write, and read the admission token in the same
|
|
617
|
+
* round trip: the RFC 0007 serve gate and the §2.3 idempotency record for
|
|
618
|
+
* `clientCommitId`. The lock is retained through terminal-result commit,
|
|
619
|
+
* so the idempotency read is exact and a racing duplicate cannot rerun.
|
|
620
|
+
* `targets` are the rows the commit is known to touch before its operations
|
|
621
|
+
* are built; a storage with per-statement network cost folds that read-ahead
|
|
622
|
+
* into the admission read and answers the following `getRow` calls from it.
|
|
588
623
|
* Missing support fails closed before an app-row mutation.
|
|
589
624
|
*/
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
625
|
+
preparePushApply(
|
|
626
|
+
clientId: string,
|
|
627
|
+
clientCommitId: string,
|
|
628
|
+
runningSchemaVersion: number,
|
|
629
|
+
targets?: readonly {
|
|
630
|
+
readonly table: string;
|
|
631
|
+
readonly rowIds: readonly string[];
|
|
632
|
+
}[],
|
|
633
|
+
): Promise<PreparedPushApply>;
|
|
597
634
|
/**
|
|
598
635
|
* Rejection finalization while the push-apply serialization lock is still
|
|
599
636
|
* held: discard every candidate write, persist the rejected idempotency
|
|
600
|
-
* result, and finish the transaction atomically.
|
|
601
|
-
*
|
|
637
|
+
* result, and finish the transaction atomically. Returns the persisted
|
|
638
|
+
* canonical outcome, so the caller never re-reads it. Required for every
|
|
639
|
+
* push so a concurrent duplicate cannot rerun operations, validators, or
|
|
640
|
+
* merges.
|
|
602
641
|
*/
|
|
603
|
-
commitRejectedPushResult
|
|
642
|
+
commitRejectedPushResult(
|
|
604
643
|
clientId: string,
|
|
605
644
|
clientCommitId: string,
|
|
606
645
|
result: StoredPushResult,
|
|
607
|
-
): Promise<
|
|
646
|
+
): Promise<StoredPushResult>;
|
|
608
647
|
upsertRow(
|
|
609
648
|
table: string,
|
|
610
649
|
row: StoredRow,
|
|
@@ -717,10 +756,10 @@ export interface ServerStorage {
|
|
|
717
756
|
* same command races a push on Postgres and a SQLite-only test conceals it.
|
|
718
757
|
*
|
|
719
758
|
* A trusted command must therefore run through `registerRemoteCommand`,
|
|
720
|
-
* which builds after `
|
|
721
|
-
*
|
|
722
|
-
*
|
|
723
|
-
*
|
|
759
|
+
* which builds after `preparePushApply` and shares the committed
|
|
760
|
+
* transaction, or call `preparePushApply` before its first authority,
|
|
761
|
+
* version, or replay read. SSP2 request handling does this for you; only
|
|
762
|
+
* host-authored commands choose.
|
|
724
763
|
*/
|
|
725
764
|
begin(partition: string): Promise<StorageTransaction>;
|
|
726
765
|
|