@syncular/client 0.30.11 → 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 +7 -0
- package/dist/apply.d.ts +5 -1
- package/dist/apply.js +44 -36
- package/dist/client.js +75 -59
- package/dist/failed-overlay.d.ts +4 -3
- package/dist/failed-overlay.js +58 -43
- package/dist/outbox.d.ts +2 -1
- package/dist/outbox.js +14 -24
- package/dist/outcomes.js +6 -5
- package/dist/schema.d.ts +1 -0
- package/dist/schema.js +63 -72
- package/package.json +3 -3
- package/src/apply.ts +81 -50
- package/src/client.ts +115 -76
- package/src/failed-overlay.ts +68 -57
- package/src/outbox.ts +19 -28
- package/src/outcomes.ts +7 -6
- package/src/schema.ts +81 -88
package/dist/failed-overlay.js
CHANGED
|
@@ -1,16 +1,17 @@
|
|
|
1
|
+
export const RETAINED_ROWS = '_syncular_failed_rows';
|
|
1
2
|
import { ClientSyncError } from './errors.js';
|
|
2
3
|
import { mapRowValues, fromSqlValue, jsonToRowValue, quoteIdent, rowValueToJson, SYNC_VERSION_COLUMN, toSqlValue, } from './schema.js';
|
|
3
4
|
import { deleteLocalRow, upsertLocalRow } from './apply.js';
|
|
4
5
|
/** Protected failure state. The server base advances independently of intent. */
|
|
5
6
|
export function ensureFailedOverlaySchema(db) {
|
|
6
|
-
db.exec(
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
.some((row) => row.name === '
|
|
10
|
-
|
|
7
|
+
db.exec(`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))`);
|
|
8
|
+
const columns = db.query(`PRAGMA table_info(${RETAINED_ROWS})`);
|
|
9
|
+
for (const column of ['unique_conflicts TEXT', 'commit_seq INTEGER'])
|
|
10
|
+
if (!columns.some((row) => row.name === column.split(' ')[0]))
|
|
11
|
+
db.exec(`ALTER TABLE ${RETAINED_ROWS} ADD COLUMN ${column}`);
|
|
11
12
|
}
|
|
12
13
|
export function retainedBaseWrite(db, table, rowId, values, version) {
|
|
13
|
-
db.exec(
|
|
14
|
+
db.exec(`UPDATE ${RETAINED_ROWS} SET base=?,version=? WHERE tbl=? AND id=?`, [
|
|
14
15
|
values === undefined ? null : JSON.stringify(values),
|
|
15
16
|
version ?? null,
|
|
16
17
|
table.name,
|
|
@@ -18,13 +19,13 @@ export function retainedBaseWrite(db, table, rowId, values, version) {
|
|
|
18
19
|
]);
|
|
19
20
|
if (!table.indexes.some((index) => index.unique))
|
|
20
21
|
return;
|
|
21
|
-
for (const retained of db.query(
|
|
22
|
+
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])) {
|
|
22
23
|
const prior = retained.unique_conflicts === null
|
|
23
24
|
? []
|
|
24
|
-
: JSON.parse(
|
|
25
|
+
: JSON.parse(retained.unique_conflicts);
|
|
25
26
|
const conflicts = prior.filter((conflict) => conflict.rowId !== rowId);
|
|
26
27
|
if (values !== undefined && retained.intent !== null) {
|
|
27
|
-
const intent = JSON.parse(
|
|
28
|
+
const intent = JSON.parse(retained.intent);
|
|
28
29
|
conflicts.push(...uniqueConflicts(db, table, mapRowValues(intent, jsonToRowValue))
|
|
29
30
|
.filter((conflict) => conflict.rowId === rowId)
|
|
30
31
|
.map((conflict) => ({
|
|
@@ -32,25 +33,27 @@ export function retainedBaseWrite(db, table, rowId, values, version) {
|
|
|
32
33
|
serverRow: mapRowValues(conflict.serverRow, rowValueToJson),
|
|
33
34
|
})));
|
|
34
35
|
}
|
|
35
|
-
db.exec(
|
|
36
|
+
db.exec(`UPDATE ${RETAINED_ROWS} SET unique_conflicts=? WHERE commit_id=? AND idx=?`, [
|
|
36
37
|
conflicts.length
|
|
37
38
|
? JSON.stringify(conflicts.sort((a, b) => table.indexes.findIndex((index) => index.name === a.index) -
|
|
38
39
|
table.indexes.findIndex((index) => index.name === b.index)))
|
|
39
40
|
: null,
|
|
40
|
-
|
|
41
|
+
retained.commit_id,
|
|
41
42
|
Number(retained.idx),
|
|
42
43
|
]);
|
|
43
44
|
}
|
|
44
45
|
}
|
|
45
|
-
export function retainFailedRows(db, commit, images) {
|
|
46
|
+
export function retainFailedRows(db, commit, images, commitSeq) {
|
|
46
47
|
for (const [index, operation] of commit.operations.entries()) {
|
|
47
48
|
const image = images.find((image) => image.opIndex === index);
|
|
49
|
+
if (commitSeq !== undefined && (image?.deliverySeq ?? -1) >= commitSeq)
|
|
50
|
+
continue;
|
|
48
51
|
const initial = operation.op === 'upsert'
|
|
49
52
|
? { ...image?.values, ...operation.values }
|
|
50
53
|
: undefined;
|
|
51
54
|
if (!image)
|
|
52
|
-
throw new ClientSyncError('sync.local_corrupt', '
|
|
53
|
-
db.exec(
|
|
55
|
+
throw new ClientSyncError('sync.local_corrupt', 'missing intent before-image');
|
|
56
|
+
db.exec(`INSERT INTO ${RETAINED_ROWS}(commit_id,idx,tbl,id,at,op,intent,base,version,commit_seq)VALUES(?,?,?,?,?,?,?,?,?,?)`, [
|
|
54
57
|
commit.clientCommitId,
|
|
55
58
|
index,
|
|
56
59
|
operation.table,
|
|
@@ -60,6 +63,7 @@ export function retainFailedRows(db, commit, images) {
|
|
|
60
63
|
initial ? JSON.stringify(initial) : null,
|
|
61
64
|
image.values ? JSON.stringify(image.values) : null,
|
|
62
65
|
image.syncVersion ?? null,
|
|
66
|
+
commitSeq ?? null,
|
|
63
67
|
]);
|
|
64
68
|
}
|
|
65
69
|
}
|
|
@@ -83,33 +87,36 @@ export function uniqueConflicts(db, table, values) {
|
|
|
83
87
|
serverVersion: Number(row[SYNC_VERSION_COLUMN]),
|
|
84
88
|
})));
|
|
85
89
|
}
|
|
86
|
-
|
|
90
|
+
function retainedRows(db, rowKeys) {
|
|
91
|
+
return db.query(`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`, [rowKeys ? JSON.stringify(rowKeys) : null]);
|
|
92
|
+
}
|
|
93
|
+
export function restoreFailedBases(db, schema, absentIntent = false, rowKeys) {
|
|
87
94
|
const seen = new Set();
|
|
88
|
-
for (const row of db
|
|
95
|
+
for (const row of retainedRows(db, rowKeys)) {
|
|
89
96
|
const key = JSON.stringify([row.tbl, row.id]);
|
|
90
97
|
if (!absentIntent && seen.has(key))
|
|
91
98
|
continue;
|
|
92
99
|
seen.add(key);
|
|
93
|
-
const table = schema.tables.get(
|
|
100
|
+
const table = schema.tables.get(row.tbl);
|
|
94
101
|
if (!table)
|
|
95
|
-
throw new ClientSyncError('sync.unknown_table', 'retained
|
|
102
|
+
throw new ClientSyncError('sync.unknown_table', 'retained table is unknown');
|
|
96
103
|
const json = absentIntent ? row.intent : row.base;
|
|
97
104
|
if (absentIntent && row.intent !== null) {
|
|
98
|
-
const values = JSON.parse(
|
|
105
|
+
const values = JSON.parse(row.intent);
|
|
99
106
|
const conflicts = uniqueConflicts(db, table, mapRowValues(values, jsonToRowValue));
|
|
100
107
|
// Hydrate a pre-0.30.11 failure after its winner was already imported.
|
|
101
108
|
// Pending and retained overlays cannot supply authoritative row evidence.
|
|
102
|
-
if (row.unique_conflicts === null) {
|
|
109
|
+
if (row.commit_seq === null && row.unique_conflicts === null) {
|
|
103
110
|
const authorized = conflicts.filter((conflict) => conflict.serverVersion >= 0 &&
|
|
104
|
-
!db.query(
|
|
111
|
+
!db.query(`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`, [table.name, conflict.rowId, table.name, conflict.rowId]).length);
|
|
105
112
|
if (authorized.length)
|
|
106
|
-
db.exec(
|
|
113
|
+
db.exec(`UPDATE ${RETAINED_ROWS} SET unique_conflicts=? WHERE commit_id=? AND idx=?`, [
|
|
107
114
|
JSON.stringify(authorized.map((conflict) => ({
|
|
108
115
|
...conflict,
|
|
109
116
|
serverRow: mapRowValues(conflict.serverRow, rowValueToJson),
|
|
110
117
|
}))),
|
|
111
|
-
|
|
112
|
-
|
|
118
|
+
row.commit_id,
|
|
119
|
+
row.idx,
|
|
113
120
|
]);
|
|
114
121
|
}
|
|
115
122
|
if (conflicts.length)
|
|
@@ -117,26 +124,30 @@ export function restoreFailedBases(db, schema, absentIntent = false) {
|
|
|
117
124
|
}
|
|
118
125
|
if (absentIntent &&
|
|
119
126
|
(json === null ||
|
|
120
|
-
db.query(`SELECT 1 FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)}=?`, [
|
|
127
|
+
db.query(`SELECT 1 FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)}=?`, [row.id]).length))
|
|
121
128
|
continue;
|
|
122
129
|
if (json === null)
|
|
123
|
-
deleteLocalRow(db, table,
|
|
130
|
+
deleteLocalRow(db, table, row.id, false);
|
|
124
131
|
else {
|
|
125
132
|
const values = JSON.parse(String(json));
|
|
126
133
|
if (absentIntent) {
|
|
127
|
-
const operation = JSON.parse(
|
|
128
|
-
if (
|
|
134
|
+
const operation = JSON.parse(row.op);
|
|
135
|
+
if (row.commit_seq === null &&
|
|
136
|
+
!table.columns.every((column) => Object.hasOwn(operation.values ?? {}, column.name)))
|
|
129
137
|
continue;
|
|
130
138
|
}
|
|
131
|
-
upsertLocalRow(db, table, table.columns.map((column) => jsonToRowValue(values[column.name] ?? null)),
|
|
139
|
+
upsertLocalRow(db, table, table.columns.map((column) => jsonToRowValue(values[column.name] ?? null)), (row.version ?? -1), false);
|
|
132
140
|
}
|
|
133
141
|
}
|
|
142
|
+
return seen.size > 0;
|
|
134
143
|
}
|
|
135
|
-
export function failedOverlayCommits(db) {
|
|
144
|
+
export function failedOverlayCommits(db, acknowledged = false, rowKeys) {
|
|
136
145
|
const commits = new Map();
|
|
137
|
-
for (const row of db
|
|
138
|
-
|
|
139
|
-
|
|
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);
|
|
140
151
|
const prior = commits.get(id);
|
|
141
152
|
if (prior)
|
|
142
153
|
prior.operations.push(operation);
|
|
@@ -144,7 +155,7 @@ export function failedOverlayCommits(db) {
|
|
|
144
155
|
commits.set(id, {
|
|
145
156
|
seq: 0,
|
|
146
157
|
clientCommitId: id,
|
|
147
|
-
createdAtMs:
|
|
158
|
+
createdAtMs: row.at,
|
|
148
159
|
operations: [operation],
|
|
149
160
|
});
|
|
150
161
|
}
|
|
@@ -152,17 +163,21 @@ export function failedOverlayCommits(db) {
|
|
|
152
163
|
}
|
|
153
164
|
export function dropFailedRows(db, matches) {
|
|
154
165
|
const ids = new Set();
|
|
155
|
-
for (const row of db
|
|
156
|
-
const json
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
166
|
+
for (const row of retainedRows(db)) {
|
|
167
|
+
for (const json of [row.intent, row.base]) {
|
|
168
|
+
if (json === null)
|
|
169
|
+
continue;
|
|
170
|
+
const values = JSON.parse(String(json));
|
|
171
|
+
if (matches(row.tbl, values))
|
|
172
|
+
ids.add(row.commit_id);
|
|
173
|
+
}
|
|
162
174
|
}
|
|
163
|
-
const dropped =
|
|
175
|
+
const dropped = [
|
|
176
|
+
...failedOverlayCommits(db),
|
|
177
|
+
...failedOverlayCommits(db, true),
|
|
178
|
+
].filter((commit) => ids.has(commit.clientCommitId));
|
|
164
179
|
for (const id of ids) {
|
|
165
|
-
db.exec(
|
|
180
|
+
db.exec(`DELETE FROM ${RETAINED_ROWS} WHERE commit_id=?`, [id]);
|
|
166
181
|
db.exec("UPDATE _syncular_commit_outcomes SET operations=NULL,results='[]' WHERE client_commit_id=?", [id]);
|
|
167
182
|
}
|
|
168
183
|
return dropped;
|
package/dist/outbox.d.ts
CHANGED
|
@@ -34,12 +34,13 @@ export interface OutboxCommit {
|
|
|
34
34
|
export interface OutboxBeforeImage {
|
|
35
35
|
readonly opIndex: number;
|
|
36
36
|
readonly existed: boolean;
|
|
37
|
+
readonly deliverySeq?: number;
|
|
37
38
|
readonly syncVersion?: number;
|
|
38
39
|
readonly values?: Readonly<Record<string, JsonRowValue>>;
|
|
39
40
|
}
|
|
40
41
|
export declare function appendOutboxCommit(db: ClientDatabase, clientCommitId: string, operations: readonly OutboxOperation[], nowMs: number, beforeImages?: readonly OutboxBeforeImage[]): void;
|
|
41
42
|
/** Pending commits in FIFO creation order (§7.1). Full reads serve replay and the public listing. */
|
|
42
|
-
export declare function listOutbox(db: ClientDatabase): OutboxCommit[];
|
|
43
|
+
export declare function listOutbox(db: ClientDatabase, rowKeys?: readonly string[]): OutboxCommit[];
|
|
43
44
|
/** Keyset pages bound staging; laziness decodes only commits consumed by the encoder. */
|
|
44
45
|
export declare function iterateOutbox(db: ClientDatabase, throughSeq: number): Generator<OutboxCommit>;
|
|
45
46
|
/** Routine status reads never load operation bodies. */
|
package/dist/outbox.js
CHANGED
|
@@ -7,26 +7,20 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { encodeSparseRow, } from '@syncular/core';
|
|
9
9
|
import { ClientSyncError } from './errors.js';
|
|
10
|
-
import { jsonToRowValue, } from './schema.js';
|
|
10
|
+
import { jsonToRowValue, matchesLocalScopes, } from './schema.js';
|
|
11
11
|
export function appendOutboxCommit(db, clientCommitId, operations, nowMs, beforeImages = []) {
|
|
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
15
|
db.exec('INSERT INTO _syncular_outbox(client_commit_id,created_at_ms,operations)VALUES(?,?,?)', [clientCommitId, nowMs, JSON.stringify(operations)]);
|
|
16
|
-
|
|
17
|
-
db.exec('INSERT INTO _syncular_outbox_before_images(client_commit_id,op_index,existed,sync_version,values_json)VALUES(?,?,?,?,?)', [
|
|
18
|
-
clientCommitId,
|
|
19
|
-
image.opIndex,
|
|
20
|
-
image.existed ? 1 : 0,
|
|
21
|
-
image.syncVersion ?? null,
|
|
22
|
-
image.values === undefined ? null : JSON.stringify(image.values),
|
|
23
|
-
]);
|
|
24
|
-
}
|
|
16
|
+
replaceOutboxBeforeImages(db, clientCommitId, beforeImages);
|
|
25
17
|
}
|
|
26
18
|
/** Pending commits in FIFO creation order (§7.1). Full reads serve replay and the public listing. */
|
|
27
|
-
export function listOutbox(db) {
|
|
19
|
+
export function listOutbox(db, rowKeys) {
|
|
28
20
|
return db
|
|
29
|
-
.query(
|
|
21
|
+
.query(rowKeys === undefined
|
|
22
|
+
? 'SELECT seq, client_commit_id, created_at_ms, operations FROM _syncular_outbox ORDER BY seq ASC'
|
|
23
|
+
: "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", rowKeys && [JSON.stringify(rowKeys)])
|
|
30
24
|
.map(decodeOutboxRow);
|
|
31
25
|
}
|
|
32
26
|
function decodeOutboxRow(row) {
|
|
@@ -67,10 +61,13 @@ export function deleteOutboxCommit(db, clientCommitId) {
|
|
|
67
61
|
}
|
|
68
62
|
export function listOutboxBeforeImages(db, clientCommitId) {
|
|
69
63
|
return db
|
|
70
|
-
.query('SELECT op_index,existed,sync_version,values_json FROM _syncular_outbox_before_images WHERE client_commit_id=? ORDER BY op_index', [clientCommitId])
|
|
64
|
+
.query('SELECT op_index,existed,sync_version,values_json,delivery_seq FROM _syncular_outbox_before_images WHERE client_commit_id=? ORDER BY op_index', [clientCommitId])
|
|
71
65
|
.map((row) => ({
|
|
72
66
|
opIndex: row.op_index,
|
|
73
67
|
existed: row.existed === 1,
|
|
68
|
+
...(typeof row.delivery_seq === 'number'
|
|
69
|
+
? { deliverySeq: row.delivery_seq }
|
|
70
|
+
: {}),
|
|
74
71
|
...(typeof row.sync_version === 'number'
|
|
75
72
|
? { syncVersion: row.sync_version }
|
|
76
73
|
: {}),
|
|
@@ -83,8 +80,7 @@ export function listOutboxBeforeImages(db, clientCommitId) {
|
|
|
83
80
|
}
|
|
84
81
|
export function replaceOutboxBeforeImages(db, clientCommitId, replacements) {
|
|
85
82
|
for (const image of replacements) {
|
|
86
|
-
db.exec('
|
|
87
|
-
db.exec('INSERT INTO _syncular_outbox_before_images(client_commit_id,op_index,existed,sync_version,values_json)VALUES(?,?,?,?,?)', [
|
|
83
|
+
db.exec('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', [
|
|
88
84
|
clientCommitId,
|
|
89
85
|
image.opIndex,
|
|
90
86
|
image.existed ? 1 : 0,
|
|
@@ -109,7 +105,7 @@ function sparseValues(table, values) {
|
|
|
109
105
|
// bump removed (or renamed) it — the commit cannot be expressed now.
|
|
110
106
|
for (const key of Object.keys(values)) {
|
|
111
107
|
if (!table.columnIndex.has(key)) {
|
|
112
|
-
throw new OutboxEncodeError(`outbox
|
|
108
|
+
throw new OutboxEncodeError(`outbox column ${JSON.stringify(key)} is absent from table ${JSON.stringify(table.name)} (§7.4.4)`);
|
|
113
109
|
}
|
|
114
110
|
}
|
|
115
111
|
return table.columns.map((column) => {
|
|
@@ -142,7 +138,7 @@ export async function encodeOutboxCommit(schema, commit, encryption, storedKeyId
|
|
|
142
138
|
const table = schema.tables.get(op.table);
|
|
143
139
|
if (table === undefined) {
|
|
144
140
|
// §7.4.4: the bump removed this table — the commit cannot be encoded.
|
|
145
|
-
throw new OutboxEncodeError(`outbox
|
|
141
|
+
throw new OutboxEncodeError(`outbox table ${JSON.stringify(op.table)} is absent from the current schema (§7.4.4)`);
|
|
146
142
|
}
|
|
147
143
|
if (op.values === undefined) {
|
|
148
144
|
throw new ClientSyncError('sync.invalid_request', `outbox upsert on ${op.table}/${op.rowId} has no values`);
|
|
@@ -183,13 +179,7 @@ export function dropOutboxCommitsInScope(db, table, effective) {
|
|
|
183
179
|
const inScope = commit.operations.some((op) => {
|
|
184
180
|
if (op.table !== table.name || op.values === undefined)
|
|
185
181
|
return false;
|
|
186
|
-
return
|
|
187
|
-
const column = table.scopeColumnByVariable.get(variable);
|
|
188
|
-
if (column === undefined)
|
|
189
|
-
return false;
|
|
190
|
-
const value = op.values?.[column];
|
|
191
|
-
return typeof value === 'string' && values.includes(value);
|
|
192
|
-
});
|
|
182
|
+
return matchesLocalScopes(table, effective, op.values);
|
|
193
183
|
});
|
|
194
184
|
if (inScope) {
|
|
195
185
|
deleteOutboxCommit(db, commit.clientCommitId);
|
package/dist/outcomes.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { RETAINED_ROWS } from './failed-overlay.js';
|
|
1
2
|
import { ClientSyncError } from './errors.js';
|
|
2
3
|
import { mapRowValues, jsonToRowValue, rowValueToJson, } from './schema.js';
|
|
3
4
|
function encodeResults(results) {
|
|
@@ -32,25 +33,25 @@ function decodeResults(raw) {
|
|
|
32
33
|
}
|
|
33
34
|
function parseOutcome(db, row) {
|
|
34
35
|
const retainedRows = db
|
|
35
|
-
.query(
|
|
36
|
+
.query(`SELECT tbl,id,intent,base,version,unique_conflicts FROM ${RETAINED_ROWS} WHERE commit_seq IS NULL AND commit_id=? ORDER BY idx`, [row.client_commit_id])
|
|
36
37
|
.map((retained) => {
|
|
37
38
|
const decode = (value) => value === null
|
|
38
39
|
? null
|
|
39
40
|
: Object.fromEntries(Object.entries(JSON.parse(String(value))).map(([key, value]) => [key, jsonToRowValue(value)]));
|
|
40
41
|
return {
|
|
41
|
-
table:
|
|
42
|
-
rowId:
|
|
42
|
+
table: retained.tbl,
|
|
43
|
+
rowId: retained.id,
|
|
43
44
|
localRow: decode(retained.intent),
|
|
44
45
|
...(retained.unique_conflicts === null
|
|
45
46
|
? {}
|
|
46
47
|
: {
|
|
47
|
-
uniqueConflicts: JSON.parse(
|
|
48
|
+
uniqueConflicts: JSON.parse(retained.unique_conflicts).map((conflict) => ({
|
|
48
49
|
...conflict,
|
|
49
50
|
serverRow: decode(JSON.stringify(conflict.serverRow)),
|
|
50
51
|
})),
|
|
51
52
|
}),
|
|
52
53
|
serverRow: decode(retained.base),
|
|
53
|
-
serverVersion: retained.version === null ? null :
|
|
54
|
+
serverVersion: retained.version === null ? null : retained.version,
|
|
54
55
|
};
|
|
55
56
|
});
|
|
56
57
|
return {
|
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 matchesLocalScopes(table: CompiledClientTable, scopes: Readonly<Record<string, readonly string[]>>, values: Readonly<Record<string, JsonRowValue>>): boolean;
|
|
96
97
|
export declare function quoteIdent(name: string): string;
|
|
97
98
|
/**
|
|
98
99
|
* §5.11: the app-side type of a column for local (plaintext) storage. For an
|
package/dist/schema.js
CHANGED
|
@@ -10,42 +10,41 @@ const ALLOWED_FTS_TOKENIZERS = new Set([
|
|
|
10
10
|
'porter unicode61',
|
|
11
11
|
'trigram',
|
|
12
12
|
]);
|
|
13
|
+
function invalidSchema(message) {
|
|
14
|
+
throw new Error(message);
|
|
15
|
+
}
|
|
13
16
|
export function compileClientSchema(schema) {
|
|
14
17
|
const tables = new Map();
|
|
15
18
|
const schemaObjectNames = new Set();
|
|
16
19
|
for (const table of schema.tables) {
|
|
17
20
|
const tableLabel = `table ${table.name}`;
|
|
18
21
|
if (tables.has(table.name)) {
|
|
19
|
-
|
|
22
|
+
invalidSchema(`duplicate table ${JSON.stringify(table.name)}`);
|
|
20
23
|
}
|
|
21
24
|
if (schemaObjectNames.has(table.name)) {
|
|
22
|
-
|
|
25
|
+
invalidSchema(`table ${JSON.stringify(table.name)} conflicts with an index or FTS projection`);
|
|
23
26
|
}
|
|
24
27
|
schemaObjectNames.add(table.name);
|
|
25
28
|
const columnIndex = new Map();
|
|
26
29
|
table.columns.forEach((column, index) => {
|
|
27
30
|
if (columnIndex.has(column.name)) {
|
|
28
|
-
|
|
31
|
+
invalidSchema(`${tableLabel}: duplicate column ${JSON.stringify(column.name)}`);
|
|
29
32
|
}
|
|
30
33
|
columnIndex.set(column.name, index);
|
|
31
34
|
});
|
|
32
35
|
const primaryKeyIndex = columnIndex.get(table.primaryKey);
|
|
33
36
|
if (primaryKeyIndex === undefined) {
|
|
34
|
-
|
|
37
|
+
invalidSchema(`${tableLabel}: primary key ${JSON.stringify(table.primaryKey)} is not a column`);
|
|
35
38
|
}
|
|
36
39
|
// §2.4 primary-key eligibility: a primary key MUST render to a `rowId`
|
|
37
40
|
// string every implementation produces identically and local storage
|
|
38
41
|
// resolves without deferring to a SQLite build.
|
|
39
42
|
const primaryKeyColumn = table.columns[primaryKeyIndex];
|
|
40
|
-
if (
|
|
41
|
-
|
|
42
|
-
primaryKeyColumn.type !== 'integer' &&
|
|
43
|
-
primaryKeyColumn.type !== 'boolean' &&
|
|
44
|
-
primaryKeyColumn.type !== 'json') {
|
|
45
|
-
throw new Error(`${tableLabel}: primary key ${JSON.stringify(table.primaryKey)} has an ineligible column type (§2.4); a primary key must be string, integer, boolean, or json`);
|
|
43
|
+
if (!['string', 'integer', 'boolean', 'json'].includes(primaryKeyColumn.type)) {
|
|
44
|
+
invalidSchema(`${tableLabel}: primary key ${JSON.stringify(table.primaryKey)} has an ineligible type (§2.4)`);
|
|
46
45
|
}
|
|
47
46
|
if (table.scopes.length === 0) {
|
|
48
|
-
|
|
47
|
+
invalidSchema(`${tableLabel}: every synced table declares at least one scope pattern (§3.1)`);
|
|
49
48
|
}
|
|
50
49
|
const scopeColumnByVariable = new Map();
|
|
51
50
|
const scopePrefixByVariable = new Map();
|
|
@@ -53,17 +52,17 @@ export function compileClientSchema(schema) {
|
|
|
53
52
|
const pattern = typeof spec === 'string' ? spec : spec.pattern;
|
|
54
53
|
const match = PATTERN_RE.exec(pattern);
|
|
55
54
|
if (match === null || match[1] === undefined || match[2] === undefined) {
|
|
56
|
-
|
|
55
|
+
invalidSchema(`${tableLabel}: scope pattern ${JSON.stringify(pattern)} must be 'prefix:{variable}'`);
|
|
57
56
|
}
|
|
58
57
|
const prefix = match[1];
|
|
59
58
|
const variable = match[2];
|
|
60
59
|
const column = typeof spec === 'string' ? variable : spec.column;
|
|
61
60
|
if (!columnIndex.has(column)) {
|
|
62
|
-
|
|
61
|
+
invalidSchema(`${tableLabel}: scope pattern ${JSON.stringify(pattern)} names unknown column ${JSON.stringify(column)}`);
|
|
63
62
|
}
|
|
64
63
|
const existing = scopeColumnByVariable.get(variable);
|
|
65
64
|
if (existing !== undefined && existing !== column) {
|
|
66
|
-
|
|
65
|
+
invalidSchema(`${tableLabel}: variable ${JSON.stringify(variable)} maps to two different columns (§3.1)`);
|
|
67
66
|
}
|
|
68
67
|
scopeColumnByVariable.set(variable, column);
|
|
69
68
|
scopePrefixByVariable.set(variable, prefix);
|
|
@@ -84,44 +83,28 @@ export function compileClientSchema(schema) {
|
|
|
84
83
|
for (const alias of ambiguous)
|
|
85
84
|
columnIndexByCamel.delete(alias);
|
|
86
85
|
const indexes = table.indexes ?? [];
|
|
87
|
-
for (const index of indexes) {
|
|
88
|
-
if (schemaObjectNames.has(index.name)) {
|
|
89
|
-
throw new Error(`${tableLabel}: index ${JSON.stringify(index.name)} conflicts with another schema object`);
|
|
90
|
-
}
|
|
91
|
-
schemaObjectNames.add(index.name);
|
|
92
|
-
for (const column of index.columns) {
|
|
93
|
-
if (!columnIndex.has(column)) {
|
|
94
|
-
throw new Error(`${tableLabel}: index ${JSON.stringify(index.name)} names unknown column ${JSON.stringify(column)}`);
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
86
|
const ftsIndexes = table.ftsIndexes ?? [];
|
|
99
|
-
for (const index of ftsIndexes) {
|
|
100
|
-
const
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
87
|
+
for (const index of [...indexes, ...ftsIndexes]) {
|
|
88
|
+
const fts = 'tokenize' in index;
|
|
89
|
+
const label = `${tableLabel}: ${fts ? 'FTS projection' : 'index'} ${JSON.stringify(index.name)}`;
|
|
90
|
+
if (schemaObjectNames.has(index.name))
|
|
91
|
+
invalidSchema(`${label} conflicts with another schema object`);
|
|
104
92
|
schemaObjectNames.add(index.name);
|
|
105
|
-
if (index.columns.length
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
const
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
if (column
|
|
116
|
-
|
|
117
|
-
}
|
|
118
|
-
if (localColumnType(column) !== 'string') {
|
|
119
|
-
throw new Error(`${projectionLabel} column ${JSON.stringify(columnName)} must have string type`);
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
if (!ALLOWED_FTS_TOKENIZERS.has(index.tokenize)) {
|
|
123
|
-
throw new Error(`${projectionLabel} tokenizer ${JSON.stringify(index.tokenize)} is not allowlisted`);
|
|
93
|
+
if (fts && (!index.columns.length || index.columns.length > 32))
|
|
94
|
+
invalidSchema(`${label} needs between 1 and 32 columns`);
|
|
95
|
+
const seen = new Set();
|
|
96
|
+
for (const name of index.columns) {
|
|
97
|
+
if (fts && seen.has(name))
|
|
98
|
+
invalidSchema(`${label} repeats column ${JSON.stringify(name)}`);
|
|
99
|
+
seen.add(name);
|
|
100
|
+
const column = table.columns[columnIndex.get(name) ?? -1];
|
|
101
|
+
if (!column)
|
|
102
|
+
invalidSchema(`${label} names unknown column ${JSON.stringify(name)}`);
|
|
103
|
+
if (fts && localColumnType(column) !== 'string')
|
|
104
|
+
invalidSchema(`${label} column ${JSON.stringify(name)} must have string type`);
|
|
124
105
|
}
|
|
106
|
+
if (fts && !ALLOWED_FTS_TOKENIZERS.has(index.tokenize))
|
|
107
|
+
invalidSchema(`${label} tokenizer ${JSON.stringify(index.tokenize)} is not allowlisted`);
|
|
125
108
|
}
|
|
126
109
|
tables.set(table.name, {
|
|
127
110
|
name: table.name,
|
|
@@ -173,6 +156,14 @@ export function stripSyncColumns(rows) {
|
|
|
173
156
|
return copy;
|
|
174
157
|
});
|
|
175
158
|
}
|
|
159
|
+
export function matchesLocalScopes(table, scopes, values) {
|
|
160
|
+
const entries = Object.entries(scopes);
|
|
161
|
+
return (entries.length > 0 &&
|
|
162
|
+
entries.every(([variable, allowed]) => {
|
|
163
|
+
const column = table.scopeColumnByVariable.get(variable);
|
|
164
|
+
return column !== undefined && allowed.includes(String(values[column]));
|
|
165
|
+
}));
|
|
166
|
+
}
|
|
176
167
|
export function quoteIdent(name) {
|
|
177
168
|
return `"${name.replaceAll('"', '""')}"`;
|
|
178
169
|
}
|
|
@@ -247,18 +238,21 @@ const FTS_SOURCE_ID_COLUMN = '_syncular_source_id';
|
|
|
247
238
|
* comment below for the full rationale.
|
|
248
239
|
*/
|
|
249
240
|
function createFtsProjection(db, table, index) {
|
|
241
|
+
const projection = quoteIdent(index.name);
|
|
242
|
+
const sourceTable = quoteIdent(table.name);
|
|
243
|
+
const primaryKey = quoteIdent(table.primaryKey);
|
|
250
244
|
const existed = db.query("SELECT 1 AS present FROM sqlite_master WHERE type='table' AND name=?", [index.name]).length > 0;
|
|
251
245
|
const indexedColumns = index.columns.map(quoteIdent);
|
|
252
246
|
const tokenizer = index.tokenize.replaceAll("'", "''");
|
|
253
|
-
db.exec(`CREATE VIRTUAL TABLE IF NOT EXISTS ${
|
|
247
|
+
db.exec(`CREATE VIRTUAL TABLE IF NOT EXISTS ${projection} USING fts5(${quoteIdent(FTS_SOURCE_ID_COLUMN)} UNINDEXED,${indexedColumns.join(', ')},tokenize='${tokenizer}')`);
|
|
254
248
|
const mapping = quoteIdent(`_syncular_fts_${index.name}`);
|
|
255
249
|
const mapped = db.query("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", [
|
|
256
250
|
`_syncular_fts_${index.name}`,
|
|
257
251
|
]).length > 0;
|
|
258
252
|
db.exec(`CREATE TABLE IF NOT EXISTS ${mapping}(id INTEGER PRIMARY KEY,source_id TEXT NOT NULL UNIQUE)`);
|
|
259
|
-
const sourceId = `CAST(${
|
|
260
|
-
const newSourceId = `CAST(new.${
|
|
261
|
-
const oldSourceId = `CAST(old.${
|
|
253
|
+
const sourceId = `CAST(${primaryKey} AS TEXT)`;
|
|
254
|
+
const newSourceId = `CAST(new.${primaryKey} AS TEXT)`;
|
|
255
|
+
const oldSourceId = `CAST(old.${primaryKey} AS TEXT)`;
|
|
262
256
|
const projectionColumns = [
|
|
263
257
|
quoteIdent(FTS_SOURCE_ID_COLUMN),
|
|
264
258
|
...indexedColumns,
|
|
@@ -267,9 +261,9 @@ function createFtsProjection(db, table, index) {
|
|
|
267
261
|
newSourceId,
|
|
268
262
|
...index.columns.map((column) => `new.${quoteIdent(column)}`),
|
|
269
263
|
].join(', ');
|
|
270
|
-
const deleteFor = (value) => `DELETE FROM ${
|
|
271
|
-
const deleteDisplaced = (select) => `DELETE FROM ${
|
|
272
|
-
const insertNew = `INSERT INTO ${mapping}(source_id)VALUES(${newSourceId});INSERT INTO ${
|
|
264
|
+
const deleteFor = (value) => `DELETE FROM ${projection} WHERE rowid=(SELECT id FROM ${mapping} WHERE source_id=${value});DELETE FROM ${mapping} WHERE source_id=${value}`;
|
|
265
|
+
const deleteDisplaced = (select) => `DELETE FROM ${projection} WHERE rowid IN (SELECT id FROM ${mapping} WHERE source_id IN (${select}));DELETE FROM ${mapping} WHERE source_id IN (${select})`;
|
|
266
|
+
const insertNew = `INSERT INTO ${mapping}(source_id)VALUES(${newSourceId});INSERT INTO ${projection}(rowid,${projectionColumns})VALUES((SELECT id FROM ${mapping} WHERE source_id=${newSourceId}),${newValues})`;
|
|
273
267
|
// A clean insert cannot already have a projection row because the source
|
|
274
268
|
// primary key is unique. Keep clean inserts linear by moving replacement
|
|
275
269
|
// cleanup behind indexed source-table existence checks. SQLite does not
|
|
@@ -297,7 +291,7 @@ function createFtsProjection(db, table, index) {
|
|
|
297
291
|
for (const suffix of ['bi', 'ai', 'ad', 'au', 'bu']) {
|
|
298
292
|
db.exec(`DROP TRIGGER IF EXISTS ${quoteIdent(`${index.name}_${suffix}`)}`);
|
|
299
293
|
}
|
|
300
|
-
const replacementExists = `EXISTS (SELECT 1 FROM ${
|
|
294
|
+
const replacementExists = `EXISTS (SELECT 1 FROM ${sourceTable} WHERE ${primaryKey} = new.${primaryKey})`;
|
|
301
295
|
// Per secondary UNIQUE index: the different-PK rows about to be displaced
|
|
302
296
|
// via that unique key. `=` makes NULL unique values match nothing, which is
|
|
303
297
|
// exactly SQLite's unique-index semantics (NULLs never conflict).
|
|
@@ -306,7 +300,7 @@ function createFtsProjection(db, table, index) {
|
|
|
306
300
|
const match = spec.columns
|
|
307
301
|
.map((column) => `${quoteIdent(column)} = new.${quoteIdent(column)}`)
|
|
308
302
|
.join(' AND ');
|
|
309
|
-
return `SELECT ${sourceId} FROM ${
|
|
303
|
+
return `SELECT ${sourceId} FROM ${sourceTable} WHERE ${match} AND ${primaryKey} !=new.${primaryKey}`;
|
|
310
304
|
});
|
|
311
305
|
const insertGuardCondition = [
|
|
312
306
|
replacementExists,
|
|
@@ -316,7 +310,7 @@ function createFtsProjection(db, table, index) {
|
|
|
316
310
|
deleteFor(newSourceId),
|
|
317
311
|
...displacedByUnique.map(deleteDisplaced),
|
|
318
312
|
].join('; ');
|
|
319
|
-
const createTrigger = (suffix, timing, body, when = '') => db.exec(`CREATE TRIGGER ${quoteIdent(`${index.name}_${suffix}`)} ${timing} ON ${
|
|
313
|
+
const createTrigger = (suffix, timing, body, when = '') => db.exec(`CREATE TRIGGER ${quoteIdent(`${index.name}_${suffix}`)} ${timing} ON ${sourceTable}${when ? ` WHEN ${when}` : ''} BEGIN ${body};END`);
|
|
320
314
|
createTrigger('bi', 'BEFORE INSERT', insertGuardBody, insertGuardCondition);
|
|
321
315
|
createTrigger('ai', 'AFTER INSERT', insertNew);
|
|
322
316
|
createTrigger('ad', 'AFTER DELETE', deleteFor(oldSourceId));
|
|
@@ -333,11 +327,11 @@ function createFtsProjection(db, table, index) {
|
|
|
333
327
|
createTrigger('bu', 'BEFORE UPDATE', updateGuardBody, updateGuardCondition);
|
|
334
328
|
}
|
|
335
329
|
if (!existed) {
|
|
336
|
-
db.exec(`INSERT INTO ${
|
|
330
|
+
db.exec(`INSERT INTO ${projection}(${projectionColumns})SELECT ${sourceId},${indexedColumns.join(', ')} FROM ${sourceTable}`);
|
|
337
331
|
}
|
|
338
332
|
if (!mapped || !existed) {
|
|
339
333
|
db.exec(`DELETE FROM ${mapping}`);
|
|
340
|
-
db.exec(`INSERT INTO ${mapping}(id,source_id)SELECT rowid,${quoteIdent(FTS_SOURCE_ID_COLUMN)} FROM ${
|
|
334
|
+
db.exec(`INSERT INTO ${mapping}(id,source_id)SELECT rowid,${quoteIdent(FTS_SOURCE_ID_COLUMN)} FROM ${projection}`);
|
|
341
335
|
}
|
|
342
336
|
}
|
|
343
337
|
/**
|
|
@@ -373,6 +367,10 @@ export function ensureLocalBookkeepingSchema(db) {
|
|
|
373
367
|
db.exec("INSERT OR IGNORE INTO _syncular_meta(key,value)VALUES('localRevision','0')");
|
|
374
368
|
createTable('_syncular_outbox', 'seq INTEGER PRIMARY KEY AUTOINCREMENT,client_commit_id TEXT NOT NULL UNIQUE,created_at_ms INTEGER NOT NULL,operations TEXT NOT NULL');
|
|
375
369
|
createTable('_syncular_outbox_before_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
|
+
if (!db
|
|
371
|
+
.query('PRAGMA table_info(_syncular_outbox_before_images)')
|
|
372
|
+
.some((row) => row.name === 'delivery_seq'))
|
|
373
|
+
db.exec('ALTER TABLE _syncular_outbox_before_images ADD COLUMN delivery_seq INTEGER');
|
|
376
374
|
createTable('_syncular_commit_outcomes', "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");
|
|
377
375
|
// Outcomes created before durable aggregate recovery retain no commit
|
|
378
376
|
// envelope. New failed outcomes store it in the protected client DB.
|
|
@@ -449,14 +447,7 @@ export function fromSqlValue(column, value) {
|
|
|
449
447
|
return value !== 0 && value !== false;
|
|
450
448
|
case 'integer':
|
|
451
449
|
return typeof value === 'bigint' ? Number(value) : value;
|
|
452
|
-
|
|
453
|
-
return value;
|
|
454
|
-
case 'bytes':
|
|
455
|
-
case 'crdt':
|
|
456
|
-
return value;
|
|
457
|
-
case 'string':
|
|
458
|
-
case 'json':
|
|
459
|
-
case 'blob_ref':
|
|
450
|
+
default:
|
|
460
451
|
return value;
|
|
461
452
|
}
|
|
462
453
|
}
|
|
@@ -473,13 +464,13 @@ export function normalizeRecordKeys(table, record) {
|
|
|
473
464
|
const index = table.columnIndex.get(key) ?? table.columnIndexByCamel.get(key);
|
|
474
465
|
if (index === undefined) {
|
|
475
466
|
if (key.startsWith('_sync_')) {
|
|
476
|
-
throw invalidRequest(`table ${table.name}: ${JSON.stringify(key)} is an internal sync column
|
|
467
|
+
throw invalidRequest(`table ${table.name}: ${JSON.stringify(key)} is an internal sync column; omit it from mutation values`);
|
|
477
468
|
}
|
|
478
|
-
throw invalidRequest(`table ${table.name}: unknown column ${JSON.stringify(key)} in mutation values
|
|
469
|
+
throw invalidRequest(`table ${table.name}: unknown column ${JSON.stringify(key)} in mutation values`);
|
|
479
470
|
}
|
|
480
471
|
const sqlName = table.columns[index].name;
|
|
481
472
|
if (normalized.has(sqlName)) {
|
|
482
|
-
throw invalidRequest(`table ${table.name}: column ${JSON.stringify(sqlName)} appears twice in mutation values
|
|
473
|
+
throw invalidRequest(`table ${table.name}: column ${JSON.stringify(sqlName)} appears twice in mutation values`);
|
|
483
474
|
}
|
|
484
475
|
normalized.set(sqlName, value);
|
|
485
476
|
}
|