@syncular/client 0.30.12 → 0.30.14
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/dist/apply.d.ts +3 -2
- package/dist/apply.js +63 -15
- package/dist/blob.d.ts +1 -0
- package/dist/blob.js +4 -3
- package/dist/client.d.ts +3 -0
- package/dist/client.js +180 -159
- package/dist/failed-overlay.d.ts +6 -3
- package/dist/failed-overlay.js +50 -48
- package/dist/outbox.d.ts +4 -1
- package/dist/outbox.js +18 -12
- package/dist/outcomes.d.ts +1 -0
- package/dist/outcomes.js +12 -7
- package/dist/previous-version.js +3 -3
- package/dist/schema.d.ts +1 -0
- package/dist/schema.js +23 -14
- package/dist/state.d.ts +2 -0
- package/dist/state.js +15 -15
- package/dist/window.d.ts +2 -0
- package/dist/window.js +13 -10
- package/dist/worker-entry.js +14 -35
- package/dist/worker-host.d.ts +1 -0
- package/dist/worker-host.js +3 -0
- package/dist/worker-protocol.d.ts +1 -0
- package/package.json +3 -3
- package/src/apply.ts +106 -37
- package/src/blob.ts +4 -3
- package/src/client.ts +224 -197
- package/src/failed-overlay.ts +96 -78
- package/src/outbox.ts +19 -16
- package/src/outcomes.ts +12 -12
- package/src/previous-version.ts +3 -3
- package/src/schema.ts +24 -18
- package/src/state.ts +15 -15
- package/src/window.ts +13 -15
- package/src/worker-entry.ts +13 -50
- package/src/worker-host.ts +4 -0
- package/src/worker-protocol.ts +1 -0
package/dist/apply.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type OverlayScope } from './failed-overlay.js';
|
|
1
2
|
/**
|
|
2
3
|
* Local application of server data: `COMMIT` frames (§4.5), rows segments
|
|
3
4
|
* (§5.2, §5.6), and the scope-matched delete shared by the §3.3 purge
|
|
@@ -8,7 +9,7 @@ import type { ClientDatabase } from './database.js';
|
|
|
8
9
|
import type { EncryptionConfig } from './encryption.js';
|
|
9
10
|
import { type CompiledClientSchema, type CompiledClientTable } from './schema.js';
|
|
10
11
|
/** Lets the client wrap the physical write in its revision transaction. */
|
|
11
|
-
export type ApplyTransaction =
|
|
12
|
+
export type ApplyTransaction = (fn: () => OverlayScope) => unknown;
|
|
12
13
|
export declare function upsertLocalRow(db: ClientDatabase, table: CompiledClientTable, values: readonly RowValue[], syncVersion: number, serverWrite?: boolean): void;
|
|
13
14
|
export declare function deleteLocalRow(db: ClientDatabase, table: CompiledClientTable, rowId: string, serverWrite?: boolean): void;
|
|
14
15
|
/**
|
|
@@ -23,7 +24,7 @@ export declare function deleteLocalRow(db: ClientDatabase, table: CompiledClient
|
|
|
23
24
|
* (`client.decrypt_failed`) aborts before any local write, so the commit
|
|
24
25
|
* never half-applies.
|
|
25
26
|
*/
|
|
26
|
-
export declare function applyCommitFrame(db: ClientDatabase, schema: CompiledClientSchema, frame: CommitFrame, encryption?: EncryptionConfig, transaction?: (fn: () =>
|
|
27
|
+
export declare function applyCommitFrame(db: ClientDatabase, schema: CompiledClientSchema, frame: CommitFrame, encryption?: EncryptionConfig, transaction?: (fn: () => OverlayScope) => unknown): Promise<void>;
|
|
27
28
|
/**
|
|
28
29
|
* §5.2: the segment's column table must match the generated schema for
|
|
29
30
|
* (table, schemaVersion) — order, names, types, nullability. A mismatch is
|
package/dist/apply.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { hasUniqueIndex } from './schema.js';
|
|
2
|
+
import { OUTBOX_TABLE, OUTBOX_IMAGES, countOutbox } from './outbox.js';
|
|
1
3
|
import { RETAINED_ROWS } from './failed-overlay.js';
|
|
2
4
|
import { retainedBaseWrite, restoreFailedBases } from './failed-overlay.js';
|
|
3
5
|
/**
|
|
@@ -76,13 +78,13 @@ export async function applyCommitFrame(db, schema, frame, encryption, transactio
|
|
|
76
78
|
});
|
|
77
79
|
}
|
|
78
80
|
transaction(() => {
|
|
79
|
-
const
|
|
80
|
-
?
|
|
81
|
-
:
|
|
82
|
-
restoreFailedBases(db, schema,
|
|
81
|
+
const scope = resolved.map((change, index) => JSON.stringify(hasUniqueIndex(change.table)
|
|
82
|
+
? [change.table.name]
|
|
83
|
+
: [change.table.name, frame.changes[index].rowId]));
|
|
84
|
+
restoreFailedBases(db, schema, scope);
|
|
83
85
|
for (const [index, change] of resolved.entries()) {
|
|
84
86
|
const rowId = frame.changes[index].rowId;
|
|
85
|
-
db.exec(
|
|
87
|
+
db.exec(`UPDATE ${OUTBOX_IMAGES} SET delivery_seq=MAX(COALESCE(delivery_seq,0),?) WHERE (client_commit_id,op_index) IN(SELECT client_commit_id,json_each.key FROM ${OUTBOX_TABLE},json_each(operations) WHERE json_extract(value,'$.table')=? AND json_extract(value,'$.rowId')=?)`, [frame.commitSeq, change.table.name, rowId]);
|
|
86
88
|
db.exec(`DELETE FROM ${RETAINED_ROWS} WHERE tbl=? AND id=? AND commit_seq<=?`, [change.table.name, rowId, frame.commitSeq]);
|
|
87
89
|
if (change.op === 'delete') {
|
|
88
90
|
deleteLocalRow(db, change.table, change.rowId);
|
|
@@ -91,7 +93,7 @@ export async function applyCommitFrame(db, schema, frame, encryption, transactio
|
|
|
91
93
|
upsertLocalRow(db, change.table, change.values, change.rowVersion);
|
|
92
94
|
}
|
|
93
95
|
}
|
|
94
|
-
return
|
|
96
|
+
return scope;
|
|
95
97
|
});
|
|
96
98
|
}
|
|
97
99
|
/**
|
|
@@ -147,12 +149,27 @@ export function localScopePredicate(table, effective) {
|
|
|
147
149
|
.join(' AND ');
|
|
148
150
|
return { sql, params };
|
|
149
151
|
}
|
|
152
|
+
function scopedRows(db, table, scope) {
|
|
153
|
+
return scope
|
|
154
|
+
? db.query(`SELECT ${quoteIdent(table.primaryKey)} AS id FROM ${quoteIdent(table.name)} WHERE ${scope.sql}`, scope.params)
|
|
155
|
+
: [];
|
|
156
|
+
}
|
|
157
|
+
function bootstrapScope(db, table, effective) {
|
|
158
|
+
const rows = scopedRows(db, table, localScopePredicate(table, effective));
|
|
159
|
+
rows.push(...db
|
|
160
|
+
.query(`SELECT id,base,intent FROM ${RETAINED_ROWS} WHERE tbl=?`, [
|
|
161
|
+
table.name,
|
|
162
|
+
])
|
|
163
|
+
.filter((row) => [row.base, row.intent].some((json) => json !== null &&
|
|
164
|
+
matchesLocalScopes(table, effective, JSON.parse(json)))));
|
|
165
|
+
return rows.map((row) => JSON.stringify([table.name, String(row.id)]));
|
|
166
|
+
}
|
|
150
167
|
export function deleteScopedRows(db, table, effective) {
|
|
151
168
|
const scope = localScopePredicate(table, effective);
|
|
152
169
|
if (!scope)
|
|
153
170
|
return;
|
|
154
171
|
const { sql, params } = scope;
|
|
155
|
-
for (const row of db
|
|
172
|
+
for (const row of scopedRows(db, table, scope)) {
|
|
156
173
|
retainedBaseWrite(db, table, String(row.id));
|
|
157
174
|
}
|
|
158
175
|
db.exec(`DELETE FROM ${quoteIdent(table.name)} WHERE ${sql}`, params);
|
|
@@ -287,18 +304,37 @@ export async function applySqliteSegment(db, schema, table, bytes, descriptor, o
|
|
|
287
304
|
predicates.push(`${primaryKey} <= ?`);
|
|
288
305
|
params.push(boundary);
|
|
289
306
|
}
|
|
307
|
+
const replay = countOutbox(db) ||
|
|
308
|
+
db.query(`SELECT 1 FROM ${RETAINED_ROWS} WHERE tbl=? LIMIT 1`, [
|
|
309
|
+
table.name,
|
|
310
|
+
]).length;
|
|
311
|
+
const rows = replay
|
|
312
|
+
? db.query(`SELECT * FROM ${source} WHERE ${predicates.join(' AND ') || 'true'}`, params)
|
|
313
|
+
: [];
|
|
290
314
|
(options.transaction ?? ((fn) => db.transaction(fn)))(() => {
|
|
291
|
-
|
|
315
|
+
const scope = !replay
|
|
316
|
+
? []
|
|
317
|
+
: hasUniqueIndex(table)
|
|
318
|
+
? [JSON.stringify([table.name])]
|
|
319
|
+
: [
|
|
320
|
+
...(after === undefined && options.clearFirst
|
|
321
|
+
? bootstrapScope(db, table, options.effective)
|
|
322
|
+
: []),
|
|
323
|
+
...rows.map((row) => JSON.stringify([table.name, String(row[table.primaryKey])])),
|
|
324
|
+
];
|
|
325
|
+
const protectedRows = restoreFailedBases(db, schema, scope);
|
|
292
326
|
if (after === undefined && options.clearFirst)
|
|
293
327
|
deleteScopedRows(db, table, options.effective);
|
|
294
328
|
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);
|
|
295
329
|
applied += Number(db.query('SELECT changes()AS n')[0]?.n ?? 0);
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
330
|
+
if (protectedRows)
|
|
331
|
+
for (const row of rows) {
|
|
332
|
+
retainedBaseWrite(db, table, String(row[table.primaryKey]), Object.fromEntries(table.columns.map((column) => [
|
|
333
|
+
column.name,
|
|
334
|
+
rowValueToJson(row[column.name]),
|
|
335
|
+
])), Number(row._syncular_version));
|
|
336
|
+
}
|
|
337
|
+
return scope;
|
|
302
338
|
});
|
|
303
339
|
options.onProgress?.(applied);
|
|
304
340
|
await yieldToHost();
|
|
@@ -347,7 +383,18 @@ export async function applyRowsSegment(db, schema, table, segment, options, encr
|
|
|
347
383
|
if (!clearThisBlock && rows.length === 0)
|
|
348
384
|
continue;
|
|
349
385
|
(options.transaction ?? ((fn) => db.transaction(fn)))(() => {
|
|
350
|
-
const
|
|
386
|
+
const scope = hasUniqueIndex(table)
|
|
387
|
+
? [JSON.stringify([table.name])]
|
|
388
|
+
: [
|
|
389
|
+
...(clearThisBlock
|
|
390
|
+
? bootstrapScope(db, table, options.effective)
|
|
391
|
+
: []),
|
|
392
|
+
...rows.map((row) => JSON.stringify([
|
|
393
|
+
table.name,
|
|
394
|
+
String(row.values[table.primaryKeyIndex]),
|
|
395
|
+
])),
|
|
396
|
+
];
|
|
397
|
+
const protectedRows = restoreFailedBases(db, schema, scope);
|
|
351
398
|
if (clearThisBlock) {
|
|
352
399
|
deleteScopedRows(db, table, options.effective);
|
|
353
400
|
}
|
|
@@ -357,6 +404,7 @@ export async function applyRowsSegment(db, schema, table, segment, options, encr
|
|
|
357
404
|
if (applied % 1024 === 0)
|
|
358
405
|
options.onProgress?.(applied);
|
|
359
406
|
}
|
|
407
|
+
return scope;
|
|
360
408
|
});
|
|
361
409
|
options.onProgress?.(applied);
|
|
362
410
|
await yieldToHost();
|
package/dist/blob.d.ts
CHANGED
package/dist/blob.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
export const BLOB_COMMIT_REFS = '_syncular_blob_commit_refs';
|
|
1
2
|
/**
|
|
2
3
|
* Client-side blob cache + transport (SPEC.md §5.9.7).
|
|
3
4
|
*
|
|
@@ -70,7 +71,7 @@ export function enforceBlobCacheCap(db, schema, maxBytes) {
|
|
|
70
71
|
if (total <= maxBytes)
|
|
71
72
|
return [];
|
|
72
73
|
// Oldest eligible body first, with blob ID as a stable tie.
|
|
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
|
|
74
|
+
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 ${BLOB_COMMIT_REFS})AND blob_id NOT IN(${visibleBlobIdsSql(schema)})ORDER BY created_at_ms ASC,blob_id ASC`);
|
|
74
75
|
const evicted = [];
|
|
75
76
|
db.transaction(() => {
|
|
76
77
|
for (const row of candidates) {
|
|
@@ -86,7 +87,7 @@ export function enforceBlobCacheCap(db, schema, maxBytes) {
|
|
|
86
87
|
}
|
|
87
88
|
export function listPendingUploads(db) {
|
|
88
89
|
return db
|
|
89
|
-
.query(
|
|
90
|
+
.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 ${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`)
|
|
90
91
|
.map((row) => {
|
|
91
92
|
if (typeof row.blob_id !== 'string' ||
|
|
92
93
|
(row.media_type !== null && typeof row.media_type !== 'string')) {
|
|
@@ -138,6 +139,6 @@ function visibleBlobIdsSql(schema) {
|
|
|
138
139
|
: selects.join(' UNION ');
|
|
139
140
|
}
|
|
140
141
|
export function deleteUnreferencedCachedBlobs(db, 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
|
|
142
|
+
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 ${BLOB_COMMIT_REFS})AND blob_id NOT IN(${visibleBlobIdsSql(schema)})`);
|
|
142
143
|
}
|
|
143
144
|
export { parseBlobRef, serializeBlobRef };
|
package/dist/client.d.ts
CHANGED
|
@@ -104,6 +104,7 @@ export interface SyncClientLimits {
|
|
|
104
104
|
readonly outcomeRetentionMaxEntries?: number;
|
|
105
105
|
}
|
|
106
106
|
export interface SyncClientConfig {
|
|
107
|
+
readonly transportEnabled?: boolean;
|
|
107
108
|
/** Keep rejected/conflicting local intent visible until explicit resolution. */
|
|
108
109
|
readonly retainFailedCommits?: boolean;
|
|
109
110
|
readonly database: ClientDatabase;
|
|
@@ -295,6 +296,8 @@ export declare class SyncClient {
|
|
|
295
296
|
/** Acquire leadership, create local tables, resolve the clientId. */
|
|
296
297
|
start(): Promise<void>;
|
|
297
298
|
close(): Promise<void>;
|
|
299
|
+
/** Host-owned transport policy; the local replica remains available. */
|
|
300
|
+
setTransportEnabled(enabled: boolean): void;
|
|
298
301
|
/** Current fail-closed local-replica security state. */
|
|
299
302
|
securityLifecycle(): SecurityLifecycle;
|
|
300
303
|
/**
|