@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/dist/prune.js
CHANGED
|
@@ -7,6 +7,16 @@
|
|
|
7
7
|
* least the newest `minRetainedCommits` commits are always retained.
|
|
8
8
|
*/
|
|
9
9
|
import { emitEvent } from './events.js';
|
|
10
|
+
import { StorageQueryError } from './storage-errors.js';
|
|
11
|
+
/** Shared validation for the built-in atomic pruning adapters. */
|
|
12
|
+
export function validateCommitPruneQuery(query) {
|
|
13
|
+
if (!Number.isSafeInteger(query.throughSeq) ||
|
|
14
|
+
query.throughSeq < 0 ||
|
|
15
|
+
typeof query.logEpoch !== 'string' ||
|
|
16
|
+
query.logEpoch.length === 0) {
|
|
17
|
+
throw new StorageQueryError('sync.storage.invalid_prune_cursor');
|
|
18
|
+
}
|
|
19
|
+
}
|
|
10
20
|
export const DEFAULT_RETENTION = {
|
|
11
21
|
activeWindowMs: 14 * 24 * 60 * 60 * 1000,
|
|
12
22
|
ageForceMs: 30 * 24 * 60 * 60 * 1000,
|
|
@@ -16,24 +26,18 @@ export const DEFAULT_RETENTION = {
|
|
|
16
26
|
export async function pruneCommitLog(options) {
|
|
17
27
|
const { storage, partition, nowMs } = options;
|
|
18
28
|
const policy = { ...DEFAULT_RETENTION, ...options.retention };
|
|
29
|
+
const logEpoch = await storage.getPartitionLogEpoch(partition);
|
|
30
|
+
if (logEpoch === undefined)
|
|
31
|
+
throw new StorageQueryError('sync.storage.partition_unregistered');
|
|
19
32
|
const maxSeq = await storage.getMaxCommitSeq(partition);
|
|
20
|
-
const
|
|
21
|
-
const activeCursors = cursors
|
|
22
|
-
.filter((c) => c.updatedAtMs >= nowMs - policy.activeWindowMs)
|
|
23
|
-
.map((c) => c.cursor);
|
|
24
|
-
const cursorFloor = activeCursors.length > 0
|
|
25
|
-
? Math.min(...activeCursors)
|
|
26
|
-
: Number.MAX_SAFE_INTEGER;
|
|
33
|
+
const cursorFloor = (await storage.getActiveClientCursorFloor(partition, nowMs - policy.activeWindowMs)) ?? Number.MAX_SAFE_INTEGER;
|
|
27
34
|
const forcedSeq = await storage.getCommitSeqBefore(partition, nowMs - policy.ageForceMs);
|
|
28
35
|
const retainFloor = maxSeq - policy.minRetainedCommits;
|
|
29
36
|
const target = Math.min(Math.max(cursorFloor, forcedSeq), retainFloor);
|
|
30
|
-
const current = await storage.
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
await storage.setHorizonSeq(partition, horizon);
|
|
35
|
-
removedCommits = await storage.pruneCommitsThrough(partition, horizon);
|
|
36
|
-
}
|
|
37
|
+
const { previousHorizonSeq: current, horizonSeq: horizon, removedCommits, } = await storage.pruneCommitsThrough(partition, {
|
|
38
|
+
logEpoch,
|
|
39
|
+
throughSeq: Math.max(0, target),
|
|
40
|
+
});
|
|
37
41
|
const events = options.events;
|
|
38
42
|
if (events !== undefined) {
|
|
39
43
|
emitEvent(events, {
|
package/dist/pull.js
CHANGED
|
@@ -6,6 +6,8 @@ import { decodeRow, encodeRowsSegment, } from '@syncular/core';
|
|
|
6
6
|
import { clockOf, limitsOf } from './context.js';
|
|
7
7
|
import { scopeDigest } from './scopes.js';
|
|
8
8
|
import { issueSegmentUrl } from './signed-url.js';
|
|
9
|
+
// One artifact build per owning storage pair and complete immutable identity.
|
|
10
|
+
const imageBuilds = new WeakMap();
|
|
9
11
|
/**
|
|
10
12
|
* Resolve the §5.3 image builder: the host-injected one if present, else the
|
|
11
13
|
* in-tree `buildSqliteImage` on a Bun runtime (dynamic import so `bun:sqlite`
|
|
@@ -177,33 +179,17 @@ async function* sqliteImageSegment(ctx, schema, limits, plan, asOf, digest, trac
|
|
|
177
179
|
const buildImage = await resolveImageBuilder(ctx);
|
|
178
180
|
if (buildImage === undefined)
|
|
179
181
|
return false;
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
const rows = [...probe];
|
|
185
|
-
let afterRowId = probe[probe.length - 1]?.rowId ?? null;
|
|
186
|
-
for (;;) {
|
|
187
|
-
const scanned = await storage.scanRows(partition, {
|
|
188
|
-
table: plan.table.name,
|
|
189
|
-
scopeFilter: plan.effective,
|
|
190
|
-
afterRowId,
|
|
191
|
-
limit: 50_000,
|
|
192
|
-
});
|
|
193
|
-
rows.push(...scanned);
|
|
194
|
-
const last = scanned[scanned.length - 1];
|
|
195
|
-
if (scanned.length < 50_000 || last === undefined)
|
|
196
|
-
break;
|
|
197
|
-
afterRowId = last.rowId;
|
|
182
|
+
let stores = imageBuilds.get(storage);
|
|
183
|
+
if (stores === undefined) {
|
|
184
|
+
stores = new WeakMap();
|
|
185
|
+
imageBuilds.set(storage, stores);
|
|
198
186
|
}
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
});
|
|
206
|
-
const record = await segments.put({
|
|
187
|
+
let builds = stores.get(segments);
|
|
188
|
+
if (builds === undefined) {
|
|
189
|
+
builds = new Map();
|
|
190
|
+
stores.set(segments, builds);
|
|
191
|
+
}
|
|
192
|
+
const identity = {
|
|
207
193
|
partition,
|
|
208
194
|
logEpoch,
|
|
209
195
|
table: plan.table.name,
|
|
@@ -211,18 +197,63 @@ async function* sqliteImageSegment(ctx, schema, limits, plan, asOf, digest, trac
|
|
|
211
197
|
mediaType: 'sqlite',
|
|
212
198
|
scopeDigest: digest,
|
|
213
199
|
asOfCommitSeq: asOf,
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
200
|
+
};
|
|
201
|
+
const key = JSON.stringify(identity);
|
|
202
|
+
let building = builds.get(key);
|
|
203
|
+
let builtHere = false;
|
|
204
|
+
if (building === undefined) {
|
|
205
|
+
building = (async () => {
|
|
206
|
+
// Another request can finish while this one's eligibility probe awaits.
|
|
207
|
+
const cached = await segments.find(identity, clockOf(ctx)());
|
|
208
|
+
if (cached !== undefined)
|
|
209
|
+
return cached;
|
|
210
|
+
builtHere = true;
|
|
211
|
+
let rowCount = 0;
|
|
212
|
+
const bytes = await buildImage({
|
|
213
|
+
table: plan.table,
|
|
214
|
+
schemaVersion: schema.version,
|
|
215
|
+
asOfCommitSeq: asOf,
|
|
216
|
+
scopeDigest: digest,
|
|
217
|
+
rowBatches: (async function* () {
|
|
218
|
+
rowCount += probe.length;
|
|
219
|
+
yield probe;
|
|
220
|
+
let afterRowId = probe[probe.length - 1].rowId;
|
|
221
|
+
for (;;) {
|
|
222
|
+
const rows = await storage.scanRows(partition, {
|
|
223
|
+
table: plan.table.name,
|
|
224
|
+
scopeFilter: plan.effective,
|
|
225
|
+
afterRowId,
|
|
226
|
+
limit: 5_000,
|
|
227
|
+
});
|
|
228
|
+
rowCount += rows.length;
|
|
229
|
+
yield rows;
|
|
230
|
+
const last = rows[rows.length - 1];
|
|
231
|
+
if (rows.length < 5_000 || last === undefined)
|
|
232
|
+
break;
|
|
233
|
+
afterRowId = last.rowId;
|
|
234
|
+
}
|
|
235
|
+
})(),
|
|
236
|
+
});
|
|
237
|
+
return segments.put({ ...identity, rowCount, rowCursor: null, nextRowCursor: null }, bytes, clockOf(ctx)());
|
|
238
|
+
})();
|
|
239
|
+
builds.set(key, building);
|
|
240
|
+
}
|
|
241
|
+
let record;
|
|
242
|
+
try {
|
|
243
|
+
record = await building;
|
|
244
|
+
}
|
|
245
|
+
finally {
|
|
246
|
+
if (builds.get(key) === building)
|
|
247
|
+
builds.delete(key);
|
|
248
|
+
}
|
|
218
249
|
trace?.segments.push({
|
|
219
250
|
mediaType: 'sqlite',
|
|
220
251
|
delivery: 'ref',
|
|
221
|
-
origin: 'built',
|
|
252
|
+
origin: builtHere ? 'built' : 'reused',
|
|
222
253
|
bytes: record.byteLength,
|
|
223
254
|
rows: record.rowCount,
|
|
224
255
|
});
|
|
225
|
-
yield segmentRefFrame(record, await signedUrlFields(ctx, limits, record.segmentId, digest,
|
|
256
|
+
yield segmentRefFrame(record, await signedUrlFields(ctx, limits, record.segmentId, digest, clockOf(ctx)()));
|
|
226
257
|
return true;
|
|
227
258
|
}
|
|
228
259
|
async function* bootstrapSegments(ctx, schema, limits, plan, asOf, startRowCursor, trace, logEpoch) {
|
package/dist/push.js
CHANGED
|
@@ -98,7 +98,7 @@ async function runValidator(validators, table, op, rowId, values, storedValues,
|
|
|
98
98
|
// §6.7: a non-ValidationRejection throw is still a rejection, mapped to
|
|
99
99
|
// the generic server-side constraint code (§10.2) — the validator's
|
|
100
100
|
// failure never crashes the request or leaks its message as a code.
|
|
101
|
-
return errorRecord(opIndex, 'sync.constraint_violation',
|
|
101
|
+
return errorRecord(opIndex, 'sync.constraint_violation', 'write validator failed');
|
|
102
102
|
}
|
|
103
103
|
return undefined;
|
|
104
104
|
}
|
|
@@ -124,7 +124,7 @@ async function mergeCrdtColumns(table, values, storedValues, opIndex, mergers) {
|
|
|
124
124
|
continue; // NULL clear or absent
|
|
125
125
|
const merger = mergers?.[crdtType];
|
|
126
126
|
if (merger === undefined) {
|
|
127
|
-
return errorRecord(opIndex, 'sync.crdt_merge_failed',
|
|
127
|
+
return errorRecord(opIndex, 'sync.crdt_merge_failed', 'no CRDT merger registered');
|
|
128
128
|
}
|
|
129
129
|
const storedRaw = storedValues?.[index];
|
|
130
130
|
const stored = storedRaw instanceof Uint8Array ? storedRaw : null;
|
|
@@ -132,8 +132,8 @@ async function mergeCrdtColumns(table, values, storedValues, opIndex, mergers) {
|
|
|
132
132
|
try {
|
|
133
133
|
merged = await merger(stored, incoming);
|
|
134
134
|
}
|
|
135
|
-
catch
|
|
136
|
-
return errorRecord(opIndex, 'sync.crdt_merge_failed',
|
|
135
|
+
catch {
|
|
136
|
+
return errorRecord(opIndex, 'sync.crdt_merge_failed', 'CRDT merger failed');
|
|
137
137
|
}
|
|
138
138
|
values[index] = merged;
|
|
139
139
|
changed = true;
|
|
@@ -452,7 +452,7 @@ async function runCommitValidator(validator, tx, schema, clientId, clientCommitI
|
|
|
452
452
|
if (error instanceof ValidationRejection) {
|
|
453
453
|
return errorRecord(operations[0]?.opIndex ?? 0, error.code, error.message, false, error.details);
|
|
454
454
|
}
|
|
455
|
-
return errorRecord(operations[0]?.opIndex ?? 0, 'sync.constraint_violation',
|
|
455
|
+
return errorRecord(operations[0]?.opIndex ?? 0, 'sync.constraint_violation', 'whole-commit validator failed');
|
|
456
456
|
}
|
|
457
457
|
return undefined;
|
|
458
458
|
}
|
package/dist/sqlite-bun.js
CHANGED
|
@@ -27,10 +27,10 @@ export class SqliteLeaseStore extends SharedSqliteLeaseStore {
|
|
|
27
27
|
super(database(value), options);
|
|
28
28
|
}
|
|
29
29
|
}
|
|
30
|
-
export const buildSqliteImage = (input) => {
|
|
30
|
+
export const buildSqliteImage = async (input) => {
|
|
31
31
|
const db = new BunSqliteDatabase();
|
|
32
32
|
try {
|
|
33
|
-
writeSqliteImage(db, input);
|
|
33
|
+
await writeSqliteImage(db, input);
|
|
34
34
|
return db.serialize();
|
|
35
35
|
}
|
|
36
36
|
finally {
|
package/dist/sqlite-image.d.ts
CHANGED
|
@@ -10,7 +10,7 @@ export interface SqliteImageInput {
|
|
|
10
10
|
readonly schemaVersion: number;
|
|
11
11
|
readonly asOfCommitSeq: number;
|
|
12
12
|
readonly scopeDigest: string;
|
|
13
|
-
readonly
|
|
13
|
+
readonly rowBatches: AsyncIterable<readonly StoredRow[]> | Iterable<readonly StoredRow[]>;
|
|
14
14
|
}
|
|
15
15
|
/**
|
|
16
16
|
* The §5.3 image-builder capability, injected through
|
|
@@ -20,6 +20,6 @@ export interface SqliteImageInput {
|
|
|
20
20
|
* on the pull path. A Bun or Node host passes `buildSqliteImage`; a Workers
|
|
21
21
|
* host omits it and serves the rows lane.
|
|
22
22
|
*/
|
|
23
|
-
export type SqliteImageBuilder = (input: SqliteImageInput) => Uint8Array
|
|
23
|
+
export type SqliteImageBuilder = (input: SqliteImageInput) => Promise<Uint8Array>;
|
|
24
24
|
/** Populate a §5.3 image database for a whole-table snapshot. */
|
|
25
|
-
export declare function writeSqliteImage(db: SqliteDatabase, input: SqliteImageInput): void
|
|
25
|
+
export declare function writeSqliteImage(db: SqliteDatabase, input: SqliteImageInput): Promise<void>;
|
package/dist/sqlite-image.js
CHANGED
|
@@ -45,8 +45,8 @@ function toSql(value) {
|
|
|
45
45
|
return value;
|
|
46
46
|
}
|
|
47
47
|
/** Populate a §5.3 image database for a whole-table snapshot. */
|
|
48
|
-
export function writeSqliteImage(db, input) {
|
|
49
|
-
const { table,
|
|
48
|
+
export async function writeSqliteImage(db, input) {
|
|
49
|
+
const { table, rowBatches } = input;
|
|
50
50
|
const primaryKey = table.columns[table.primaryKeyIndex]?.name;
|
|
51
51
|
const columnDefs = table.columns.map((column) => {
|
|
52
52
|
const notNull = column.nullable ? '' : ' NOT NULL';
|
|
@@ -59,7 +59,6 @@ export function writeSqliteImage(db, input) {
|
|
|
59
59
|
format INTEGER NOT NULL, "table" TEXT NOT NULL,
|
|
60
60
|
"schemaVersion" INTEGER NOT NULL, "asOfCommitSeq" INTEGER NOT NULL,
|
|
61
61
|
"scopeDigest" TEXT NOT NULL, "rowCount" INTEGER NOT NULL)`);
|
|
62
|
-
db.query(`INSERT INTO ${IMAGE_METADATA_TABLE} VALUES (1, ?, ?, ?, ?, ?)`).run(table.name, input.schemaVersion, input.asOfCommitSeq, input.scopeDigest, rows.length);
|
|
63
62
|
const names = [
|
|
64
63
|
...table.columns.map((column) => quoteIdent(column.name)),
|
|
65
64
|
quoteIdent(IMAGE_VERSION_COLUMN),
|
|
@@ -68,10 +67,15 @@ export function writeSqliteImage(db, input) {
|
|
|
68
67
|
VALUES (${names.map(() => '?').join(', ')})`);
|
|
69
68
|
db.exec('BEGIN');
|
|
70
69
|
try {
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
70
|
+
let rowCount = 0;
|
|
71
|
+
for await (const rows of rowBatches) {
|
|
72
|
+
for (const row of rows) {
|
|
73
|
+
const values = decodeRow(table.columns, row.payload);
|
|
74
|
+
insert.run(...values.map(toSql), row.serverVersion);
|
|
75
|
+
}
|
|
76
|
+
rowCount += rows.length;
|
|
74
77
|
}
|
|
78
|
+
db.query(`INSERT INTO ${IMAGE_METADATA_TABLE} VALUES (1, ?, ?, ?, ?, ?)`).run(table.name, input.schemaVersion, input.asOfCommitSeq, input.scopeDigest, rowCount);
|
|
75
79
|
db.exec('COMMIT');
|
|
76
80
|
}
|
|
77
81
|
catch (error) {
|
package/dist/sqlite-node.js
CHANGED
|
@@ -30,13 +30,13 @@ export class SqliteLeaseStore extends SharedSqliteLeaseStore {
|
|
|
30
30
|
super(database(value), options);
|
|
31
31
|
}
|
|
32
32
|
}
|
|
33
|
-
export const buildSqliteImage = (input) => {
|
|
33
|
+
export const buildSqliteImage = async (input) => {
|
|
34
34
|
const directory = mkdtempSync(join(tmpdir(), 'syncular-server-image-'));
|
|
35
35
|
const path = join(directory, 'segment.db');
|
|
36
36
|
const db = new NodeSqliteDatabase(path);
|
|
37
37
|
try {
|
|
38
38
|
try {
|
|
39
|
-
writeSqliteImage(db, input);
|
|
39
|
+
await writeSqliteImage(db, input);
|
|
40
40
|
}
|
|
41
41
|
finally {
|
|
42
42
|
db.close();
|
package/dist/sqlite-storage.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { CommitPruneQuery, CommitPruneResult } from './storage.js';
|
|
1
2
|
import type { CompiledSchema, CompiledTable } from './schema.js';
|
|
2
3
|
import { type SqliteDatabase } from './sqlite-driver.js';
|
|
3
4
|
import type { AuthoritativeQueryRequest, AuthoritativeQueryResult, ClientCursorInfo, ClientRecord, CommitMetadata, CommitMetadataQuery, CommitWindowQuery, IndexRowScanQuery, PartitionRegistryEntry, PrunedReactionCounts, ReactionClaimQuery, ReactionFailureUpdate, ReactionListQuery, ReactionPruneQuery, RowScanQuery, ScopeActivityQuery, ScopeCommitActivity, ServerStorage, StorageTransaction, StoredCommit, StoredPushResult, StoredReaction, StoredRow } from './storage.js';
|
|
@@ -16,9 +17,10 @@ export declare class SqliteServerStorage implements ServerStorage {
|
|
|
16
17
|
writeRow(partition: string, table: string, row: StoredRow): void;
|
|
17
18
|
getMaxCommitSeq(partition: string): Promise<number>;
|
|
18
19
|
queryAuthoritative(partition: string, query: AuthoritativeQueryRequest): Promise<AuthoritativeQueryResult>;
|
|
20
|
+
getPartitionLogEpoch(partition: string): Promise<string | undefined>;
|
|
19
21
|
getHorizonSeq(partition: string): Promise<number>;
|
|
20
22
|
setHorizonSeq(partition: string, seq: number): Promise<void>;
|
|
21
|
-
pruneCommitsThrough(partition: string,
|
|
23
|
+
pruneCommitsThrough(partition: string, query: CommitPruneQuery): Promise<CommitPruneResult>;
|
|
22
24
|
getCommitSeqBefore(partition: string, createdBeforeMs: number): Promise<number>;
|
|
23
25
|
getRow(partition: string, table: string, rowId: string): Promise<StoredRow | undefined>;
|
|
24
26
|
getPushResult(partition: string, clientId: string, clientCommitId: string): Promise<StoredPushResult | undefined>;
|
|
@@ -35,6 +37,7 @@ export declare class SqliteServerStorage implements ServerStorage {
|
|
|
35
37
|
scanRowsByIndex(partition: string, query: IndexRowScanQuery): Promise<StoredRow[]>;
|
|
36
38
|
getClientRecord(partition: string, clientId: string): Promise<ClientRecord | undefined>;
|
|
37
39
|
putClientRecord(partition: string, record: ClientRecord): Promise<void>;
|
|
40
|
+
getActiveClientCursorFloor(partition: string, cutoffMs: number): Promise<number | null>;
|
|
38
41
|
listClientCursors(partition: string): Promise<ClientCursorInfo[]>;
|
|
39
42
|
listRowsReferencingBlob(partition: string, blobId: string): Promise<{
|
|
40
43
|
readonly table: string;
|
package/dist/sqlite-storage.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { validateCommitPruneQuery } from './prune.js';
|
|
2
|
+
import { StorageQueryError } from './storage-errors.js';
|
|
1
3
|
/**
|
|
2
4
|
* SQLite server storage over the shared synchronous driver.
|
|
3
5
|
*
|
|
@@ -199,7 +201,7 @@ export class SqliteServerStorage {
|
|
|
199
201
|
/** Set by `ensureSchema`: app-table lookup for the relational row store. */
|
|
200
202
|
#tables;
|
|
201
203
|
#schemaVersion;
|
|
202
|
-
async #
|
|
204
|
+
async #serializeWrite(operation) {
|
|
203
205
|
const previous = this.#transactionTail;
|
|
204
206
|
let release;
|
|
205
207
|
this.#transactionTail = new Promise((resolve) => {
|
|
@@ -327,7 +329,7 @@ export class SqliteServerStorage {
|
|
|
327
329
|
async rotatePartitionLogEpoch(partition, logEpoch, authenticatedAtMs) {
|
|
328
330
|
if (logEpoch.length === 0)
|
|
329
331
|
throw new Error('log epoch must be non-empty');
|
|
330
|
-
return this.#
|
|
332
|
+
return this.#serializeWrite(() => {
|
|
331
333
|
this.db.exec('BEGIN IMMEDIATE');
|
|
332
334
|
try {
|
|
333
335
|
this.db
|
|
@@ -443,7 +445,7 @@ export class SqliteServerStorage {
|
|
|
443
445
|
if (this.#tables === undefined) {
|
|
444
446
|
throw new Error('ensureSchema(schema) must run before registered queries');
|
|
445
447
|
}
|
|
446
|
-
const prepared = bindAuthoritativePartition(prepareAuthoritativeQuery(query.
|
|
448
|
+
const prepared = bindAuthoritativePartition(prepareAuthoritativeQuery(query.plan, query.params, query.tables, this.#tables), partition);
|
|
447
449
|
const previous = this.#transactionTail;
|
|
448
450
|
let release;
|
|
449
451
|
this.#transactionTail = new Promise((resolve) => {
|
|
@@ -473,6 +475,11 @@ export class SqliteServerStorage {
|
|
|
473
475
|
release();
|
|
474
476
|
}
|
|
475
477
|
}
|
|
478
|
+
async getPartitionLogEpoch(partition) {
|
|
479
|
+
return this.db
|
|
480
|
+
.query('SELECT log_epoch FROM sync_partition_registry WHERE partition=?')
|
|
481
|
+
.get(partition)?.log_epoch;
|
|
482
|
+
}
|
|
476
483
|
async getHorizonSeq(partition) {
|
|
477
484
|
const row = this.db
|
|
478
485
|
.query('SELECT horizon_seq FROM sync_partitions WHERE partition=?')
|
|
@@ -480,24 +487,52 @@ export class SqliteServerStorage {
|
|
|
480
487
|
return row?.horizon_seq ?? 0;
|
|
481
488
|
}
|
|
482
489
|
async setHorizonSeq(partition, seq) {
|
|
483
|
-
this
|
|
484
|
-
.
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
490
|
+
await this.#serializeWrite(() => {
|
|
491
|
+
this.db
|
|
492
|
+
.query(`INSERT INTO sync_partitions(partition, horizon_seq) VALUES (?,?)
|
|
493
|
+
ON CONFLICT(partition) DO UPDATE SET horizon_seq=max(horizon_seq,excluded.horizon_seq)`)
|
|
494
|
+
.run(partition, seq);
|
|
495
|
+
});
|
|
489
496
|
}
|
|
490
|
-
async pruneCommitsThrough(partition,
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
.
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
497
|
+
async pruneCommitsThrough(partition, query) {
|
|
498
|
+
validateCommitPruneQuery(query);
|
|
499
|
+
return this.#serializeWrite(() => {
|
|
500
|
+
this.db.exec('BEGIN IMMEDIATE');
|
|
501
|
+
try {
|
|
502
|
+
const epoch = this.db
|
|
503
|
+
.query('SELECT log_epoch FROM sync_partition_registry WHERE partition=?')
|
|
504
|
+
.get(partition)?.log_epoch;
|
|
505
|
+
if (epoch !== query.logEpoch)
|
|
506
|
+
throw new StorageQueryError('sync.storage.prune_epoch_mismatch');
|
|
507
|
+
const previousHorizonSeq = this.db
|
|
508
|
+
.query('SELECT horizon_seq FROM sync_partitions WHERE partition=?')
|
|
509
|
+
.get(partition)?.horizon_seq ?? 0;
|
|
510
|
+
const horizonSeq = Math.max(previousHorizonSeq, query.throughSeq);
|
|
511
|
+
this.db
|
|
512
|
+
.query(`INSERT INTO sync_partitions(partition, horizon_seq) VALUES (?,?)
|
|
513
|
+
ON CONFLICT(partition) DO UPDATE SET horizon_seq=excluded.horizon_seq`)
|
|
514
|
+
.run(partition, horizonSeq);
|
|
515
|
+
const removed = this.db
|
|
516
|
+
.query('DELETE FROM sync_commits WHERE partition=? AND commit_seq<=?')
|
|
517
|
+
.run(partition, horizonSeq);
|
|
518
|
+
this.db
|
|
519
|
+
.query('DELETE FROM sync_changes WHERE partition=? AND commit_seq<=?')
|
|
520
|
+
.run(partition, horizonSeq);
|
|
521
|
+
this.db
|
|
522
|
+
.query('DELETE FROM sync_change_scopes WHERE partition=? AND commit_seq<=?')
|
|
523
|
+
.run(partition, horizonSeq);
|
|
524
|
+
this.db.exec('COMMIT');
|
|
525
|
+
return {
|
|
526
|
+
previousHorizonSeq,
|
|
527
|
+
horizonSeq,
|
|
528
|
+
removedCommits: Number(removed.changes),
|
|
529
|
+
};
|
|
530
|
+
}
|
|
531
|
+
catch (error) {
|
|
532
|
+
this.db.exec('ROLLBACK');
|
|
533
|
+
throw error;
|
|
534
|
+
}
|
|
535
|
+
});
|
|
501
536
|
}
|
|
502
537
|
async getCommitSeqBefore(partition, createdBeforeMs) {
|
|
503
538
|
const row = this.db
|
|
@@ -527,7 +562,7 @@ export class SqliteServerStorage {
|
|
|
527
562
|
async claimReactions(partition, query) {
|
|
528
563
|
if (query.types.length === 0 || query.limit <= 0)
|
|
529
564
|
return [];
|
|
530
|
-
return this.#
|
|
565
|
+
return this.#serializeWrite(() => {
|
|
531
566
|
const typeParams = query.types.map(() => '?').join(',');
|
|
532
567
|
const records = this.db
|
|
533
568
|
.query(`UPDATE sync_reactions
|
|
@@ -553,7 +588,7 @@ export class SqliteServerStorage {
|
|
|
553
588
|
});
|
|
554
589
|
}
|
|
555
590
|
async completeReaction(partition, idempotencyKey, leaseOwner, completedAtMs) {
|
|
556
|
-
return this.#
|
|
591
|
+
return this.#serializeWrite(() => {
|
|
557
592
|
const result = this.db
|
|
558
593
|
.query(`UPDATE sync_reactions
|
|
559
594
|
SET status='completed', completed_at_ms=?,
|
|
@@ -565,7 +600,7 @@ export class SqliteServerStorage {
|
|
|
565
600
|
});
|
|
566
601
|
}
|
|
567
602
|
async extendReactionLease(partition, idempotencyKey, leaseOwner, leaseExpiresAtMs) {
|
|
568
|
-
return this.#
|
|
603
|
+
return this.#serializeWrite(() => {
|
|
569
604
|
const result = this.db
|
|
570
605
|
.query(`UPDATE sync_reactions SET lease_expires_at_ms=?
|
|
571
606
|
WHERE partition=? AND idempotency_key=?
|
|
@@ -576,7 +611,7 @@ export class SqliteServerStorage {
|
|
|
576
611
|
}
|
|
577
612
|
async failReaction(partition, idempotencyKey, update) {
|
|
578
613
|
const retry = update.retryAtMs !== undefined;
|
|
579
|
-
return this.#
|
|
614
|
+
return this.#serializeWrite(() => {
|
|
580
615
|
const result = this.db
|
|
581
616
|
.query(`UPDATE sync_reactions
|
|
582
617
|
SET status=?, available_at_ms=?, last_failure=?,
|
|
@@ -588,7 +623,7 @@ export class SqliteServerStorage {
|
|
|
588
623
|
});
|
|
589
624
|
}
|
|
590
625
|
async retryReaction(partition, idempotencyKey, nowMs) {
|
|
591
|
-
return this.#
|
|
626
|
+
return this.#serializeWrite(() => {
|
|
592
627
|
const result = this.db
|
|
593
628
|
.query(`UPDATE sync_reactions
|
|
594
629
|
SET status='pending', attempts=0, available_at_ms=?,
|
|
@@ -626,7 +661,7 @@ export class SqliteServerStorage {
|
|
|
626
661
|
async pruneReactions(partition, query) {
|
|
627
662
|
if (query.limit <= 0)
|
|
628
663
|
return { completed: 0, deadLetter: 0 };
|
|
629
|
-
return this.#
|
|
664
|
+
return this.#serializeWrite(() => {
|
|
630
665
|
const records = this.db
|
|
631
666
|
.query(`DELETE FROM sync_reactions
|
|
632
667
|
WHERE partition=? AND idempotency_key IN (
|
|
@@ -751,6 +786,12 @@ export class SqliteServerStorage {
|
|
|
751
786
|
.query('INSERT OR REPLACE INTO sync_clients(partition, client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms) VALUES (?,?,?,?,?,?,?)')
|
|
752
787
|
.run(partition, record.clientId, record.actorId, record.wireVersion, record.cursor, JSON.stringify(record.subscriptions), record.updatedAtMs);
|
|
753
788
|
}
|
|
789
|
+
async getActiveClientCursorFloor(partition, cutoffMs) {
|
|
790
|
+
const row = this.db
|
|
791
|
+
.query('SELECT MIN(cursor) AS cursor FROM sync_clients WHERE partition=? AND updated_at_ms>=?')
|
|
792
|
+
.get(partition, cutoffMs);
|
|
793
|
+
return row.cursor;
|
|
794
|
+
}
|
|
754
795
|
async listClientCursors(partition) {
|
|
755
796
|
const records = this.db
|
|
756
797
|
.query('SELECT client_id, cursor, updated_at_ms FROM sync_clients WHERE partition=?')
|
package/dist/storage-errors.d.ts
CHANGED
|
@@ -9,7 +9,7 @@ export declare class StorageConstraintError extends Error {
|
|
|
9
9
|
constructor(cause: unknown, opIndex?: number);
|
|
10
10
|
}
|
|
11
11
|
/** Stable, privacy-safe failures for trusted server storage queries. */
|
|
12
|
-
export type StorageQueryErrorCode = 'sync.storage.scan_requires_scope' | 'sync.storage.index_not_found' | 'sync.storage.index_not_materialized' | 'sync.storage.index_value_count_mismatch' | 'sync.storage.invalid_limit';
|
|
12
|
+
export type StorageQueryErrorCode = 'sync.storage.scan_requires_scope' | 'sync.storage.index_not_found' | 'sync.storage.index_not_materialized' | 'sync.storage.index_value_count_mismatch' | 'sync.storage.invalid_limit' | 'sync.storage.prune_epoch_mismatch' | 'sync.storage.partition_unregistered' | 'sync.storage.invalid_prune_cursor';
|
|
13
13
|
/**
|
|
14
14
|
* Host-only query error. Messages never include identifiers, values, SQL,
|
|
15
15
|
* paths, or row data; callers branch on `code`, never message text.
|
package/dist/storage-errors.js
CHANGED
|
@@ -12,6 +12,9 @@ export class StorageConstraintError extends Error {
|
|
|
12
12
|
}
|
|
13
13
|
}
|
|
14
14
|
const STORAGE_QUERY_MESSAGES = {
|
|
15
|
+
'sync.storage.prune_epoch_mismatch': 'partition log epoch changed; recompute retention inputs',
|
|
16
|
+
'sync.storage.partition_unregistered': 'pruning requires a registered partition',
|
|
17
|
+
'sync.storage.invalid_prune_cursor': 'pruning requires a non-negative safe integer cursor and a non-empty log epoch',
|
|
15
18
|
'sync.storage.scan_requires_scope': 'scope-indexed row scans require at least one scope variable',
|
|
16
19
|
'sync.storage.index_not_found': 'trusted row lookup requires a declared relational index',
|
|
17
20
|
'sync.storage.index_not_materialized': 'trusted row lookup requires a materialized relational table',
|
package/dist/storage.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { AuthoritativeRelationPlan } from './authoritative-query.js';
|
|
1
2
|
/**
|
|
2
3
|
* Storage interface (defined by the SPEC's needs, implementation-agnostic).
|
|
3
4
|
*
|
|
@@ -18,6 +19,15 @@
|
|
|
18
19
|
*/
|
|
19
20
|
import type { PushOperationResult, RowValue, ScopeMap } from '@syncular/core';
|
|
20
21
|
import type { CompiledSchema } from './schema.js';
|
|
22
|
+
export interface CommitPruneQuery {
|
|
23
|
+
readonly logEpoch: string;
|
|
24
|
+
readonly throughSeq: number;
|
|
25
|
+
}
|
|
26
|
+
export interface CommitPruneResult {
|
|
27
|
+
readonly previousHorizonSeq: number;
|
|
28
|
+
readonly horizonSeq: number;
|
|
29
|
+
readonly removedCommits: number;
|
|
30
|
+
}
|
|
21
31
|
/** The current stored state of a synced row. */
|
|
22
32
|
export interface StoredRow {
|
|
23
33
|
readonly rowId: string;
|
|
@@ -244,7 +254,7 @@ export interface ScopeActivityQuery {
|
|
|
244
254
|
export type AuthoritativeQueryValue = string | number | bigint | boolean | Uint8Array | null;
|
|
245
255
|
export interface AuthoritativeQueryRequest {
|
|
246
256
|
/** Generated, positional SQLite-family SQL. It never comes from the request. */
|
|
247
|
-
readonly
|
|
257
|
+
readonly plan: AuthoritativeRelationPlan;
|
|
248
258
|
readonly params: readonly AuthoritativeQueryValue[];
|
|
249
259
|
/** Generated dependency set, used to validate and partition every relation. */
|
|
250
260
|
readonly tables: readonly string[];
|
|
@@ -355,15 +365,18 @@ export interface ServerStorage {
|
|
|
355
365
|
rotatePartitionLogEpoch(partition: string, logEpoch: string, authenticatedAtMs: number): Promise<PartitionRegistryEntry>;
|
|
356
366
|
/** Registry entries ordered by partition for maintenance loops. */
|
|
357
367
|
listPartitionRegistry(): Promise<PartitionRegistryEntry[]>;
|
|
368
|
+
/** Read continuity without refreshing authenticated activity. */
|
|
369
|
+
getPartitionLogEpoch(partition: string): Promise<string | undefined>;
|
|
358
370
|
begin(partition: string): Promise<StorageTransaction>;
|
|
359
371
|
getMaxCommitSeq(partition: string): Promise<number>;
|
|
360
372
|
getHorizonSeq(partition: string): Promise<number>;
|
|
373
|
+
/** Monotonic within the current epoch; use atomic pruning for maintenance. */
|
|
361
374
|
setHorizonSeq(partition: string, seq: number): Promise<void>;
|
|
362
375
|
/**
|
|
363
|
-
*
|
|
364
|
-
*
|
|
376
|
+
* Atomically verifies the log epoch, advances the horizon monotonically,
|
|
377
|
+
* and removes log/change/scope records through the effective horizon.
|
|
365
378
|
*/
|
|
366
|
-
pruneCommitsThrough(partition: string,
|
|
379
|
+
pruneCommitsThrough(partition: string, query: CommitPruneQuery): Promise<CommitPruneResult>;
|
|
367
380
|
/** Newest commitSeq created strictly before the timestamp; 0 if none. */
|
|
368
381
|
getCommitSeqBefore(partition: string, createdBeforeMs: number): Promise<number>;
|
|
369
382
|
getRow(partition: string, table: string, rowId: string): Promise<StoredRow | undefined>;
|
|
@@ -412,7 +425,9 @@ export interface ServerStorage {
|
|
|
412
425
|
scanRowsByIndex?(partition: string, query: IndexRowScanQuery): Promise<StoredRow[]>;
|
|
413
426
|
getClientRecord(partition: string, clientId: string): Promise<ClientRecord | undefined>;
|
|
414
427
|
putClientRecord(partition: string, record: ClientRecord): Promise<void>;
|
|
415
|
-
/**
|
|
428
|
+
/** Minimum cursor with updatedAtMs >= cutoff; null when none are active. */
|
|
429
|
+
getActiveClientCursorFloor(partition: string, cutoffMs: number): Promise<number | null>;
|
|
430
|
+
/** Cursor records for client listings and administrative counts. */
|
|
416
431
|
listClientCursors(partition: string): Promise<ClientCursorInfo[]>;
|
|
417
432
|
/**
|
|
418
433
|
* Blob reference index reads (§5.9.4) — ADDITIVE, optional (mirrors the
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@syncular/server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.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.
|
|
71
|
+
"@syncular/core": "0.16.1"
|
|
72
72
|
},
|
|
73
73
|
"devDependencies": {
|
|
74
74
|
"@electric-sql/pglite": "^0.5.4"
|
package/src/admin.ts
CHANGED
|
@@ -396,12 +396,10 @@ export class SyncularAdmin {
|
|
|
396
396
|
const nowMs = this.#clock();
|
|
397
397
|
const maxCommitSeq = await this.#storage.getMaxCommitSeq(partition);
|
|
398
398
|
const horizonSeq = await this.#storage.getHorizonSeq(partition);
|
|
399
|
-
const
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
const activeCursorFloor =
|
|
404
|
-
activeCursors.length > 0 ? Math.min(...activeCursors) : null;
|
|
399
|
+
const activeCursorFloor = await this.#storage.getActiveClientCursorFloor(
|
|
400
|
+
partition,
|
|
401
|
+
nowMs - this.#retention.activeWindowMs,
|
|
402
|
+
);
|
|
405
403
|
const cursorFloor = activeCursorFloor ?? Number.MAX_SAFE_INTEGER;
|
|
406
404
|
const forcedSeq = await this.#storage.getCommitSeqBefore(
|
|
407
405
|
partition,
|