@syncular/server 0.16.1 → 0.17.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 +25 -5
- package/dist/d1-storage.d.ts +9 -0
- package/dist/d1-storage.js +409 -106
- package/dist/postgres-storage.d.ts +2 -0
- package/dist/postgres-storage.js +41 -14
- package/dist/pull.js +16 -7
- package/dist/realtime.d.ts +1 -1
- package/dist/realtime.js +16 -10
- package/dist/relational-rows.d.ts +7 -1
- package/dist/relational-rows.js +17 -2
- package/dist/sqlite-storage.d.ts +2 -0
- package/dist/sqlite-storage.js +26 -6
- package/dist/storage-errors.d.ts +1 -1
- package/dist/storage-errors.js +4 -0
- package/dist/storage.d.ts +9 -0
- package/package.json +2 -2
- package/src/d1-storage.ts +555 -142
- package/src/postgres-storage.ts +64 -21
- package/src/pull.ts +22 -7
- package/src/realtime.ts +20 -9
- package/src/relational-rows.ts +18 -2
- package/src/sqlite-storage.ts +45 -9
- package/src/storage-errors.ts +12 -0
- package/src/storage.ts +21 -0
package/src/postgres-storage.ts
CHANGED
|
@@ -230,6 +230,13 @@ async function lockPartitionOn(
|
|
|
230
230
|
client: PgQueryable,
|
|
231
231
|
partition: string,
|
|
232
232
|
): Promise<void> {
|
|
233
|
+
const locked = await client.query(
|
|
234
|
+
'SELECT max_commit_seq FROM sync_partitions WHERE partition=$1 FOR UPDATE',
|
|
235
|
+
[partition],
|
|
236
|
+
);
|
|
237
|
+
if (locked.rows.length > 0) return;
|
|
238
|
+
// The first writer initializes the partition. Concurrent initializers may
|
|
239
|
+
// wait on this insert, so acquire the row lock again before applying writes.
|
|
233
240
|
await client.query(
|
|
234
241
|
`INSERT INTO sync_partitions(partition, max_commit_seq) VALUES ($1, 0)
|
|
235
242
|
ON CONFLICT (partition) DO NOTHING`,
|
|
@@ -753,32 +760,40 @@ class PostgresTransaction implements StorageTransaction {
|
|
|
753
760
|
// Allocate the next dense commitSeq under a per-partition row lock: the
|
|
754
761
|
// UPDATE … RETURNING serializes concurrent pushes to this partition and
|
|
755
762
|
// never leaves a gap on rollback (see the file header).
|
|
756
|
-
const { rows } = await q.query<{
|
|
757
|
-
`
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
VALUES ($1,$2,$3,$4,$5,$6)`,
|
|
763
|
+
const { rows } = await q.query<{ commit_seq: unknown }>(
|
|
764
|
+
`WITH allocated AS (
|
|
765
|
+
INSERT INTO sync_partitions(partition, max_commit_seq) VALUES ($1, 1)
|
|
766
|
+
ON CONFLICT (partition) DO UPDATE
|
|
767
|
+
SET max_commit_seq = sync_partitions.max_commit_seq + 1
|
|
768
|
+
RETURNING max_commit_seq
|
|
769
|
+
)
|
|
770
|
+
INSERT INTO sync_commits(partition, commit_seq, client_id, client_commit_id, actor_id, created_at_ms)
|
|
771
|
+
SELECT $1, max_commit_seq, $2, $3, $4, $5 FROM allocated
|
|
772
|
+
RETURNING commit_seq`,
|
|
767
773
|
[
|
|
768
774
|
p,
|
|
769
|
-
commitSeq,
|
|
770
775
|
commit.clientId,
|
|
771
776
|
commit.clientCommitId,
|
|
772
777
|
commit.actorId,
|
|
773
778
|
commit.createdAtMs,
|
|
774
779
|
],
|
|
775
780
|
);
|
|
781
|
+
const commitSeq = asNumber(rows[0]?.commit_seq);
|
|
776
782
|
for (let idx = 0; idx < commit.changes.length; idx++) {
|
|
777
783
|
const change = commit.changes[idx];
|
|
778
784
|
if (change === undefined) continue;
|
|
785
|
+
// Bind serialized scopes as text before parsing JSONB. Drivers that
|
|
786
|
+
// encode JSONB parameters would otherwise store this string as a scalar.
|
|
779
787
|
await q.query(
|
|
780
|
-
`
|
|
781
|
-
|
|
788
|
+
`WITH inserted AS (
|
|
789
|
+
INSERT INTO sync_changes(partition, commit_seq, idx, tbl, row_id, op, row_version, scopes, payload)
|
|
790
|
+
VALUES ($1,$2,$3,$4,$5,$6,$7,$8::text::jsonb,$9)
|
|
791
|
+
RETURNING partition, tbl, commit_seq, scopes
|
|
792
|
+
)
|
|
793
|
+
INSERT INTO sync_change_scopes(partition, tbl, var, value, commit_seq)
|
|
794
|
+
SELECT inserted.partition, inserted.tbl, scope.key, scope.value, inserted.commit_seq
|
|
795
|
+
FROM inserted CROSS JOIN LATERAL jsonb_each_text(inserted.scopes) AS scope
|
|
796
|
+
ON CONFLICT DO NOTHING`,
|
|
782
797
|
[
|
|
783
798
|
p,
|
|
784
799
|
commitSeq,
|
|
@@ -791,13 +806,6 @@ class PostgresTransaction implements StorageTransaction {
|
|
|
791
806
|
change.payload ?? null,
|
|
792
807
|
],
|
|
793
808
|
);
|
|
794
|
-
for (const [variable, value] of Object.entries(change.scopes)) {
|
|
795
|
-
await q.query(
|
|
796
|
-
`INSERT INTO sync_change_scopes(partition, tbl, var, value, commit_seq)
|
|
797
|
-
VALUES ($1,$2,$3,$4,$5) ON CONFLICT DO NOTHING`,
|
|
798
|
-
[p, change.table, variable, value, commitSeq],
|
|
799
|
-
);
|
|
800
|
-
}
|
|
801
809
|
}
|
|
802
810
|
return commitSeq;
|
|
803
811
|
}
|
|
@@ -986,6 +994,9 @@ export class PostgresServerStorage implements ServerStorage {
|
|
|
986
994
|
await client.query('DELETE FROM sync_row_scopes WHERE tbl=$1', [
|
|
987
995
|
tableName,
|
|
988
996
|
]);
|
|
997
|
+
await client.query('DELETE FROM sync_blob_refs WHERE tbl=$1', [
|
|
998
|
+
tableName,
|
|
999
|
+
]);
|
|
989
1000
|
await client.query(dropTableDdl(tableName));
|
|
990
1001
|
}
|
|
991
1002
|
for (const statement of schemaDdl(
|
|
@@ -1676,6 +1687,38 @@ export class PostgresServerStorage implements ServerStorage {
|
|
|
1676
1687
|
);
|
|
1677
1688
|
}
|
|
1678
1689
|
|
|
1690
|
+
async advanceClientCursor(
|
|
1691
|
+
partition: string,
|
|
1692
|
+
clientId: string,
|
|
1693
|
+
actorId: string,
|
|
1694
|
+
logEpoch: string,
|
|
1695
|
+
cursor: number,
|
|
1696
|
+
updatedAtMs: number,
|
|
1697
|
+
): Promise<void> {
|
|
1698
|
+
await this.#exec.query(
|
|
1699
|
+
`UPDATE sync_clients
|
|
1700
|
+
SET cursor=GREATEST(cursor, $1), updated_at_ms=GREATEST(updated_at_ms, $2)
|
|
1701
|
+
WHERE partition=$3 AND client_id=$4 AND actor_id=$5
|
|
1702
|
+
AND EXISTS (SELECT 1 FROM sync_partition_registry
|
|
1703
|
+
WHERE partition=sync_clients.partition AND log_epoch=$6)`,
|
|
1704
|
+
[cursor, updatedAtMs, partition, clientId, actorId, logEpoch],
|
|
1705
|
+
);
|
|
1706
|
+
}
|
|
1707
|
+
|
|
1708
|
+
async updateClientCursor(
|
|
1709
|
+
partition: string,
|
|
1710
|
+
clientId: string,
|
|
1711
|
+
cursor: number,
|
|
1712
|
+
updatedAtMs: number,
|
|
1713
|
+
): Promise<void> {
|
|
1714
|
+
await this.#exec.query(
|
|
1715
|
+
`UPDATE sync_clients
|
|
1716
|
+
SET cursor=GREATEST(cursor, $3), updated_at_ms=GREATEST(updated_at_ms, $4)
|
|
1717
|
+
WHERE partition=$1 AND client_id=$2`,
|
|
1718
|
+
[partition, clientId, cursor, updatedAtMs],
|
|
1719
|
+
);
|
|
1720
|
+
}
|
|
1721
|
+
|
|
1679
1722
|
async getActiveClientCursorFloor(
|
|
1680
1723
|
partition: string,
|
|
1681
1724
|
cutoffMs: number,
|
package/src/pull.ts
CHANGED
|
@@ -501,6 +501,28 @@ export async function* subscriptionSection(
|
|
|
501
501
|
|
|
502
502
|
const token = parseBootstrapToken(sub.bootstrapState, sub.table);
|
|
503
503
|
|
|
504
|
+
let commits: StoredCommit[] = [];
|
|
505
|
+
if (
|
|
506
|
+
token === undefined &&
|
|
507
|
+
sub.cursor >= 0 &&
|
|
508
|
+
sub.cursor >= horizonSeq &&
|
|
509
|
+
sub.cursor <= maxSeq
|
|
510
|
+
) {
|
|
511
|
+
commits = await ctx.storage.readCommitWindow(ctx.partition, {
|
|
512
|
+
table: sub.table,
|
|
513
|
+
scopeFilter: plan.effective,
|
|
514
|
+
afterSeq: sub.cursor,
|
|
515
|
+
throughSeq: maxSeq,
|
|
516
|
+
limitChanges: limits.limitCommits + 1,
|
|
517
|
+
});
|
|
518
|
+
// Validate continuity before committing to an active section. A prune
|
|
519
|
+
// during a paged read can otherwise make an incomplete window look empty.
|
|
520
|
+
horizonSeq = Math.max(
|
|
521
|
+
horizonSeq,
|
|
522
|
+
await ctx.storage.getHorizonSeq(ctx.partition),
|
|
523
|
+
);
|
|
524
|
+
}
|
|
525
|
+
|
|
504
526
|
// §4.6: a cursor behind the horizon (and not resuming a bootstrap)
|
|
505
527
|
// cannot compute deltas — answer `reset` and echo the cursor.
|
|
506
528
|
if (token === undefined && sub.cursor >= 0 && sub.cursor < horizonSeq) {
|
|
@@ -574,13 +596,6 @@ export async function* subscriptionSection(
|
|
|
574
596
|
effectiveScopes: plan.effective,
|
|
575
597
|
bootstrap: false,
|
|
576
598
|
};
|
|
577
|
-
const commits = await ctx.storage.readCommitWindow(ctx.partition, {
|
|
578
|
-
table: sub.table,
|
|
579
|
-
scopeFilter: plan.effective,
|
|
580
|
-
afterSeq: sub.cursor,
|
|
581
|
-
throughSeq: maxSeq,
|
|
582
|
-
limitChanges: limits.limitCommits + 1,
|
|
583
|
-
});
|
|
584
599
|
let delivered = 0;
|
|
585
600
|
let deliveredCommits = 0;
|
|
586
601
|
let lastDeliveredSeq = sub.cursor;
|
package/src/realtime.ts
CHANGED
|
@@ -307,6 +307,10 @@ export class RealtimeSession {
|
|
|
307
307
|
const cursor = (parsed as { cursor?: unknown }).cursor;
|
|
308
308
|
if (typeof cursor !== 'number' || !Number.isSafeInteger(cursor)) return;
|
|
309
309
|
this.cursor = Math.max(this.cursor, cursor);
|
|
310
|
+
// The client may have caught up through another binding (§8.4). Its ack
|
|
311
|
+
// proves those commits no longer need socket notifications, so the next
|
|
312
|
+
// notification must be adjacent to the acknowledged cursor.
|
|
313
|
+
this.lastKnownSeq = Math.max(this.lastKnownSeq, this.cursor);
|
|
310
314
|
if (this.cursor >= this.lastKnownSeq) this.wakePending = false;
|
|
311
315
|
// §8.2: acks update the client cursor record without an HTTP pull.
|
|
312
316
|
void this.#persistCursor();
|
|
@@ -626,16 +630,14 @@ export class RealtimeSession {
|
|
|
626
630
|
|
|
627
631
|
async #persistCursor(): Promise<void> {
|
|
628
632
|
try {
|
|
629
|
-
|
|
633
|
+
await this.#storage.advanceClientCursor(
|
|
630
634
|
this.partition,
|
|
631
635
|
this.clientId,
|
|
636
|
+
this.actorId,
|
|
637
|
+
this.logEpoch,
|
|
638
|
+
this.cursor,
|
|
639
|
+
this.#clock(),
|
|
632
640
|
);
|
|
633
|
-
if (record === undefined) return;
|
|
634
|
-
await this.#storage.putClientRecord(this.partition, {
|
|
635
|
-
...record,
|
|
636
|
-
cursor: Math.max(record.cursor, this.cursor),
|
|
637
|
-
updatedAtMs: this.#clock(),
|
|
638
|
-
});
|
|
639
641
|
} catch {
|
|
640
642
|
// Cursor persistence is best-effort; the next pull repairs it.
|
|
641
643
|
}
|
|
@@ -688,8 +690,9 @@ export class RealtimeSession {
|
|
|
688
690
|
}
|
|
689
691
|
}
|
|
690
692
|
|
|
691
|
-
/** Called by the hub for every applied commit
|
|
693
|
+
/** Called by the hub for every applied commit notification. */
|
|
692
694
|
deliverCommit(commit: StoredCommit): void {
|
|
695
|
+
const contiguous = commit.commitSeq === this.lastKnownSeq + 1;
|
|
693
696
|
this.lastKnownSeq = Math.max(this.lastKnownSeq, commit.commitSeq);
|
|
694
697
|
const sections: Array<{
|
|
695
698
|
registration: Registration;
|
|
@@ -716,6 +719,14 @@ export class RealtimeSession {
|
|
|
716
719
|
);
|
|
717
720
|
if (changes.length > 0) sections.push({ registration, changes });
|
|
718
721
|
}
|
|
722
|
+
if (!contiguous) {
|
|
723
|
+
// Pushes allocate commitSeq under the partition lock but notify after
|
|
724
|
+
// commit, so racing notifications may arrive out of order. A gap,
|
|
725
|
+
// duplicate, or regression cannot be a delta: the client would apply
|
|
726
|
+
// and acknowledge a cursor that does not prove the intervening log.
|
|
727
|
+
this.sendWake('catchup-required');
|
|
728
|
+
return;
|
|
729
|
+
}
|
|
719
730
|
if (sections.length === 0) return;
|
|
720
731
|
if (this.#activeRound !== undefined) {
|
|
721
732
|
// §8.7 interleaving: no 0x00 messages while a response stream is
|
|
@@ -771,7 +782,7 @@ export class RealtimeSession {
|
|
|
771
782
|
tagged[0] = REALTIME_TAG_DELTA;
|
|
772
783
|
tagged.set(bytes, 1);
|
|
773
784
|
this.#sendSafe(tagged);
|
|
774
|
-
this.cursor = commit.commitSeq;
|
|
785
|
+
this.cursor = Math.max(this.cursor, commit.commitSeq);
|
|
775
786
|
const events = this.#events;
|
|
776
787
|
if (events !== undefined) {
|
|
777
788
|
emitEvent(events, {
|
package/src/relational-rows.ts
CHANGED
|
@@ -205,7 +205,7 @@ export function physicalIndexName(declaredName: string): string {
|
|
|
205
205
|
|
|
206
206
|
/**
|
|
207
207
|
* CREATE INDEX IF NOT EXISTS for the table's user-declared indexes. These use
|
|
208
|
-
* the
|
|
208
|
+
* the declared columns with a leading server partition column. Cross-table
|
|
209
209
|
* index-name uniqueness is the user's schema concern, as it is client-side.
|
|
210
210
|
* Server-side the physical name carries the {@link SYNC_INDEX_PREFIX}
|
|
211
211
|
* ownership marker.
|
|
@@ -215,7 +215,7 @@ export function createIndexDdl(table: CompiledTable): string[] {
|
|
|
215
215
|
if (!table.materialize) return [];
|
|
216
216
|
return table.indexes.map((index) => {
|
|
217
217
|
const unique = index.unique ? 'UNIQUE ' : '';
|
|
218
|
-
const columns = index.columns
|
|
218
|
+
const columns = [SYNC_PARTITION_COLUMN, ...index.columns]
|
|
219
219
|
.map((column) => quoteIdent(column))
|
|
220
220
|
.join(', ');
|
|
221
221
|
return `CREATE ${unique}INDEX IF NOT EXISTS ${quoteIdent(physicalIndexName(index.name))} ON ${quoteIdent(table.name)} (${columns})`;
|
|
@@ -477,6 +477,22 @@ export function deleteRowSql(
|
|
|
477
477
|
return `DELETE FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(SYNC_PARTITION_COLUMN)}=${p[0]} AND ${quoteIdent(SYNC_ROW_ID_COLUMN)}=${p[1]}`;
|
|
478
478
|
}
|
|
479
479
|
|
|
480
|
+
/**
|
|
481
|
+
* SQLite: remove the old row's exact scope keys before replacing or deleting the row.
|
|
482
|
+
* Params: [partition, table, rowId, partition, rowId]. The caller keeps this
|
|
483
|
+
* statement and the row write in the same transaction (or D1 atomic batch).
|
|
484
|
+
*/
|
|
485
|
+
export function deleteSqliteRowScopesSql(table: CompiledTable): string {
|
|
486
|
+
return `DELETE FROM sync_row_scopes
|
|
487
|
+
WHERE partition=? AND tbl=? AND row_id=?
|
|
488
|
+
AND (var, value) IN (
|
|
489
|
+
SELECT key, value FROM json_each((
|
|
490
|
+
SELECT ${quoteIdent(SYNC_SCOPES_COLUMN)} FROM ${quoteIdent(table.name)}
|
|
491
|
+
WHERE ${quoteIdent(SYNC_PARTITION_COLUMN)}=? AND ${quoteIdent(SYNC_ROW_ID_COLUMN)}=?
|
|
492
|
+
))
|
|
493
|
+
)`;
|
|
494
|
+
}
|
|
495
|
+
|
|
480
496
|
/**
|
|
481
497
|
* The schema-version marker table gates DDL work. `ensureSchema` compares the
|
|
482
498
|
* stored version and skips
|
package/src/sqlite-storage.ts
CHANGED
|
@@ -17,6 +17,7 @@ import { syncError } from './errors';
|
|
|
17
17
|
import {
|
|
18
18
|
commitWindowPageSql,
|
|
19
19
|
deleteRowSql,
|
|
20
|
+
deleteSqliteRowScopesSql,
|
|
20
21
|
dropTableDdl,
|
|
21
22
|
indexRowPageStatement,
|
|
22
23
|
layoutsOf,
|
|
@@ -228,13 +229,15 @@ class SqliteTransaction implements StorageTransaction {
|
|
|
228
229
|
async deleteRow(table: string, rowId: string): Promise<void> {
|
|
229
230
|
this.#assertOpen();
|
|
230
231
|
const db = this.#storage.db;
|
|
231
|
-
|
|
232
|
+
const compiled = this.#storage.table(table);
|
|
233
|
+
db.query(deleteSqliteRowScopesSql(compiled)).run(
|
|
234
|
+
this.#partition,
|
|
235
|
+
table,
|
|
236
|
+
rowId,
|
|
232
237
|
this.#partition,
|
|
233
238
|
rowId,
|
|
234
239
|
);
|
|
235
|
-
db.query(
|
|
236
|
-
'DELETE FROM sync_row_scopes WHERE partition=? AND tbl=? AND row_id=?',
|
|
237
|
-
).run(this.#partition, table, rowId);
|
|
240
|
+
db.query(deleteRowSql(compiled, 'sqlite')).run(this.#partition, rowId);
|
|
238
241
|
// §5.9.4: a deleted row references no blobs.
|
|
239
242
|
db.query(
|
|
240
243
|
'DELETE FROM sync_blob_refs WHERE partition=? AND tbl=? AND row_id=?',
|
|
@@ -485,6 +488,9 @@ export class SqliteServerStorage implements ServerStorage {
|
|
|
485
488
|
this.db
|
|
486
489
|
.query('DELETE FROM sync_row_scopes WHERE tbl=?')
|
|
487
490
|
.run(tableName);
|
|
491
|
+
this.db
|
|
492
|
+
.query('DELETE FROM sync_blob_refs WHERE tbl=?')
|
|
493
|
+
.run(tableName);
|
|
488
494
|
this.db.exec(dropTableDdl(tableName));
|
|
489
495
|
}
|
|
490
496
|
for (const statement of schemaDdl(
|
|
@@ -683,6 +689,9 @@ export class SqliteServerStorage implements ServerStorage {
|
|
|
683
689
|
/** Internal: write a row + refresh its scope-index entries. */
|
|
684
690
|
writeRow(partition: string, table: string, row: StoredRow): void {
|
|
685
691
|
const compiled = this.table(table);
|
|
692
|
+
this.db
|
|
693
|
+
.query(deleteSqliteRowScopesSql(compiled))
|
|
694
|
+
.run(partition, table, row.rowId, partition, row.rowId);
|
|
686
695
|
this.db
|
|
687
696
|
.query(upsertSql(compiled, 'sqlite'))
|
|
688
697
|
.run(
|
|
@@ -694,11 +703,6 @@ export class SqliteServerStorage implements ServerStorage {
|
|
|
694
703
|
| null
|
|
695
704
|
)[]),
|
|
696
705
|
);
|
|
697
|
-
this.db
|
|
698
|
-
.query(
|
|
699
|
-
'DELETE FROM sync_row_scopes WHERE partition=? AND tbl=? AND row_id=?',
|
|
700
|
-
)
|
|
701
|
-
.run(partition, table, row.rowId);
|
|
702
706
|
for (const [variable, value] of Object.entries(row.scopes)) {
|
|
703
707
|
this.db
|
|
704
708
|
.query(
|
|
@@ -1272,6 +1276,38 @@ export class SqliteServerStorage implements ServerStorage {
|
|
|
1272
1276
|
);
|
|
1273
1277
|
}
|
|
1274
1278
|
|
|
1279
|
+
async advanceClientCursor(
|
|
1280
|
+
partition: string,
|
|
1281
|
+
clientId: string,
|
|
1282
|
+
actorId: string,
|
|
1283
|
+
logEpoch: string,
|
|
1284
|
+
cursor: number,
|
|
1285
|
+
updatedAtMs: number,
|
|
1286
|
+
): Promise<void> {
|
|
1287
|
+
this.db
|
|
1288
|
+
.query(`UPDATE sync_clients
|
|
1289
|
+
SET cursor=MAX(cursor, ?), updated_at_ms=MAX(updated_at_ms, ?)
|
|
1290
|
+
WHERE partition=? AND client_id=? AND actor_id=?
|
|
1291
|
+
AND EXISTS (SELECT 1 FROM sync_partition_registry
|
|
1292
|
+
WHERE partition=sync_clients.partition AND log_epoch=?)`)
|
|
1293
|
+
.run(cursor, updatedAtMs, partition, clientId, actorId, logEpoch);
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
async updateClientCursor(
|
|
1297
|
+
partition: string,
|
|
1298
|
+
clientId: string,
|
|
1299
|
+
cursor: number,
|
|
1300
|
+
updatedAtMs: number,
|
|
1301
|
+
): Promise<void> {
|
|
1302
|
+
this.db
|
|
1303
|
+
.query(
|
|
1304
|
+
`UPDATE sync_clients
|
|
1305
|
+
SET cursor=MAX(cursor, ?), updated_at_ms=MAX(updated_at_ms, ?)
|
|
1306
|
+
WHERE partition=? AND client_id=?`,
|
|
1307
|
+
)
|
|
1308
|
+
.run(cursor, updatedAtMs, partition, clientId);
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1275
1311
|
async getActiveClientCursorFloor(
|
|
1276
1312
|
partition: string,
|
|
1277
1313
|
cutoffMs: number,
|
package/src/storage-errors.ts
CHANGED
|
@@ -15,6 +15,10 @@ export class StorageConstraintError extends Error {
|
|
|
15
15
|
|
|
16
16
|
/** Stable, privacy-safe failures for trusted server storage queries. */
|
|
17
17
|
export type StorageQueryErrorCode =
|
|
18
|
+
| 'sync.storage.schema_migration_pending'
|
|
19
|
+
| 'sync.storage.schema_migration_conflict'
|
|
20
|
+
| 'sync.storage.schema_changed'
|
|
21
|
+
| 'sync.storage.invalid_migration_budget'
|
|
18
22
|
| 'sync.storage.scan_requires_scope'
|
|
19
23
|
| 'sync.storage.index_not_found'
|
|
20
24
|
| 'sync.storage.index_not_materialized'
|
|
@@ -26,6 +30,14 @@ export type StorageQueryErrorCode =
|
|
|
26
30
|
|
|
27
31
|
const STORAGE_QUERY_MESSAGES: Readonly<Record<StorageQueryErrorCode, string>> =
|
|
28
32
|
{
|
|
33
|
+
'sync.storage.schema_migration_pending':
|
|
34
|
+
'schema migration requires another invocation',
|
|
35
|
+
'sync.storage.schema_migration_conflict':
|
|
36
|
+
'another schema migration target is already pending',
|
|
37
|
+
'sync.storage.schema_changed':
|
|
38
|
+
'storage schema is not ready for this operation',
|
|
39
|
+
'sync.storage.invalid_migration_budget':
|
|
40
|
+
'migration statement budget must be an integer from 10 through 1000',
|
|
29
41
|
'sync.storage.prune_epoch_mismatch':
|
|
30
42
|
'partition log epoch changed; recompute retention inputs',
|
|
31
43
|
'sync.storage.partition_unregistered':
|
package/src/storage.ts
CHANGED
|
@@ -560,6 +560,27 @@ export interface ServerStorage {
|
|
|
560
560
|
clientId: string,
|
|
561
561
|
): Promise<ClientRecord | undefined>;
|
|
562
562
|
putClientRecord(partition: string, record: ClientRecord): Promise<void>;
|
|
563
|
+
/**
|
|
564
|
+
* Atomically advance an existing record's cursor and activity timestamp.
|
|
565
|
+
* Preserve registration fields; require matching actor and current log epoch.
|
|
566
|
+
* Missing records and mismatched identities are unchanged (§8.2).
|
|
567
|
+
*/
|
|
568
|
+
advanceClientCursor(
|
|
569
|
+
partition: string,
|
|
570
|
+
clientId: string,
|
|
571
|
+
actorId: string,
|
|
572
|
+
logEpoch: string,
|
|
573
|
+
cursor: number,
|
|
574
|
+
updatedAtMs: number,
|
|
575
|
+
): Promise<void>;
|
|
576
|
+
/** Advance an existing client's ACK cursor and timestamp atomically,
|
|
577
|
+
* preserving actor, wire version, and subscriptions. Missing records stay absent. */
|
|
578
|
+
updateClientCursor(
|
|
579
|
+
partition: string,
|
|
580
|
+
clientId: string,
|
|
581
|
+
cursor: number,
|
|
582
|
+
updatedAtMs: number,
|
|
583
|
+
): Promise<void>;
|
|
563
584
|
/** Minimum cursor with updatedAtMs >= cutoff; null when none are active. */
|
|
564
585
|
getActiveClientCursorFloor(
|
|
565
586
|
partition: string,
|