@syncular/server 0.15.48 → 0.16.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/README.md +41 -4
- package/dist/admin.js +1 -5
- package/dist/authoritative-query.d.ts +14 -6
- package/dist/authoritative-query.js +60 -82
- package/dist/d1-storage.d.ts +4 -1
- package/dist/d1-storage.js +59 -15
- package/dist/operations.d.ts +2 -0
- package/dist/operations.js +23 -4
- package/dist/postgres-storage.d.ts +4 -1
- package/dist/postgres-storage.js +39 -10
- package/dist/prune.d.ts +3 -1
- package/dist/prune.js +18 -14
- package/dist/pull.js +63 -32
- package/dist/push.js +5 -5
- package/dist/sqlite-bun.js +2 -2
- package/dist/sqlite-image.d.ts +3 -3
- package/dist/sqlite-image.js +10 -6
- package/dist/sqlite-node.js +2 -2
- package/dist/sqlite-storage.d.ts +4 -1
- package/dist/sqlite-storage.js +67 -26
- package/dist/storage-errors.d.ts +1 -1
- package/dist/storage-errors.js +3 -0
- package/dist/storage.d.ts +20 -5
- package/package.json +2 -2
- package/src/admin.ts +4 -6
- package/src/authoritative-query.ts +89 -94
- package/src/d1-storage.ts +86 -17
- package/src/operations.ts +31 -3
- package/src/postgres-storage.ts +82 -25
- package/src/prune.ts +29 -15
- package/src/pull.ts +80 -42
- package/src/push.ts +5 -5
- package/src/sqlite-bun.ts +2 -2
- package/src/sqlite-image.ts +26 -15
- package/src/sqlite-node.ts +2 -2
- package/src/sqlite-storage.ts +86 -28
- package/src/storage-errors.ts +10 -1
- package/src/storage.ts +29 -5
package/src/pull.ts
CHANGED
|
@@ -17,10 +17,16 @@ import { clockOf, limitsOf } from './context';
|
|
|
17
17
|
import type { PullSegmentSummary } from './events';
|
|
18
18
|
import type { CompiledSchema, CompiledTable } from './schema';
|
|
19
19
|
import { scopeDigest } from './scopes';
|
|
20
|
-
import type { SegmentRecord } from './segment-store';
|
|
20
|
+
import type { SegmentRecord, SegmentStore } from './segment-store';
|
|
21
21
|
import { issueSegmentUrl } from './signed-url';
|
|
22
22
|
import type { SqliteImageBuilder } from './sqlite-image';
|
|
23
|
-
import type { StoredCommit, StoredRow } from './storage';
|
|
23
|
+
import type { ServerStorage, StoredCommit, StoredRow } from './storage';
|
|
24
|
+
|
|
25
|
+
// One artifact build per owning storage pair and complete immutable identity.
|
|
26
|
+
const imageBuilds = new WeakMap<
|
|
27
|
+
ServerStorage,
|
|
28
|
+
WeakMap<SegmentStore, Map<string, Promise<SegmentRecord>>>
|
|
29
|
+
>();
|
|
24
30
|
|
|
25
31
|
/**
|
|
26
32
|
* Resolve the §5.3 image builder: the host-injected one if present, else the
|
|
@@ -260,57 +266,89 @@ async function* sqliteImageSegment(
|
|
|
260
266
|
// support floor, not a fallback (§5.3: sqlite is an *accept*, not a demand).
|
|
261
267
|
const buildImage = await resolveImageBuilder(ctx);
|
|
262
268
|
if (buildImage === undefined) return false;
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
const rows: StoredRow[] = [...probe];
|
|
268
|
-
let afterRowId: string | null = probe[probe.length - 1]?.rowId ?? null;
|
|
269
|
-
for (;;) {
|
|
270
|
-
const scanned: StoredRow[] = await storage.scanRows(partition, {
|
|
271
|
-
table: plan.table.name,
|
|
272
|
-
scopeFilter: plan.effective,
|
|
273
|
-
afterRowId,
|
|
274
|
-
limit: 50_000,
|
|
275
|
-
});
|
|
276
|
-
rows.push(...scanned);
|
|
277
|
-
const last = scanned[scanned.length - 1];
|
|
278
|
-
if (scanned.length < 50_000 || last === undefined) break;
|
|
279
|
-
afterRowId = last.rowId;
|
|
269
|
+
let stores = imageBuilds.get(storage);
|
|
270
|
+
if (stores === undefined) {
|
|
271
|
+
stores = new WeakMap();
|
|
272
|
+
imageBuilds.set(storage, stores);
|
|
280
273
|
}
|
|
281
|
-
|
|
282
|
-
|
|
274
|
+
let builds = stores.get(segments);
|
|
275
|
+
if (builds === undefined) {
|
|
276
|
+
builds = new Map();
|
|
277
|
+
stores.set(segments, builds);
|
|
278
|
+
}
|
|
279
|
+
const identity = {
|
|
280
|
+
partition,
|
|
281
|
+
logEpoch,
|
|
282
|
+
table: plan.table.name,
|
|
283
283
|
schemaVersion: schema.version,
|
|
284
|
-
|
|
284
|
+
mediaType: 'sqlite' as const,
|
|
285
285
|
scopeDigest: digest,
|
|
286
|
-
|
|
287
|
-
}
|
|
288
|
-
const
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
rowCount
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
286
|
+
asOfCommitSeq: asOf,
|
|
287
|
+
};
|
|
288
|
+
const key = JSON.stringify(identity);
|
|
289
|
+
let building = builds.get(key);
|
|
290
|
+
let builtHere = false;
|
|
291
|
+
if (building === undefined) {
|
|
292
|
+
building = (async () => {
|
|
293
|
+
// Another request can finish while this one's eligibility probe awaits.
|
|
294
|
+
const cached = await segments.find(identity, clockOf(ctx)());
|
|
295
|
+
if (cached !== undefined) return cached;
|
|
296
|
+
builtHere = true;
|
|
297
|
+
let rowCount = 0;
|
|
298
|
+
const bytes = await buildImage({
|
|
299
|
+
table: plan.table,
|
|
300
|
+
schemaVersion: schema.version,
|
|
301
|
+
asOfCommitSeq: asOf,
|
|
302
|
+
scopeDigest: digest,
|
|
303
|
+
rowBatches: (async function* () {
|
|
304
|
+
rowCount += probe.length;
|
|
305
|
+
yield probe;
|
|
306
|
+
let afterRowId = probe[probe.length - 1]!.rowId;
|
|
307
|
+
for (;;) {
|
|
308
|
+
const rows = await storage.scanRows(partition, {
|
|
309
|
+
table: plan.table.name,
|
|
310
|
+
scopeFilter: plan.effective,
|
|
311
|
+
afterRowId,
|
|
312
|
+
limit: 5_000,
|
|
313
|
+
});
|
|
314
|
+
rowCount += rows.length;
|
|
315
|
+
yield rows;
|
|
316
|
+
const last = rows[rows.length - 1];
|
|
317
|
+
if (rows.length < 5_000 || last === undefined) break;
|
|
318
|
+
afterRowId = last.rowId;
|
|
319
|
+
}
|
|
320
|
+
})(),
|
|
321
|
+
});
|
|
322
|
+
return segments.put(
|
|
323
|
+
{ ...identity, rowCount, rowCursor: null, nextRowCursor: null },
|
|
324
|
+
bytes,
|
|
325
|
+
clockOf(ctx)(),
|
|
326
|
+
);
|
|
327
|
+
})();
|
|
328
|
+
builds.set(key, building);
|
|
329
|
+
}
|
|
330
|
+
let record: SegmentRecord;
|
|
331
|
+
try {
|
|
332
|
+
record = await building;
|
|
333
|
+
} finally {
|
|
334
|
+
if (builds.get(key) === building) builds.delete(key);
|
|
335
|
+
}
|
|
304
336
|
trace?.segments.push({
|
|
305
337
|
mediaType: 'sqlite',
|
|
306
338
|
delivery: 'ref',
|
|
307
|
-
origin: 'built',
|
|
339
|
+
origin: builtHere ? 'built' : 'reused',
|
|
308
340
|
bytes: record.byteLength,
|
|
309
341
|
rows: record.rowCount,
|
|
310
342
|
});
|
|
311
343
|
yield segmentRefFrame(
|
|
312
344
|
record,
|
|
313
|
-
await signedUrlFields(
|
|
345
|
+
await signedUrlFields(
|
|
346
|
+
ctx,
|
|
347
|
+
limits,
|
|
348
|
+
record.segmentId,
|
|
349
|
+
digest,
|
|
350
|
+
clockOf(ctx)(),
|
|
351
|
+
),
|
|
314
352
|
);
|
|
315
353
|
return true;
|
|
316
354
|
}
|
package/src/push.ts
CHANGED
|
@@ -185,7 +185,7 @@ async function runValidator(
|
|
|
185
185
|
return errorRecord(
|
|
186
186
|
opIndex,
|
|
187
187
|
'sync.constraint_violation',
|
|
188
|
-
|
|
188
|
+
'write validator failed',
|
|
189
189
|
);
|
|
190
190
|
}
|
|
191
191
|
return undefined;
|
|
@@ -220,7 +220,7 @@ async function mergeCrdtColumns(
|
|
|
220
220
|
return errorRecord(
|
|
221
221
|
opIndex,
|
|
222
222
|
'sync.crdt_merge_failed',
|
|
223
|
-
|
|
223
|
+
'no CRDT merger registered',
|
|
224
224
|
);
|
|
225
225
|
}
|
|
226
226
|
const storedRaw = storedValues?.[index];
|
|
@@ -228,11 +228,11 @@ async function mergeCrdtColumns(
|
|
|
228
228
|
let merged: Uint8Array;
|
|
229
229
|
try {
|
|
230
230
|
merged = await merger(stored, incoming);
|
|
231
|
-
} catch
|
|
231
|
+
} catch {
|
|
232
232
|
return errorRecord(
|
|
233
233
|
opIndex,
|
|
234
234
|
'sync.crdt_merge_failed',
|
|
235
|
-
|
|
235
|
+
'CRDT merger failed',
|
|
236
236
|
);
|
|
237
237
|
}
|
|
238
238
|
values[index] = merged;
|
|
@@ -732,7 +732,7 @@ async function runCommitValidator(
|
|
|
732
732
|
return errorRecord(
|
|
733
733
|
operations[0]?.opIndex ?? 0,
|
|
734
734
|
'sync.constraint_violation',
|
|
735
|
-
|
|
735
|
+
'whole-commit validator failed',
|
|
736
736
|
);
|
|
737
737
|
}
|
|
738
738
|
return undefined;
|
package/src/sqlite-bun.ts
CHANGED
|
@@ -40,10 +40,10 @@ export class SqliteLeaseStore extends SharedSqliteLeaseStore {
|
|
|
40
40
|
}
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
export const buildSqliteImage: SqliteImageBuilder = (input) => {
|
|
43
|
+
export const buildSqliteImage: SqliteImageBuilder = async (input) => {
|
|
44
44
|
const db = new BunSqliteDatabase();
|
|
45
45
|
try {
|
|
46
|
-
writeSqliteImage(db, input);
|
|
46
|
+
await writeSqliteImage(db, input);
|
|
47
47
|
return db.serialize();
|
|
48
48
|
} finally {
|
|
49
49
|
db.close();
|
package/src/sqlite-image.ts
CHANGED
|
@@ -57,7 +57,9 @@ export interface SqliteImageInput {
|
|
|
57
57
|
readonly schemaVersion: number;
|
|
58
58
|
readonly asOfCommitSeq: number;
|
|
59
59
|
readonly scopeDigest: string;
|
|
60
|
-
readonly
|
|
60
|
+
readonly rowBatches:
|
|
61
|
+
| AsyncIterable<readonly StoredRow[]>
|
|
62
|
+
| Iterable<readonly StoredRow[]>;
|
|
61
63
|
}
|
|
62
64
|
|
|
63
65
|
/**
|
|
@@ -68,14 +70,16 @@ export interface SqliteImageInput {
|
|
|
68
70
|
* on the pull path. A Bun or Node host passes `buildSqliteImage`; a Workers
|
|
69
71
|
* host omits it and serves the rows lane.
|
|
70
72
|
*/
|
|
71
|
-
export type SqliteImageBuilder = (
|
|
73
|
+
export type SqliteImageBuilder = (
|
|
74
|
+
input: SqliteImageInput,
|
|
75
|
+
) => Promise<Uint8Array>;
|
|
72
76
|
|
|
73
77
|
/** Populate a §5.3 image database for a whole-table snapshot. */
|
|
74
|
-
export function writeSqliteImage(
|
|
78
|
+
export async function writeSqliteImage(
|
|
75
79
|
db: SqliteDatabase,
|
|
76
80
|
input: SqliteImageInput,
|
|
77
|
-
): void {
|
|
78
|
-
const { table,
|
|
81
|
+
): Promise<void> {
|
|
82
|
+
const { table, rowBatches } = input;
|
|
79
83
|
const primaryKey = table.columns[table.primaryKeyIndex]?.name;
|
|
80
84
|
const columnDefs = table.columns.map((column) => {
|
|
81
85
|
const notNull = column.nullable ? '' : ' NOT NULL';
|
|
@@ -90,13 +94,6 @@ export function writeSqliteImage(
|
|
|
90
94
|
"schemaVersion" INTEGER NOT NULL, "asOfCommitSeq" INTEGER NOT NULL,
|
|
91
95
|
"scopeDigest" TEXT NOT NULL, "rowCount" INTEGER NOT NULL)`,
|
|
92
96
|
);
|
|
93
|
-
db.query(`INSERT INTO ${IMAGE_METADATA_TABLE} VALUES (1, ?, ?, ?, ?, ?)`).run(
|
|
94
|
-
table.name,
|
|
95
|
-
input.schemaVersion,
|
|
96
|
-
input.asOfCommitSeq,
|
|
97
|
-
input.scopeDigest,
|
|
98
|
-
rows.length,
|
|
99
|
-
);
|
|
100
97
|
const names = [
|
|
101
98
|
...table.columns.map((column) => quoteIdent(column.name)),
|
|
102
99
|
quoteIdent(IMAGE_VERSION_COLUMN),
|
|
@@ -107,10 +104,24 @@ export function writeSqliteImage(
|
|
|
107
104
|
);
|
|
108
105
|
db.exec('BEGIN');
|
|
109
106
|
try {
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
107
|
+
let rowCount = 0;
|
|
108
|
+
for await (const rows of rowBatches) {
|
|
109
|
+
for (const row of rows) {
|
|
110
|
+
const values = decodeRow(table.columns, row.payload);
|
|
111
|
+
insert.run(...values.map(toSql), row.serverVersion);
|
|
112
|
+
}
|
|
113
|
+
rowCount += rows.length;
|
|
113
114
|
}
|
|
115
|
+
db.query(
|
|
116
|
+
`INSERT INTO ${IMAGE_METADATA_TABLE} VALUES (1, ?, ?, ?, ?, ?)`,
|
|
117
|
+
).run(
|
|
118
|
+
table.name,
|
|
119
|
+
input.schemaVersion,
|
|
120
|
+
input.asOfCommitSeq,
|
|
121
|
+
input.scopeDigest,
|
|
122
|
+
rowCount,
|
|
123
|
+
);
|
|
124
|
+
|
|
114
125
|
db.exec('COMMIT');
|
|
115
126
|
} catch (error) {
|
|
116
127
|
db.exec('ROLLBACK');
|
package/src/sqlite-node.ts
CHANGED
|
@@ -43,13 +43,13 @@ export class SqliteLeaseStore extends SharedSqliteLeaseStore {
|
|
|
43
43
|
}
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
-
export const buildSqliteImage: SqliteImageBuilder = (input) => {
|
|
46
|
+
export const buildSqliteImage: SqliteImageBuilder = async (input) => {
|
|
47
47
|
const directory = mkdtempSync(join(tmpdir(), 'syncular-server-image-'));
|
|
48
48
|
const path = join(directory, 'segment.db');
|
|
49
49
|
const db = new NodeSqliteDatabase(path);
|
|
50
50
|
try {
|
|
51
51
|
try {
|
|
52
|
-
writeSqliteImage(db, input);
|
|
52
|
+
await writeSqliteImage(db, input);
|
|
53
53
|
} finally {
|
|
54
54
|
db.close();
|
|
55
55
|
}
|
package/src/sqlite-storage.ts
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { validateCommitPruneQuery } from './prune';
|
|
2
|
+
import { StorageQueryError } from './storage-errors';
|
|
3
|
+
import type { CommitPruneQuery, CommitPruneResult } from './storage';
|
|
1
4
|
/**
|
|
2
5
|
* SQLite server storage over the shared synchronous driver.
|
|
3
6
|
*
|
|
@@ -390,7 +393,7 @@ export class SqliteServerStorage implements ServerStorage {
|
|
|
390
393
|
#tables: ReadonlyMap<string, CompiledTable> | undefined;
|
|
391
394
|
#schemaVersion: number | undefined;
|
|
392
395
|
|
|
393
|
-
async #
|
|
396
|
+
async #serializeWrite<T>(operation: () => T): Promise<T> {
|
|
394
397
|
const previous = this.#transactionTail;
|
|
395
398
|
let release!: () => void;
|
|
396
399
|
this.#transactionTail = new Promise<void>((resolve) => {
|
|
@@ -559,7 +562,7 @@ export class SqliteServerStorage implements ServerStorage {
|
|
|
559
562
|
authenticatedAtMs: number,
|
|
560
563
|
): Promise<PartitionRegistryEntry> {
|
|
561
564
|
if (logEpoch.length === 0) throw new Error('log epoch must be non-empty');
|
|
562
|
-
return this.#
|
|
565
|
+
return this.#serializeWrite(() => {
|
|
563
566
|
this.db.exec('BEGIN IMMEDIATE');
|
|
564
567
|
try {
|
|
565
568
|
this.db
|
|
@@ -725,7 +728,7 @@ export class SqliteServerStorage implements ServerStorage {
|
|
|
725
728
|
}
|
|
726
729
|
const prepared = bindAuthoritativePartition(
|
|
727
730
|
prepareAuthoritativeQuery(
|
|
728
|
-
query.
|
|
731
|
+
query.plan,
|
|
729
732
|
query.params,
|
|
730
733
|
query.tables,
|
|
731
734
|
this.#tables,
|
|
@@ -763,6 +766,14 @@ export class SqliteServerStorage implements ServerStorage {
|
|
|
763
766
|
}
|
|
764
767
|
}
|
|
765
768
|
|
|
769
|
+
async getPartitionLogEpoch(partition: string): Promise<string | undefined> {
|
|
770
|
+
return this.db
|
|
771
|
+
.query<{ log_epoch: string }, [string]>(
|
|
772
|
+
'SELECT log_epoch FROM sync_partition_registry WHERE partition=?',
|
|
773
|
+
)
|
|
774
|
+
.get(partition)?.log_epoch;
|
|
775
|
+
}
|
|
776
|
+
|
|
766
777
|
async getHorizonSeq(partition: string): Promise<number> {
|
|
767
778
|
const row = this.db
|
|
768
779
|
.query<{ horizon_seq: number }, [string]>(
|
|
@@ -773,27 +784,62 @@ export class SqliteServerStorage implements ServerStorage {
|
|
|
773
784
|
}
|
|
774
785
|
|
|
775
786
|
async setHorizonSeq(partition: string, seq: number): Promise<void> {
|
|
776
|
-
this
|
|
777
|
-
.
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
787
|
+
await this.#serializeWrite(() => {
|
|
788
|
+
this.db
|
|
789
|
+
.query(`INSERT INTO sync_partitions(partition, horizon_seq) VALUES (?,?)
|
|
790
|
+
ON CONFLICT(partition) DO UPDATE SET horizon_seq=max(horizon_seq,excluded.horizon_seq)`)
|
|
791
|
+
.run(partition, seq);
|
|
792
|
+
});
|
|
782
793
|
}
|
|
783
794
|
|
|
784
|
-
async pruneCommitsThrough(
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
.
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
795
|
+
async pruneCommitsThrough(
|
|
796
|
+
partition: string,
|
|
797
|
+
query: CommitPruneQuery,
|
|
798
|
+
): Promise<CommitPruneResult> {
|
|
799
|
+
validateCommitPruneQuery(query);
|
|
800
|
+
return this.#serializeWrite(() => {
|
|
801
|
+
this.db.exec('BEGIN IMMEDIATE');
|
|
802
|
+
try {
|
|
803
|
+
const epoch = this.db
|
|
804
|
+
.query<{ log_epoch: string }, [string]>(
|
|
805
|
+
'SELECT log_epoch FROM sync_partition_registry WHERE partition=?',
|
|
806
|
+
)
|
|
807
|
+
.get(partition)?.log_epoch;
|
|
808
|
+
if (epoch !== query.logEpoch)
|
|
809
|
+
throw new StorageQueryError('sync.storage.prune_epoch_mismatch');
|
|
810
|
+
const previousHorizonSeq =
|
|
811
|
+
this.db
|
|
812
|
+
.query<{ horizon_seq: number }, [string]>(
|
|
813
|
+
'SELECT horizon_seq FROM sync_partitions WHERE partition=?',
|
|
814
|
+
)
|
|
815
|
+
.get(partition)?.horizon_seq ?? 0;
|
|
816
|
+
const horizonSeq = Math.max(previousHorizonSeq, query.throughSeq);
|
|
817
|
+
this.db
|
|
818
|
+
.query(`INSERT INTO sync_partitions(partition, horizon_seq) VALUES (?,?)
|
|
819
|
+
ON CONFLICT(partition) DO UPDATE SET horizon_seq=excluded.horizon_seq`)
|
|
820
|
+
.run(partition, horizonSeq);
|
|
821
|
+
const removed = this.db
|
|
822
|
+
.query('DELETE FROM sync_commits WHERE partition=? AND commit_seq<=?')
|
|
823
|
+
.run(partition, horizonSeq);
|
|
824
|
+
this.db
|
|
825
|
+
.query('DELETE FROM sync_changes WHERE partition=? AND commit_seq<=?')
|
|
826
|
+
.run(partition, horizonSeq);
|
|
827
|
+
this.db
|
|
828
|
+
.query(
|
|
829
|
+
'DELETE FROM sync_change_scopes WHERE partition=? AND commit_seq<=?',
|
|
830
|
+
)
|
|
831
|
+
.run(partition, horizonSeq);
|
|
832
|
+
this.db.exec('COMMIT');
|
|
833
|
+
return {
|
|
834
|
+
previousHorizonSeq,
|
|
835
|
+
horizonSeq,
|
|
836
|
+
removedCommits: Number(removed.changes),
|
|
837
|
+
};
|
|
838
|
+
} catch (error) {
|
|
839
|
+
this.db.exec('ROLLBACK');
|
|
840
|
+
throw error;
|
|
841
|
+
}
|
|
842
|
+
});
|
|
797
843
|
}
|
|
798
844
|
|
|
799
845
|
async getCommitSeqBefore(
|
|
@@ -847,7 +893,7 @@ export class SqliteServerStorage implements ServerStorage {
|
|
|
847
893
|
query: ReactionClaimQuery,
|
|
848
894
|
): Promise<StoredReaction[]> {
|
|
849
895
|
if (query.types.length === 0 || query.limit <= 0) return [];
|
|
850
|
-
return this.#
|
|
896
|
+
return this.#serializeWrite(() => {
|
|
851
897
|
const typeParams = query.types.map(() => '?').join(',');
|
|
852
898
|
const records = this.db
|
|
853
899
|
.query<SqliteReactionRecord, (string | number)[]>(
|
|
@@ -895,7 +941,7 @@ export class SqliteServerStorage implements ServerStorage {
|
|
|
895
941
|
leaseOwner: string,
|
|
896
942
|
completedAtMs: number,
|
|
897
943
|
): Promise<boolean> {
|
|
898
|
-
return this.#
|
|
944
|
+
return this.#serializeWrite(() => {
|
|
899
945
|
const result = this.db
|
|
900
946
|
.query(
|
|
901
947
|
`UPDATE sync_reactions
|
|
@@ -915,7 +961,7 @@ export class SqliteServerStorage implements ServerStorage {
|
|
|
915
961
|
leaseOwner: string,
|
|
916
962
|
leaseExpiresAtMs: number,
|
|
917
963
|
): Promise<boolean> {
|
|
918
|
-
return this.#
|
|
964
|
+
return this.#serializeWrite(() => {
|
|
919
965
|
const result = this.db
|
|
920
966
|
.query(
|
|
921
967
|
`UPDATE sync_reactions SET lease_expires_at_ms=?
|
|
@@ -933,7 +979,7 @@ export class SqliteServerStorage implements ServerStorage {
|
|
|
933
979
|
update: ReactionFailureUpdate,
|
|
934
980
|
): Promise<boolean> {
|
|
935
981
|
const retry = update.retryAtMs !== undefined;
|
|
936
|
-
return this.#
|
|
982
|
+
return this.#serializeWrite(() => {
|
|
937
983
|
const result = this.db
|
|
938
984
|
.query(
|
|
939
985
|
`UPDATE sync_reactions
|
|
@@ -959,7 +1005,7 @@ export class SqliteServerStorage implements ServerStorage {
|
|
|
959
1005
|
idempotencyKey: string,
|
|
960
1006
|
nowMs: number,
|
|
961
1007
|
): Promise<boolean> {
|
|
962
|
-
return this.#
|
|
1008
|
+
return this.#serializeWrite(() => {
|
|
963
1009
|
const result = this.db
|
|
964
1010
|
.query(
|
|
965
1011
|
`UPDATE sync_reactions
|
|
@@ -1014,7 +1060,7 @@ export class SqliteServerStorage implements ServerStorage {
|
|
|
1014
1060
|
query: ReactionPruneQuery,
|
|
1015
1061
|
): Promise<PrunedReactionCounts> {
|
|
1016
1062
|
if (query.limit <= 0) return { completed: 0, deadLetter: 0 };
|
|
1017
|
-
return this.#
|
|
1063
|
+
return this.#serializeWrite(() => {
|
|
1018
1064
|
const records = this.db
|
|
1019
1065
|
.query<
|
|
1020
1066
|
{ status: 'completed' | 'dead-letter' },
|
|
@@ -1226,6 +1272,18 @@ export class SqliteServerStorage implements ServerStorage {
|
|
|
1226
1272
|
);
|
|
1227
1273
|
}
|
|
1228
1274
|
|
|
1275
|
+
async getActiveClientCursorFloor(
|
|
1276
|
+
partition: string,
|
|
1277
|
+
cutoffMs: number,
|
|
1278
|
+
): Promise<number | null> {
|
|
1279
|
+
const row = this.db
|
|
1280
|
+
.query<{ cursor: number | null }, [string, number]>(
|
|
1281
|
+
'SELECT MIN(cursor) AS cursor FROM sync_clients WHERE partition=? AND updated_at_ms>=?',
|
|
1282
|
+
)
|
|
1283
|
+
.get(partition, cutoffMs);
|
|
1284
|
+
return row!.cursor;
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1229
1287
|
async listClientCursors(partition: string): Promise<ClientCursorInfo[]> {
|
|
1230
1288
|
const records = this.db
|
|
1231
1289
|
.query<
|
package/src/storage-errors.ts
CHANGED
|
@@ -19,10 +19,19 @@ export type StorageQueryErrorCode =
|
|
|
19
19
|
| 'sync.storage.index_not_found'
|
|
20
20
|
| 'sync.storage.index_not_materialized'
|
|
21
21
|
| 'sync.storage.index_value_count_mismatch'
|
|
22
|
-
| 'sync.storage.invalid_limit'
|
|
22
|
+
| 'sync.storage.invalid_limit'
|
|
23
|
+
| 'sync.storage.prune_epoch_mismatch'
|
|
24
|
+
| 'sync.storage.partition_unregistered'
|
|
25
|
+
| 'sync.storage.invalid_prune_cursor';
|
|
23
26
|
|
|
24
27
|
const STORAGE_QUERY_MESSAGES: Readonly<Record<StorageQueryErrorCode, string>> =
|
|
25
28
|
{
|
|
29
|
+
'sync.storage.prune_epoch_mismatch':
|
|
30
|
+
'partition log epoch changed; recompute retention inputs',
|
|
31
|
+
'sync.storage.partition_unregistered':
|
|
32
|
+
'pruning requires a registered partition',
|
|
33
|
+
'sync.storage.invalid_prune_cursor':
|
|
34
|
+
'pruning requires a non-negative safe integer cursor and a non-empty log epoch',
|
|
26
35
|
'sync.storage.scan_requires_scope':
|
|
27
36
|
'scope-indexed row scans require at least one scope variable',
|
|
28
37
|
'sync.storage.index_not_found':
|
package/src/storage.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { AuthoritativeRelationPlan } from './authoritative-query';
|
|
1
2
|
/**
|
|
2
3
|
* Storage interface (defined by the SPEC's needs, implementation-agnostic).
|
|
3
4
|
*
|
|
@@ -19,6 +20,17 @@
|
|
|
19
20
|
import type { PushOperationResult, RowValue, ScopeMap } from '@syncular/core';
|
|
20
21
|
import type { CompiledSchema } from './schema';
|
|
21
22
|
|
|
23
|
+
export interface CommitPruneQuery {
|
|
24
|
+
readonly logEpoch: string;
|
|
25
|
+
readonly throughSeq: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface CommitPruneResult {
|
|
29
|
+
readonly previousHorizonSeq: number;
|
|
30
|
+
readonly horizonSeq: number;
|
|
31
|
+
readonly removedCommits: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
22
34
|
/** The current stored state of a synced row. */
|
|
23
35
|
export interface StoredRow {
|
|
24
36
|
readonly rowId: string;
|
|
@@ -281,7 +293,7 @@ export type AuthoritativeQueryValue =
|
|
|
281
293
|
|
|
282
294
|
export interface AuthoritativeQueryRequest {
|
|
283
295
|
/** Generated, positional SQLite-family SQL. It never comes from the request. */
|
|
284
|
-
readonly
|
|
296
|
+
readonly plan: AuthoritativeRelationPlan;
|
|
285
297
|
readonly params: readonly AuthoritativeQueryValue[];
|
|
286
298
|
/** Generated dependency set, used to validate and partition every relation. */
|
|
287
299
|
readonly tables: readonly string[];
|
|
@@ -422,16 +434,23 @@ export interface ServerStorage {
|
|
|
422
434
|
/** Registry entries ordered by partition for maintenance loops. */
|
|
423
435
|
listPartitionRegistry(): Promise<PartitionRegistryEntry[]>;
|
|
424
436
|
|
|
437
|
+
/** Read continuity without refreshing authenticated activity. */
|
|
438
|
+
getPartitionLogEpoch(partition: string): Promise<string | undefined>;
|
|
439
|
+
|
|
425
440
|
begin(partition: string): Promise<StorageTransaction>;
|
|
426
441
|
|
|
427
442
|
getMaxCommitSeq(partition: string): Promise<number>;
|
|
428
443
|
getHorizonSeq(partition: string): Promise<number>;
|
|
444
|
+
/** Monotonic within the current epoch; use atomic pruning for maintenance. */
|
|
429
445
|
setHorizonSeq(partition: string, seq: number): Promise<void>;
|
|
430
446
|
/**
|
|
431
|
-
*
|
|
432
|
-
*
|
|
447
|
+
* Atomically verifies the log epoch, advances the horizon monotonically,
|
|
448
|
+
* and removes log/change/scope records through the effective horizon.
|
|
433
449
|
*/
|
|
434
|
-
pruneCommitsThrough(
|
|
450
|
+
pruneCommitsThrough(
|
|
451
|
+
partition: string,
|
|
452
|
+
query: CommitPruneQuery,
|
|
453
|
+
): Promise<CommitPruneResult>;
|
|
435
454
|
/** Newest commitSeq created strictly before the timestamp; 0 if none. */
|
|
436
455
|
getCommitSeqBefore(
|
|
437
456
|
partition: string,
|
|
@@ -541,7 +560,12 @@ export interface ServerStorage {
|
|
|
541
560
|
clientId: string,
|
|
542
561
|
): Promise<ClientRecord | undefined>;
|
|
543
562
|
putClientRecord(partition: string, record: ClientRecord): Promise<void>;
|
|
544
|
-
/**
|
|
563
|
+
/** Minimum cursor with updatedAtMs >= cutoff; null when none are active. */
|
|
564
|
+
getActiveClientCursorFloor(
|
|
565
|
+
partition: string,
|
|
566
|
+
cutoffMs: number,
|
|
567
|
+
): Promise<number | null>;
|
|
568
|
+
/** Cursor records for client listings and administrative counts. */
|
|
545
569
|
listClientCursors(partition: string): Promise<ClientCursorInfo[]>;
|
|
546
570
|
|
|
547
571
|
/**
|