@syncular/client 0.30.10 → 0.30.11
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 +8 -0
- package/dist/apply.js +17 -25
- package/dist/blob.js +13 -39
- package/dist/client.js +118 -117
- package/dist/failed-overlay.d.ts +6 -2
- package/dist/failed-overlay.js +82 -15
- package/dist/outbox.js +11 -21
- package/dist/outcomes.d.ts +8 -0
- package/dist/outcomes.js +18 -27
- package/dist/previous-version.js +14 -28
- package/dist/schema.d.ts +2 -0
- package/dist/schema.js +50 -85
- 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 +17 -25
- package/src/blob.ts +17 -39
- package/src/client.ts +222 -163
- package/src/failed-overlay.ts +155 -17
- package/src/outbox.ts +11 -21
- package/src/outcomes.ts +35 -31
- package/src/previous-version.ts +14 -28
- package/src/schema.ts +91 -96
- 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,21 @@
|
|
|
1
1
|
import { ClientSyncError } from './errors';
|
|
2
2
|
import type { ClientDatabase } from './database';
|
|
3
|
-
import type {
|
|
4
|
-
|
|
3
|
+
import type {
|
|
4
|
+
CompiledClientSchema,
|
|
5
|
+
CompiledClientTable,
|
|
6
|
+
JsonRowValue,
|
|
7
|
+
} from './schema';
|
|
8
|
+
import {
|
|
9
|
+
mapRowValues,
|
|
10
|
+
fromSqlValue,
|
|
11
|
+
jsonToRowValue,
|
|
12
|
+
quoteIdent,
|
|
13
|
+
rowValueToJson,
|
|
14
|
+
SYNC_VERSION_COLUMN,
|
|
15
|
+
toSqlValue,
|
|
16
|
+
} from './schema';
|
|
17
|
+
import type { RowValue } from '@syncular/core';
|
|
18
|
+
import type { RetainedCommitRow } from './outcomes';
|
|
5
19
|
import type {
|
|
6
20
|
OutboxCommit,
|
|
7
21
|
OutboxBeforeImage,
|
|
@@ -11,29 +25,78 @@ import { deleteLocalRow, upsertLocalRow } from './apply';
|
|
|
11
25
|
|
|
12
26
|
/** Protected failure state. The server base advances independently of intent. */
|
|
13
27
|
export function ensureFailedOverlaySchema(db: ClientDatabase): void {
|
|
14
|
-
db.exec(
|
|
15
|
-
commit_id TEXT NOT NULL,
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
28
|
+
db.exec(
|
|
29
|
+
'CREATE TABLE IF NOT EXISTS _syncular_failed_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,PRIMARY KEY(commit_id,idx))',
|
|
30
|
+
);
|
|
31
|
+
if (
|
|
32
|
+
!db
|
|
33
|
+
.query('PRAGMA table_info(_syncular_failed_rows)')
|
|
34
|
+
.some((row) => row.name === 'unique_conflicts')
|
|
35
|
+
)
|
|
36
|
+
db.exec(
|
|
37
|
+
'ALTER TABLE _syncular_failed_rows ADD COLUMN unique_conflicts TEXT',
|
|
38
|
+
);
|
|
19
39
|
}
|
|
20
40
|
|
|
21
41
|
export function retainedBaseWrite(
|
|
22
42
|
db: ClientDatabase,
|
|
23
|
-
table:
|
|
43
|
+
table: CompiledClientTable,
|
|
24
44
|
rowId: string,
|
|
25
45
|
values?: Readonly<Record<string, JsonRowValue>>,
|
|
26
46
|
version?: number,
|
|
27
47
|
): void {
|
|
28
48
|
db.exec(
|
|
29
|
-
'UPDATE _syncular_failed_rows SET base
|
|
49
|
+
'UPDATE _syncular_failed_rows SET base=?,version=? WHERE tbl=? AND id=?',
|
|
30
50
|
[
|
|
31
51
|
values === undefined ? null : JSON.stringify(values),
|
|
32
52
|
version ?? null,
|
|
33
|
-
table,
|
|
53
|
+
table.name,
|
|
34
54
|
rowId,
|
|
35
55
|
],
|
|
36
56
|
);
|
|
57
|
+
if (!table.indexes.some((index) => index.unique)) return;
|
|
58
|
+
for (const retained of db.query(
|
|
59
|
+
'SELECT commit_id,idx,intent,unique_conflicts FROM _syncular_failed_rows WHERE tbl=?',
|
|
60
|
+
[table.name],
|
|
61
|
+
)) {
|
|
62
|
+
const prior: readonly (Omit<
|
|
63
|
+
NonNullable<RetainedCommitRow['uniqueConflicts']>[number],
|
|
64
|
+
'serverRow'
|
|
65
|
+
> & { serverRow: Readonly<Record<string, JsonRowValue>> })[] =
|
|
66
|
+
retained.unique_conflicts === null
|
|
67
|
+
? []
|
|
68
|
+
: JSON.parse(String(retained.unique_conflicts));
|
|
69
|
+
const conflicts = prior.filter((conflict) => conflict.rowId !== rowId);
|
|
70
|
+
if (values !== undefined && retained.intent !== null) {
|
|
71
|
+
const intent: Record<string, JsonRowValue> = JSON.parse(
|
|
72
|
+
String(retained.intent),
|
|
73
|
+
);
|
|
74
|
+
conflicts.push(
|
|
75
|
+
...uniqueConflicts(db, table, mapRowValues(intent, jsonToRowValue))
|
|
76
|
+
.filter((conflict) => conflict.rowId === rowId)
|
|
77
|
+
.map((conflict) => ({
|
|
78
|
+
...conflict,
|
|
79
|
+
serverRow: mapRowValues(conflict.serverRow, rowValueToJson),
|
|
80
|
+
})),
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
db.exec(
|
|
84
|
+
'UPDATE _syncular_failed_rows SET unique_conflicts=? WHERE commit_id=? AND idx=?',
|
|
85
|
+
[
|
|
86
|
+
conflicts.length
|
|
87
|
+
? JSON.stringify(
|
|
88
|
+
conflicts.sort(
|
|
89
|
+
(a, b) =>
|
|
90
|
+
table.indexes.findIndex((index) => index.name === a.index) -
|
|
91
|
+
table.indexes.findIndex((index) => index.name === b.index),
|
|
92
|
+
),
|
|
93
|
+
)
|
|
94
|
+
: null,
|
|
95
|
+
String(retained.commit_id),
|
|
96
|
+
Number(retained.idx),
|
|
97
|
+
],
|
|
98
|
+
);
|
|
99
|
+
}
|
|
37
100
|
}
|
|
38
101
|
|
|
39
102
|
export function retainFailedRows(
|
|
@@ -53,8 +116,7 @@ export function retainFailedRows(
|
|
|
53
116
|
'retained intent has no before-image',
|
|
54
117
|
);
|
|
55
118
|
db.exec(
|
|
56
|
-
|
|
57
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
119
|
+
'INSERT INTO _syncular_failed_rows(commit_id,idx,tbl,id,at,op,intent,base,version)VALUES(?,?,?,?,?,?,?,?,?)',
|
|
58
120
|
[
|
|
59
121
|
commit.clientCommitId,
|
|
60
122
|
index,
|
|
@@ -70,6 +132,40 @@ export function retainFailedRows(
|
|
|
70
132
|
}
|
|
71
133
|
}
|
|
72
134
|
|
|
135
|
+
/** Match declared keys through SQLite, preserving index affinity and NULL semantics. */
|
|
136
|
+
export function uniqueConflicts(
|
|
137
|
+
db: ClientDatabase,
|
|
138
|
+
table: CompiledClientTable,
|
|
139
|
+
values: Readonly<Record<string, RowValue>>,
|
|
140
|
+
): NonNullable<RetainedCommitRow['uniqueConflicts']> {
|
|
141
|
+
return table.indexes
|
|
142
|
+
.filter((index) => index.unique)
|
|
143
|
+
.flatMap((index) =>
|
|
144
|
+
db
|
|
145
|
+
.query(
|
|
146
|
+
`SELECT * FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)} !=? AND ${index.columns.map((column) => `${quoteIdent(column)} = ?`).join(' AND ')}`,
|
|
147
|
+
[
|
|
148
|
+
toSqlValue(values[table.primaryKey] ?? null),
|
|
149
|
+
...index.columns.map((column) =>
|
|
150
|
+
toSqlValue(values[column] ?? null),
|
|
151
|
+
),
|
|
152
|
+
],
|
|
153
|
+
)
|
|
154
|
+
.map((row) => ({
|
|
155
|
+
index: index.name,
|
|
156
|
+
columns: index.columns,
|
|
157
|
+
rowId: String(row[table.primaryKey]),
|
|
158
|
+
serverRow: Object.fromEntries(
|
|
159
|
+
table.columns.map((column) => [
|
|
160
|
+
column.name,
|
|
161
|
+
fromSqlValue(column, row[column.name] ?? null),
|
|
162
|
+
]),
|
|
163
|
+
),
|
|
164
|
+
serverVersion: Number(row[SYNC_VERSION_COLUMN]),
|
|
165
|
+
})),
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
73
169
|
export function restoreFailedBases(
|
|
74
170
|
db: ClientDatabase,
|
|
75
171
|
schema: CompiledClientSchema,
|
|
@@ -77,7 +173,7 @@ export function restoreFailedBases(
|
|
|
77
173
|
): void {
|
|
78
174
|
const seen = new Set<string>();
|
|
79
175
|
for (const row of db.query(
|
|
80
|
-
'SELECT * FROM _syncular_failed_rows ORDER BY at,
|
|
176
|
+
'SELECT * FROM _syncular_failed_rows ORDER BY at,commit_id,idx',
|
|
81
177
|
)) {
|
|
82
178
|
const key = JSON.stringify([row.tbl, row.id]);
|
|
83
179
|
if (!absentIntent && seen.has(key)) continue;
|
|
@@ -89,11 +185,48 @@ export function restoreFailedBases(
|
|
|
89
185
|
'retained intent references an unknown table',
|
|
90
186
|
);
|
|
91
187
|
const json = absentIntent ? row.intent : row.base;
|
|
188
|
+
if (absentIntent && row.intent !== null) {
|
|
189
|
+
const values: Record<string, JsonRowValue> = JSON.parse(
|
|
190
|
+
String(row.intent),
|
|
191
|
+
);
|
|
192
|
+
const conflicts = uniqueConflicts(
|
|
193
|
+
db,
|
|
194
|
+
table,
|
|
195
|
+
mapRowValues(values, jsonToRowValue),
|
|
196
|
+
);
|
|
197
|
+
// Hydrate a pre-0.30.11 failure after its winner was already imported.
|
|
198
|
+
// Pending and retained overlays cannot supply authoritative row evidence.
|
|
199
|
+
if (row.unique_conflicts === null) {
|
|
200
|
+
const authorized = conflicts.filter(
|
|
201
|
+
(conflict) =>
|
|
202
|
+
conflict.serverVersion >= 0 &&
|
|
203
|
+
!db.query(
|
|
204
|
+
"SELECT 1 FROM _syncular_failed_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",
|
|
205
|
+
[table.name, conflict.rowId, table.name, conflict.rowId],
|
|
206
|
+
).length,
|
|
207
|
+
);
|
|
208
|
+
if (authorized.length)
|
|
209
|
+
db.exec(
|
|
210
|
+
'UPDATE _syncular_failed_rows SET unique_conflicts=? WHERE commit_id=? AND idx=?',
|
|
211
|
+
[
|
|
212
|
+
JSON.stringify(
|
|
213
|
+
authorized.map((conflict) => ({
|
|
214
|
+
...conflict,
|
|
215
|
+
serverRow: mapRowValues(conflict.serverRow, rowValueToJson),
|
|
216
|
+
})),
|
|
217
|
+
),
|
|
218
|
+
String(row.commit_id),
|
|
219
|
+
Number(row.idx),
|
|
220
|
+
],
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
if (conflicts.length) continue;
|
|
224
|
+
}
|
|
92
225
|
if (
|
|
93
226
|
absentIntent &&
|
|
94
227
|
(json === null ||
|
|
95
228
|
db.query(
|
|
96
|
-
`SELECT 1 FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)}
|
|
229
|
+
`SELECT 1 FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)}=?`,
|
|
97
230
|
[String(row.id)],
|
|
98
231
|
).length)
|
|
99
232
|
)
|
|
@@ -134,7 +267,7 @@ export function failedOverlayCommits(db: ClientDatabase): OutboxCommit[] {
|
|
|
134
267
|
}
|
|
135
268
|
>();
|
|
136
269
|
for (const row of db.query(
|
|
137
|
-
'SELECT * FROM _syncular_failed_rows ORDER BY at,
|
|
270
|
+
'SELECT * FROM _syncular_failed_rows ORDER BY at,commit_id,idx',
|
|
138
271
|
)) {
|
|
139
272
|
const id = String(row.commit_id);
|
|
140
273
|
const operation: OutboxOperation = JSON.parse(String(row.op));
|
|
@@ -168,7 +301,12 @@ export function dropFailedRows(
|
|
|
168
301
|
const dropped = failedOverlayCommits(db).filter((commit) =>
|
|
169
302
|
ids.has(commit.clientCommitId),
|
|
170
303
|
);
|
|
171
|
-
for (const id of ids)
|
|
172
|
-
db.exec('DELETE FROM _syncular_failed_rows WHERE commit_id
|
|
304
|
+
for (const id of ids) {
|
|
305
|
+
db.exec('DELETE FROM _syncular_failed_rows WHERE commit_id=?', [id]);
|
|
306
|
+
db.exec(
|
|
307
|
+
"UPDATE _syncular_commit_outcomes SET operations=NULL,results='[]' WHERE client_commit_id=?",
|
|
308
|
+
[id],
|
|
309
|
+
);
|
|
310
|
+
}
|
|
173
311
|
return dropped;
|
|
174
312
|
}
|
package/src/outbox.ts
CHANGED
|
@@ -66,15 +66,12 @@ export function appendOutboxCommit(
|
|
|
66
66
|
);
|
|
67
67
|
}
|
|
68
68
|
db.exec(
|
|
69
|
-
|
|
70
|
-
VALUES (?, ?, ?)`,
|
|
69
|
+
'INSERT INTO _syncular_outbox(client_commit_id,created_at_ms,operations)VALUES(?,?,?)',
|
|
71
70
|
[clientCommitId, nowMs, JSON.stringify(operations)],
|
|
72
71
|
);
|
|
73
72
|
for (const image of beforeImages) {
|
|
74
73
|
db.exec(
|
|
75
|
-
|
|
76
|
-
client_commit_id, op_index, existed, sync_version, values_json
|
|
77
|
-
) VALUES (?, ?, ?, ?, ?)`,
|
|
74
|
+
'INSERT INTO _syncular_outbox_before_images(client_commit_id,op_index,existed,sync_version,values_json)VALUES(?,?,?,?,?)',
|
|
78
75
|
[
|
|
79
76
|
clientCommitId,
|
|
80
77
|
image.opIndex,
|
|
@@ -90,8 +87,7 @@ export function appendOutboxCommit(
|
|
|
90
87
|
export function listOutbox(db: ClientDatabase): OutboxCommit[] {
|
|
91
88
|
return db
|
|
92
89
|
.query(
|
|
93
|
-
|
|
94
|
-
FROM _syncular_outbox ORDER BY seq ASC`,
|
|
90
|
+
'SELECT seq, client_commit_id, created_at_ms, operations FROM _syncular_outbox ORDER BY seq ASC',
|
|
95
91
|
)
|
|
96
92
|
.map(decodeOutboxRow);
|
|
97
93
|
}
|
|
@@ -113,8 +109,7 @@ export function* iterateOutbox(
|
|
|
113
109
|
let afterSeq = 0;
|
|
114
110
|
while (afterSeq < throughSeq) {
|
|
115
111
|
const rows = db.query(
|
|
116
|
-
|
|
117
|
-
WHERE seq > ? AND seq <= ? ORDER BY seq ASC LIMIT 32`,
|
|
112
|
+
'SELECT seq, client_commit_id, created_at_ms, operations FROM _syncular_outbox WHERE seq > ? AND seq <= ? ORDER BY seq ASC LIMIT 32',
|
|
118
113
|
[afterSeq, throughSeq],
|
|
119
114
|
);
|
|
120
115
|
if (rows.length === 0) return;
|
|
@@ -128,7 +123,7 @@ export function* iterateOutbox(
|
|
|
128
123
|
|
|
129
124
|
/** Routine status reads never load operation bodies. */
|
|
130
125
|
export function countOutbox(db: ClientDatabase): number {
|
|
131
|
-
return db.query('SELECT COUNT(*)
|
|
126
|
+
return db.query('SELECT COUNT(*)AS count FROM _syncular_outbox')[0]!
|
|
132
127
|
.count as number;
|
|
133
128
|
}
|
|
134
129
|
|
|
@@ -136,14 +131,14 @@ export function deleteOutboxCommit(
|
|
|
136
131
|
db: ClientDatabase,
|
|
137
132
|
clientCommitId: string,
|
|
138
133
|
): void {
|
|
139
|
-
db.exec('DELETE FROM _syncular_blob_commit_refs WHERE commit_id
|
|
134
|
+
db.exec('DELETE FROM _syncular_blob_commit_refs WHERE commit_id=?', [
|
|
140
135
|
clientCommitId,
|
|
141
136
|
]);
|
|
142
137
|
db.exec(
|
|
143
|
-
'DELETE FROM _syncular_outbox_before_images WHERE client_commit_id
|
|
138
|
+
'DELETE FROM _syncular_outbox_before_images WHERE client_commit_id=?',
|
|
144
139
|
[clientCommitId],
|
|
145
140
|
);
|
|
146
|
-
db.exec('DELETE FROM _syncular_outbox WHERE client_commit_id
|
|
141
|
+
db.exec('DELETE FROM _syncular_outbox WHERE client_commit_id=?', [
|
|
147
142
|
clientCommitId,
|
|
148
143
|
]);
|
|
149
144
|
}
|
|
@@ -154,9 +149,7 @@ export function listOutboxBeforeImages(
|
|
|
154
149
|
): OutboxBeforeImage[] {
|
|
155
150
|
return db
|
|
156
151
|
.query(
|
|
157
|
-
|
|
158
|
-
FROM _syncular_outbox_before_images
|
|
159
|
-
WHERE client_commit_id = ? ORDER BY op_index`,
|
|
152
|
+
'SELECT op_index,existed,sync_version,values_json FROM _syncular_outbox_before_images WHERE client_commit_id=? ORDER BY op_index',
|
|
160
153
|
[clientCommitId],
|
|
161
154
|
)
|
|
162
155
|
.map((row) => ({
|
|
@@ -182,14 +175,11 @@ 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 = ?`,
|
|
178
|
+
'DELETE FROM _syncular_outbox_before_images WHERE client_commit_id=? AND op_index=?',
|
|
187
179
|
[clientCommitId, image.opIndex],
|
|
188
180
|
);
|
|
189
181
|
db.exec(
|
|
190
|
-
|
|
191
|
-
client_commit_id, op_index, existed, sync_version, values_json
|
|
192
|
-
) VALUES (?, ?, ?, ?, ?)`,
|
|
182
|
+
'INSERT INTO _syncular_outbox_before_images(client_commit_id,op_index,existed,sync_version,values_json)VALUES(?,?,?,?,?)',
|
|
193
183
|
[
|
|
194
184
|
clientCommitId,
|
|
195
185
|
image.opIndex,
|
package/src/outcomes.ts
CHANGED
|
@@ -10,7 +10,12 @@ import type { RejectionDetails, RowValue } from '@syncular/core';
|
|
|
10
10
|
import type { ClientDatabase } from './database';
|
|
11
11
|
import { ClientSyncError } from './errors';
|
|
12
12
|
import type { OutboxOperation } from './outbox';
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
mapRowValues,
|
|
15
|
+
type JsonRowValue,
|
|
16
|
+
jsonToRowValue,
|
|
17
|
+
rowValueToJson,
|
|
18
|
+
} from './schema';
|
|
14
19
|
|
|
15
20
|
export interface ConflictRecord {
|
|
16
21
|
readonly clientCommitId: string;
|
|
@@ -70,6 +75,14 @@ export type CommitOperationOutcome =
|
|
|
70
75
|
};
|
|
71
76
|
|
|
72
77
|
export interface RetainedCommitRow {
|
|
78
|
+
/** Authorized different-primary-key server rows matching intended unique keys. */
|
|
79
|
+
readonly uniqueConflicts?: readonly {
|
|
80
|
+
readonly index: string;
|
|
81
|
+
readonly columns: readonly string[];
|
|
82
|
+
readonly rowId: string;
|
|
83
|
+
readonly serverRow: Readonly<Record<string, RowValue>>;
|
|
84
|
+
readonly serverVersion: number;
|
|
85
|
+
}[];
|
|
73
86
|
readonly table: string;
|
|
74
87
|
readonly rowId: string;
|
|
75
88
|
/** Complete intended row, or null for a local deletion. */
|
|
@@ -132,12 +145,7 @@ function encodeResults(results: readonly CommitOperationOutcome[]): string {
|
|
|
132
145
|
status: 'conflict',
|
|
133
146
|
conflict: {
|
|
134
147
|
...result.conflict,
|
|
135
|
-
serverRow:
|
|
136
|
-
Object.entries(result.conflict.serverRow).map(([key, value]) => [
|
|
137
|
-
key,
|
|
138
|
-
rowValueToJson(value),
|
|
139
|
-
]),
|
|
140
|
-
),
|
|
148
|
+
serverRow: mapRowValues(result.conflict.serverRow, rowValueToJson),
|
|
141
149
|
},
|
|
142
150
|
};
|
|
143
151
|
});
|
|
@@ -154,12 +162,7 @@ function decodeResults(raw: string): CommitOperationOutcome[] {
|
|
|
154
162
|
...result.conflict,
|
|
155
163
|
// Pre-column-version journal entries carry no conflictColumns.
|
|
156
164
|
conflictColumns: result.conflict.conflictColumns ?? [],
|
|
157
|
-
serverRow:
|
|
158
|
-
Object.entries(result.conflict.serverRow).map(([key, value]) => [
|
|
159
|
-
key,
|
|
160
|
-
jsonToRowValue(value),
|
|
161
|
-
]),
|
|
162
|
-
),
|
|
165
|
+
serverRow: mapRowValues(result.conflict.serverRow, jsonToRowValue),
|
|
163
166
|
},
|
|
164
167
|
};
|
|
165
168
|
});
|
|
@@ -171,7 +174,7 @@ function parseOutcome(
|
|
|
171
174
|
): CommitOutcome {
|
|
172
175
|
const retainedRows: RetainedCommitRow[] = db
|
|
173
176
|
.query(
|
|
174
|
-
'SELECT tbl,
|
|
177
|
+
'SELECT tbl,id,intent,base,version,unique_conflicts FROM _syncular_failed_rows WHERE commit_id=? ORDER BY idx',
|
|
175
178
|
[String(row.client_commit_id)],
|
|
176
179
|
)
|
|
177
180
|
.map((retained) => {
|
|
@@ -187,6 +190,18 @@ function parseOutcome(
|
|
|
187
190
|
table: String(retained.tbl),
|
|
188
191
|
rowId: String(retained.id),
|
|
189
192
|
localRow: decode(retained.intent),
|
|
193
|
+
...(retained.unique_conflicts === null
|
|
194
|
+
? {}
|
|
195
|
+
: {
|
|
196
|
+
uniqueConflicts: (
|
|
197
|
+
JSON.parse(String(retained.unique_conflicts)) as NonNullable<
|
|
198
|
+
RetainedCommitRow['uniqueConflicts']
|
|
199
|
+
>
|
|
200
|
+
).map((conflict) => ({
|
|
201
|
+
...conflict,
|
|
202
|
+
serverRow: decode(JSON.stringify(conflict.serverRow))!,
|
|
203
|
+
})),
|
|
204
|
+
}),
|
|
190
205
|
serverRow: decode(retained.base),
|
|
191
206
|
serverVersion:
|
|
192
207
|
retained.version === null ? null : Number(retained.version),
|
|
@@ -217,9 +232,7 @@ export function recordCommitOutcome(
|
|
|
217
232
|
outcome: Omit<CommitOutcome, 'sequence' | 'resolution'>,
|
|
218
233
|
): CommitOutcome {
|
|
219
234
|
db.exec(
|
|
220
|
-
|
|
221
|
-
client_commit_id, status, recorded_at_ms, results, operations, resolution
|
|
222
|
-
) VALUES (?, ?, ?, ?, ?, 'active')`,
|
|
235
|
+
"INSERT INTO _syncular_commit_outcomes(client_commit_id,status,recorded_at_ms,results,operations,resolution)VALUES(?,?,?,?,?,'active')",
|
|
223
236
|
[
|
|
224
237
|
outcome.clientCommitId,
|
|
225
238
|
outcome.status,
|
|
@@ -238,9 +251,7 @@ export function commitOutcome(
|
|
|
238
251
|
clientCommitId: string,
|
|
239
252
|
): CommitOutcome | undefined {
|
|
240
253
|
const row = db.query(
|
|
241
|
-
|
|
242
|
-
resolution, resolved_at_ms, replacement_client_commit_id
|
|
243
|
-
FROM _syncular_commit_outcomes WHERE client_commit_id = ?`,
|
|
254
|
+
'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
255
|
[clientCommitId],
|
|
245
256
|
)[0];
|
|
246
257
|
return row === undefined ? undefined : parseOutcome(db, row);
|
|
@@ -261,10 +272,7 @@ export function listCommitOutcomes(
|
|
|
261
272
|
? "WHERE resolution = 'active' AND status IN ('conflict', 'rejected')"
|
|
262
273
|
: '';
|
|
263
274
|
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 ?'}`,
|
|
275
|
+
`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
276
|
limit === undefined ? [] : [limit],
|
|
269
277
|
);
|
|
270
278
|
return rows.map((row) => parseOutcome(db, row));
|
|
@@ -276,9 +284,7 @@ export function persistCommitOutcomeResolution(
|
|
|
276
284
|
nowMs: number,
|
|
277
285
|
): CommitOutcome | undefined {
|
|
278
286
|
db.exec(
|
|
279
|
-
|
|
280
|
-
SET resolution = ?, resolved_at_ms = ?, replacement_client_commit_id = ?
|
|
281
|
-
WHERE client_commit_id = ? AND resolution = 'active'`,
|
|
287
|
+
"UPDATE _syncular_commit_outcomes SET resolution=?,resolved_at_ms=?,replacement_client_commit_id=? WHERE client_commit_id=? AND resolution='active'",
|
|
282
288
|
[
|
|
283
289
|
input.resolution,
|
|
284
290
|
nowMs,
|
|
@@ -309,13 +315,11 @@ export function pruneCommitOutcomes(
|
|
|
309
315
|
const excess = Math.max(0, (count ?? 0) - maxEntries);
|
|
310
316
|
if (excess === 0) return 0;
|
|
311
317
|
const candidates = db.query(
|
|
312
|
-
|
|
313
|
-
WHERE status IN ('applied', 'cached') OR resolution != 'active'
|
|
314
|
-
ORDER BY seq ASC LIMIT ?`,
|
|
318
|
+
"SELECT seq FROM _syncular_commit_outcomes WHERE status IN('applied','cached')OR resolution !='active' ORDER BY seq ASC LIMIT ?",
|
|
315
319
|
[excess],
|
|
316
320
|
);
|
|
317
321
|
for (const candidate of candidates) {
|
|
318
|
-
db.exec('DELETE FROM _syncular_commit_outcomes WHERE seq
|
|
322
|
+
db.exec('DELETE FROM _syncular_commit_outcomes WHERE seq=?', [
|
|
319
323
|
candidate.seq as number,
|
|
320
324
|
]);
|
|
321
325
|
}
|
package/src/previous-version.ts
CHANGED
|
@@ -453,12 +453,12 @@ export function readPreviousVersionContainer(
|
|
|
453
453
|
db: ClientDatabase,
|
|
454
454
|
): PreviousVersionRecord | undefined {
|
|
455
455
|
const meta = db.query(
|
|
456
|
-
"SELECT 1 AS present FROM sqlite_master WHERE type
|
|
456
|
+
"SELECT 1 AS present FROM sqlite_master WHERE type='table' AND name=?",
|
|
457
457
|
[CONTAINER_META_TABLE],
|
|
458
458
|
);
|
|
459
459
|
if (meta.length === 0) return undefined;
|
|
460
460
|
const raw = db.query(
|
|
461
|
-
`SELECT record FROM ${quoteIdent(CONTAINER_META_TABLE)} WHERE id
|
|
461
|
+
`SELECT record FROM ${quoteIdent(CONTAINER_META_TABLE)} WHERE id=1`,
|
|
462
462
|
)[0]?.record;
|
|
463
463
|
if (raw === undefined) return undefined;
|
|
464
464
|
try {
|
|
@@ -504,13 +504,11 @@ function decodeRecord(value: string): PreviousVersionRecord {
|
|
|
504
504
|
|
|
505
505
|
function writeRecord(db: ClientDatabase, record: PreviousVersionRecord): void {
|
|
506
506
|
db.exec(
|
|
507
|
-
`CREATE TABLE IF NOT EXISTS ${quoteIdent(CONTAINER_META_TABLE)} (
|
|
508
|
-
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
509
|
-
record TEXT NOT NULL)`,
|
|
507
|
+
`CREATE TABLE IF NOT EXISTS ${quoteIdent(CONTAINER_META_TABLE)}(id INTEGER PRIMARY KEY CHECK(id=1),record TEXT NOT NULL)`,
|
|
510
508
|
);
|
|
511
509
|
db.exec(`DELETE FROM ${quoteIdent(CONTAINER_META_TABLE)}`);
|
|
512
510
|
db.exec(
|
|
513
|
-
`INSERT INTO ${quoteIdent(CONTAINER_META_TABLE)}(id,
|
|
511
|
+
`INSERT INTO ${quoteIdent(CONTAINER_META_TABLE)}(id,record)VALUES(1,?)`,
|
|
514
512
|
[JSON.stringify(record)],
|
|
515
513
|
);
|
|
516
514
|
}
|
|
@@ -541,9 +539,7 @@ export function capturePreviousVersion(
|
|
|
541
539
|
const discovered = new Set(
|
|
542
540
|
replica
|
|
543
541
|
.query(
|
|
544
|
-
|
|
545
|
-
AND name NOT LIKE '_syncular_%'
|
|
546
|
-
AND name NOT LIKE 'sqlite_%'`,
|
|
542
|
+
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE '_syncular_%' AND name NOT LIKE 'sqlite_%'",
|
|
547
543
|
)
|
|
548
544
|
.map((row) => String(row.name)),
|
|
549
545
|
);
|
|
@@ -567,7 +563,7 @@ export function capturePreviousVersion(
|
|
|
567
563
|
for (const table of tables) {
|
|
568
564
|
const probeRows = Number(
|
|
569
565
|
replica.query(
|
|
570
|
-
`SELECT COUNT(*)
|
|
566
|
+
`SELECT COUNT(*)AS count FROM(SELECT 1 FROM ${quoteIdent(table.name)} LIMIT ?)`,
|
|
571
567
|
[config.maxRows + 1],
|
|
572
568
|
)[0]?.count ?? 0,
|
|
573
569
|
);
|
|
@@ -584,15 +580,12 @@ export function capturePreviousVersion(
|
|
|
584
580
|
.join(' + ');
|
|
585
581
|
if (table.columns.length === 0) continue;
|
|
586
582
|
const oversized = replica.query(
|
|
587
|
-
`SELECT 1 AS hit FROM ${quoteIdent(table.name)}
|
|
588
|
-
WHERE (${byteSum}) > ? LIMIT 1`,
|
|
583
|
+
`SELECT 1 AS hit FROM ${quoteIdent(table.name)} WHERE(${byteSum})>? LIMIT 1`,
|
|
589
584
|
[config.maxRowBytes],
|
|
590
585
|
);
|
|
591
586
|
if (oversized.length > 0) return over(measuredRows, measuredBytes);
|
|
592
587
|
const total = replica.query(
|
|
593
|
-
`SELECT COALESCE(SUM(bytes),
|
|
594
|
-
SELECT (${byteSum}) AS bytes FROM ${quoteIdent(table.name)} LIMIT ?
|
|
595
|
-
)`,
|
|
588
|
+
`SELECT COALESCE(SUM(bytes),0)AS total FROM(SELECT(${byteSum})AS bytes FROM ${quoteIdent(table.name)} LIMIT ?)`,
|
|
596
589
|
[perTableRows[index] ?? 0],
|
|
597
590
|
)[0]?.total;
|
|
598
591
|
measuredBytes += Number(total ?? 0);
|
|
@@ -604,11 +597,7 @@ export function capturePreviousVersion(
|
|
|
604
597
|
container.transaction(() => {
|
|
605
598
|
dropPreviousVersionContainer(container);
|
|
606
599
|
container.exec(
|
|
607
|
-
`CREATE TABLE ${quoteIdent(CONTAINER_TABLE)} (
|
|
608
|
-
tbl TEXT NOT NULL,
|
|
609
|
-
row_id TEXT NOT NULL,
|
|
610
|
-
payload TEXT NOT NULL,
|
|
611
|
-
PRIMARY KEY (tbl, row_id))`,
|
|
600
|
+
`CREATE TABLE ${quoteIdent(CONTAINER_TABLE)}(tbl TEXT NOT NULL,row_id TEXT NOT NULL,payload TEXT NOT NULL,PRIMARY KEY(tbl,row_id))`,
|
|
612
601
|
);
|
|
613
602
|
for (const table of tables) {
|
|
614
603
|
const selectColumns = table.columns
|
|
@@ -617,8 +606,7 @@ export function capturePreviousVersion(
|
|
|
617
606
|
let lastRowId = -1;
|
|
618
607
|
for (;;) {
|
|
619
608
|
const rows = replica.query(
|
|
620
|
-
`SELECT rowid AS _rid
|
|
621
|
-
WHERE rowid > ? ORDER BY rowid ASC LIMIT ?`,
|
|
609
|
+
`SELECT rowid AS _rid,${selectColumns} FROM ${quoteIdent(table.name)} WHERE rowid>? ORDER BY rowid ASC LIMIT ?`,
|
|
622
610
|
[lastRowId, COPY_BATCH_ROWS],
|
|
623
611
|
);
|
|
624
612
|
if (rows.length === 0) break;
|
|
@@ -632,8 +620,7 @@ export function capturePreviousVersion(
|
|
|
632
620
|
payload[column.name] = rowValueToJson(value);
|
|
633
621
|
}
|
|
634
622
|
container.exec(
|
|
635
|
-
`INSERT INTO ${quoteIdent(CONTAINER_TABLE)}(tbl,
|
|
636
|
-
VALUES (?, ?, ?)`,
|
|
623
|
+
`INSERT INTO ${quoteIdent(CONTAINER_TABLE)}(tbl,row_id,payload)VALUES(?,?,?)`,
|
|
637
624
|
[table.name, rowIdText(table, row), JSON.stringify(payload)],
|
|
638
625
|
);
|
|
639
626
|
}
|
|
@@ -703,8 +690,7 @@ export function readPreviousVersionRows(
|
|
|
703
690
|
? ''
|
|
704
691
|
: ` AND row_id IN (${rowIds.map(() => '?').join(', ')})`;
|
|
705
692
|
const rows = db.query(
|
|
706
|
-
`SELECT row_id,
|
|
707
|
-
WHERE tbl = ?${where} ORDER BY row_id ASC LIMIT ?`,
|
|
693
|
+
`SELECT row_id,payload FROM ${quoteIdent(CONTAINER_TABLE)} WHERE tbl=?${where} ORDER BY row_id ASC LIMIT ?`,
|
|
708
694
|
[table.name, ...rowIds, limit + 1],
|
|
709
695
|
);
|
|
710
696
|
const truncated = rows.length > limit;
|
|
@@ -738,7 +724,7 @@ export function writePreviousVersionRefusal(
|
|
|
738
724
|
|
|
739
725
|
/** Delete the durable refusal. Part of the orphan sweep and of a successful capture. */
|
|
740
726
|
export function clearPreviousVersionRefusal(db: ClientDatabase): void {
|
|
741
|
-
db.exec('DELETE FROM _syncular_meta WHERE key
|
|
727
|
+
db.exec('DELETE FROM _syncular_meta WHERE key=?', [
|
|
742
728
|
PREVIOUS_VERSION_CONTEXT_KEY,
|
|
743
729
|
]);
|
|
744
730
|
}
|
|
@@ -906,7 +892,7 @@ export function writePreviousVersionAudit(
|
|
|
906
892
|
|
|
907
893
|
/** Delete the advisory audit. Part of a discard, never of a normal bump. */
|
|
908
894
|
export function clearPreviousVersionAudit(db: ClientDatabase): void {
|
|
909
|
-
db.exec('DELETE FROM _syncular_meta WHERE key
|
|
895
|
+
db.exec('DELETE FROM _syncular_meta WHERE key=?', [
|
|
910
896
|
PREVIOUS_VERSION_AUDIT_KEY,
|
|
911
897
|
]);
|
|
912
898
|
}
|