@syncular/client 0.30.12 → 0.30.14
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/apply.d.ts +3 -2
- package/dist/apply.js +63 -15
- package/dist/blob.d.ts +1 -0
- package/dist/blob.js +4 -3
- package/dist/client.d.ts +3 -0
- package/dist/client.js +180 -159
- package/dist/failed-overlay.d.ts +6 -3
- package/dist/failed-overlay.js +50 -48
- package/dist/outbox.d.ts +4 -1
- package/dist/outbox.js +18 -12
- package/dist/outcomes.d.ts +1 -0
- package/dist/outcomes.js +12 -7
- package/dist/previous-version.js +3 -3
- package/dist/schema.d.ts +1 -0
- package/dist/schema.js +23 -14
- package/dist/state.d.ts +2 -0
- package/dist/state.js +15 -15
- package/dist/window.d.ts +2 -0
- package/dist/window.js +13 -10
- package/dist/worker-entry.js +14 -35
- package/dist/worker-host.d.ts +1 -0
- package/dist/worker-host.js +3 -0
- package/dist/worker-protocol.d.ts +1 -0
- package/package.json +3 -3
- package/src/apply.ts +106 -37
- package/src/blob.ts +4 -3
- package/src/client.ts +224 -197
- package/src/failed-overlay.ts +96 -78
- package/src/outbox.ts +19 -16
- package/src/outcomes.ts +12 -12
- package/src/previous-version.ts +3 -3
- package/src/schema.ts +24 -18
- package/src/state.ts +15 -15
- package/src/window.ts +13 -15
- package/src/worker-entry.ts +13 -50
- package/src/worker-host.ts +4 -0
- package/src/worker-protocol.ts +1 -0
package/src/failed-overlay.ts
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { hasUniqueIndex } from './schema';
|
|
2
|
+
import { OUTCOMES_TABLE } from './outcomes';
|
|
3
|
+
import { OUTBOX_TABLE } from './outbox';
|
|
1
4
|
export const RETAINED_ROWS = '_syncular_failed_rows';
|
|
2
5
|
import { ClientSyncError } from './errors';
|
|
3
6
|
import type { ClientDatabase } from './database';
|
|
@@ -48,7 +51,7 @@ export function retainedBaseWrite(
|
|
|
48
51
|
table.name,
|
|
49
52
|
rowId,
|
|
50
53
|
]);
|
|
51
|
-
if (!table
|
|
54
|
+
if (!hasUniqueIndex(table)) return;
|
|
52
55
|
for (const retained of db.query(
|
|
53
56
|
`SELECT commit_id,idx,intent,unique_conflicts FROM ${RETAINED_ROWS} WHERE tbl=? AND commit_seq IS NULL`,
|
|
54
57
|
[table.name],
|
|
@@ -164,90 +167,51 @@ export function uniqueConflicts(
|
|
|
164
167
|
);
|
|
165
168
|
}
|
|
166
169
|
|
|
167
|
-
|
|
170
|
+
/** Exact row keys are JSON [table,id]; table scopes include unique-key peers. */
|
|
171
|
+
export type OverlayScope = readonly string[];
|
|
172
|
+
|
|
173
|
+
export function overlayScopePredicate(table: string, id: string): string {
|
|
174
|
+
return `?1 IS NULL OR json_array(${table},${id}) IN(SELECT value FROM json_each(?1)) OR json_array(${table}) IN(SELECT value FROM json_each(?1))`;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function retainedRows(
|
|
178
|
+
db: ClientDatabase,
|
|
179
|
+
scope?: OverlayScope,
|
|
180
|
+
acknowledged?: boolean,
|
|
181
|
+
) {
|
|
168
182
|
return db.query(
|
|
169
|
-
`SELECT * FROM ${RETAINED_ROWS} WHERE
|
|
170
|
-
[
|
|
183
|
+
`SELECT * FROM ${RETAINED_ROWS} WHERE (${overlayScopePredicate('tbl', 'id')})${acknowledged === undefined ? '' : ` AND commit_seq IS${acknowledged ? ' NOT' : ''} NULL`} ORDER BY commit_seq,at,commit_id,idx`,
|
|
184
|
+
[scope ? JSON.stringify(scope) : null],
|
|
171
185
|
);
|
|
172
186
|
}
|
|
173
187
|
|
|
188
|
+
function retainedTable(
|
|
189
|
+
schema: CompiledClientSchema,
|
|
190
|
+
name: string,
|
|
191
|
+
): CompiledClientTable {
|
|
192
|
+
const table = schema.tables.get(name);
|
|
193
|
+
if (!table)
|
|
194
|
+
throw new ClientSyncError(
|
|
195
|
+
'sync.unknown_table',
|
|
196
|
+
'retained table is unknown',
|
|
197
|
+
);
|
|
198
|
+
return table;
|
|
199
|
+
}
|
|
200
|
+
|
|
174
201
|
export function restoreFailedBases(
|
|
175
202
|
db: ClientDatabase,
|
|
176
203
|
schema: CompiledClientSchema,
|
|
177
|
-
|
|
178
|
-
rowKeys?: readonly string[],
|
|
204
|
+
scope?: OverlayScope,
|
|
179
205
|
): boolean {
|
|
180
206
|
const seen = new Set<string>();
|
|
181
|
-
for (const row of retainedRows(db,
|
|
207
|
+
for (const row of retainedRows(db, scope)) {
|
|
182
208
|
const key = JSON.stringify([row.tbl, row.id]);
|
|
183
|
-
if (
|
|
209
|
+
if (seen.has(key)) continue;
|
|
184
210
|
seen.add(key);
|
|
185
|
-
const table = schema
|
|
186
|
-
if (
|
|
187
|
-
throw new ClientSyncError(
|
|
188
|
-
'sync.unknown_table',
|
|
189
|
-
'retained table is unknown',
|
|
190
|
-
);
|
|
191
|
-
const json = absentIntent ? row.intent : row.base;
|
|
192
|
-
if (absentIntent && row.intent !== null) {
|
|
193
|
-
const values: Record<string, JsonRowValue> = JSON.parse(
|
|
194
|
-
row.intent as string,
|
|
195
|
-
);
|
|
196
|
-
const conflicts = uniqueConflicts(
|
|
197
|
-
db,
|
|
198
|
-
table,
|
|
199
|
-
mapRowValues(values, jsonToRowValue),
|
|
200
|
-
);
|
|
201
|
-
// Hydrate a pre-0.30.11 failure after its winner was already imported.
|
|
202
|
-
// Pending and retained overlays cannot supply authoritative row evidence.
|
|
203
|
-
if (row.commit_seq === null && row.unique_conflicts === null) {
|
|
204
|
-
const authorized = conflicts.filter(
|
|
205
|
-
(conflict) =>
|
|
206
|
-
conflict.serverVersion >= 0 &&
|
|
207
|
-
!db.query(
|
|
208
|
-
`SELECT 1 FROM ${RETAINED_ROWS} WHERE tbl=? AND id=? UNION ALL SELECT 1 FROM _syncular_outbox,json_each(operations)WHERE json_extract(json_each.value,'$.table')=? AND json_extract(json_each.value,'$.rowId')=? LIMIT 1`,
|
|
209
|
-
[table.name, conflict.rowId, table.name, conflict.rowId],
|
|
210
|
-
).length,
|
|
211
|
-
);
|
|
212
|
-
if (authorized.length)
|
|
213
|
-
db.exec(
|
|
214
|
-
`UPDATE ${RETAINED_ROWS} SET unique_conflicts=? WHERE commit_id=? AND idx=?`,
|
|
215
|
-
[
|
|
216
|
-
JSON.stringify(
|
|
217
|
-
authorized.map((conflict) => ({
|
|
218
|
-
...conflict,
|
|
219
|
-
serverRow: mapRowValues(conflict.serverRow, rowValueToJson),
|
|
220
|
-
})),
|
|
221
|
-
),
|
|
222
|
-
row.commit_id as string,
|
|
223
|
-
row.idx as number,
|
|
224
|
-
],
|
|
225
|
-
);
|
|
226
|
-
}
|
|
227
|
-
if (conflicts.length) continue;
|
|
228
|
-
}
|
|
229
|
-
if (
|
|
230
|
-
absentIntent &&
|
|
231
|
-
(json === null ||
|
|
232
|
-
db.query(
|
|
233
|
-
`SELECT 1 FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)}=?`,
|
|
234
|
-
[row.id as string],
|
|
235
|
-
).length)
|
|
236
|
-
)
|
|
237
|
-
continue;
|
|
238
|
-
if (json === null) deleteLocalRow(db, table, row.id as string, false);
|
|
211
|
+
const table = retainedTable(schema, row.tbl as string);
|
|
212
|
+
if (row.base === null) deleteLocalRow(db, table, row.id as string, false);
|
|
239
213
|
else {
|
|
240
|
-
const values: Record<string, JsonRowValue> = JSON.parse(String(
|
|
241
|
-
if (absentIntent) {
|
|
242
|
-
const operation: OutboxOperation = JSON.parse(row.op as string);
|
|
243
|
-
if (
|
|
244
|
-
row.commit_seq === null &&
|
|
245
|
-
!table.columns.every((column) =>
|
|
246
|
-
Object.hasOwn(operation.values ?? {}, column.name),
|
|
247
|
-
)
|
|
248
|
-
)
|
|
249
|
-
continue;
|
|
250
|
-
}
|
|
214
|
+
const values: Record<string, JsonRowValue> = JSON.parse(String(row.base));
|
|
251
215
|
upsertLocalRow(
|
|
252
216
|
db,
|
|
253
217
|
table,
|
|
@@ -265,7 +229,8 @@ export function restoreFailedBases(
|
|
|
265
229
|
export function failedOverlayCommits(
|
|
266
230
|
db: ClientDatabase,
|
|
267
231
|
acknowledged = false,
|
|
268
|
-
|
|
232
|
+
scope?: OverlayScope,
|
|
233
|
+
schema?: CompiledClientSchema,
|
|
269
234
|
): OutboxCommit[] {
|
|
270
235
|
const commits = new Map<
|
|
271
236
|
string,
|
|
@@ -276,10 +241,55 @@ export function failedOverlayCommits(
|
|
|
276
241
|
operations: OutboxOperation[];
|
|
277
242
|
}
|
|
278
243
|
>();
|
|
279
|
-
for (const row of retainedRows(db,
|
|
280
|
-
if ((row.commit_seq !== null) !== acknowledged) continue;
|
|
244
|
+
for (const row of retainedRows(db, scope, acknowledged)) {
|
|
281
245
|
const id = row.commit_id as string;
|
|
282
|
-
|
|
246
|
+
let operation: OutboxOperation = JSON.parse(row.op as string);
|
|
247
|
+
if (schema && operation.op === 'upsert') {
|
|
248
|
+
const table = retainedTable(schema, operation.table);
|
|
249
|
+
if (acknowledged) {
|
|
250
|
+
if (
|
|
251
|
+
!db.query(
|
|
252
|
+
`SELECT 1 FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)}=?`,
|
|
253
|
+
[operation.rowId],
|
|
254
|
+
).length
|
|
255
|
+
)
|
|
256
|
+
operation = {
|
|
257
|
+
...operation,
|
|
258
|
+
values: JSON.parse(row.intent as string),
|
|
259
|
+
};
|
|
260
|
+
} else if (row.unique_conflicts === null && row.intent !== null) {
|
|
261
|
+
// Hydrate legacy failure evidence from authoritative unique-key peers.
|
|
262
|
+
const values: Record<string, JsonRowValue> = JSON.parse(
|
|
263
|
+
row.intent as string,
|
|
264
|
+
);
|
|
265
|
+
const authorized = uniqueConflicts(
|
|
266
|
+
db,
|
|
267
|
+
table,
|
|
268
|
+
mapRowValues(values, jsonToRowValue),
|
|
269
|
+
).filter(
|
|
270
|
+
(conflict) =>
|
|
271
|
+
conflict.serverVersion >= 0 &&
|
|
272
|
+
!db.query(
|
|
273
|
+
`SELECT 1 FROM ${RETAINED_ROWS} WHERE tbl=? AND id=? UNION ALL SELECT 1 FROM ${OUTBOX_TABLE},json_each(operations)WHERE json_extract(json_each.value,'$.table')=? AND json_extract(json_each.value,'$.rowId')=? LIMIT 1`,
|
|
274
|
+
[table.name, conflict.rowId, table.name, conflict.rowId],
|
|
275
|
+
).length,
|
|
276
|
+
);
|
|
277
|
+
if (authorized.length)
|
|
278
|
+
db.exec(
|
|
279
|
+
`UPDATE ${RETAINED_ROWS} SET unique_conflicts=? WHERE commit_id=? AND idx=?`,
|
|
280
|
+
[
|
|
281
|
+
JSON.stringify(
|
|
282
|
+
authorized.map((conflict) => ({
|
|
283
|
+
...conflict,
|
|
284
|
+
serverRow: mapRowValues(conflict.serverRow, rowValueToJson),
|
|
285
|
+
})),
|
|
286
|
+
),
|
|
287
|
+
row.commit_id as string,
|
|
288
|
+
row.idx as number,
|
|
289
|
+
],
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
283
293
|
const prior = commits.get(id);
|
|
284
294
|
if (prior) prior.operations.push(operation);
|
|
285
295
|
else
|
|
@@ -295,6 +305,7 @@ export function failedOverlayCommits(
|
|
|
295
305
|
|
|
296
306
|
export function dropFailedRows(
|
|
297
307
|
db: ClientDatabase,
|
|
308
|
+
schema: CompiledClientSchema,
|
|
298
309
|
matches: (
|
|
299
310
|
table: string,
|
|
300
311
|
values: Readonly<Record<string, JsonRowValue>>,
|
|
@@ -312,10 +323,17 @@ export function dropFailedRows(
|
|
|
312
323
|
...failedOverlayCommits(db),
|
|
313
324
|
...failedOverlayCommits(db, true),
|
|
314
325
|
].filter((commit) => ids.has(commit.clientCommitId));
|
|
326
|
+
restoreFailedBases(
|
|
327
|
+
db,
|
|
328
|
+
schema,
|
|
329
|
+
dropped.flatMap((commit) =>
|
|
330
|
+
commit.operations.map((op) => JSON.stringify([op.table, op.rowId])),
|
|
331
|
+
),
|
|
332
|
+
);
|
|
315
333
|
for (const id of ids) {
|
|
316
334
|
db.exec(`DELETE FROM ${RETAINED_ROWS} WHERE commit_id=?`, [id]);
|
|
317
335
|
db.exec(
|
|
318
|
-
|
|
336
|
+
`UPDATE ${OUTCOMES_TABLE} SET operations=NULL,results='[]' WHERE client_commit_id=?`,
|
|
319
337
|
[id],
|
|
320
338
|
);
|
|
321
339
|
}
|
package/src/outbox.ts
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
import { BLOB_COMMIT_REFS } from './blob';
|
|
2
|
+
export const OUTBOX_TABLE = '_syncular_outbox';
|
|
3
|
+
export const OUTBOX_IMAGES = '_syncular_outbox_before_images';
|
|
4
|
+
import { overlayScopePredicate, type OverlayScope } from './failed-overlay';
|
|
1
5
|
/**
|
|
2
6
|
* The durable outbox (SPEC.md §7.1) with encode-at-send (the §0 binary-push
|
|
3
7
|
* outbox rule): local mutations are persisted in a schema-agnostic JSON
|
|
@@ -68,7 +72,7 @@ export function appendOutboxCommit(
|
|
|
68
72
|
);
|
|
69
73
|
}
|
|
70
74
|
db.exec(
|
|
71
|
-
|
|
75
|
+
`INSERT INTO ${OUTBOX_TABLE}(client_commit_id,created_at_ms,operations)VALUES(?,?,?)`,
|
|
72
76
|
[clientCommitId, nowMs, JSON.stringify(operations)],
|
|
73
77
|
);
|
|
74
78
|
replaceOutboxBeforeImages(db, clientCommitId, beforeImages);
|
|
@@ -77,14 +81,14 @@ export function appendOutboxCommit(
|
|
|
77
81
|
/** Pending commits in FIFO creation order (§7.1). Full reads serve replay and the public listing. */
|
|
78
82
|
export function listOutbox(
|
|
79
83
|
db: ClientDatabase,
|
|
80
|
-
|
|
84
|
+
scope?: OverlayScope,
|
|
81
85
|
): OutboxCommit[] {
|
|
82
86
|
return db
|
|
83
87
|
.query(
|
|
84
|
-
|
|
85
|
-
?
|
|
86
|
-
:
|
|
87
|
-
|
|
88
|
+
scope === undefined
|
|
89
|
+
? `SELECT * FROM ${OUTBOX_TABLE} ORDER BY seq ASC`
|
|
90
|
+
: `SELECT * FROM ${OUTBOX_TABLE} WHERE EXISTS(SELECT 1 FROM json_each(operations) WHERE ${overlayScopePredicate("json_extract(value,'$.table')", "json_extract(value,'$.rowId')")}) ORDER BY seq`,
|
|
91
|
+
scope && [JSON.stringify(scope)],
|
|
88
92
|
)
|
|
89
93
|
.map(decodeOutboxRow);
|
|
90
94
|
}
|
|
@@ -106,7 +110,7 @@ export function* iterateOutbox(
|
|
|
106
110
|
let afterSeq = 0;
|
|
107
111
|
while (afterSeq < throughSeq) {
|
|
108
112
|
const rows = db.query(
|
|
109
|
-
|
|
113
|
+
`SELECT * FROM ${OUTBOX_TABLE} WHERE seq > ? AND seq <= ? ORDER BY seq ASC LIMIT 32`,
|
|
110
114
|
[afterSeq, throughSeq],
|
|
111
115
|
);
|
|
112
116
|
if (rows.length === 0) return;
|
|
@@ -120,7 +124,7 @@ export function* iterateOutbox(
|
|
|
120
124
|
|
|
121
125
|
/** Routine status reads never load operation bodies. */
|
|
122
126
|
export function countOutbox(db: ClientDatabase): number {
|
|
123
|
-
return db.query(
|
|
127
|
+
return db.query(`SELECT COUNT(*)AS count FROM ${OUTBOX_TABLE}`)[0]!
|
|
124
128
|
.count as number;
|
|
125
129
|
}
|
|
126
130
|
|
|
@@ -128,14 +132,13 @@ export function deleteOutboxCommit(
|
|
|
128
132
|
db: ClientDatabase,
|
|
129
133
|
clientCommitId: string,
|
|
130
134
|
): void {
|
|
131
|
-
db.exec(
|
|
135
|
+
db.exec(`DELETE FROM ${BLOB_COMMIT_REFS} WHERE commit_id=?`, [
|
|
132
136
|
clientCommitId,
|
|
133
137
|
]);
|
|
134
|
-
db.exec(
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
db.exec('DELETE FROM _syncular_outbox WHERE client_commit_id=?', [
|
|
138
|
+
db.exec(`DELETE FROM ${OUTBOX_IMAGES} WHERE client_commit_id=?`, [
|
|
139
|
+
clientCommitId,
|
|
140
|
+
]);
|
|
141
|
+
db.exec(`DELETE FROM ${OUTBOX_TABLE} WHERE client_commit_id=?`, [
|
|
139
142
|
clientCommitId,
|
|
140
143
|
]);
|
|
141
144
|
}
|
|
@@ -146,7 +149,7 @@ export function listOutboxBeforeImages(
|
|
|
146
149
|
): OutboxBeforeImage[] {
|
|
147
150
|
return db
|
|
148
151
|
.query(
|
|
149
|
-
|
|
152
|
+
`SELECT op_index,existed,sync_version,values_json,delivery_seq FROM ${OUTBOX_IMAGES} WHERE client_commit_id=? ORDER BY op_index`,
|
|
150
153
|
[clientCommitId],
|
|
151
154
|
)
|
|
152
155
|
.map((row) => ({
|
|
@@ -175,7 +178,7 @@ export function replaceOutboxBeforeImages(
|
|
|
175
178
|
): void {
|
|
176
179
|
for (const image of replacements) {
|
|
177
180
|
db.exec(
|
|
178
|
-
|
|
181
|
+
`INSERT INTO ${OUTBOX_IMAGES}(client_commit_id,op_index,existed,sync_version,values_json)VALUES(?,?,?,?,?) ON CONFLICT(client_commit_id,op_index)DO UPDATE SET existed=excluded.existed,sync_version=excluded.sync_version,values_json=excluded.values_json`,
|
|
179
182
|
[
|
|
180
183
|
clientCommitId,
|
|
181
184
|
image.opIndex,
|
package/src/outcomes.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
export const OUTCOMES_TABLE = '_syncular_commit_outcomes';
|
|
2
|
+
const SELECT_OUTCOME = `SELECT * FROM ${OUTCOMES_TABLE}`;
|
|
1
3
|
import { RETAINED_ROWS } from './failed-overlay';
|
|
2
4
|
/**
|
|
3
5
|
* Durable per-client commit outcomes.
|
|
@@ -233,7 +235,7 @@ export function recordCommitOutcome(
|
|
|
233
235
|
outcome: Omit<CommitOutcome, 'sequence' | 'resolution'>,
|
|
234
236
|
): CommitOutcome {
|
|
235
237
|
db.exec(
|
|
236
|
-
|
|
238
|
+
`INSERT INTO ${OUTCOMES_TABLE}(client_commit_id,status,recorded_at_ms,results,operations,resolution)VALUES(?,?,?,?,?,'active')`,
|
|
237
239
|
[
|
|
238
240
|
outcome.clientCommitId,
|
|
239
241
|
outcome.status,
|
|
@@ -251,10 +253,9 @@ export function commitOutcome(
|
|
|
251
253
|
db: ClientDatabase,
|
|
252
254
|
clientCommitId: string,
|
|
253
255
|
): CommitOutcome | undefined {
|
|
254
|
-
const row = db.query(
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
)[0];
|
|
256
|
+
const row = db.query(`${SELECT_OUTCOME} WHERE client_commit_id=?`, [
|
|
257
|
+
clientCommitId,
|
|
258
|
+
])[0];
|
|
258
259
|
return row === undefined ? undefined : parseOutcome(db, row);
|
|
259
260
|
}
|
|
260
261
|
|
|
@@ -273,7 +274,7 @@ export function listCommitOutcomes(
|
|
|
273
274
|
? "WHERE resolution = 'active' AND status IN ('conflict', 'rejected')"
|
|
274
275
|
: '';
|
|
275
276
|
const rows = db.query(
|
|
276
|
-
|
|
277
|
+
`${SELECT_OUTCOME} ${where} ORDER BY seq DESC${limit === undefined ? '' : ' LIMIT ?'}`,
|
|
277
278
|
limit === undefined ? [] : [limit],
|
|
278
279
|
);
|
|
279
280
|
return rows.map((row) => parseOutcome(db, row));
|
|
@@ -285,7 +286,7 @@ export function persistCommitOutcomeResolution(
|
|
|
285
286
|
nowMs: number,
|
|
286
287
|
): CommitOutcome | undefined {
|
|
287
288
|
db.exec(
|
|
288
|
-
|
|
289
|
+
`UPDATE ${OUTCOMES_TABLE} SET resolution=?,resolved_at_ms=?,replacement_client_commit_id=? WHERE client_commit_id=? AND resolution='active'`,
|
|
289
290
|
[
|
|
290
291
|
input.resolution,
|
|
291
292
|
nowMs,
|
|
@@ -310,17 +311,16 @@ export function pruneCommitOutcomes(
|
|
|
310
311
|
'outcome retention maxEntries must be a positive safe integer',
|
|
311
312
|
);
|
|
312
313
|
}
|
|
313
|
-
const count = db.query(
|
|
314
|
-
|
|
315
|
-
)[0]?.count as number | undefined;
|
|
314
|
+
const count = db.query(`SELECT COUNT(*) AS count FROM ${OUTCOMES_TABLE}`)[0]
|
|
315
|
+
?.count as number | undefined;
|
|
316
316
|
const excess = Math.max(0, (count ?? 0) - maxEntries);
|
|
317
317
|
if (excess === 0) return 0;
|
|
318
318
|
const candidates = db.query(
|
|
319
|
-
|
|
319
|
+
`SELECT seq FROM ${OUTCOMES_TABLE} WHERE status IN('applied','cached')OR resolution !='active' ORDER BY seq ASC LIMIT ?`,
|
|
320
320
|
[excess],
|
|
321
321
|
);
|
|
322
322
|
for (const candidate of candidates) {
|
|
323
|
-
db.exec(
|
|
323
|
+
db.exec(`DELETE FROM ${OUTCOMES_TABLE} WHERE seq=?`, [
|
|
324
324
|
candidate.seq as number,
|
|
325
325
|
]);
|
|
326
326
|
}
|
package/src/previous-version.ts
CHANGED
|
@@ -42,7 +42,7 @@ import {
|
|
|
42
42
|
quoteIdent,
|
|
43
43
|
rowValueToJson,
|
|
44
44
|
} from './schema';
|
|
45
|
-
import { getMeta, setMeta } from './state';
|
|
45
|
+
import { getMeta, setMeta, META_TABLE } from './state';
|
|
46
46
|
|
|
47
47
|
/** `_syncular_meta` key holding the persisted {@link LocalSchemaDescriptor}. */
|
|
48
48
|
const LOCAL_SCHEMA_DESCRIPTOR_KEY = 'localSchemaDescriptor';
|
|
@@ -724,7 +724,7 @@ export function writePreviousVersionRefusal(
|
|
|
724
724
|
|
|
725
725
|
/** Delete the durable refusal. Part of the orphan sweep and of a successful capture. */
|
|
726
726
|
export function clearPreviousVersionRefusal(db: ClientDatabase): void {
|
|
727
|
-
db.exec(
|
|
727
|
+
db.exec(`DELETE FROM ${META_TABLE} WHERE key=?`, [
|
|
728
728
|
PREVIOUS_VERSION_CONTEXT_KEY,
|
|
729
729
|
]);
|
|
730
730
|
}
|
|
@@ -892,7 +892,7 @@ export function writePreviousVersionAudit(
|
|
|
892
892
|
|
|
893
893
|
/** Delete the advisory audit. Part of a discard, never of a normal bump. */
|
|
894
894
|
export function clearPreviousVersionAudit(db: ClientDatabase): void {
|
|
895
|
-
db.exec(
|
|
895
|
+
db.exec(`DELETE FROM ${META_TABLE} WHERE key=?`, [
|
|
896
896
|
PREVIOUS_VERSION_AUDIT_KEY,
|
|
897
897
|
]);
|
|
898
898
|
}
|
package/src/schema.ts
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
import { BLOB_COMMIT_REFS } from './blob';
|
|
2
|
+
import { PENDING_EVICTIONS } from './window';
|
|
3
|
+
import { WINDOWS_TABLE } from './window';
|
|
4
|
+
import { OUTBOX_TABLE, OUTBOX_IMAGES } from './outbox';
|
|
5
|
+
import { OUTCOMES_TABLE } from './outcomes';
|
|
6
|
+
import { SUBSCRIPTIONS_TABLE, META_TABLE } from './state';
|
|
1
7
|
import { ensureFailedOverlaySchema } from './failed-overlay';
|
|
2
8
|
/**
|
|
3
9
|
* Client schema IR (SPEC.md §2.4, §3.1) — the same shape the server
|
|
@@ -265,6 +271,10 @@ export function stripSyncColumns(rows: SqlRow[]): SqlRow[] {
|
|
|
265
271
|
});
|
|
266
272
|
}
|
|
267
273
|
|
|
274
|
+
export function hasUniqueIndex(table: CompiledClientTable): boolean {
|
|
275
|
+
return table.indexes.some((index) => index.unique);
|
|
276
|
+
}
|
|
277
|
+
|
|
268
278
|
export function matchesLocalScopes(
|
|
269
279
|
table: CompiledClientTable,
|
|
270
280
|
scopes: Readonly<Record<string, readonly string[]>>,
|
|
@@ -531,64 +541,60 @@ export function ensureLocalBookkeepingSchema(db: ClientDatabase): void {
|
|
|
531
541
|
ensureFailedOverlaySchema(db);
|
|
532
542
|
const createTable = (name: string, columns: string) =>
|
|
533
543
|
db.exec(`CREATE TABLE IF NOT EXISTS ${name}(${columns})`);
|
|
534
|
-
createTable(
|
|
544
|
+
createTable(META_TABLE, 'key TEXT PRIMARY KEY,value TEXT NOT NULL');
|
|
535
545
|
createTable(
|
|
536
|
-
|
|
546
|
+
BLOB_COMMIT_REFS,
|
|
537
547
|
'commit_id TEXT NOT NULL,blob_id TEXT NOT NULL,PRIMARY KEY(commit_id,blob_id)',
|
|
538
548
|
);
|
|
539
549
|
db.exec(
|
|
540
|
-
|
|
550
|
+
`CREATE INDEX IF NOT EXISTS ${BLOB_COMMIT_REFS}_body ON ${BLOB_COMMIT_REFS}(blob_id)`,
|
|
541
551
|
);
|
|
542
552
|
db.exec(
|
|
543
|
-
|
|
553
|
+
`INSERT OR IGNORE INTO ${META_TABLE}(key,value)VALUES('localRevision','0')`,
|
|
544
554
|
);
|
|
545
555
|
createTable(
|
|
546
|
-
|
|
556
|
+
OUTBOX_TABLE,
|
|
547
557
|
'seq INTEGER PRIMARY KEY AUTOINCREMENT,client_commit_id TEXT NOT NULL UNIQUE,created_at_ms INTEGER NOT NULL,operations TEXT NOT NULL',
|
|
548
558
|
);
|
|
549
559
|
createTable(
|
|
550
|
-
|
|
560
|
+
OUTBOX_IMAGES,
|
|
551
561
|
'client_commit_id TEXT NOT NULL,op_index INTEGER NOT NULL,existed INTEGER NOT NULL CHECK(existed IN (0,1)),sync_version INTEGER,values_json TEXT,PRIMARY KEY(client_commit_id,op_index)',
|
|
552
562
|
);
|
|
553
563
|
if (
|
|
554
564
|
!db
|
|
555
|
-
.query(
|
|
565
|
+
.query(`PRAGMA table_info(${OUTBOX_IMAGES})`)
|
|
556
566
|
.some((row) => row.name === 'delivery_seq')
|
|
557
567
|
)
|
|
558
|
-
db.exec(
|
|
559
|
-
'ALTER TABLE _syncular_outbox_before_images ADD COLUMN delivery_seq INTEGER',
|
|
560
|
-
);
|
|
568
|
+
db.exec(`ALTER TABLE ${OUTBOX_IMAGES} ADD COLUMN delivery_seq INTEGER`);
|
|
561
569
|
createTable(
|
|
562
|
-
|
|
570
|
+
OUTCOMES_TABLE,
|
|
563
571
|
"seq INTEGER PRIMARY KEY AUTOINCREMENT,client_commit_id TEXT NOT NULL UNIQUE,status TEXT NOT NULL CHECK(status IN ('applied','cached','conflict','rejected')),recorded_at_ms INTEGER NOT NULL,results TEXT NOT NULL,operations TEXT,resolution TEXT NOT NULL DEFAULT 'active' CHECK(resolution IN ('active','resolved_keep_server','superseded','dismissed')),resolved_at_ms INTEGER,replacement_client_commit_id TEXT",
|
|
564
572
|
);
|
|
565
573
|
// Outcomes created before durable aggregate recovery retain no commit
|
|
566
574
|
// envelope. New failed outcomes store it in the protected client DB.
|
|
567
575
|
try {
|
|
568
|
-
db.exec(
|
|
569
|
-
'ALTER TABLE _syncular_commit_outcomes ADD COLUMN operations TEXT',
|
|
570
|
-
);
|
|
576
|
+
db.exec(`ALTER TABLE ${OUTCOMES_TABLE} ADD COLUMN operations TEXT`);
|
|
571
577
|
} catch {
|
|
572
578
|
// column already exists — the CREATE above included it
|
|
573
579
|
}
|
|
574
580
|
db.exec(
|
|
575
|
-
|
|
581
|
+
`CREATE INDEX IF NOT EXISTS ${OUTCOMES_TABLE}_resolution_seq ON ${OUTCOMES_TABLE}(resolution,seq)`,
|
|
576
582
|
);
|
|
577
583
|
createTable(
|
|
578
|
-
|
|
584
|
+
SUBSCRIPTIONS_TABLE,
|
|
579
585
|
"id TEXT PRIMARY KEY,tbl TEXT NOT NULL,requested_scopes TEXT NOT NULL,params TEXT,cursor INTEGER NOT NULL DEFAULT -1,bootstrap_state TEXT,effective_scopes TEXT,status TEXT NOT NULL DEFAULT 'active',reason_code TEXT",
|
|
580
586
|
);
|
|
581
587
|
// §4.8 window registry: which units (scope values) of a window base are
|
|
582
588
|
// live locally — the completeness oracle (I3) and the shrink driver
|
|
583
589
|
// (a unit's omission from the next pull unregisters it, §4.1).
|
|
584
590
|
createTable(
|
|
585
|
-
|
|
591
|
+
WINDOWS_TABLE,
|
|
586
592
|
'base TEXT NOT NULL,unit TEXT NOT NULL,sub_id TEXT NOT NULL,PRIMARY KEY(base,unit)',
|
|
587
593
|
);
|
|
588
594
|
// §4.8 E1: units that left the window but still had outbox-pinned rows.
|
|
589
595
|
// Retried when the outbox drains; cancelled if the unit re-enters.
|
|
590
596
|
createTable(
|
|
591
|
-
|
|
597
|
+
PENDING_EVICTIONS,
|
|
592
598
|
'sub_id TEXT PRIMARY KEY,tbl TEXT NOT NULL,effective_scopes TEXT NOT NULL',
|
|
593
599
|
);
|
|
594
600
|
});
|
package/src/state.ts
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
export const META_TABLE = '_syncular_meta';
|
|
2
|
+
import { PENDING_EVICTIONS } from './window';
|
|
3
|
+
import { WINDOWS_TABLE } from './window';
|
|
4
|
+
export const SUBSCRIPTIONS_TABLE = '_syncular_subscriptions';
|
|
1
5
|
/**
|
|
2
6
|
* Durable client sync state: per-subscription cursor, bootstrap resume
|
|
3
7
|
* token (round-tripped opaquely, §4.7), and the last-echoed effective
|
|
@@ -58,7 +62,7 @@ function rowToRecord(row: Record<string, unknown>): SubscriptionRecord {
|
|
|
58
62
|
|
|
59
63
|
export function loadSubscriptions(db: ClientDatabase): SubscriptionRecord[] {
|
|
60
64
|
return db
|
|
61
|
-
.query(
|
|
65
|
+
.query(`SELECT * FROM ${SUBSCRIPTIONS_TABLE} ORDER BY rowid ASC`)
|
|
62
66
|
.map(rowToRecord);
|
|
63
67
|
}
|
|
64
68
|
|
|
@@ -66,7 +70,7 @@ export function getSubscription(
|
|
|
66
70
|
db: ClientDatabase,
|
|
67
71
|
id: string,
|
|
68
72
|
): SubscriptionRecord | undefined {
|
|
69
|
-
const row = db.query(
|
|
73
|
+
const row = db.query(`SELECT * FROM ${SUBSCRIPTIONS_TABLE} WHERE id=?`, [
|
|
70
74
|
id,
|
|
71
75
|
])[0];
|
|
72
76
|
return row === undefined ? undefined : rowToRecord(row);
|
|
@@ -77,7 +81,7 @@ export function saveSubscription(
|
|
|
77
81
|
record: SubscriptionRecord,
|
|
78
82
|
): void {
|
|
79
83
|
db.exec(
|
|
80
|
-
|
|
84
|
+
`INSERT OR REPLACE INTO ${SUBSCRIPTIONS_TABLE}(id,tbl,requested_scopes,params,cursor,bootstrap_state,effective_scopes,status,reason_code)VALUES(?,?,?,?,?,?,?,?,?)`,
|
|
81
85
|
[
|
|
82
86
|
record.id,
|
|
83
87
|
record.table,
|
|
@@ -95,7 +99,7 @@ export function saveSubscription(
|
|
|
95
99
|
}
|
|
96
100
|
|
|
97
101
|
export function deleteSubscription(db: ClientDatabase, id: string): void {
|
|
98
|
-
db.exec(
|
|
102
|
+
db.exec(`DELETE FROM ${SUBSCRIPTIONS_TABLE} WHERE id=?`, [id]);
|
|
99
103
|
}
|
|
100
104
|
|
|
101
105
|
/**
|
|
@@ -107,7 +111,7 @@ export function deleteSubscription(db: ClientDatabase, id: string): void {
|
|
|
107
111
|
*/
|
|
108
112
|
export function resetSubscriptionsForBump(db: ClientDatabase): void {
|
|
109
113
|
db.exec(
|
|
110
|
-
|
|
114
|
+
`UPDATE ${SUBSCRIPTIONS_TABLE} SET cursor=-1,bootstrap_state=NULL,effective_scopes=NULL,status='active',reason_code=NULL`,
|
|
111
115
|
);
|
|
112
116
|
}
|
|
113
117
|
|
|
@@ -184,7 +188,7 @@ export function pruneUnknownSubscriptions(
|
|
|
184
188
|
// Shrinking a pinned window already removed its registration. Its deferred
|
|
185
189
|
// eviction still carries the old scope declaration and needs the same fence.
|
|
186
190
|
for (const row of db.query(
|
|
187
|
-
|
|
191
|
+
`SELECT sub_id,tbl,effective_scopes FROM ${PENDING_EVICTIONS}`,
|
|
188
192
|
)) {
|
|
189
193
|
if (
|
|
190
194
|
!compatible({
|
|
@@ -197,25 +201,21 @@ export function pruneUnknownSubscriptions(
|
|
|
197
201
|
if (staleIds.size === 0) return retained;
|
|
198
202
|
db.transaction(() => {
|
|
199
203
|
for (const id of staleIds) {
|
|
200
|
-
db.exec(
|
|
201
|
-
db.exec(
|
|
202
|
-
|
|
203
|
-
]);
|
|
204
|
-
db.exec('DELETE FROM _syncular_subscriptions WHERE id=?', [id]);
|
|
204
|
+
db.exec(`DELETE FROM ${WINDOWS_TABLE} WHERE sub_id=?`, [id]);
|
|
205
|
+
db.exec(`DELETE FROM ${PENDING_EVICTIONS} WHERE sub_id=?`, [id]);
|
|
206
|
+
db.exec(`DELETE FROM ${SUBSCRIPTIONS_TABLE} WHERE id=?`, [id]);
|
|
205
207
|
}
|
|
206
208
|
});
|
|
207
209
|
return retained;
|
|
208
210
|
}
|
|
209
211
|
|
|
210
212
|
export function getMeta(db: ClientDatabase, key: string): string | undefined {
|
|
211
|
-
const row = db.query(
|
|
212
|
-
key,
|
|
213
|
-
])[0];
|
|
213
|
+
const row = db.query(`SELECT value FROM ${META_TABLE} WHERE key=?`, [key])[0];
|
|
214
214
|
return row === undefined ? undefined : (row.value as string);
|
|
215
215
|
}
|
|
216
216
|
|
|
217
217
|
export function setMeta(db: ClientDatabase, key: string, value: string): void {
|
|
218
|
-
db.exec(
|
|
218
|
+
db.exec(`INSERT OR REPLACE INTO ${META_TABLE}(key,value)VALUES(?,?)`, [
|
|
219
219
|
key,
|
|
220
220
|
value,
|
|
221
221
|
]);
|