@syncular/client 0.30.10 → 0.30.12
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 +15 -0
- package/dist/apply.d.ts +5 -1
- package/dist/apply.js +58 -58
- package/dist/blob.js +13 -39
- package/dist/client.js +178 -161
- package/dist/failed-overlay.d.ts +10 -5
- package/dist/failed-overlay.js +118 -36
- package/dist/outbox.d.ts +2 -1
- package/dist/outbox.js +20 -40
- package/dist/outcomes.d.ts +8 -0
- package/dist/outcomes.js +22 -30
- package/dist/previous-version.js +14 -28
- package/dist/schema.d.ts +3 -0
- package/dist/schema.js +89 -133
- package/dist/state.d.ts +6 -9
- package/dist/state.js +66 -32
- package/dist/wasm-database.js +40 -24
- package/dist/worker-host.js +37 -11
- package/package.json +3 -3
- package/src/apply.ts +95 -72
- package/src/blob.ts +17 -39
- package/src/client.ts +312 -214
- package/src/failed-overlay.ts +197 -48
- package/src/outbox.ts +25 -44
- package/src/outcomes.ts +40 -35
- package/src/previous-version.ts +14 -28
- package/src/schema.ts +154 -166
- package/src/state.ts +90 -33
- package/src/wasm-database.ts +56 -29
- package/src/worker-host.ts +34 -12
package/src/failed-overlay.ts
CHANGED
|
@@ -1,7 +1,22 @@
|
|
|
1
|
+
export const RETAINED_ROWS = '_syncular_failed_rows';
|
|
1
2
|
import { ClientSyncError } from './errors';
|
|
2
3
|
import type { ClientDatabase } from './database';
|
|
3
|
-
import type {
|
|
4
|
-
|
|
4
|
+
import type {
|
|
5
|
+
CompiledClientSchema,
|
|
6
|
+
CompiledClientTable,
|
|
7
|
+
JsonRowValue,
|
|
8
|
+
} from './schema';
|
|
9
|
+
import {
|
|
10
|
+
mapRowValues,
|
|
11
|
+
fromSqlValue,
|
|
12
|
+
jsonToRowValue,
|
|
13
|
+
quoteIdent,
|
|
14
|
+
rowValueToJson,
|
|
15
|
+
SYNC_VERSION_COLUMN,
|
|
16
|
+
toSqlValue,
|
|
17
|
+
} from './schema';
|
|
18
|
+
import type { RowValue } from '@syncular/core';
|
|
19
|
+
import type { RetainedCommitRow } from './outcomes';
|
|
5
20
|
import type {
|
|
6
21
|
OutboxCommit,
|
|
7
22
|
OutboxBeforeImage,
|
|
@@ -11,38 +26,83 @@ import { deleteLocalRow, upsertLocalRow } from './apply';
|
|
|
11
26
|
|
|
12
27
|
/** Protected failure state. The server base advances independently of intent. */
|
|
13
28
|
export function ensureFailedOverlaySchema(db: ClientDatabase): void {
|
|
14
|
-
db.exec(
|
|
15
|
-
commit_id TEXT NOT NULL,
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
29
|
+
db.exec(
|
|
30
|
+
`CREATE TABLE IF NOT EXISTS ${RETAINED_ROWS}(commit_id TEXT NOT NULL,idx INTEGER NOT NULL,tbl TEXT NOT NULL,id TEXT NOT NULL,at INTEGER NOT NULL,op TEXT NOT NULL,intent TEXT,base TEXT,version INTEGER,unique_conflicts TEXT,commit_seq INTEGER,PRIMARY KEY(commit_id,idx))`,
|
|
31
|
+
);
|
|
32
|
+
const columns = db.query(`PRAGMA table_info(${RETAINED_ROWS})`);
|
|
33
|
+
for (const column of ['unique_conflicts TEXT', 'commit_seq INTEGER'])
|
|
34
|
+
if (!columns.some((row) => row.name === column.split(' ')[0]))
|
|
35
|
+
db.exec(`ALTER TABLE ${RETAINED_ROWS} ADD COLUMN ${column}`);
|
|
19
36
|
}
|
|
20
37
|
|
|
21
38
|
export function retainedBaseWrite(
|
|
22
39
|
db: ClientDatabase,
|
|
23
|
-
table:
|
|
40
|
+
table: CompiledClientTable,
|
|
24
41
|
rowId: string,
|
|
25
42
|
values?: Readonly<Record<string, JsonRowValue>>,
|
|
26
43
|
version?: number,
|
|
27
44
|
): void {
|
|
28
|
-
db.exec(
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
45
|
+
db.exec(`UPDATE ${RETAINED_ROWS} SET base=?,version=? WHERE tbl=? AND id=?`, [
|
|
46
|
+
values === undefined ? null : JSON.stringify(values),
|
|
47
|
+
version ?? null,
|
|
48
|
+
table.name,
|
|
49
|
+
rowId,
|
|
50
|
+
]);
|
|
51
|
+
if (!table.indexes.some((index) => index.unique)) return;
|
|
52
|
+
for (const retained of db.query(
|
|
53
|
+
`SELECT commit_id,idx,intent,unique_conflicts FROM ${RETAINED_ROWS} WHERE tbl=? AND commit_seq IS NULL`,
|
|
54
|
+
[table.name],
|
|
55
|
+
)) {
|
|
56
|
+
const prior: readonly (Omit<
|
|
57
|
+
NonNullable<RetainedCommitRow['uniqueConflicts']>[number],
|
|
58
|
+
'serverRow'
|
|
59
|
+
> & { serverRow: Readonly<Record<string, JsonRowValue>> })[] =
|
|
60
|
+
retained.unique_conflicts === null
|
|
61
|
+
? []
|
|
62
|
+
: JSON.parse(retained.unique_conflicts as string);
|
|
63
|
+
const conflicts = prior.filter((conflict) => conflict.rowId !== rowId);
|
|
64
|
+
if (values !== undefined && retained.intent !== null) {
|
|
65
|
+
const intent: Record<string, JsonRowValue> = JSON.parse(
|
|
66
|
+
retained.intent as string,
|
|
67
|
+
);
|
|
68
|
+
conflicts.push(
|
|
69
|
+
...uniqueConflicts(db, table, mapRowValues(intent, jsonToRowValue))
|
|
70
|
+
.filter((conflict) => conflict.rowId === rowId)
|
|
71
|
+
.map((conflict) => ({
|
|
72
|
+
...conflict,
|
|
73
|
+
serverRow: mapRowValues(conflict.serverRow, rowValueToJson),
|
|
74
|
+
})),
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
db.exec(
|
|
78
|
+
`UPDATE ${RETAINED_ROWS} SET unique_conflicts=? WHERE commit_id=? AND idx=?`,
|
|
79
|
+
[
|
|
80
|
+
conflicts.length
|
|
81
|
+
? JSON.stringify(
|
|
82
|
+
conflicts.sort(
|
|
83
|
+
(a, b) =>
|
|
84
|
+
table.indexes.findIndex((index) => index.name === a.index) -
|
|
85
|
+
table.indexes.findIndex((index) => index.name === b.index),
|
|
86
|
+
),
|
|
87
|
+
)
|
|
88
|
+
: null,
|
|
89
|
+
retained.commit_id as string,
|
|
90
|
+
Number(retained.idx),
|
|
91
|
+
],
|
|
92
|
+
);
|
|
93
|
+
}
|
|
37
94
|
}
|
|
38
95
|
|
|
39
96
|
export function retainFailedRows(
|
|
40
97
|
db: ClientDatabase,
|
|
41
98
|
commit: OutboxCommit,
|
|
42
99
|
images: readonly OutboxBeforeImage[],
|
|
100
|
+
commitSeq?: number,
|
|
43
101
|
): void {
|
|
44
102
|
for (const [index, operation] of commit.operations.entries()) {
|
|
45
103
|
const image = images.find((image) => image.opIndex === index);
|
|
104
|
+
if (commitSeq !== undefined && (image?.deliverySeq ?? -1) >= commitSeq)
|
|
105
|
+
continue;
|
|
46
106
|
const initial =
|
|
47
107
|
operation.op === 'upsert'
|
|
48
108
|
? { ...image?.values, ...operation.values }
|
|
@@ -50,11 +110,10 @@ export function retainFailedRows(
|
|
|
50
110
|
if (!image)
|
|
51
111
|
throw new ClientSyncError(
|
|
52
112
|
'sync.local_corrupt',
|
|
53
|
-
'
|
|
113
|
+
'missing intent before-image',
|
|
54
114
|
);
|
|
55
115
|
db.exec(
|
|
56
|
-
`INSERT INTO
|
|
57
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
116
|
+
`INSERT INTO ${RETAINED_ROWS}(commit_id,idx,tbl,id,at,op,intent,base,version,commit_seq)VALUES(?,?,?,?,?,?,?,?,?,?)`,
|
|
58
117
|
[
|
|
59
118
|
commit.clientCommitId,
|
|
60
119
|
index,
|
|
@@ -65,45 +124,124 @@ export function retainFailedRows(
|
|
|
65
124
|
initial ? JSON.stringify(initial) : null,
|
|
66
125
|
image.values ? JSON.stringify(image.values) : null,
|
|
67
126
|
image.syncVersion ?? null,
|
|
127
|
+
commitSeq ?? null,
|
|
68
128
|
],
|
|
69
129
|
);
|
|
70
130
|
}
|
|
71
131
|
}
|
|
72
132
|
|
|
133
|
+
/** Match declared keys through SQLite, preserving index affinity and NULL semantics. */
|
|
134
|
+
export function uniqueConflicts(
|
|
135
|
+
db: ClientDatabase,
|
|
136
|
+
table: CompiledClientTable,
|
|
137
|
+
values: Readonly<Record<string, RowValue>>,
|
|
138
|
+
): NonNullable<RetainedCommitRow['uniqueConflicts']> {
|
|
139
|
+
return table.indexes
|
|
140
|
+
.filter((index) => index.unique)
|
|
141
|
+
.flatMap((index) =>
|
|
142
|
+
db
|
|
143
|
+
.query(
|
|
144
|
+
`SELECT * FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)} !=? AND ${index.columns.map((column) => `${quoteIdent(column)} = ?`).join(' AND ')}`,
|
|
145
|
+
[
|
|
146
|
+
toSqlValue(values[table.primaryKey] ?? null),
|
|
147
|
+
...index.columns.map((column) =>
|
|
148
|
+
toSqlValue(values[column] ?? null),
|
|
149
|
+
),
|
|
150
|
+
],
|
|
151
|
+
)
|
|
152
|
+
.map((row) => ({
|
|
153
|
+
index: index.name,
|
|
154
|
+
columns: index.columns,
|
|
155
|
+
rowId: String(row[table.primaryKey]),
|
|
156
|
+
serverRow: Object.fromEntries(
|
|
157
|
+
table.columns.map((column) => [
|
|
158
|
+
column.name,
|
|
159
|
+
fromSqlValue(column, row[column.name] ?? null),
|
|
160
|
+
]),
|
|
161
|
+
),
|
|
162
|
+
serverVersion: Number(row[SYNC_VERSION_COLUMN]),
|
|
163
|
+
})),
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function retainedRows(db: ClientDatabase, rowKeys?: readonly string[]) {
|
|
168
|
+
return db.query(
|
|
169
|
+
`SELECT * FROM ${RETAINED_ROWS} WHERE ?1 IS NULL OR json_array(tbl,id) IN(SELECT value FROM json_each(?1)) ORDER BY commit_seq,at,commit_id,idx`,
|
|
170
|
+
[rowKeys ? JSON.stringify(rowKeys) : null],
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
73
174
|
export function restoreFailedBases(
|
|
74
175
|
db: ClientDatabase,
|
|
75
176
|
schema: CompiledClientSchema,
|
|
76
177
|
absentIntent = false,
|
|
77
|
-
|
|
178
|
+
rowKeys?: readonly string[],
|
|
179
|
+
): boolean {
|
|
78
180
|
const seen = new Set<string>();
|
|
79
|
-
for (const row of db
|
|
80
|
-
'SELECT * FROM _syncular_failed_rows ORDER BY at, commit_id, idx',
|
|
81
|
-
)) {
|
|
181
|
+
for (const row of retainedRows(db, rowKeys)) {
|
|
82
182
|
const key = JSON.stringify([row.tbl, row.id]);
|
|
83
183
|
if (!absentIntent && seen.has(key)) continue;
|
|
84
184
|
seen.add(key);
|
|
85
|
-
const table = schema.tables.get(
|
|
185
|
+
const table = schema.tables.get(row.tbl as string);
|
|
86
186
|
if (!table)
|
|
87
187
|
throw new ClientSyncError(
|
|
88
188
|
'sync.unknown_table',
|
|
89
|
-
'retained
|
|
189
|
+
'retained table is unknown',
|
|
90
190
|
);
|
|
91
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
|
+
}
|
|
92
229
|
if (
|
|
93
230
|
absentIntent &&
|
|
94
231
|
(json === null ||
|
|
95
232
|
db.query(
|
|
96
|
-
`SELECT 1 FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)}
|
|
97
|
-
[
|
|
233
|
+
`SELECT 1 FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)}=?`,
|
|
234
|
+
[row.id as string],
|
|
98
235
|
).length)
|
|
99
236
|
)
|
|
100
237
|
continue;
|
|
101
|
-
if (json === null) deleteLocalRow(db, table,
|
|
238
|
+
if (json === null) deleteLocalRow(db, table, row.id as string, false);
|
|
102
239
|
else {
|
|
103
240
|
const values: Record<string, JsonRowValue> = JSON.parse(String(json));
|
|
104
241
|
if (absentIntent) {
|
|
105
|
-
const operation: OutboxOperation = JSON.parse(
|
|
242
|
+
const operation: OutboxOperation = JSON.parse(row.op as string);
|
|
106
243
|
if (
|
|
244
|
+
row.commit_seq === null &&
|
|
107
245
|
!table.columns.every((column) =>
|
|
108
246
|
Object.hasOwn(operation.values ?? {}, column.name),
|
|
109
247
|
)
|
|
@@ -116,14 +254,19 @@ export function restoreFailedBases(
|
|
|
116
254
|
table.columns.map((column) =>
|
|
117
255
|
jsonToRowValue(values[column.name] ?? null),
|
|
118
256
|
),
|
|
119
|
-
|
|
257
|
+
(row.version ?? -1) as number,
|
|
120
258
|
false,
|
|
121
259
|
);
|
|
122
260
|
}
|
|
123
261
|
}
|
|
262
|
+
return seen.size > 0;
|
|
124
263
|
}
|
|
125
264
|
|
|
126
|
-
export function failedOverlayCommits(
|
|
265
|
+
export function failedOverlayCommits(
|
|
266
|
+
db: ClientDatabase,
|
|
267
|
+
acknowledged = false,
|
|
268
|
+
rowKeys?: readonly string[],
|
|
269
|
+
): OutboxCommit[] {
|
|
127
270
|
const commits = new Map<
|
|
128
271
|
string,
|
|
129
272
|
{
|
|
@@ -133,18 +276,17 @@ export function failedOverlayCommits(db: ClientDatabase): OutboxCommit[] {
|
|
|
133
276
|
operations: OutboxOperation[];
|
|
134
277
|
}
|
|
135
278
|
>();
|
|
136
|
-
for (const row of db
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
const
|
|
140
|
-
const operation: OutboxOperation = JSON.parse(String(row.op));
|
|
279
|
+
for (const row of retainedRows(db, rowKeys)) {
|
|
280
|
+
if ((row.commit_seq !== null) !== acknowledged) continue;
|
|
281
|
+
const id = row.commit_id as string;
|
|
282
|
+
const operation: OutboxOperation = JSON.parse(row.op as string);
|
|
141
283
|
const prior = commits.get(id);
|
|
142
284
|
if (prior) prior.operations.push(operation);
|
|
143
285
|
else
|
|
144
286
|
commits.set(id, {
|
|
145
287
|
seq: 0,
|
|
146
288
|
clientCommitId: id,
|
|
147
|
-
createdAtMs:
|
|
289
|
+
createdAtMs: row.at as number,
|
|
148
290
|
operations: [operation],
|
|
149
291
|
});
|
|
150
292
|
}
|
|
@@ -159,16 +301,23 @@ export function dropFailedRows(
|
|
|
159
301
|
) => boolean,
|
|
160
302
|
): OutboxCommit[] {
|
|
161
303
|
const ids = new Set<string>();
|
|
162
|
-
for (const row of db
|
|
163
|
-
const json
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
304
|
+
for (const row of retainedRows(db)) {
|
|
305
|
+
for (const json of [row.intent, row.base]) {
|
|
306
|
+
if (json === null) continue;
|
|
307
|
+
const values: Record<string, JsonRowValue> = JSON.parse(String(json));
|
|
308
|
+
if (matches(row.tbl as string, values)) ids.add(row.commit_id as string);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
const dropped = [
|
|
312
|
+
...failedOverlayCommits(db),
|
|
313
|
+
...failedOverlayCommits(db, true),
|
|
314
|
+
].filter((commit) => ids.has(commit.clientCommitId));
|
|
315
|
+
for (const id of ids) {
|
|
316
|
+
db.exec(`DELETE FROM ${RETAINED_ROWS} WHERE commit_id=?`, [id]);
|
|
317
|
+
db.exec(
|
|
318
|
+
"UPDATE _syncular_commit_outcomes SET operations=NULL,results='[]' WHERE client_commit_id=?",
|
|
319
|
+
[id],
|
|
320
|
+
);
|
|
167
321
|
}
|
|
168
|
-
const dropped = failedOverlayCommits(db).filter((commit) =>
|
|
169
|
-
ids.has(commit.clientCommitId),
|
|
170
|
-
);
|
|
171
|
-
for (const id of ids)
|
|
172
|
-
db.exec('DELETE FROM _syncular_failed_rows WHERE commit_id = ?', [id]);
|
|
173
322
|
return dropped;
|
|
174
323
|
}
|
package/src/outbox.ts
CHANGED
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
type CompiledClientTable,
|
|
21
21
|
type JsonRowValue,
|
|
22
22
|
jsonToRowValue,
|
|
23
|
+
matchesLocalScopes,
|
|
23
24
|
} from './schema';
|
|
24
25
|
|
|
25
26
|
export interface OutboxOperation {
|
|
@@ -48,6 +49,7 @@ export interface OutboxCommit {
|
|
|
48
49
|
export interface OutboxBeforeImage {
|
|
49
50
|
readonly opIndex: number;
|
|
50
51
|
readonly existed: boolean;
|
|
52
|
+
readonly deliverySeq?: number;
|
|
51
53
|
readonly syncVersion?: number;
|
|
52
54
|
readonly values?: Readonly<Record<string, JsonRowValue>>;
|
|
53
55
|
}
|
|
@@ -66,32 +68,23 @@ export function appendOutboxCommit(
|
|
|
66
68
|
);
|
|
67
69
|
}
|
|
68
70
|
db.exec(
|
|
69
|
-
|
|
70
|
-
VALUES (?, ?, ?)`,
|
|
71
|
+
'INSERT INTO _syncular_outbox(client_commit_id,created_at_ms,operations)VALUES(?,?,?)',
|
|
71
72
|
[clientCommitId, nowMs, JSON.stringify(operations)],
|
|
72
73
|
);
|
|
73
|
-
|
|
74
|
-
db.exec(
|
|
75
|
-
`INSERT INTO _syncular_outbox_before_images(
|
|
76
|
-
client_commit_id, op_index, existed, sync_version, values_json
|
|
77
|
-
) VALUES (?, ?, ?, ?, ?)`,
|
|
78
|
-
[
|
|
79
|
-
clientCommitId,
|
|
80
|
-
image.opIndex,
|
|
81
|
-
image.existed ? 1 : 0,
|
|
82
|
-
image.syncVersion ?? null,
|
|
83
|
-
image.values === undefined ? null : JSON.stringify(image.values),
|
|
84
|
-
],
|
|
85
|
-
);
|
|
86
|
-
}
|
|
74
|
+
replaceOutboxBeforeImages(db, clientCommitId, beforeImages);
|
|
87
75
|
}
|
|
88
76
|
|
|
89
77
|
/** Pending commits in FIFO creation order (§7.1). Full reads serve replay and the public listing. */
|
|
90
|
-
export function listOutbox(
|
|
78
|
+
export function listOutbox(
|
|
79
|
+
db: ClientDatabase,
|
|
80
|
+
rowKeys?: readonly string[],
|
|
81
|
+
): OutboxCommit[] {
|
|
91
82
|
return db
|
|
92
83
|
.query(
|
|
93
|
-
|
|
94
|
-
|
|
84
|
+
rowKeys === undefined
|
|
85
|
+
? 'SELECT seq, client_commit_id, created_at_ms, operations FROM _syncular_outbox ORDER BY seq ASC'
|
|
86
|
+
: "SELECT * FROM _syncular_outbox WHERE EXISTS(SELECT 1 FROM json_each(operations) WHERE json_array(json_extract(value,'$.table'),json_extract(value,'$.rowId')) IN(SELECT value FROM json_each(?))) ORDER BY seq",
|
|
87
|
+
rowKeys && [JSON.stringify(rowKeys)],
|
|
95
88
|
)
|
|
96
89
|
.map(decodeOutboxRow);
|
|
97
90
|
}
|
|
@@ -113,8 +106,7 @@ export function* iterateOutbox(
|
|
|
113
106
|
let afterSeq = 0;
|
|
114
107
|
while (afterSeq < throughSeq) {
|
|
115
108
|
const rows = db.query(
|
|
116
|
-
|
|
117
|
-
WHERE seq > ? AND seq <= ? ORDER BY seq ASC LIMIT 32`,
|
|
109
|
+
'SELECT seq, client_commit_id, created_at_ms, operations FROM _syncular_outbox WHERE seq > ? AND seq <= ? ORDER BY seq ASC LIMIT 32',
|
|
118
110
|
[afterSeq, throughSeq],
|
|
119
111
|
);
|
|
120
112
|
if (rows.length === 0) return;
|
|
@@ -128,7 +120,7 @@ export function* iterateOutbox(
|
|
|
128
120
|
|
|
129
121
|
/** Routine status reads never load operation bodies. */
|
|
130
122
|
export function countOutbox(db: ClientDatabase): number {
|
|
131
|
-
return db.query('SELECT COUNT(*)
|
|
123
|
+
return db.query('SELECT COUNT(*)AS count FROM _syncular_outbox')[0]!
|
|
132
124
|
.count as number;
|
|
133
125
|
}
|
|
134
126
|
|
|
@@ -136,14 +128,14 @@ export function deleteOutboxCommit(
|
|
|
136
128
|
db: ClientDatabase,
|
|
137
129
|
clientCommitId: string,
|
|
138
130
|
): void {
|
|
139
|
-
db.exec('DELETE FROM _syncular_blob_commit_refs WHERE commit_id
|
|
131
|
+
db.exec('DELETE FROM _syncular_blob_commit_refs WHERE commit_id=?', [
|
|
140
132
|
clientCommitId,
|
|
141
133
|
]);
|
|
142
134
|
db.exec(
|
|
143
|
-
'DELETE FROM _syncular_outbox_before_images WHERE client_commit_id
|
|
135
|
+
'DELETE FROM _syncular_outbox_before_images WHERE client_commit_id=?',
|
|
144
136
|
[clientCommitId],
|
|
145
137
|
);
|
|
146
|
-
db.exec('DELETE FROM _syncular_outbox WHERE client_commit_id
|
|
138
|
+
db.exec('DELETE FROM _syncular_outbox WHERE client_commit_id=?', [
|
|
147
139
|
clientCommitId,
|
|
148
140
|
]);
|
|
149
141
|
}
|
|
@@ -154,14 +146,15 @@ export function listOutboxBeforeImages(
|
|
|
154
146
|
): OutboxBeforeImage[] {
|
|
155
147
|
return db
|
|
156
148
|
.query(
|
|
157
|
-
|
|
158
|
-
FROM _syncular_outbox_before_images
|
|
159
|
-
WHERE client_commit_id = ? ORDER BY op_index`,
|
|
149
|
+
'SELECT op_index,existed,sync_version,values_json,delivery_seq FROM _syncular_outbox_before_images WHERE client_commit_id=? ORDER BY op_index',
|
|
160
150
|
[clientCommitId],
|
|
161
151
|
)
|
|
162
152
|
.map((row) => ({
|
|
163
153
|
opIndex: row.op_index as number,
|
|
164
154
|
existed: row.existed === 1,
|
|
155
|
+
...(typeof row.delivery_seq === 'number'
|
|
156
|
+
? { deliverySeq: row.delivery_seq }
|
|
157
|
+
: {}),
|
|
165
158
|
...(typeof row.sync_version === 'number'
|
|
166
159
|
? { syncVersion: row.sync_version }
|
|
167
160
|
: {}),
|
|
@@ -182,14 +175,7 @@ export function replaceOutboxBeforeImages(
|
|
|
182
175
|
): void {
|
|
183
176
|
for (const image of replacements) {
|
|
184
177
|
db.exec(
|
|
185
|
-
|
|
186
|
-
WHERE client_commit_id = ? AND op_index = ?`,
|
|
187
|
-
[clientCommitId, image.opIndex],
|
|
188
|
-
);
|
|
189
|
-
db.exec(
|
|
190
|
-
`INSERT INTO _syncular_outbox_before_images(
|
|
191
|
-
client_commit_id, op_index, existed, sync_version, values_json
|
|
192
|
-
) VALUES (?, ?, ?, ?, ?)`,
|
|
178
|
+
'INSERT INTO _syncular_outbox_before_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',
|
|
193
179
|
[
|
|
194
180
|
clientCommitId,
|
|
195
181
|
image.opIndex,
|
|
@@ -222,7 +208,7 @@ function sparseValues(
|
|
|
222
208
|
for (const key of Object.keys(values)) {
|
|
223
209
|
if (!table.columnIndex.has(key)) {
|
|
224
210
|
throw new OutboxEncodeError(
|
|
225
|
-
`outbox
|
|
211
|
+
`outbox column ${JSON.stringify(key)} is absent from table ${JSON.stringify(table.name)} (§7.4.4)`,
|
|
226
212
|
);
|
|
227
213
|
}
|
|
228
214
|
}
|
|
@@ -266,7 +252,7 @@ export async function encodeOutboxCommit(
|
|
|
266
252
|
if (table === undefined) {
|
|
267
253
|
// §7.4.4: the bump removed this table — the commit cannot be encoded.
|
|
268
254
|
throw new OutboxEncodeError(
|
|
269
|
-
`outbox
|
|
255
|
+
`outbox table ${JSON.stringify(op.table)} is absent from the current schema (§7.4.4)`,
|
|
270
256
|
);
|
|
271
257
|
}
|
|
272
258
|
if (op.values === undefined) {
|
|
@@ -320,12 +306,7 @@ export function dropOutboxCommitsInScope(
|
|
|
320
306
|
for (const commit of listOutbox(db)) {
|
|
321
307
|
const inScope = commit.operations.some((op) => {
|
|
322
308
|
if (op.table !== table.name || op.values === undefined) return false;
|
|
323
|
-
return
|
|
324
|
-
const column = table.scopeColumnByVariable.get(variable);
|
|
325
|
-
if (column === undefined) return false;
|
|
326
|
-
const value = op.values?.[column];
|
|
327
|
-
return typeof value === 'string' && values.includes(value);
|
|
328
|
-
});
|
|
309
|
+
return matchesLocalScopes(table, effective, op.values);
|
|
329
310
|
});
|
|
330
311
|
if (inScope) {
|
|
331
312
|
deleteOutboxCommit(db, commit.clientCommitId);
|
package/src/outcomes.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { RETAINED_ROWS } from './failed-overlay';
|
|
1
2
|
/**
|
|
2
3
|
* Durable per-client commit outcomes.
|
|
3
4
|
*
|
|
@@ -10,7 +11,12 @@ import type { RejectionDetails, RowValue } from '@syncular/core';
|
|
|
10
11
|
import type { ClientDatabase } from './database';
|
|
11
12
|
import { ClientSyncError } from './errors';
|
|
12
13
|
import type { OutboxOperation } from './outbox';
|
|
13
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
mapRowValues,
|
|
16
|
+
type JsonRowValue,
|
|
17
|
+
jsonToRowValue,
|
|
18
|
+
rowValueToJson,
|
|
19
|
+
} from './schema';
|
|
14
20
|
|
|
15
21
|
export interface ConflictRecord {
|
|
16
22
|
readonly clientCommitId: string;
|
|
@@ -70,6 +76,14 @@ export type CommitOperationOutcome =
|
|
|
70
76
|
};
|
|
71
77
|
|
|
72
78
|
export interface RetainedCommitRow {
|
|
79
|
+
/** Authorized different-primary-key server rows matching intended unique keys. */
|
|
80
|
+
readonly uniqueConflicts?: readonly {
|
|
81
|
+
readonly index: string;
|
|
82
|
+
readonly columns: readonly string[];
|
|
83
|
+
readonly rowId: string;
|
|
84
|
+
readonly serverRow: Readonly<Record<string, RowValue>>;
|
|
85
|
+
readonly serverVersion: number;
|
|
86
|
+
}[];
|
|
73
87
|
readonly table: string;
|
|
74
88
|
readonly rowId: string;
|
|
75
89
|
/** Complete intended row, or null for a local deletion. */
|
|
@@ -132,12 +146,7 @@ function encodeResults(results: readonly CommitOperationOutcome[]): string {
|
|
|
132
146
|
status: 'conflict',
|
|
133
147
|
conflict: {
|
|
134
148
|
...result.conflict,
|
|
135
|
-
serverRow:
|
|
136
|
-
Object.entries(result.conflict.serverRow).map(([key, value]) => [
|
|
137
|
-
key,
|
|
138
|
-
rowValueToJson(value),
|
|
139
|
-
]),
|
|
140
|
-
),
|
|
149
|
+
serverRow: mapRowValues(result.conflict.serverRow, rowValueToJson),
|
|
141
150
|
},
|
|
142
151
|
};
|
|
143
152
|
});
|
|
@@ -154,12 +163,7 @@ function decodeResults(raw: string): CommitOperationOutcome[] {
|
|
|
154
163
|
...result.conflict,
|
|
155
164
|
// Pre-column-version journal entries carry no conflictColumns.
|
|
156
165
|
conflictColumns: result.conflict.conflictColumns ?? [],
|
|
157
|
-
serverRow:
|
|
158
|
-
Object.entries(result.conflict.serverRow).map(([key, value]) => [
|
|
159
|
-
key,
|
|
160
|
-
jsonToRowValue(value),
|
|
161
|
-
]),
|
|
162
|
-
),
|
|
166
|
+
serverRow: mapRowValues(result.conflict.serverRow, jsonToRowValue),
|
|
163
167
|
},
|
|
164
168
|
};
|
|
165
169
|
});
|
|
@@ -171,8 +175,8 @@ function parseOutcome(
|
|
|
171
175
|
): CommitOutcome {
|
|
172
176
|
const retainedRows: RetainedCommitRow[] = db
|
|
173
177
|
.query(
|
|
174
|
-
|
|
175
|
-
[
|
|
178
|
+
`SELECT tbl,id,intent,base,version,unique_conflicts FROM ${RETAINED_ROWS} WHERE commit_seq IS NULL AND commit_id=? ORDER BY idx`,
|
|
179
|
+
[row.client_commit_id as string],
|
|
176
180
|
)
|
|
177
181
|
.map((retained) => {
|
|
178
182
|
const decode = (value: unknown): Record<string, RowValue> | null =>
|
|
@@ -184,12 +188,24 @@ function parseOutcome(
|
|
|
184
188
|
).map(([key, value]) => [key, jsonToRowValue(value)]),
|
|
185
189
|
);
|
|
186
190
|
return {
|
|
187
|
-
table:
|
|
188
|
-
rowId:
|
|
191
|
+
table: retained.tbl as string,
|
|
192
|
+
rowId: retained.id as string,
|
|
189
193
|
localRow: decode(retained.intent),
|
|
194
|
+
...(retained.unique_conflicts === null
|
|
195
|
+
? {}
|
|
196
|
+
: {
|
|
197
|
+
uniqueConflicts: (
|
|
198
|
+
JSON.parse(retained.unique_conflicts as string) as NonNullable<
|
|
199
|
+
RetainedCommitRow['uniqueConflicts']
|
|
200
|
+
>
|
|
201
|
+
).map((conflict) => ({
|
|
202
|
+
...conflict,
|
|
203
|
+
serverRow: decode(JSON.stringify(conflict.serverRow))!,
|
|
204
|
+
})),
|
|
205
|
+
}),
|
|
190
206
|
serverRow: decode(retained.base),
|
|
191
207
|
serverVersion:
|
|
192
|
-
retained.version === null ? null :
|
|
208
|
+
retained.version === null ? null : (retained.version as number),
|
|
193
209
|
};
|
|
194
210
|
});
|
|
195
211
|
return {
|
|
@@ -217,9 +233,7 @@ export function recordCommitOutcome(
|
|
|
217
233
|
outcome: Omit<CommitOutcome, 'sequence' | 'resolution'>,
|
|
218
234
|
): CommitOutcome {
|
|
219
235
|
db.exec(
|
|
220
|
-
|
|
221
|
-
client_commit_id, status, recorded_at_ms, results, operations, resolution
|
|
222
|
-
) VALUES (?, ?, ?, ?, ?, 'active')`,
|
|
236
|
+
"INSERT INTO _syncular_commit_outcomes(client_commit_id,status,recorded_at_ms,results,operations,resolution)VALUES(?,?,?,?,?,'active')",
|
|
223
237
|
[
|
|
224
238
|
outcome.clientCommitId,
|
|
225
239
|
outcome.status,
|
|
@@ -238,9 +252,7 @@ export function commitOutcome(
|
|
|
238
252
|
clientCommitId: string,
|
|
239
253
|
): CommitOutcome | undefined {
|
|
240
254
|
const row = db.query(
|
|
241
|
-
|
|
242
|
-
resolution, resolved_at_ms, replacement_client_commit_id
|
|
243
|
-
FROM _syncular_commit_outcomes WHERE client_commit_id = ?`,
|
|
255
|
+
'SELECT seq,client_commit_id,status,recorded_at_ms,results,operations,resolution,resolved_at_ms,replacement_client_commit_id FROM _syncular_commit_outcomes WHERE client_commit_id=?',
|
|
244
256
|
[clientCommitId],
|
|
245
257
|
)[0];
|
|
246
258
|
return row === undefined ? undefined : parseOutcome(db, row);
|
|
@@ -261,10 +273,7 @@ export function listCommitOutcomes(
|
|
|
261
273
|
? "WHERE resolution = 'active' AND status IN ('conflict', 'rejected')"
|
|
262
274
|
: '';
|
|
263
275
|
const rows = db.query(
|
|
264
|
-
`SELECT seq,
|
|
265
|
-
resolution, resolved_at_ms, replacement_client_commit_id
|
|
266
|
-
FROM _syncular_commit_outcomes ${where}
|
|
267
|
-
ORDER BY seq DESC${limit === undefined ? '' : ' LIMIT ?'}`,
|
|
276
|
+
`SELECT seq,client_commit_id,status,recorded_at_ms,results,operations,resolution,resolved_at_ms,replacement_client_commit_id FROM _syncular_commit_outcomes ${where} ORDER BY seq DESC${limit === undefined ? '' : ' LIMIT ?'}`,
|
|
268
277
|
limit === undefined ? [] : [limit],
|
|
269
278
|
);
|
|
270
279
|
return rows.map((row) => parseOutcome(db, row));
|
|
@@ -276,9 +285,7 @@ export function persistCommitOutcomeResolution(
|
|
|
276
285
|
nowMs: number,
|
|
277
286
|
): CommitOutcome | undefined {
|
|
278
287
|
db.exec(
|
|
279
|
-
|
|
280
|
-
SET resolution = ?, resolved_at_ms = ?, replacement_client_commit_id = ?
|
|
281
|
-
WHERE client_commit_id = ? AND resolution = 'active'`,
|
|
288
|
+
"UPDATE _syncular_commit_outcomes SET resolution=?,resolved_at_ms=?,replacement_client_commit_id=? WHERE client_commit_id=? AND resolution='active'",
|
|
282
289
|
[
|
|
283
290
|
input.resolution,
|
|
284
291
|
nowMs,
|
|
@@ -309,13 +316,11 @@ export function pruneCommitOutcomes(
|
|
|
309
316
|
const excess = Math.max(0, (count ?? 0) - maxEntries);
|
|
310
317
|
if (excess === 0) return 0;
|
|
311
318
|
const candidates = db.query(
|
|
312
|
-
|
|
313
|
-
WHERE status IN ('applied', 'cached') OR resolution != 'active'
|
|
314
|
-
ORDER BY seq ASC LIMIT ?`,
|
|
319
|
+
"SELECT seq FROM _syncular_commit_outcomes WHERE status IN('applied','cached')OR resolution !='active' ORDER BY seq ASC LIMIT ?",
|
|
315
320
|
[excess],
|
|
316
321
|
);
|
|
317
322
|
for (const candidate of candidates) {
|
|
318
|
-
db.exec('DELETE FROM _syncular_commit_outcomes WHERE seq
|
|
323
|
+
db.exec('DELETE FROM _syncular_commit_outcomes WHERE seq=?', [
|
|
319
324
|
candidate.seq as number,
|
|
320
325
|
]);
|
|
321
326
|
}
|