@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/README.md
CHANGED
|
@@ -389,6 +389,14 @@ and scope revocation remove whole retained aggregates. If a sparse conflict
|
|
|
389
389
|
loses its server base, the client keeps the sparse operation and conflict
|
|
390
390
|
evidence without materializing a local row from the saved before-image.
|
|
391
391
|
|
|
392
|
+
A retained insert whose unique key belongs to another server primary key remains
|
|
393
|
+
journal intent while reads show the server winner. `retainedRows[].uniqueConflicts`
|
|
394
|
+
contains each matching index, its columns, competing `rowId`, authorized
|
|
395
|
+
`serverRow` and `serverVersion`. Keep-mine patches that competing ID at its
|
|
396
|
+
current version and links the replacement through `superseded`. Edit can choose
|
|
397
|
+
a free unique key. Take-server uses `resolved_keep_server`. Revocation and local
|
|
398
|
+
security purge erase the retained aggregate's protected journal payloads.
|
|
399
|
+
|
|
392
400
|
## Application-authorized local security purge
|
|
393
401
|
|
|
394
402
|
`purgeLocalData({ purgeId, targets })` is the narrow local-storage primitive
|
package/dist/apply.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { retainedBaseWrite } from './failed-overlay.js';
|
|
1
|
+
import { retainedBaseWrite, restoreFailedBases } from './failed-overlay.js';
|
|
2
2
|
/**
|
|
3
3
|
* Local application of server data: `COMMIT` frames (§4.5), rows segments
|
|
4
4
|
* (§5.2, §5.6), and the scope-matched delete shared by the §3.3 purge
|
|
@@ -17,21 +17,20 @@ function upsertSql(table) {
|
|
|
17
17
|
.filter((name) => name !== quoteIdent(table.primaryKey))
|
|
18
18
|
.map((name) => `${name}=excluded.${name}`)
|
|
19
19
|
.join(', ');
|
|
20
|
-
return `INSERT INTO ${quoteIdent(table.name)}
|
|
21
|
-
ON CONFLICT (${quoteIdent(table.primaryKey)}) DO UPDATE SET ${updates}`;
|
|
20
|
+
return `INSERT INTO ${quoteIdent(table.name)}(${names.join(', ')})VALUES(${placeholders})ON CONFLICT(${quoteIdent(table.primaryKey)})DO UPDATE SET ${updates}`;
|
|
22
21
|
}
|
|
23
22
|
export function upsertLocalRow(db, table, values, syncVersion, serverWrite = true) {
|
|
23
|
+
db.exec(upsertSql(table), [...values.map(toSqlValue), syncVersion]);
|
|
24
24
|
if (serverWrite)
|
|
25
|
-
retainedBaseWrite(db, table
|
|
25
|
+
retainedBaseWrite(db, table, String(values[table.primaryKeyIndex]), Object.fromEntries(table.columns.map((column, index) => [
|
|
26
26
|
column.name,
|
|
27
27
|
rowValueToJson(values[index] ?? null),
|
|
28
28
|
])), syncVersion);
|
|
29
|
-
db.exec(upsertSql(table), [...values.map(toSqlValue), syncVersion]);
|
|
30
29
|
}
|
|
31
30
|
export function deleteLocalRow(db, table, rowId, serverWrite = true) {
|
|
32
31
|
if (serverWrite)
|
|
33
|
-
retainedBaseWrite(db, table
|
|
34
|
-
db.exec(`DELETE FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)}
|
|
32
|
+
retainedBaseWrite(db, table, rowId);
|
|
33
|
+
db.exec(`DELETE FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)}=?`, [rowId]);
|
|
35
34
|
}
|
|
36
35
|
/**
|
|
37
36
|
* Apply one `COMMIT` frame in one local transaction (§1.4 rule 4).
|
|
@@ -76,6 +75,7 @@ export async function applyCommitFrame(db, schema, frame, encryption, transactio
|
|
|
76
75
|
});
|
|
77
76
|
}
|
|
78
77
|
transaction(() => {
|
|
78
|
+
restoreFailedBases(db, schema);
|
|
79
79
|
for (const change of resolved) {
|
|
80
80
|
if (change.op === 'delete') {
|
|
81
81
|
deleteLocalRow(db, change.table, change.rowId);
|
|
@@ -140,7 +140,7 @@ export function deleteScopedRows(db, table, effective) {
|
|
|
140
140
|
params.push(...values);
|
|
141
141
|
}
|
|
142
142
|
for (const row of db.query(`SELECT ${quoteIdent(table.primaryKey)} AS id FROM ${quoteIdent(table.name)} WHERE ${clauses.join(' AND ')}`, params)) {
|
|
143
|
-
retainedBaseWrite(db, table
|
|
143
|
+
retainedBaseWrite(db, table, String(row.id));
|
|
144
144
|
}
|
|
145
145
|
db.exec(`DELETE FROM ${quoteIdent(table.name)} WHERE ${clauses.join(' AND ')}`, params);
|
|
146
146
|
}
|
|
@@ -178,9 +178,7 @@ export function evictScopedRows(db, table, effective, pinnedRowIds) {
|
|
|
178
178
|
params.push(...ids);
|
|
179
179
|
}
|
|
180
180
|
const target = quoteIdent(table.name);
|
|
181
|
-
db.exec(`DELETE FROM ${target} WHERE ${pk} IN (
|
|
182
|
-
SELECT ${pk} FROM ${target} WHERE ${clauses.join(' AND ')}${pinnedClause} LIMIT 1024
|
|
183
|
-
)`, params);
|
|
181
|
+
db.exec(`DELETE FROM ${target} WHERE ${pk} IN(SELECT ${pk} FROM ${target} WHERE ${clauses.join(' AND ')}${pinnedClause} LIMIT 1024)`, params);
|
|
184
182
|
const remaining = db.query(`SELECT 1 FROM ${target} WHERE ${clauses.join(' AND ')}${pinnedClause} LIMIT 1`, params).length > 0;
|
|
185
183
|
const deferred = remaining ||
|
|
186
184
|
db.query(`SELECT 1 FROM ${target} WHERE ${clauses.join(' AND ')} LIMIT 1`, params.slice(0, params.length - pinnedRowIds.size)).length > 0;
|
|
@@ -211,10 +209,7 @@ export async function applySqliteSegment(db, schema, table, bytes, descriptor, o
|
|
|
211
209
|
// SQLite database or lacks the metadata table fails right here.
|
|
212
210
|
let meta;
|
|
213
211
|
try {
|
|
214
|
-
meta = db.query(`SELECT format,
|
|
215
|
-
"asOfCommitSeq" AS pin, "scopeDigest" AS sd,
|
|
216
|
-
"rowCount" AS rc
|
|
217
|
-
FROM ${IMAGE_ALIAS}."_syncular_segment"`);
|
|
212
|
+
meta = db.query(`SELECT format,"table" AS tbl,"schemaVersion" AS sv,"asOfCommitSeq" AS pin,"scopeDigest" AS sd,"rowCount" AS rc FROM ${IMAGE_ALIAS}."_syncular_segment"`);
|
|
218
213
|
}
|
|
219
214
|
catch {
|
|
220
215
|
imageInvalid('bytes are not a SQLite image with _syncular_segment');
|
|
@@ -256,7 +251,7 @@ export async function applySqliteSegment(db, schema, table, bytes, descriptor, o
|
|
|
256
251
|
if (primaryKeys.length !== 1 || primaryKeys[0]?.name !== table.primaryKey)
|
|
257
252
|
imageInvalid('image primary key does not match generated schema');
|
|
258
253
|
const source = `${IMAGE_ALIAS}.${quoteIdent(table.name)}`;
|
|
259
|
-
const count = Number(db.query(`SELECT count(*)
|
|
254
|
+
const count = Number(db.query(`SELECT count(*)AS n FROM ${source}`)[0]?.n);
|
|
260
255
|
if (count !== descriptor.rowCount)
|
|
261
256
|
imageInvalid('image row count does not match descriptor');
|
|
262
257
|
const names = table.columns.map((column) => quoteIdent(column.name));
|
|
@@ -271,9 +266,7 @@ export async function applySqliteSegment(db, schema, table, bytes, descriptor, o
|
|
|
271
266
|
for (;;) {
|
|
272
267
|
if (options.isCurrent?.() === false)
|
|
273
268
|
return applied;
|
|
274
|
-
const boundary = db.query(`SELECT ${primaryKey} AS boundary FROM ${source}
|
|
275
|
-
${after === undefined ? '' : `WHERE ${primaryKey} > ?`}
|
|
276
|
-
ORDER BY ${primaryKey} LIMIT 1 OFFSET 1023`, after === undefined ? [] : [after])[0]?.boundary;
|
|
269
|
+
const boundary = db.query(`SELECT ${primaryKey} AS boundary FROM ${source} ${after === undefined ? '' : `WHERE ${primaryKey} > ?`} ORDER BY ${primaryKey} LIMIT 1 OFFSET 1023`, after === undefined ? [] : [after])[0]?.boundary;
|
|
277
270
|
if (boundary === null)
|
|
278
271
|
imageInvalid('image primary key must not be null');
|
|
279
272
|
const predicates = [];
|
|
@@ -287,15 +280,13 @@ export async function applySqliteSegment(db, schema, table, bytes, descriptor, o
|
|
|
287
280
|
params.push(boundary);
|
|
288
281
|
}
|
|
289
282
|
(options.transaction ?? ((fn) => db.transaction(fn)))(() => {
|
|
283
|
+
restoreFailedBases(db, schema);
|
|
290
284
|
if (after === undefined && options.clearFirst)
|
|
291
285
|
deleteScopedRows(db, table, options.effective);
|
|
292
|
-
db.exec(`INSERT INTO ${quoteIdent(table.name)}
|
|
293
|
-
|
|
294
|
-
FROM ${source} WHERE ${predicates.join(' AND ') || 'true'}
|
|
295
|
-
ON CONFLICT (${primaryKey}) DO UPDATE SET ${updates}`, params);
|
|
296
|
-
applied += Number(db.query('SELECT changes() AS n')[0]?.n ?? 0);
|
|
286
|
+
db.exec(`INSERT INTO ${quoteIdent(table.name)}(${insertNames.join(', ')})SELECT ${[...names, quoteIdent('_syncular_version')].join(', ')} FROM ${source} WHERE ${predicates.join(' AND ') || 'true'} ON CONFLICT(${primaryKey})DO UPDATE SET ${updates}`, params);
|
|
287
|
+
applied += Number(db.query('SELECT changes()AS n')[0]?.n ?? 0);
|
|
297
288
|
for (const row of db.query(`SELECT * FROM ${source} WHERE ${predicates.join(' AND ') || 'true'}`, params)) {
|
|
298
|
-
retainedBaseWrite(db, table
|
|
289
|
+
retainedBaseWrite(db, table, String(row[table.primaryKey]), Object.fromEntries(table.columns.map((column) => [
|
|
299
290
|
column.name,
|
|
300
291
|
rowValueToJson(row[column.name]),
|
|
301
292
|
])), Number(row._syncular_version));
|
|
@@ -348,6 +339,7 @@ export async function applyRowsSegment(db, schema, table, segment, options, encr
|
|
|
348
339
|
if (!clearThisBlock && rows.length === 0)
|
|
349
340
|
continue;
|
|
350
341
|
(options.transaction ?? ((fn) => db.transaction(fn)))(() => {
|
|
342
|
+
restoreFailedBases(db, schema);
|
|
351
343
|
if (clearThisBlock) {
|
|
352
344
|
deleteScopedRows(db, table, options.effective);
|
|
353
345
|
}
|
package/dist/blob.js
CHANGED
|
@@ -20,7 +20,7 @@ export async function computeBlobId(bytes) {
|
|
|
20
20
|
return `sha256:${hex}`;
|
|
21
21
|
}
|
|
22
22
|
export function ensureBlobSchema(db) {
|
|
23
|
-
const existing = db.query("SELECT 1 FROM sqlite_master WHERE type
|
|
23
|
+
const existing = db.query("SELECT 1 FROM sqlite_master WHERE type='table' AND name='_syncular_blobs'");
|
|
24
24
|
if (existing.length > 0) {
|
|
25
25
|
const columns = db
|
|
26
26
|
.query('PRAGMA table_info(_syncular_blobs)')
|
|
@@ -39,24 +39,16 @@ export function ensureBlobSchema(db) {
|
|
|
39
39
|
}
|
|
40
40
|
}
|
|
41
41
|
else {
|
|
42
|
-
db.exec(
|
|
43
|
-
blob_id TEXT PRIMARY KEY,
|
|
44
|
-
bytes BLOB NOT NULL,
|
|
45
|
-
byte_length INTEGER NOT NULL,
|
|
46
|
-
media_type TEXT,
|
|
47
|
-
created_at_ms INTEGER NOT NULL)`);
|
|
42
|
+
db.exec('CREATE TABLE _syncular_blobs(blob_id TEXT PRIMARY KEY,bytes BLOB NOT NULL,byte_length INTEGER NOT NULL,media_type TEXT,created_at_ms INTEGER NOT NULL)');
|
|
48
43
|
}
|
|
49
|
-
db.exec(
|
|
50
|
-
blob_id TEXT PRIMARY KEY, media_type TEXT, created_at_ms INTEGER NOT NULL)`);
|
|
44
|
+
db.exec('CREATE TABLE IF NOT EXISTS _syncular_blob_uploads(blob_id TEXT PRIMARY KEY,media_type TEXT,created_at_ms INTEGER NOT NULL)');
|
|
51
45
|
}
|
|
52
46
|
/** Put bytes into the content-addressed cache without rewriting an existing body. */
|
|
53
47
|
export function putCachedBlob(db, blobId, bytes, nowMs, mediaType) {
|
|
54
|
-
db.exec(
|
|
55
|
-
blob_id, bytes, byte_length, media_type, created_at_ms)
|
|
56
|
-
VALUES (?,?,?,?,?) ON CONFLICT(blob_id) DO NOTHING`, [blobId, bytes, bytes.length, mediaType ?? null, nowMs]);
|
|
48
|
+
db.exec('INSERT INTO _syncular_blobs(blob_id,bytes,byte_length,media_type,created_at_ms)VALUES(?,?,?,?,?)ON CONFLICT(blob_id)DO NOTHING', [blobId, bytes, bytes.length, mediaType ?? null, nowMs]);
|
|
57
49
|
}
|
|
58
50
|
export function getCachedBlob(db, blobId) {
|
|
59
|
-
const rows = db.query('SELECT bytes,
|
|
51
|
+
const rows = db.query('SELECT bytes,byte_length,media_type FROM _syncular_blobs WHERE blob_id=?', [blobId]);
|
|
60
52
|
const row = rows[0];
|
|
61
53
|
if (row === undefined)
|
|
62
54
|
return undefined;
|
|
@@ -73,23 +65,19 @@ export function getCachedBlob(db, blobId) {
|
|
|
73
65
|
* until back under the cap. Returns the evicted blobIds.
|
|
74
66
|
*/
|
|
75
67
|
export function enforceBlobCacheCap(db, schema, maxBytes) {
|
|
76
|
-
const totalRow = db.query('SELECT COALESCE(SUM(byte_length),
|
|
68
|
+
const totalRow = db.query('SELECT COALESCE(SUM(byte_length),0)AS total FROM _syncular_blobs')[0];
|
|
77
69
|
let total = Number(totalRow?.total ?? 0);
|
|
78
70
|
if (total <= maxBytes)
|
|
79
71
|
return [];
|
|
80
72
|
// Oldest eligible body first, with blob ID as a stable tie.
|
|
81
|
-
const candidates = db.query(`SELECT blob_id,
|
|
82
|
-
WHERE blob_id NOT IN (SELECT blob_id FROM _syncular_blob_uploads)
|
|
83
|
-
AND blob_id NOT IN (SELECT blob_id FROM _syncular_blob_commit_refs)
|
|
84
|
-
AND blob_id NOT IN (${visibleBlobIdsSql(schema)})
|
|
85
|
-
ORDER BY created_at_ms ASC, blob_id ASC`);
|
|
73
|
+
const candidates = db.query(`SELECT blob_id,byte_length FROM _syncular_blobs WHERE blob_id NOT IN(SELECT blob_id FROM _syncular_blob_uploads)AND blob_id NOT IN(SELECT blob_id FROM _syncular_blob_commit_refs)AND blob_id NOT IN(${visibleBlobIdsSql(schema)})ORDER BY created_at_ms ASC,blob_id ASC`);
|
|
86
74
|
const evicted = [];
|
|
87
75
|
db.transaction(() => {
|
|
88
76
|
for (const row of candidates) {
|
|
89
77
|
if (total <= maxBytes)
|
|
90
78
|
break;
|
|
91
79
|
const blobId = row.blob_id;
|
|
92
|
-
db.exec('DELETE FROM _syncular_blobs WHERE blob_id
|
|
80
|
+
db.exec('DELETE FROM _syncular_blobs WHERE blob_id=?', [blobId]);
|
|
93
81
|
total -= Number(row.byte_length);
|
|
94
82
|
evicted.push(blobId);
|
|
95
83
|
}
|
|
@@ -98,13 +86,7 @@ export function enforceBlobCacheCap(db, schema, maxBytes) {
|
|
|
98
86
|
}
|
|
99
87
|
export function listPendingUploads(db) {
|
|
100
88
|
return db
|
|
101
|
-
.query(
|
|
102
|
-
SELECT blob_id, media_type, created_at_ms FROM _syncular_blob_uploads
|
|
103
|
-
UNION ALL
|
|
104
|
-
SELECT DISTINCT r.blob_id, b.media_type, 9223372036854775807 AS created_at_ms
|
|
105
|
-
FROM _syncular_blob_commit_refs r LEFT JOIN _syncular_blobs b ON b.blob_id = r.blob_id
|
|
106
|
-
WHERE NOT EXISTS (SELECT 1 FROM _syncular_blob_uploads u WHERE u.blob_id = r.blob_id)
|
|
107
|
-
) ORDER BY created_at_ms, blob_id`)
|
|
89
|
+
.query('SELECT blob_id,media_type FROM(SELECT blob_id,media_type,created_at_ms FROM _syncular_blob_uploads UNION ALL SELECT DISTINCT r.blob_id,b.media_type,9223372036854775807 AS created_at_ms FROM _syncular_blob_commit_refs r LEFT JOIN _syncular_blobs b ON b.blob_id=r.blob_id WHERE NOT EXISTS(SELECT 1 FROM _syncular_blob_uploads u WHERE u.blob_id=r.blob_id))ORDER BY created_at_ms,blob_id')
|
|
108
90
|
.map((row) => {
|
|
109
91
|
if (typeof row.blob_id !== 'string' ||
|
|
110
92
|
(row.media_type !== null && typeof row.media_type !== 'string')) {
|
|
@@ -117,11 +99,10 @@ export function listPendingUploads(db) {
|
|
|
117
99
|
});
|
|
118
100
|
}
|
|
119
101
|
export function recordPendingUpload(db, blobId, nowMs, mediaType) {
|
|
120
|
-
db.exec(
|
|
121
|
-
VALUES (?,?,?)`, [blobId, mediaType ?? null, nowMs]);
|
|
102
|
+
db.exec('INSERT OR IGNORE INTO _syncular_blob_uploads(blob_id,media_type,created_at_ms)VALUES(?,?,?)', [blobId, mediaType ?? null, nowMs]);
|
|
122
103
|
}
|
|
123
104
|
export function clearPendingUpload(db, blobId) {
|
|
124
|
-
db.exec('DELETE FROM _syncular_blob_uploads WHERE blob_id
|
|
105
|
+
db.exec('DELETE FROM _syncular_blob_uploads WHERE blob_id=?', [blobId]);
|
|
125
106
|
}
|
|
126
107
|
/**
|
|
127
108
|
* All `blob_ref` column names per table.
|
|
@@ -149,11 +130,7 @@ function visibleBlobIdsSql(schema) {
|
|
|
149
130
|
for (const [tableName, columns] of blobRefColumnsBySchema(schema)) {
|
|
150
131
|
for (const column of columns) {
|
|
151
132
|
const identifier = quoteIdent(column);
|
|
152
|
-
selects.push(`SELECT json_extract(${identifier},
|
|
153
|
-
FROM ${quoteIdent(tableName)}
|
|
154
|
-
WHERE typeof(${identifier}) = 'text'
|
|
155
|
-
AND json_valid(${identifier})
|
|
156
|
-
AND json_type(${identifier}, '$.blobId') = 'text'`);
|
|
133
|
+
selects.push(`SELECT json_extract(${identifier},'$.blobId')AS blob_id FROM ${quoteIdent(tableName)} WHERE typeof(${identifier})='text' AND json_valid(${identifier})AND json_type(${identifier},'$.blobId')='text'`);
|
|
157
134
|
}
|
|
158
135
|
}
|
|
159
136
|
return selects.length === 0
|
|
@@ -161,9 +138,6 @@ function visibleBlobIdsSql(schema) {
|
|
|
161
138
|
: selects.join(' UNION ');
|
|
162
139
|
}
|
|
163
140
|
export function deleteUnreferencedCachedBlobs(db, schema) {
|
|
164
|
-
db.exec(`DELETE FROM _syncular_blobs
|
|
165
|
-
WHERE blob_id NOT IN (SELECT blob_id FROM _syncular_blob_uploads)
|
|
166
|
-
AND blob_id NOT IN (SELECT blob_id FROM _syncular_blob_commit_refs)
|
|
167
|
-
AND blob_id NOT IN (${visibleBlobIdsSql(schema)})`);
|
|
141
|
+
db.exec(`DELETE FROM _syncular_blobs WHERE blob_id NOT IN(SELECT blob_id FROM _syncular_blob_uploads)AND blob_id NOT IN(SELECT blob_id FROM _syncular_blob_commit_refs)AND blob_id NOT IN(${visibleBlobIdsSql(schema)})`);
|
|
168
142
|
}
|
|
169
143
|
export { parseBlobRef, serializeBlobRef };
|