@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/dist/failed-overlay.js
CHANGED
|
@@ -1,21 +1,46 @@
|
|
|
1
1
|
import { ClientSyncError } from './errors.js';
|
|
2
|
-
import { jsonToRowValue, quoteIdent } from './schema.js';
|
|
2
|
+
import { mapRowValues, fromSqlValue, jsonToRowValue, quoteIdent, rowValueToJson, SYNC_VERSION_COLUMN, toSqlValue, } from './schema.js';
|
|
3
3
|
import { deleteLocalRow, upsertLocalRow } from './apply.js';
|
|
4
4
|
/** Protected failure state. The server base advances independently of intent. */
|
|
5
5
|
export function ensureFailedOverlaySchema(db) {
|
|
6
|
-
db.exec(
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
6
|
+
db.exec('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))');
|
|
7
|
+
if (!db
|
|
8
|
+
.query('PRAGMA table_info(_syncular_failed_rows)')
|
|
9
|
+
.some((row) => row.name === 'unique_conflicts'))
|
|
10
|
+
db.exec('ALTER TABLE _syncular_failed_rows ADD COLUMN unique_conflicts TEXT');
|
|
11
11
|
}
|
|
12
12
|
export function retainedBaseWrite(db, table, rowId, values, version) {
|
|
13
|
-
db.exec('UPDATE _syncular_failed_rows SET base
|
|
13
|
+
db.exec('UPDATE _syncular_failed_rows SET base=?,version=? WHERE tbl=? AND id=?', [
|
|
14
14
|
values === undefined ? null : JSON.stringify(values),
|
|
15
15
|
version ?? null,
|
|
16
|
-
table,
|
|
16
|
+
table.name,
|
|
17
17
|
rowId,
|
|
18
18
|
]);
|
|
19
|
+
if (!table.indexes.some((index) => index.unique))
|
|
20
|
+
return;
|
|
21
|
+
for (const retained of db.query('SELECT commit_id,idx,intent,unique_conflicts FROM _syncular_failed_rows WHERE tbl=?', [table.name])) {
|
|
22
|
+
const prior = retained.unique_conflicts === null
|
|
23
|
+
? []
|
|
24
|
+
: JSON.parse(String(retained.unique_conflicts));
|
|
25
|
+
const conflicts = prior.filter((conflict) => conflict.rowId !== rowId);
|
|
26
|
+
if (values !== undefined && retained.intent !== null) {
|
|
27
|
+
const intent = JSON.parse(String(retained.intent));
|
|
28
|
+
conflicts.push(...uniqueConflicts(db, table, mapRowValues(intent, jsonToRowValue))
|
|
29
|
+
.filter((conflict) => conflict.rowId === rowId)
|
|
30
|
+
.map((conflict) => ({
|
|
31
|
+
...conflict,
|
|
32
|
+
serverRow: mapRowValues(conflict.serverRow, rowValueToJson),
|
|
33
|
+
})));
|
|
34
|
+
}
|
|
35
|
+
db.exec('UPDATE _syncular_failed_rows SET unique_conflicts=? WHERE commit_id=? AND idx=?', [
|
|
36
|
+
conflicts.length
|
|
37
|
+
? JSON.stringify(conflicts.sort((a, b) => table.indexes.findIndex((index) => index.name === a.index) -
|
|
38
|
+
table.indexes.findIndex((index) => index.name === b.index)))
|
|
39
|
+
: null,
|
|
40
|
+
String(retained.commit_id),
|
|
41
|
+
Number(retained.idx),
|
|
42
|
+
]);
|
|
43
|
+
}
|
|
19
44
|
}
|
|
20
45
|
export function retainFailedRows(db, commit, images) {
|
|
21
46
|
for (const [index, operation] of commit.operations.entries()) {
|
|
@@ -25,8 +50,7 @@ export function retainFailedRows(db, commit, images) {
|
|
|
25
50
|
: undefined;
|
|
26
51
|
if (!image)
|
|
27
52
|
throw new ClientSyncError('sync.local_corrupt', 'retained intent has no before-image');
|
|
28
|
-
db.exec(
|
|
29
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
|
|
53
|
+
db.exec('INSERT INTO _syncular_failed_rows(commit_id,idx,tbl,id,at,op,intent,base,version)VALUES(?,?,?,?,?,?,?,?,?)', [
|
|
30
54
|
commit.clientCommitId,
|
|
31
55
|
index,
|
|
32
56
|
operation.table,
|
|
@@ -39,9 +63,29 @@ export function retainFailedRows(db, commit, images) {
|
|
|
39
63
|
]);
|
|
40
64
|
}
|
|
41
65
|
}
|
|
66
|
+
/** Match declared keys through SQLite, preserving index affinity and NULL semantics. */
|
|
67
|
+
export function uniqueConflicts(db, table, values) {
|
|
68
|
+
return table.indexes
|
|
69
|
+
.filter((index) => index.unique)
|
|
70
|
+
.flatMap((index) => db
|
|
71
|
+
.query(`SELECT * FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)} !=? AND ${index.columns.map((column) => `${quoteIdent(column)} = ?`).join(' AND ')}`, [
|
|
72
|
+
toSqlValue(values[table.primaryKey] ?? null),
|
|
73
|
+
...index.columns.map((column) => toSqlValue(values[column] ?? null)),
|
|
74
|
+
])
|
|
75
|
+
.map((row) => ({
|
|
76
|
+
index: index.name,
|
|
77
|
+
columns: index.columns,
|
|
78
|
+
rowId: String(row[table.primaryKey]),
|
|
79
|
+
serverRow: Object.fromEntries(table.columns.map((column) => [
|
|
80
|
+
column.name,
|
|
81
|
+
fromSqlValue(column, row[column.name] ?? null),
|
|
82
|
+
])),
|
|
83
|
+
serverVersion: Number(row[SYNC_VERSION_COLUMN]),
|
|
84
|
+
})));
|
|
85
|
+
}
|
|
42
86
|
export function restoreFailedBases(db, schema, absentIntent = false) {
|
|
43
87
|
const seen = new Set();
|
|
44
|
-
for (const row of db.query('SELECT * FROM _syncular_failed_rows ORDER BY at,
|
|
88
|
+
for (const row of db.query('SELECT * FROM _syncular_failed_rows ORDER BY at,commit_id,idx')) {
|
|
45
89
|
const key = JSON.stringify([row.tbl, row.id]);
|
|
46
90
|
if (!absentIntent && seen.has(key))
|
|
47
91
|
continue;
|
|
@@ -50,9 +94,30 @@ export function restoreFailedBases(db, schema, absentIntent = false) {
|
|
|
50
94
|
if (!table)
|
|
51
95
|
throw new ClientSyncError('sync.unknown_table', 'retained intent references an unknown table');
|
|
52
96
|
const json = absentIntent ? row.intent : row.base;
|
|
97
|
+
if (absentIntent && row.intent !== null) {
|
|
98
|
+
const values = JSON.parse(String(row.intent));
|
|
99
|
+
const conflicts = uniqueConflicts(db, table, mapRowValues(values, jsonToRowValue));
|
|
100
|
+
// Hydrate a pre-0.30.11 failure after its winner was already imported.
|
|
101
|
+
// Pending and retained overlays cannot supply authoritative row evidence.
|
|
102
|
+
if (row.unique_conflicts === null) {
|
|
103
|
+
const authorized = conflicts.filter((conflict) => conflict.serverVersion >= 0 &&
|
|
104
|
+
!db.query("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", [table.name, conflict.rowId, table.name, conflict.rowId]).length);
|
|
105
|
+
if (authorized.length)
|
|
106
|
+
db.exec('UPDATE _syncular_failed_rows SET unique_conflicts=? WHERE commit_id=? AND idx=?', [
|
|
107
|
+
JSON.stringify(authorized.map((conflict) => ({
|
|
108
|
+
...conflict,
|
|
109
|
+
serverRow: mapRowValues(conflict.serverRow, rowValueToJson),
|
|
110
|
+
}))),
|
|
111
|
+
String(row.commit_id),
|
|
112
|
+
Number(row.idx),
|
|
113
|
+
]);
|
|
114
|
+
}
|
|
115
|
+
if (conflicts.length)
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
53
118
|
if (absentIntent &&
|
|
54
119
|
(json === null ||
|
|
55
|
-
db.query(`SELECT 1 FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)}
|
|
120
|
+
db.query(`SELECT 1 FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)}=?`, [String(row.id)]).length))
|
|
56
121
|
continue;
|
|
57
122
|
if (json === null)
|
|
58
123
|
deleteLocalRow(db, table, String(row.id), false);
|
|
@@ -69,7 +134,7 @@ export function restoreFailedBases(db, schema, absentIntent = false) {
|
|
|
69
134
|
}
|
|
70
135
|
export function failedOverlayCommits(db) {
|
|
71
136
|
const commits = new Map();
|
|
72
|
-
for (const row of db.query('SELECT * FROM _syncular_failed_rows ORDER BY at,
|
|
137
|
+
for (const row of db.query('SELECT * FROM _syncular_failed_rows ORDER BY at,commit_id,idx')) {
|
|
73
138
|
const id = String(row.commit_id);
|
|
74
139
|
const operation = JSON.parse(String(row.op));
|
|
75
140
|
const prior = commits.get(id);
|
|
@@ -96,7 +161,9 @@ export function dropFailedRows(db, matches) {
|
|
|
96
161
|
ids.add(String(row.commit_id));
|
|
97
162
|
}
|
|
98
163
|
const dropped = failedOverlayCommits(db).filter((commit) => ids.has(commit.clientCommitId));
|
|
99
|
-
for (const id of ids)
|
|
100
|
-
db.exec('DELETE FROM _syncular_failed_rows WHERE commit_id
|
|
164
|
+
for (const id of ids) {
|
|
165
|
+
db.exec('DELETE FROM _syncular_failed_rows WHERE commit_id=?', [id]);
|
|
166
|
+
db.exec("UPDATE _syncular_commit_outcomes SET operations=NULL,results='[]' WHERE client_commit_id=?", [id]);
|
|
167
|
+
}
|
|
101
168
|
return dropped;
|
|
102
169
|
}
|
package/dist/outbox.js
CHANGED
|
@@ -12,12 +12,9 @@ export function appendOutboxCommit(db, clientCommitId, operations, nowMs, before
|
|
|
12
12
|
if (operations.length === 0) {
|
|
13
13
|
throw new ClientSyncError('sync.empty_commit', 'a local commit must carry at least one operation (§6.1)');
|
|
14
14
|
}
|
|
15
|
-
db.exec(
|
|
16
|
-
VALUES (?, ?, ?)`, [clientCommitId, nowMs, JSON.stringify(operations)]);
|
|
15
|
+
db.exec('INSERT INTO _syncular_outbox(client_commit_id,created_at_ms,operations)VALUES(?,?,?)', [clientCommitId, nowMs, JSON.stringify(operations)]);
|
|
17
16
|
for (const image of beforeImages) {
|
|
18
|
-
db.exec(
|
|
19
|
-
client_commit_id, op_index, existed, sync_version, values_json
|
|
20
|
-
) VALUES (?, ?, ?, ?, ?)`, [
|
|
17
|
+
db.exec('INSERT INTO _syncular_outbox_before_images(client_commit_id,op_index,existed,sync_version,values_json)VALUES(?,?,?,?,?)', [
|
|
21
18
|
clientCommitId,
|
|
22
19
|
image.opIndex,
|
|
23
20
|
image.existed ? 1 : 0,
|
|
@@ -29,8 +26,7 @@ export function appendOutboxCommit(db, clientCommitId, operations, nowMs, before
|
|
|
29
26
|
/** Pending commits in FIFO creation order (§7.1). Full reads serve replay and the public listing. */
|
|
30
27
|
export function listOutbox(db) {
|
|
31
28
|
return db
|
|
32
|
-
.query(
|
|
33
|
-
FROM _syncular_outbox ORDER BY seq ASC`)
|
|
29
|
+
.query('SELECT seq, client_commit_id, created_at_ms, operations FROM _syncular_outbox ORDER BY seq ASC')
|
|
34
30
|
.map(decodeOutboxRow);
|
|
35
31
|
}
|
|
36
32
|
function decodeOutboxRow(row) {
|
|
@@ -45,8 +41,7 @@ function decodeOutboxRow(row) {
|
|
|
45
41
|
export function* iterateOutbox(db, throughSeq) {
|
|
46
42
|
let afterSeq = 0;
|
|
47
43
|
while (afterSeq < throughSeq) {
|
|
48
|
-
const rows = db.query(
|
|
49
|
-
WHERE seq > ? AND seq <= ? ORDER BY seq ASC LIMIT 32`, [afterSeq, throughSeq]);
|
|
44
|
+
const rows = db.query('SELECT seq, client_commit_id, created_at_ms, operations FROM _syncular_outbox WHERE seq > ? AND seq <= ? ORDER BY seq ASC LIMIT 32', [afterSeq, throughSeq]);
|
|
50
45
|
if (rows.length === 0)
|
|
51
46
|
return;
|
|
52
47
|
for (const row of rows) {
|
|
@@ -58,23 +53,21 @@ export function* iterateOutbox(db, throughSeq) {
|
|
|
58
53
|
}
|
|
59
54
|
/** Routine status reads never load operation bodies. */
|
|
60
55
|
export function countOutbox(db) {
|
|
61
|
-
return db.query('SELECT COUNT(*)
|
|
56
|
+
return db.query('SELECT COUNT(*)AS count FROM _syncular_outbox')[0]
|
|
62
57
|
.count;
|
|
63
58
|
}
|
|
64
59
|
export function deleteOutboxCommit(db, clientCommitId) {
|
|
65
|
-
db.exec('DELETE FROM _syncular_blob_commit_refs WHERE commit_id
|
|
60
|
+
db.exec('DELETE FROM _syncular_blob_commit_refs WHERE commit_id=?', [
|
|
66
61
|
clientCommitId,
|
|
67
62
|
]);
|
|
68
|
-
db.exec('DELETE FROM _syncular_outbox_before_images WHERE client_commit_id
|
|
69
|
-
db.exec('DELETE FROM _syncular_outbox WHERE client_commit_id
|
|
63
|
+
db.exec('DELETE FROM _syncular_outbox_before_images WHERE client_commit_id=?', [clientCommitId]);
|
|
64
|
+
db.exec('DELETE FROM _syncular_outbox WHERE client_commit_id=?', [
|
|
70
65
|
clientCommitId,
|
|
71
66
|
]);
|
|
72
67
|
}
|
|
73
68
|
export function listOutboxBeforeImages(db, clientCommitId) {
|
|
74
69
|
return db
|
|
75
|
-
.query(
|
|
76
|
-
FROM _syncular_outbox_before_images
|
|
77
|
-
WHERE client_commit_id = ? ORDER BY op_index`, [clientCommitId])
|
|
70
|
+
.query('SELECT op_index,existed,sync_version,values_json FROM _syncular_outbox_before_images WHERE client_commit_id=? ORDER BY op_index', [clientCommitId])
|
|
78
71
|
.map((row) => ({
|
|
79
72
|
opIndex: row.op_index,
|
|
80
73
|
existed: row.existed === 1,
|
|
@@ -90,11 +83,8 @@ export function listOutboxBeforeImages(db, clientCommitId) {
|
|
|
90
83
|
}
|
|
91
84
|
export function replaceOutboxBeforeImages(db, clientCommitId, replacements) {
|
|
92
85
|
for (const image of replacements) {
|
|
93
|
-
db.exec(
|
|
94
|
-
|
|
95
|
-
db.exec(`INSERT INTO _syncular_outbox_before_images(
|
|
96
|
-
client_commit_id, op_index, existed, sync_version, values_json
|
|
97
|
-
) VALUES (?, ?, ?, ?, ?)`, [
|
|
86
|
+
db.exec('DELETE FROM _syncular_outbox_before_images WHERE client_commit_id=? AND op_index=?', [clientCommitId, image.opIndex]);
|
|
87
|
+
db.exec('INSERT INTO _syncular_outbox_before_images(client_commit_id,op_index,existed,sync_version,values_json)VALUES(?,?,?,?,?)', [
|
|
98
88
|
clientCommitId,
|
|
99
89
|
image.opIndex,
|
|
100
90
|
image.existed ? 1 : 0,
|
package/dist/outcomes.d.ts
CHANGED
|
@@ -51,6 +51,14 @@ export type CommitOperationOutcome = {
|
|
|
51
51
|
readonly rejection: RejectionRecord;
|
|
52
52
|
};
|
|
53
53
|
export interface RetainedCommitRow {
|
|
54
|
+
/** Authorized different-primary-key server rows matching intended unique keys. */
|
|
55
|
+
readonly uniqueConflicts?: readonly {
|
|
56
|
+
readonly index: string;
|
|
57
|
+
readonly columns: readonly string[];
|
|
58
|
+
readonly rowId: string;
|
|
59
|
+
readonly serverRow: Readonly<Record<string, RowValue>>;
|
|
60
|
+
readonly serverVersion: number;
|
|
61
|
+
}[];
|
|
54
62
|
readonly table: string;
|
|
55
63
|
readonly rowId: string;
|
|
56
64
|
/** Complete intended row, or null for a local deletion. */
|
package/dist/outcomes.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { ClientSyncError } from './errors.js';
|
|
2
|
-
import { jsonToRowValue, rowValueToJson } from './schema.js';
|
|
2
|
+
import { mapRowValues, jsonToRowValue, rowValueToJson, } from './schema.js';
|
|
3
3
|
function encodeResults(results) {
|
|
4
4
|
const stored = results.map((result) => {
|
|
5
5
|
if (result.status !== 'conflict')
|
|
@@ -8,10 +8,7 @@ function encodeResults(results) {
|
|
|
8
8
|
status: 'conflict',
|
|
9
9
|
conflict: {
|
|
10
10
|
...result.conflict,
|
|
11
|
-
serverRow:
|
|
12
|
-
key,
|
|
13
|
-
rowValueToJson(value),
|
|
14
|
-
])),
|
|
11
|
+
serverRow: mapRowValues(result.conflict.serverRow, rowValueToJson),
|
|
15
12
|
},
|
|
16
13
|
};
|
|
17
14
|
});
|
|
@@ -28,17 +25,14 @@ function decodeResults(raw) {
|
|
|
28
25
|
...result.conflict,
|
|
29
26
|
// Pre-column-version journal entries carry no conflictColumns.
|
|
30
27
|
conflictColumns: result.conflict.conflictColumns ?? [],
|
|
31
|
-
serverRow:
|
|
32
|
-
key,
|
|
33
|
-
jsonToRowValue(value),
|
|
34
|
-
])),
|
|
28
|
+
serverRow: mapRowValues(result.conflict.serverRow, jsonToRowValue),
|
|
35
29
|
},
|
|
36
30
|
};
|
|
37
31
|
});
|
|
38
32
|
}
|
|
39
33
|
function parseOutcome(db, row) {
|
|
40
34
|
const retainedRows = db
|
|
41
|
-
.query('SELECT tbl,
|
|
35
|
+
.query('SELECT tbl,id,intent,base,version,unique_conflicts FROM _syncular_failed_rows WHERE commit_id=? ORDER BY idx', [String(row.client_commit_id)])
|
|
42
36
|
.map((retained) => {
|
|
43
37
|
const decode = (value) => value === null
|
|
44
38
|
? null
|
|
@@ -47,6 +41,14 @@ function parseOutcome(db, row) {
|
|
|
47
41
|
table: String(retained.tbl),
|
|
48
42
|
rowId: String(retained.id),
|
|
49
43
|
localRow: decode(retained.intent),
|
|
44
|
+
...(retained.unique_conflicts === null
|
|
45
|
+
? {}
|
|
46
|
+
: {
|
|
47
|
+
uniqueConflicts: JSON.parse(String(retained.unique_conflicts)).map((conflict) => ({
|
|
48
|
+
...conflict,
|
|
49
|
+
serverRow: decode(JSON.stringify(conflict.serverRow)),
|
|
50
|
+
})),
|
|
51
|
+
}),
|
|
50
52
|
serverRow: decode(retained.base),
|
|
51
53
|
serverVersion: retained.version === null ? null : Number(retained.version),
|
|
52
54
|
};
|
|
@@ -71,9 +73,7 @@ function parseOutcome(db, row) {
|
|
|
71
73
|
};
|
|
72
74
|
}
|
|
73
75
|
export function recordCommitOutcome(db, outcome) {
|
|
74
|
-
db.exec(
|
|
75
|
-
client_commit_id, status, recorded_at_ms, results, operations, resolution
|
|
76
|
-
) VALUES (?, ?, ?, ?, ?, 'active')`, [
|
|
76
|
+
db.exec("INSERT INTO _syncular_commit_outcomes(client_commit_id,status,recorded_at_ms,results,operations,resolution)VALUES(?,?,?,?,?,'active')", [
|
|
77
77
|
outcome.clientCommitId,
|
|
78
78
|
outcome.status,
|
|
79
79
|
outcome.recordedAtMs,
|
|
@@ -85,9 +85,7 @@ export function recordCommitOutcome(db, outcome) {
|
|
|
85
85
|
return commitOutcome(db, outcome.clientCommitId);
|
|
86
86
|
}
|
|
87
87
|
export function commitOutcome(db, clientCommitId) {
|
|
88
|
-
const row = db.query(
|
|
89
|
-
resolution, resolved_at_ms, replacement_client_commit_id
|
|
90
|
-
FROM _syncular_commit_outcomes WHERE client_commit_id = ?`, [clientCommitId])[0];
|
|
88
|
+
const row = db.query('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=?', [clientCommitId])[0];
|
|
91
89
|
return row === undefined ? undefined : parseOutcome(db, row);
|
|
92
90
|
}
|
|
93
91
|
export function listCommitOutcomes(db, query = {}) {
|
|
@@ -98,16 +96,11 @@ export function listCommitOutcomes(db, query = {}) {
|
|
|
98
96
|
const where = query.activeOnly
|
|
99
97
|
? "WHERE resolution = 'active' AND status IN ('conflict', 'rejected')"
|
|
100
98
|
: '';
|
|
101
|
-
const rows = db.query(`SELECT seq,
|
|
102
|
-
resolution, resolved_at_ms, replacement_client_commit_id
|
|
103
|
-
FROM _syncular_commit_outcomes ${where}
|
|
104
|
-
ORDER BY seq DESC${limit === undefined ? '' : ' LIMIT ?'}`, limit === undefined ? [] : [limit]);
|
|
99
|
+
const rows = db.query(`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 ?'}`, limit === undefined ? [] : [limit]);
|
|
105
100
|
return rows.map((row) => parseOutcome(db, row));
|
|
106
101
|
}
|
|
107
102
|
export function persistCommitOutcomeResolution(db, input, nowMs) {
|
|
108
|
-
db.exec(
|
|
109
|
-
SET resolution = ?, resolved_at_ms = ?, replacement_client_commit_id = ?
|
|
110
|
-
WHERE client_commit_id = ? AND resolution = 'active'`, [
|
|
103
|
+
db.exec("UPDATE _syncular_commit_outcomes SET resolution=?,resolved_at_ms=?,replacement_client_commit_id=? WHERE client_commit_id=? AND resolution='active'", [
|
|
111
104
|
input.resolution,
|
|
112
105
|
nowMs,
|
|
113
106
|
input.replacementClientCommitId ?? null,
|
|
@@ -127,11 +120,9 @@ export function pruneCommitOutcomes(db, maxEntries) {
|
|
|
127
120
|
const excess = Math.max(0, (count ?? 0) - maxEntries);
|
|
128
121
|
if (excess === 0)
|
|
129
122
|
return 0;
|
|
130
|
-
const candidates = db.query(
|
|
131
|
-
WHERE status IN ('applied', 'cached') OR resolution != 'active'
|
|
132
|
-
ORDER BY seq ASC LIMIT ?`, [excess]);
|
|
123
|
+
const candidates = db.query("SELECT seq FROM _syncular_commit_outcomes WHERE status IN('applied','cached')OR resolution !='active' ORDER BY seq ASC LIMIT ?", [excess]);
|
|
133
124
|
for (const candidate of candidates) {
|
|
134
|
-
db.exec('DELETE FROM _syncular_commit_outcomes WHERE seq
|
|
125
|
+
db.exec('DELETE FROM _syncular_commit_outcomes WHERE seq=?', [
|
|
135
126
|
candidate.seq,
|
|
136
127
|
]);
|
|
137
128
|
}
|
package/dist/previous-version.js
CHANGED
|
@@ -241,10 +241,10 @@ export function withPreviousVersionContainer(db, fn, removeFile = false) {
|
|
|
241
241
|
}
|
|
242
242
|
/** Read the container's own metadata record; `undefined` when absent/corrupt. */
|
|
243
243
|
export function readPreviousVersionContainer(db) {
|
|
244
|
-
const meta = db.query("SELECT 1 AS present FROM sqlite_master WHERE type
|
|
244
|
+
const meta = db.query("SELECT 1 AS present FROM sqlite_master WHERE type='table' AND name=?", [CONTAINER_META_TABLE]);
|
|
245
245
|
if (meta.length === 0)
|
|
246
246
|
return undefined;
|
|
247
|
-
const raw = db.query(`SELECT record FROM ${quoteIdent(CONTAINER_META_TABLE)} WHERE id
|
|
247
|
+
const raw = db.query(`SELECT record FROM ${quoteIdent(CONTAINER_META_TABLE)} WHERE id=1`)[0]?.record;
|
|
248
248
|
if (raw === undefined)
|
|
249
249
|
return undefined;
|
|
250
250
|
try {
|
|
@@ -287,11 +287,9 @@ function decodeRecord(value) {
|
|
|
287
287
|
};
|
|
288
288
|
}
|
|
289
289
|
function writeRecord(db, record) {
|
|
290
|
-
db.exec(`CREATE TABLE IF NOT EXISTS ${quoteIdent(CONTAINER_META_TABLE)} (
|
|
291
|
-
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
292
|
-
record TEXT NOT NULL)`);
|
|
290
|
+
db.exec(`CREATE TABLE IF NOT EXISTS ${quoteIdent(CONTAINER_META_TABLE)}(id INTEGER PRIMARY KEY CHECK(id=1),record TEXT NOT NULL)`);
|
|
293
291
|
db.exec(`DELETE FROM ${quoteIdent(CONTAINER_META_TABLE)}`);
|
|
294
|
-
db.exec(`INSERT INTO ${quoteIdent(CONTAINER_META_TABLE)}(id,
|
|
292
|
+
db.exec(`INSERT INTO ${quoteIdent(CONTAINER_META_TABLE)}(id,record)VALUES(1,?)`, [JSON.stringify(record)]);
|
|
295
293
|
}
|
|
296
294
|
// ---------------------------------------------------------------------------
|
|
297
295
|
// Bounded capture (D2 / A4)
|
|
@@ -309,9 +307,7 @@ function writeRecord(db, record) {
|
|
|
309
307
|
*/
|
|
310
308
|
export function capturePreviousVersion(replica, container, oldDescriptor, newSchema, config, nowMs) {
|
|
311
309
|
const discovered = new Set(replica
|
|
312
|
-
.query(
|
|
313
|
-
AND name NOT LIKE '_syncular_%'
|
|
314
|
-
AND name NOT LIKE 'sqlite_%'`)
|
|
310
|
+
.query("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE '_syncular_%' AND name NOT LIKE 'sqlite_%'")
|
|
315
311
|
.map((row) => String(row.name)));
|
|
316
312
|
// Only descriptor-known tables carry semantic types; those are the tables the
|
|
317
313
|
// copy would hold, and the count the table budget bounds.
|
|
@@ -326,7 +322,7 @@ export function capturePreviousVersion(replica, container, oldDescriptor, newSch
|
|
|
326
322
|
let measuredRows = 0;
|
|
327
323
|
const perTableRows = [];
|
|
328
324
|
for (const table of tables) {
|
|
329
|
-
const probeRows = Number(replica.query(`SELECT COUNT(*)
|
|
325
|
+
const probeRows = Number(replica.query(`SELECT COUNT(*)AS count FROM(SELECT 1 FROM ${quoteIdent(table.name)} LIMIT ?)`, [config.maxRows + 1])[0]?.count ?? 0);
|
|
330
326
|
measuredRows += probeRows;
|
|
331
327
|
perTableRows.push(probeRows);
|
|
332
328
|
if (measuredRows > config.maxRows)
|
|
@@ -340,13 +336,10 @@ export function capturePreviousVersion(replica, container, oldDescriptor, newSch
|
|
|
340
336
|
.join(' + ');
|
|
341
337
|
if (table.columns.length === 0)
|
|
342
338
|
continue;
|
|
343
|
-
const oversized = replica.query(`SELECT 1 AS hit FROM ${quoteIdent(table.name)}
|
|
344
|
-
WHERE (${byteSum}) > ? LIMIT 1`, [config.maxRowBytes]);
|
|
339
|
+
const oversized = replica.query(`SELECT 1 AS hit FROM ${quoteIdent(table.name)} WHERE(${byteSum})>? LIMIT 1`, [config.maxRowBytes]);
|
|
345
340
|
if (oversized.length > 0)
|
|
346
341
|
return over(measuredRows, measuredBytes);
|
|
347
|
-
const total = replica.query(`SELECT COALESCE(SUM(bytes),
|
|
348
|
-
SELECT (${byteSum}) AS bytes FROM ${quoteIdent(table.name)} LIMIT ?
|
|
349
|
-
)`, [perTableRows[index] ?? 0])[0]?.total;
|
|
342
|
+
const total = replica.query(`SELECT COALESCE(SUM(bytes),0)AS total FROM(SELECT(${byteSum})AS bytes FROM ${quoteIdent(table.name)} LIMIT ?)`, [perTableRows[index] ?? 0])[0]?.total;
|
|
350
343
|
measuredBytes += Number(total ?? 0);
|
|
351
344
|
if (measuredBytes > config.maxBytes)
|
|
352
345
|
return over(measuredRows, measuredBytes);
|
|
@@ -354,19 +347,14 @@ export function capturePreviousVersion(replica, container, oldDescriptor, newSch
|
|
|
354
347
|
let copiedRows = 0;
|
|
355
348
|
container.transaction(() => {
|
|
356
349
|
dropPreviousVersionContainer(container);
|
|
357
|
-
container.exec(`CREATE TABLE ${quoteIdent(CONTAINER_TABLE)} (
|
|
358
|
-
tbl TEXT NOT NULL,
|
|
359
|
-
row_id TEXT NOT NULL,
|
|
360
|
-
payload TEXT NOT NULL,
|
|
361
|
-
PRIMARY KEY (tbl, row_id))`);
|
|
350
|
+
container.exec(`CREATE TABLE ${quoteIdent(CONTAINER_TABLE)}(tbl TEXT NOT NULL,row_id TEXT NOT NULL,payload TEXT NOT NULL,PRIMARY KEY(tbl,row_id))`);
|
|
362
351
|
for (const table of tables) {
|
|
363
352
|
const selectColumns = table.columns
|
|
364
353
|
.map((column) => quoteIdent(column.name))
|
|
365
354
|
.join(', ');
|
|
366
355
|
let lastRowId = -1;
|
|
367
356
|
for (;;) {
|
|
368
|
-
const rows = replica.query(`SELECT rowid AS _rid
|
|
369
|
-
WHERE rowid > ? ORDER BY rowid ASC LIMIT ?`, [lastRowId, COPY_BATCH_ROWS]);
|
|
357
|
+
const rows = replica.query(`SELECT rowid AS _rid,${selectColumns} FROM ${quoteIdent(table.name)} WHERE rowid>? ORDER BY rowid ASC LIMIT ?`, [lastRowId, COPY_BATCH_ROWS]);
|
|
370
358
|
if (rows.length === 0)
|
|
371
359
|
break;
|
|
372
360
|
for (const row of rows) {
|
|
@@ -375,8 +363,7 @@ export function capturePreviousVersion(replica, container, oldDescriptor, newSch
|
|
|
375
363
|
const value = fromSqlValue({ name: column.name, type: column.type, nullable: true }, (row[column.name] ?? null));
|
|
376
364
|
payload[column.name] = rowValueToJson(value);
|
|
377
365
|
}
|
|
378
|
-
container.exec(`INSERT INTO ${quoteIdent(CONTAINER_TABLE)}(tbl,
|
|
379
|
-
VALUES (?, ?, ?)`, [table.name, rowIdText(table, row), JSON.stringify(payload)]);
|
|
366
|
+
container.exec(`INSERT INTO ${quoteIdent(CONTAINER_TABLE)}(tbl,row_id,payload)VALUES(?,?,?)`, [table.name, rowIdText(table, row), JSON.stringify(payload)]);
|
|
380
367
|
}
|
|
381
368
|
lastRowId = Number(rows[rows.length - 1]?._rid ?? lastRowId);
|
|
382
369
|
copiedRows += rows.length;
|
|
@@ -431,8 +418,7 @@ export function readPreviousVersionRows(db, table, rowIds, limit) {
|
|
|
431
418
|
const where = rowIds.length === 0
|
|
432
419
|
? ''
|
|
433
420
|
: ` AND row_id IN (${rowIds.map(() => '?').join(', ')})`;
|
|
434
|
-
const rows = db.query(`SELECT row_id,
|
|
435
|
-
WHERE tbl = ?${where} ORDER BY row_id ASC LIMIT ?`, [table.name, ...rowIds, limit + 1]);
|
|
421
|
+
const rows = db.query(`SELECT row_id,payload FROM ${quoteIdent(CONTAINER_TABLE)} WHERE tbl=?${where} ORDER BY row_id ASC LIMIT ?`, [table.name, ...rowIds, limit + 1]);
|
|
436
422
|
const truncated = rows.length > limit;
|
|
437
423
|
const visible = truncated ? rows.slice(0, limit) : rows;
|
|
438
424
|
return {
|
|
@@ -455,7 +441,7 @@ export function writePreviousVersionRefusal(db, reason, measurement) {
|
|
|
455
441
|
}
|
|
456
442
|
/** Delete the durable refusal. Part of the orphan sweep and of a successful capture. */
|
|
457
443
|
export function clearPreviousVersionRefusal(db) {
|
|
458
|
-
db.exec('DELETE FROM _syncular_meta WHERE key
|
|
444
|
+
db.exec('DELETE FROM _syncular_meta WHERE key=?', [
|
|
459
445
|
PREVIOUS_VERSION_CONTEXT_KEY,
|
|
460
446
|
]);
|
|
461
447
|
}
|
|
@@ -589,7 +575,7 @@ export function writePreviousVersionAudit(db, audit) {
|
|
|
589
575
|
}
|
|
590
576
|
/** Delete the advisory audit. Part of a discard, never of a normal bump. */
|
|
591
577
|
export function clearPreviousVersionAudit(db) {
|
|
592
|
-
db.exec('DELETE FROM _syncular_meta WHERE key
|
|
578
|
+
db.exec('DELETE FROM _syncular_meta WHERE key=?', [
|
|
593
579
|
PREVIOUS_VERSION_AUDIT_KEY,
|
|
594
580
|
]);
|
|
595
581
|
}
|
package/dist/schema.d.ts
CHANGED
|
@@ -160,3 +160,5 @@ export type JsonRowValue = string | number | boolean | null | {
|
|
|
160
160
|
export declare function hexToBytes(hex: string): Uint8Array;
|
|
161
161
|
export declare function rowValueToJson(value: RowValue): JsonRowValue;
|
|
162
162
|
export declare function jsonToRowValue(value: JsonRowValue): RowValue;
|
|
163
|
+
/** Convert each column value without changing the row's presence set. */
|
|
164
|
+
export declare function mapRowValues<T, U>(values: Readonly<Record<string, T>>, convert: (value: T) => U): Record<string, U>;
|