@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/dist/failed-overlay.d.ts
CHANGED
|
@@ -10,6 +10,9 @@ export declare function retainedBaseWrite(db: ClientDatabase, table: CompiledCli
|
|
|
10
10
|
export declare function retainFailedRows(db: ClientDatabase, commit: OutboxCommit, images: readonly OutboxBeforeImage[], commitSeq?: number): void;
|
|
11
11
|
/** Match declared keys through SQLite, preserving index affinity and NULL semantics. */
|
|
12
12
|
export declare function uniqueConflicts(db: ClientDatabase, table: CompiledClientTable, values: Readonly<Record<string, RowValue>>): NonNullable<RetainedCommitRow['uniqueConflicts']>;
|
|
13
|
-
|
|
14
|
-
export
|
|
15
|
-
export declare function
|
|
13
|
+
/** Exact row keys are JSON [table,id]; table scopes include unique-key peers. */
|
|
14
|
+
export type OverlayScope = readonly string[];
|
|
15
|
+
export declare function overlayScopePredicate(table: string, id: string): string;
|
|
16
|
+
export declare function restoreFailedBases(db: ClientDatabase, schema: CompiledClientSchema, scope?: OverlayScope): boolean;
|
|
17
|
+
export declare function failedOverlayCommits(db: ClientDatabase, acknowledged?: boolean, scope?: OverlayScope, schema?: CompiledClientSchema): OutboxCommit[];
|
|
18
|
+
export declare function dropFailedRows(db: ClientDatabase, schema: CompiledClientSchema, matches: (table: string, values: Readonly<Record<string, JsonRowValue>>) => boolean): OutboxCommit[];
|
package/dist/failed-overlay.js
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { hasUniqueIndex } from './schema.js';
|
|
2
|
+
import { OUTCOMES_TABLE } from './outcomes.js';
|
|
3
|
+
import { OUTBOX_TABLE } from './outbox.js';
|
|
1
4
|
export const RETAINED_ROWS = '_syncular_failed_rows';
|
|
2
5
|
import { ClientSyncError } from './errors.js';
|
|
3
6
|
import { mapRowValues, fromSqlValue, jsonToRowValue, quoteIdent, rowValueToJson, SYNC_VERSION_COLUMN, toSqlValue, } from './schema.js';
|
|
@@ -17,7 +20,7 @@ export function retainedBaseWrite(db, table, rowId, values, version) {
|
|
|
17
20
|
table.name,
|
|
18
21
|
rowId,
|
|
19
22
|
]);
|
|
20
|
-
if (!table
|
|
23
|
+
if (!hasUniqueIndex(table))
|
|
21
24
|
return;
|
|
22
25
|
for (const retained of db.query(`SELECT commit_id,idx,intent,unique_conflicts FROM ${RETAINED_ROWS} WHERE tbl=? AND commit_seq IS NULL`, [table.name])) {
|
|
23
26
|
const prior = retained.unique_conflicts === null
|
|
@@ -87,28 +90,54 @@ export function uniqueConflicts(db, table, values) {
|
|
|
87
90
|
serverVersion: Number(row[SYNC_VERSION_COLUMN]),
|
|
88
91
|
})));
|
|
89
92
|
}
|
|
90
|
-
function
|
|
91
|
-
return
|
|
93
|
+
export function overlayScopePredicate(table, id) {
|
|
94
|
+
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))`;
|
|
92
95
|
}
|
|
93
|
-
|
|
96
|
+
function retainedRows(db, scope, acknowledged) {
|
|
97
|
+
return db.query(`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`, [scope ? JSON.stringify(scope) : null]);
|
|
98
|
+
}
|
|
99
|
+
function retainedTable(schema, name) {
|
|
100
|
+
const table = schema.tables.get(name);
|
|
101
|
+
if (!table)
|
|
102
|
+
throw new ClientSyncError('sync.unknown_table', 'retained table is unknown');
|
|
103
|
+
return table;
|
|
104
|
+
}
|
|
105
|
+
export function restoreFailedBases(db, schema, scope) {
|
|
94
106
|
const seen = new Set();
|
|
95
|
-
for (const row of retainedRows(db,
|
|
107
|
+
for (const row of retainedRows(db, scope)) {
|
|
96
108
|
const key = JSON.stringify([row.tbl, row.id]);
|
|
97
|
-
if (
|
|
109
|
+
if (seen.has(key))
|
|
98
110
|
continue;
|
|
99
111
|
seen.add(key);
|
|
100
|
-
const table = schema
|
|
101
|
-
if (
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
+
const table = retainedTable(schema, row.tbl);
|
|
113
|
+
if (row.base === null)
|
|
114
|
+
deleteLocalRow(db, table, row.id, false);
|
|
115
|
+
else {
|
|
116
|
+
const values = JSON.parse(String(row.base));
|
|
117
|
+
upsertLocalRow(db, table, table.columns.map((column) => jsonToRowValue(values[column.name] ?? null)), (row.version ?? -1), false);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return seen.size > 0;
|
|
121
|
+
}
|
|
122
|
+
export function failedOverlayCommits(db, acknowledged = false, scope, schema) {
|
|
123
|
+
const commits = new Map();
|
|
124
|
+
for (const row of retainedRows(db, scope, acknowledged)) {
|
|
125
|
+
const id = row.commit_id;
|
|
126
|
+
let operation = JSON.parse(row.op);
|
|
127
|
+
if (schema && operation.op === 'upsert') {
|
|
128
|
+
const table = retainedTable(schema, operation.table);
|
|
129
|
+
if (acknowledged) {
|
|
130
|
+
if (!db.query(`SELECT 1 FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)}=?`, [operation.rowId]).length)
|
|
131
|
+
operation = {
|
|
132
|
+
...operation,
|
|
133
|
+
values: JSON.parse(row.intent),
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
else if (row.unique_conflicts === null && row.intent !== null) {
|
|
137
|
+
// Hydrate legacy failure evidence from authoritative unique-key peers.
|
|
138
|
+
const values = JSON.parse(row.intent);
|
|
139
|
+
const authorized = uniqueConflicts(db, table, mapRowValues(values, jsonToRowValue)).filter((conflict) => conflict.serverVersion >= 0 &&
|
|
140
|
+
!db.query(`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`, [table.name, conflict.rowId, table.name, conflict.rowId]).length);
|
|
112
141
|
if (authorized.length)
|
|
113
142
|
db.exec(`UPDATE ${RETAINED_ROWS} SET unique_conflicts=? WHERE commit_id=? AND idx=?`, [
|
|
114
143
|
JSON.stringify(authorized.map((conflict) => ({
|
|
@@ -119,35 +148,7 @@ export function restoreFailedBases(db, schema, absentIntent = false, rowKeys) {
|
|
|
119
148
|
row.idx,
|
|
120
149
|
]);
|
|
121
150
|
}
|
|
122
|
-
if (conflicts.length)
|
|
123
|
-
continue;
|
|
124
|
-
}
|
|
125
|
-
if (absentIntent &&
|
|
126
|
-
(json === null ||
|
|
127
|
-
db.query(`SELECT 1 FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)}=?`, [row.id]).length))
|
|
128
|
-
continue;
|
|
129
|
-
if (json === null)
|
|
130
|
-
deleteLocalRow(db, table, row.id, false);
|
|
131
|
-
else {
|
|
132
|
-
const values = JSON.parse(String(json));
|
|
133
|
-
if (absentIntent) {
|
|
134
|
-
const operation = JSON.parse(row.op);
|
|
135
|
-
if (row.commit_seq === null &&
|
|
136
|
-
!table.columns.every((column) => Object.hasOwn(operation.values ?? {}, column.name)))
|
|
137
|
-
continue;
|
|
138
|
-
}
|
|
139
|
-
upsertLocalRow(db, table, table.columns.map((column) => jsonToRowValue(values[column.name] ?? null)), (row.version ?? -1), false);
|
|
140
151
|
}
|
|
141
|
-
}
|
|
142
|
-
return seen.size > 0;
|
|
143
|
-
}
|
|
144
|
-
export function failedOverlayCommits(db, acknowledged = false, rowKeys) {
|
|
145
|
-
const commits = new Map();
|
|
146
|
-
for (const row of retainedRows(db, rowKeys)) {
|
|
147
|
-
if ((row.commit_seq !== null) !== acknowledged)
|
|
148
|
-
continue;
|
|
149
|
-
const id = row.commit_id;
|
|
150
|
-
const operation = JSON.parse(row.op);
|
|
151
152
|
const prior = commits.get(id);
|
|
152
153
|
if (prior)
|
|
153
154
|
prior.operations.push(operation);
|
|
@@ -161,7 +162,7 @@ export function failedOverlayCommits(db, acknowledged = false, rowKeys) {
|
|
|
161
162
|
}
|
|
162
163
|
return [...commits.values()];
|
|
163
164
|
}
|
|
164
|
-
export function dropFailedRows(db, matches) {
|
|
165
|
+
export function dropFailedRows(db, schema, matches) {
|
|
165
166
|
const ids = new Set();
|
|
166
167
|
for (const row of retainedRows(db)) {
|
|
167
168
|
for (const json of [row.intent, row.base]) {
|
|
@@ -176,9 +177,10 @@ export function dropFailedRows(db, matches) {
|
|
|
176
177
|
...failedOverlayCommits(db),
|
|
177
178
|
...failedOverlayCommits(db, true),
|
|
178
179
|
].filter((commit) => ids.has(commit.clientCommitId));
|
|
180
|
+
restoreFailedBases(db, schema, dropped.flatMap((commit) => commit.operations.map((op) => JSON.stringify([op.table, op.rowId]))));
|
|
179
181
|
for (const id of ids) {
|
|
180
182
|
db.exec(`DELETE FROM ${RETAINED_ROWS} WHERE commit_id=?`, [id]);
|
|
181
|
-
db.exec(
|
|
183
|
+
db.exec(`UPDATE ${OUTCOMES_TABLE} SET operations=NULL,results='[]' WHERE client_commit_id=?`, [id]);
|
|
182
184
|
}
|
|
183
185
|
return dropped;
|
|
184
186
|
}
|
package/dist/outbox.d.ts
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
export declare const OUTBOX_TABLE = "_syncular_outbox";
|
|
2
|
+
export declare const OUTBOX_IMAGES = "_syncular_outbox_before_images";
|
|
3
|
+
import { type OverlayScope } from './failed-overlay.js';
|
|
1
4
|
/**
|
|
2
5
|
* The durable outbox (SPEC.md §7.1) with encode-at-send (the §0 binary-push
|
|
3
6
|
* outbox rule): local mutations are persisted in a schema-agnostic JSON
|
|
@@ -40,7 +43,7 @@ export interface OutboxBeforeImage {
|
|
|
40
43
|
}
|
|
41
44
|
export declare function appendOutboxCommit(db: ClientDatabase, clientCommitId: string, operations: readonly OutboxOperation[], nowMs: number, beforeImages?: readonly OutboxBeforeImage[]): void;
|
|
42
45
|
/** Pending commits in FIFO creation order (§7.1). Full reads serve replay and the public listing. */
|
|
43
|
-
export declare function listOutbox(db: ClientDatabase,
|
|
46
|
+
export declare function listOutbox(db: ClientDatabase, scope?: OverlayScope): OutboxCommit[];
|
|
44
47
|
/** Keyset pages bound staging; laziness decodes only commits consumed by the encoder. */
|
|
45
48
|
export declare function iterateOutbox(db: ClientDatabase, throughSeq: number): Generator<OutboxCommit>;
|
|
46
49
|
/** Routine status reads never load operation bodies. */
|
package/dist/outbox.js
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
import { BLOB_COMMIT_REFS } from './blob.js';
|
|
2
|
+
export const OUTBOX_TABLE = '_syncular_outbox';
|
|
3
|
+
export const OUTBOX_IMAGES = '_syncular_outbox_before_images';
|
|
4
|
+
import { overlayScopePredicate } from './failed-overlay.js';
|
|
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
|
|
@@ -12,15 +16,15 @@ export function appendOutboxCommit(db, clientCommitId, operations, nowMs, before
|
|
|
12
16
|
if (operations.length === 0) {
|
|
13
17
|
throw new ClientSyncError('sync.empty_commit', 'a local commit must carry at least one operation (§6.1)');
|
|
14
18
|
}
|
|
15
|
-
db.exec(
|
|
19
|
+
db.exec(`INSERT INTO ${OUTBOX_TABLE}(client_commit_id,created_at_ms,operations)VALUES(?,?,?)`, [clientCommitId, nowMs, JSON.stringify(operations)]);
|
|
16
20
|
replaceOutboxBeforeImages(db, clientCommitId, beforeImages);
|
|
17
21
|
}
|
|
18
22
|
/** Pending commits in FIFO creation order (§7.1). Full reads serve replay and the public listing. */
|
|
19
|
-
export function listOutbox(db,
|
|
23
|
+
export function listOutbox(db, scope) {
|
|
20
24
|
return db
|
|
21
|
-
.query(
|
|
22
|
-
?
|
|
23
|
-
:
|
|
25
|
+
.query(scope === undefined
|
|
26
|
+
? `SELECT * FROM ${OUTBOX_TABLE} ORDER BY seq ASC`
|
|
27
|
+
: `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`, scope && [JSON.stringify(scope)])
|
|
24
28
|
.map(decodeOutboxRow);
|
|
25
29
|
}
|
|
26
30
|
function decodeOutboxRow(row) {
|
|
@@ -35,7 +39,7 @@ function decodeOutboxRow(row) {
|
|
|
35
39
|
export function* iterateOutbox(db, throughSeq) {
|
|
36
40
|
let afterSeq = 0;
|
|
37
41
|
while (afterSeq < throughSeq) {
|
|
38
|
-
const rows = db.query(
|
|
42
|
+
const rows = db.query(`SELECT * FROM ${OUTBOX_TABLE} WHERE seq > ? AND seq <= ? ORDER BY seq ASC LIMIT 32`, [afterSeq, throughSeq]);
|
|
39
43
|
if (rows.length === 0)
|
|
40
44
|
return;
|
|
41
45
|
for (const row of rows) {
|
|
@@ -47,21 +51,23 @@ export function* iterateOutbox(db, throughSeq) {
|
|
|
47
51
|
}
|
|
48
52
|
/** Routine status reads never load operation bodies. */
|
|
49
53
|
export function countOutbox(db) {
|
|
50
|
-
return db.query(
|
|
54
|
+
return db.query(`SELECT COUNT(*)AS count FROM ${OUTBOX_TABLE}`)[0]
|
|
51
55
|
.count;
|
|
52
56
|
}
|
|
53
57
|
export function deleteOutboxCommit(db, clientCommitId) {
|
|
54
|
-
db.exec(
|
|
58
|
+
db.exec(`DELETE FROM ${BLOB_COMMIT_REFS} WHERE commit_id=?`, [
|
|
55
59
|
clientCommitId,
|
|
56
60
|
]);
|
|
57
|
-
db.exec(
|
|
58
|
-
|
|
61
|
+
db.exec(`DELETE FROM ${OUTBOX_IMAGES} WHERE client_commit_id=?`, [
|
|
62
|
+
clientCommitId,
|
|
63
|
+
]);
|
|
64
|
+
db.exec(`DELETE FROM ${OUTBOX_TABLE} WHERE client_commit_id=?`, [
|
|
59
65
|
clientCommitId,
|
|
60
66
|
]);
|
|
61
67
|
}
|
|
62
68
|
export function listOutboxBeforeImages(db, clientCommitId) {
|
|
63
69
|
return db
|
|
64
|
-
.query(
|
|
70
|
+
.query(`SELECT op_index,existed,sync_version,values_json,delivery_seq FROM ${OUTBOX_IMAGES} WHERE client_commit_id=? ORDER BY op_index`, [clientCommitId])
|
|
65
71
|
.map((row) => ({
|
|
66
72
|
opIndex: row.op_index,
|
|
67
73
|
existed: row.existed === 1,
|
|
@@ -80,7 +86,7 @@ export function listOutboxBeforeImages(db, clientCommitId) {
|
|
|
80
86
|
}
|
|
81
87
|
export function replaceOutboxBeforeImages(db, clientCommitId, replacements) {
|
|
82
88
|
for (const image of replacements) {
|
|
83
|
-
db.exec(
|
|
89
|
+
db.exec(`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`, [
|
|
84
90
|
clientCommitId,
|
|
85
91
|
image.opIndex,
|
|
86
92
|
image.existed ? 1 : 0,
|
package/dist/outcomes.d.ts
CHANGED
package/dist/outcomes.js
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.js';
|
|
2
4
|
import { ClientSyncError } from './errors.js';
|
|
3
5
|
import { mapRowValues, jsonToRowValue, rowValueToJson, } from './schema.js';
|
|
@@ -74,7 +76,7 @@ function parseOutcome(db, row) {
|
|
|
74
76
|
};
|
|
75
77
|
}
|
|
76
78
|
export function recordCommitOutcome(db, outcome) {
|
|
77
|
-
db.exec(
|
|
79
|
+
db.exec(`INSERT INTO ${OUTCOMES_TABLE}(client_commit_id,status,recorded_at_ms,results,operations,resolution)VALUES(?,?,?,?,?,'active')`, [
|
|
78
80
|
outcome.clientCommitId,
|
|
79
81
|
outcome.status,
|
|
80
82
|
outcome.recordedAtMs,
|
|
@@ -86,7 +88,9 @@ export function recordCommitOutcome(db, outcome) {
|
|
|
86
88
|
return commitOutcome(db, outcome.clientCommitId);
|
|
87
89
|
}
|
|
88
90
|
export function commitOutcome(db, clientCommitId) {
|
|
89
|
-
const row = db.query(
|
|
91
|
+
const row = db.query(`${SELECT_OUTCOME} WHERE client_commit_id=?`, [
|
|
92
|
+
clientCommitId,
|
|
93
|
+
])[0];
|
|
90
94
|
return row === undefined ? undefined : parseOutcome(db, row);
|
|
91
95
|
}
|
|
92
96
|
export function listCommitOutcomes(db, query = {}) {
|
|
@@ -97,11 +101,11 @@ export function listCommitOutcomes(db, query = {}) {
|
|
|
97
101
|
const where = query.activeOnly
|
|
98
102
|
? "WHERE resolution = 'active' AND status IN ('conflict', 'rejected')"
|
|
99
103
|
: '';
|
|
100
|
-
const rows = db.query(
|
|
104
|
+
const rows = db.query(`${SELECT_OUTCOME} ${where} ORDER BY seq DESC${limit === undefined ? '' : ' LIMIT ?'}`, limit === undefined ? [] : [limit]);
|
|
101
105
|
return rows.map((row) => parseOutcome(db, row));
|
|
102
106
|
}
|
|
103
107
|
export function persistCommitOutcomeResolution(db, input, nowMs) {
|
|
104
|
-
db.exec(
|
|
108
|
+
db.exec(`UPDATE ${OUTCOMES_TABLE} SET resolution=?,resolved_at_ms=?,replacement_client_commit_id=? WHERE client_commit_id=? AND resolution='active'`, [
|
|
105
109
|
input.resolution,
|
|
106
110
|
nowMs,
|
|
107
111
|
input.replacementClientCommitId ?? null,
|
|
@@ -117,13 +121,14 @@ export function pruneCommitOutcomes(db, maxEntries) {
|
|
|
117
121
|
if (!Number.isSafeInteger(maxEntries) || maxEntries < 1) {
|
|
118
122
|
throw new ClientSyncError('sync.invalid_request', 'outcome retention maxEntries must be a positive safe integer');
|
|
119
123
|
}
|
|
120
|
-
const count = db.query(
|
|
124
|
+
const count = db.query(`SELECT COUNT(*) AS count FROM ${OUTCOMES_TABLE}`)[0]
|
|
125
|
+
?.count;
|
|
121
126
|
const excess = Math.max(0, (count ?? 0) - maxEntries);
|
|
122
127
|
if (excess === 0)
|
|
123
128
|
return 0;
|
|
124
|
-
const candidates = db.query(
|
|
129
|
+
const candidates = db.query(`SELECT seq FROM ${OUTCOMES_TABLE} WHERE status IN('applied','cached')OR resolution !='active' ORDER BY seq ASC LIMIT ?`, [excess]);
|
|
125
130
|
for (const candidate of candidates) {
|
|
126
|
-
db.exec(
|
|
131
|
+
db.exec(`DELETE FROM ${OUTCOMES_TABLE} WHERE seq=?`, [
|
|
127
132
|
candidate.seq,
|
|
128
133
|
]);
|
|
129
134
|
}
|
package/dist/previous-version.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { ClientSyncError } from './errors.js';
|
|
2
2
|
import { INVALID_HOST_RESPONSE_CODE } from './local-rebootstrap.js';
|
|
3
3
|
import { fromSqlValue, jsonToRowValue, localColumnType, quoteIdent, rowValueToJson, } from './schema.js';
|
|
4
|
-
import { getMeta, setMeta } from './state.js';
|
|
4
|
+
import { getMeta, setMeta, META_TABLE } from './state.js';
|
|
5
5
|
/** `_syncular_meta` key holding the persisted {@link LocalSchemaDescriptor}. */
|
|
6
6
|
const LOCAL_SCHEMA_DESCRIPTOR_KEY = 'localSchemaDescriptor';
|
|
7
7
|
/**
|
|
@@ -441,7 +441,7 @@ export function writePreviousVersionRefusal(db, reason, measurement) {
|
|
|
441
441
|
}
|
|
442
442
|
/** Delete the durable refusal. Part of the orphan sweep and of a successful capture. */
|
|
443
443
|
export function clearPreviousVersionRefusal(db) {
|
|
444
|
-
db.exec(
|
|
444
|
+
db.exec(`DELETE FROM ${META_TABLE} WHERE key=?`, [
|
|
445
445
|
PREVIOUS_VERSION_CONTEXT_KEY,
|
|
446
446
|
]);
|
|
447
447
|
}
|
|
@@ -575,7 +575,7 @@ export function writePreviousVersionAudit(db, audit) {
|
|
|
575
575
|
}
|
|
576
576
|
/** Delete the advisory audit. Part of a discard, never of a normal bump. */
|
|
577
577
|
export function clearPreviousVersionAudit(db) {
|
|
578
|
-
db.exec(
|
|
578
|
+
db.exec(`DELETE FROM ${META_TABLE} WHERE key=?`, [
|
|
579
579
|
PREVIOUS_VERSION_AUDIT_KEY,
|
|
580
580
|
]);
|
|
581
581
|
}
|
package/dist/schema.d.ts
CHANGED
|
@@ -93,6 +93,7 @@ export declare const OPTIMISTIC_VERSION = -1;
|
|
|
93
93
|
* pass through this filter.
|
|
94
94
|
*/
|
|
95
95
|
export declare function stripSyncColumns(rows: SqlRow[]): SqlRow[];
|
|
96
|
+
export declare function hasUniqueIndex(table: CompiledClientTable): boolean;
|
|
96
97
|
export declare function matchesLocalScopes(table: CompiledClientTable, scopes: Readonly<Record<string, readonly string[]>>, values: Readonly<Record<string, JsonRowValue>>): boolean;
|
|
97
98
|
export declare function quoteIdent(name: string): string;
|
|
98
99
|
/**
|
package/dist/schema.js
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
import { BLOB_COMMIT_REFS } from './blob.js';
|
|
2
|
+
import { PENDING_EVICTIONS } from './window.js';
|
|
3
|
+
import { WINDOWS_TABLE } from './window.js';
|
|
4
|
+
import { OUTBOX_TABLE, OUTBOX_IMAGES } from './outbox.js';
|
|
5
|
+
import { OUTCOMES_TABLE } from './outcomes.js';
|
|
6
|
+
import { SUBSCRIPTIONS_TABLE, META_TABLE } from './state.js';
|
|
1
7
|
import { ensureFailedOverlaySchema } from './failed-overlay.js';
|
|
2
8
|
import { invalidRequest } from './errors.js';
|
|
3
9
|
import { snakeToCamel } from './naming.js';
|
|
@@ -156,6 +162,9 @@ export function stripSyncColumns(rows) {
|
|
|
156
162
|
return copy;
|
|
157
163
|
});
|
|
158
164
|
}
|
|
165
|
+
export function hasUniqueIndex(table) {
|
|
166
|
+
return table.indexes.some((index) => index.unique);
|
|
167
|
+
}
|
|
159
168
|
export function matchesLocalScopes(table, scopes, values) {
|
|
160
169
|
const entries = Object.entries(scopes);
|
|
161
170
|
return (entries.length > 0 &&
|
|
@@ -361,34 +370,34 @@ export function ensureLocalBookkeepingSchema(db) {
|
|
|
361
370
|
db.transaction(() => {
|
|
362
371
|
ensureFailedOverlaySchema(db);
|
|
363
372
|
const createTable = (name, columns) => db.exec(`CREATE TABLE IF NOT EXISTS ${name}(${columns})`);
|
|
364
|
-
createTable(
|
|
365
|
-
createTable(
|
|
366
|
-
db.exec(
|
|
367
|
-
db.exec(
|
|
368
|
-
createTable(
|
|
369
|
-
createTable(
|
|
373
|
+
createTable(META_TABLE, 'key TEXT PRIMARY KEY,value TEXT NOT NULL');
|
|
374
|
+
createTable(BLOB_COMMIT_REFS, 'commit_id TEXT NOT NULL,blob_id TEXT NOT NULL,PRIMARY KEY(commit_id,blob_id)');
|
|
375
|
+
db.exec(`CREATE INDEX IF NOT EXISTS ${BLOB_COMMIT_REFS}_body ON ${BLOB_COMMIT_REFS}(blob_id)`);
|
|
376
|
+
db.exec(`INSERT OR IGNORE INTO ${META_TABLE}(key,value)VALUES('localRevision','0')`);
|
|
377
|
+
createTable(OUTBOX_TABLE, 'seq INTEGER PRIMARY KEY AUTOINCREMENT,client_commit_id TEXT NOT NULL UNIQUE,created_at_ms INTEGER NOT NULL,operations TEXT NOT NULL');
|
|
378
|
+
createTable(OUTBOX_IMAGES, '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)');
|
|
370
379
|
if (!db
|
|
371
|
-
.query(
|
|
380
|
+
.query(`PRAGMA table_info(${OUTBOX_IMAGES})`)
|
|
372
381
|
.some((row) => row.name === 'delivery_seq'))
|
|
373
|
-
db.exec(
|
|
374
|
-
createTable(
|
|
382
|
+
db.exec(`ALTER TABLE ${OUTBOX_IMAGES} ADD COLUMN delivery_seq INTEGER`);
|
|
383
|
+
createTable(OUTCOMES_TABLE, "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");
|
|
375
384
|
// Outcomes created before durable aggregate recovery retain no commit
|
|
376
385
|
// envelope. New failed outcomes store it in the protected client DB.
|
|
377
386
|
try {
|
|
378
|
-
db.exec(
|
|
387
|
+
db.exec(`ALTER TABLE ${OUTCOMES_TABLE} ADD COLUMN operations TEXT`);
|
|
379
388
|
}
|
|
380
389
|
catch {
|
|
381
390
|
// column already exists — the CREATE above included it
|
|
382
391
|
}
|
|
383
|
-
db.exec(
|
|
384
|
-
createTable(
|
|
392
|
+
db.exec(`CREATE INDEX IF NOT EXISTS ${OUTCOMES_TABLE}_resolution_seq ON ${OUTCOMES_TABLE}(resolution,seq)`);
|
|
393
|
+
createTable(SUBSCRIPTIONS_TABLE, "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");
|
|
385
394
|
// §4.8 window registry: which units (scope values) of a window base are
|
|
386
395
|
// live locally — the completeness oracle (I3) and the shrink driver
|
|
387
396
|
// (a unit's omission from the next pull unregisters it, §4.1).
|
|
388
|
-
createTable(
|
|
397
|
+
createTable(WINDOWS_TABLE, 'base TEXT NOT NULL,unit TEXT NOT NULL,sub_id TEXT NOT NULL,PRIMARY KEY(base,unit)');
|
|
389
398
|
// §4.8 E1: units that left the window but still had outbox-pinned rows.
|
|
390
399
|
// Retried when the outbox drains; cancelled if the unit re-enters.
|
|
391
|
-
createTable(
|
|
400
|
+
createTable(PENDING_EVICTIONS, 'sub_id TEXT PRIMARY KEY,tbl TEXT NOT NULL,effective_scopes TEXT NOT NULL');
|
|
392
401
|
});
|
|
393
402
|
}
|
|
394
403
|
/** Create both application and protected bookkeeping tables. */
|
package/dist/state.d.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
export declare const META_TABLE = "_syncular_meta";
|
|
2
|
+
export declare const SUBSCRIPTIONS_TABLE = "_syncular_subscriptions";
|
|
1
3
|
/**
|
|
2
4
|
* Durable client sync state: per-subscription cursor, bootstrap resume
|
|
3
5
|
* token (round-tripped opaquely, §4.7), and the last-echoed effective
|
package/dist/state.js
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
export const META_TABLE = '_syncular_meta';
|
|
2
|
+
import { PENDING_EVICTIONS } from './window.js';
|
|
3
|
+
import { WINDOWS_TABLE } from './window.js';
|
|
4
|
+
export const SUBSCRIPTIONS_TABLE = '_syncular_subscriptions';
|
|
1
5
|
export const LOCAL_REVISION_KEY = 'localRevision';
|
|
2
6
|
const MAX_U64 = 18446744073709551615n;
|
|
3
7
|
function rowToRecord(row) {
|
|
@@ -23,17 +27,17 @@ function rowToRecord(row) {
|
|
|
23
27
|
}
|
|
24
28
|
export function loadSubscriptions(db) {
|
|
25
29
|
return db
|
|
26
|
-
.query(
|
|
30
|
+
.query(`SELECT * FROM ${SUBSCRIPTIONS_TABLE} ORDER BY rowid ASC`)
|
|
27
31
|
.map(rowToRecord);
|
|
28
32
|
}
|
|
29
33
|
export function getSubscription(db, id) {
|
|
30
|
-
const row = db.query(
|
|
34
|
+
const row = db.query(`SELECT * FROM ${SUBSCRIPTIONS_TABLE} WHERE id=?`, [
|
|
31
35
|
id,
|
|
32
36
|
])[0];
|
|
33
37
|
return row === undefined ? undefined : rowToRecord(row);
|
|
34
38
|
}
|
|
35
39
|
export function saveSubscription(db, record) {
|
|
36
|
-
db.exec(
|
|
40
|
+
db.exec(`INSERT OR REPLACE INTO ${SUBSCRIPTIONS_TABLE}(id,tbl,requested_scopes,params,cursor,bootstrap_state,effective_scopes,status,reason_code)VALUES(?,?,?,?,?,?,?,?,?)`, [
|
|
37
41
|
record.id,
|
|
38
42
|
record.table,
|
|
39
43
|
JSON.stringify(record.scopes),
|
|
@@ -48,7 +52,7 @@ export function saveSubscription(db, record) {
|
|
|
48
52
|
]);
|
|
49
53
|
}
|
|
50
54
|
export function deleteSubscription(db, id) {
|
|
51
|
-
db.exec(
|
|
55
|
+
db.exec(`DELETE FROM ${SUBSCRIPTIONS_TABLE} WHERE id=?`, [id]);
|
|
52
56
|
}
|
|
53
57
|
/**
|
|
54
58
|
* §7.4.3 reset: keep compatible subscription REGISTRATIONS (id, table,
|
|
@@ -58,7 +62,7 @@ export function deleteSubscription(db, id) {
|
|
|
58
62
|
* subscriptions the app still wants. Caller owns the transaction.
|
|
59
63
|
*/
|
|
60
64
|
export function resetSubscriptionsForBump(db) {
|
|
61
|
-
db.exec(
|
|
65
|
+
db.exec(`UPDATE ${SUBSCRIPTIONS_TABLE} SET cursor=-1,bootstrap_state=NULL,effective_scopes=NULL,status='active',reason_code=NULL`);
|
|
62
66
|
}
|
|
63
67
|
/** Persist declarations separately from the previous-version row descriptor. */
|
|
64
68
|
export function saveSubscriptionScopeSchema(db, schema) {
|
|
@@ -108,7 +112,7 @@ export function pruneUnknownSubscriptions(db, subscriptions, schema, bump = fals
|
|
|
108
112
|
.map((record) => record.id));
|
|
109
113
|
// Shrinking a pinned window already removed its registration. Its deferred
|
|
110
114
|
// eviction still carries the old scope declaration and needs the same fence.
|
|
111
|
-
for (const row of db.query(
|
|
115
|
+
for (const row of db.query(`SELECT sub_id,tbl,effective_scopes FROM ${PENDING_EVICTIONS}`)) {
|
|
112
116
|
if (!compatible({
|
|
113
117
|
table: String(row.tbl),
|
|
114
118
|
scopes: JSON.parse(String(row.effective_scopes)),
|
|
@@ -119,23 +123,19 @@ export function pruneUnknownSubscriptions(db, subscriptions, schema, bump = fals
|
|
|
119
123
|
return retained;
|
|
120
124
|
db.transaction(() => {
|
|
121
125
|
for (const id of staleIds) {
|
|
122
|
-
db.exec(
|
|
123
|
-
db.exec(
|
|
124
|
-
|
|
125
|
-
]);
|
|
126
|
-
db.exec('DELETE FROM _syncular_subscriptions WHERE id=?', [id]);
|
|
126
|
+
db.exec(`DELETE FROM ${WINDOWS_TABLE} WHERE sub_id=?`, [id]);
|
|
127
|
+
db.exec(`DELETE FROM ${PENDING_EVICTIONS} WHERE sub_id=?`, [id]);
|
|
128
|
+
db.exec(`DELETE FROM ${SUBSCRIPTIONS_TABLE} WHERE id=?`, [id]);
|
|
127
129
|
}
|
|
128
130
|
});
|
|
129
131
|
return retained;
|
|
130
132
|
}
|
|
131
133
|
export function getMeta(db, key) {
|
|
132
|
-
const row = db.query(
|
|
133
|
-
key,
|
|
134
|
-
])[0];
|
|
134
|
+
const row = db.query(`SELECT value FROM ${META_TABLE} WHERE key=?`, [key])[0];
|
|
135
135
|
return row === undefined ? undefined : row.value;
|
|
136
136
|
}
|
|
137
137
|
export function setMeta(db, key, value) {
|
|
138
|
-
db.exec(
|
|
138
|
+
db.exec(`INSERT OR REPLACE INTO ${META_TABLE}(key,value)VALUES(?,?)`, [
|
|
139
139
|
key,
|
|
140
140
|
value,
|
|
141
141
|
]);
|
package/dist/window.d.ts
CHANGED
package/dist/window.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
export const PENDING_EVICTIONS = '_syncular_window_pending_evict';
|
|
2
|
+
export const WINDOWS_TABLE = '_syncular_windows';
|
|
1
3
|
/**
|
|
2
4
|
* Windowed subscriptions (SPEC.md §4.8).
|
|
3
5
|
*
|
|
@@ -88,12 +90,12 @@ export async function deriveSubId(base, unit) {
|
|
|
88
90
|
/** Live units for a base, ordered by unit value. */
|
|
89
91
|
export function loadWindowUnits(db, baseKey) {
|
|
90
92
|
return db
|
|
91
|
-
.query(
|
|
93
|
+
.query(`SELECT unit,sub_id FROM ${WINDOWS_TABLE} WHERE base=? ORDER BY unit`, [baseKey])
|
|
92
94
|
.map((row) => ({ unit: row.unit, subId: row.sub_id }));
|
|
93
95
|
}
|
|
94
96
|
/** Registry lookup used to emit exact completion changes for a sub id. */
|
|
95
97
|
export function getWindowUnitBySubId(db, subId) {
|
|
96
|
-
const row = db.query(`SELECT base, unit, sub_id FROM
|
|
98
|
+
const row = db.query(`SELECT base, unit, sub_id FROM ${WINDOWS_TABLE}
|
|
97
99
|
WHERE sub_id = ? LIMIT 1`, [subId])[0];
|
|
98
100
|
return row === undefined
|
|
99
101
|
? undefined
|
|
@@ -104,30 +106,31 @@ export function getWindowUnitBySubId(db, subId) {
|
|
|
104
106
|
};
|
|
105
107
|
}
|
|
106
108
|
export function insertWindowUnit(db, baseKey, unit, subId) {
|
|
107
|
-
db.exec(
|
|
109
|
+
db.exec(`INSERT OR REPLACE INTO ${WINDOWS_TABLE}(base,unit,sub_id)VALUES(?,?,?)`, [baseKey, unit, subId]);
|
|
108
110
|
}
|
|
109
111
|
export function deleteWindowUnit(db, baseKey, unit) {
|
|
110
|
-
db.exec(
|
|
112
|
+
db.exec(`DELETE FROM ${WINDOWS_TABLE} WHERE base=? AND unit=?`, [
|
|
111
113
|
baseKey,
|
|
112
114
|
unit,
|
|
113
115
|
]);
|
|
114
116
|
}
|
|
115
117
|
/** Is a single scope value windowed-in for this base? (the oracle, I3) */
|
|
116
118
|
export function isUnitLive(db, baseKey, unit) {
|
|
117
|
-
return (db.query(
|
|
119
|
+
return (db.query(`SELECT 1 FROM ${WINDOWS_TABLE} WHERE base=? AND unit=? LIMIT 1`, [
|
|
120
|
+
baseKey,
|
|
121
|
+
unit,
|
|
122
|
+
]).length > 0);
|
|
118
123
|
}
|
|
119
124
|
export function savePendingEviction(db, subId, table, effective) {
|
|
120
|
-
db.exec(`INSERT OR REPLACE INTO
|
|
125
|
+
db.exec(`INSERT OR REPLACE INTO ${PENDING_EVICTIONS}(sub_id, tbl, effective_scopes)
|
|
121
126
|
VALUES (?, ?, ?)`, [subId, table, JSON.stringify(effective)]);
|
|
122
127
|
}
|
|
123
128
|
export function deletePendingEviction(db, subId) {
|
|
124
|
-
db.exec(
|
|
125
|
-
subId,
|
|
126
|
-
]);
|
|
129
|
+
db.exec(`DELETE FROM ${PENDING_EVICTIONS} WHERE sub_id = ?`, [subId]);
|
|
127
130
|
}
|
|
128
131
|
export function loadPendingEvictions(db) {
|
|
129
132
|
return db
|
|
130
|
-
.query(
|
|
133
|
+
.query(`SELECT sub_id, tbl, effective_scopes FROM ${PENDING_EVICTIONS}`)
|
|
131
134
|
.map((row) => ({
|
|
132
135
|
subId: row.sub_id,
|
|
133
136
|
table: row.tbl,
|