@syncular/client 0.19.0 → 0.20.1
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 +118 -6
- package/dist/apply.d.ts +16 -7
- package/dist/apply.js +83 -37
- package/dist/bun-database.d.ts +1 -1
- package/dist/bun-database.js +2 -2
- package/dist/client.d.ts +7 -4
- package/dist/client.js +121 -35
- package/dist/database.d.ts +3 -2
- package/dist/http.d.ts +0 -8
- package/dist/http.js +36 -3
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/node-database.d.ts +1 -1
- package/dist/node-database.js +2 -2
- package/dist/progress.d.ts +24 -0
- package/dist/progress.js +42 -0
- package/dist/reactive-store.js +49 -8
- package/dist/schema.js +15 -4
- package/dist/transport.d.ts +2 -1
- package/dist/wasm-database.js +44 -4
- package/dist/worker-entry.d.ts +2 -0
- package/dist/worker-entry.js +34 -7
- package/dist/worker-host.d.ts +3 -0
- package/dist/worker-host.js +13 -1
- package/dist/worker-protocol.d.ts +4 -0
- package/package.json +4 -3
- package/src/apply.ts +99 -47
- package/src/bun-database.ts +6 -2
- package/src/client.ts +153 -38
- package/src/database.ts +7 -2
- package/src/http.ts +41 -3
- package/src/index.ts +6 -0
- package/src/node-database.ts +6 -2
- package/src/progress.ts +62 -0
- package/src/reactive-store.ts +58 -8
- package/src/schema.ts +22 -4
- package/src/transport.ts +5 -1
- package/src/wasm-database.ts +86 -4
- package/src/worker-entry.ts +38 -7
- package/src/worker-host.ts +17 -1
- package/src/worker-protocol.ts +2 -0
package/src/apply.ts
CHANGED
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
type ScopeMap,
|
|
12
12
|
type SegmentRow,
|
|
13
13
|
} from '@syncular/core';
|
|
14
|
-
import type { ClientDatabase } from './database';
|
|
14
|
+
import type { ClientDatabase, SqlValue } from './database';
|
|
15
15
|
import type { EncryptionConfig } from './encryption';
|
|
16
16
|
import { ClientSyncError } from './errors';
|
|
17
17
|
import {
|
|
@@ -215,8 +215,8 @@ export function deleteScopedRows(
|
|
|
215
215
|
* scopes (same local-scope-column rule and fail-closed clause as
|
|
216
216
|
* {@link deleteScopedRows}) EXCEPT rows whose primary key is in
|
|
217
217
|
* `pinnedRowIds` (E1 — pinned by a still-pending outbox commit). Returns
|
|
218
|
-
*
|
|
219
|
-
*
|
|
218
|
+
* whether unpinned work remains and whether any scoped rows remain. Each
|
|
219
|
+
* call removes at most 1,024 rows; the caller commits before yielding. Also removes the
|
|
220
220
|
* evicted rows' `server_version` with them (E2 — no residual version
|
|
221
221
|
* cache), which is automatic since the version column is per-row.
|
|
222
222
|
*/
|
|
@@ -225,9 +225,9 @@ export function evictScopedRows(
|
|
|
225
225
|
table: CompiledClientTable,
|
|
226
226
|
effective: ScopeMap,
|
|
227
227
|
pinnedRowIds: ReadonlySet<string>,
|
|
228
|
-
): boolean {
|
|
228
|
+
): { remaining: boolean; deferred: boolean } {
|
|
229
229
|
const entries = Object.entries(effective);
|
|
230
|
-
if (entries.length === 0) return false;
|
|
230
|
+
if (entries.length === 0) return { remaining: false, deferred: false };
|
|
231
231
|
const clauses: string[] = [];
|
|
232
232
|
const params: string[] = [];
|
|
233
233
|
for (const [variable, values] of entries) {
|
|
@@ -238,7 +238,7 @@ export function evictScopedRows(
|
|
|
238
238
|
`table ${JSON.stringify(table.name)} has no local scope-column mapping for ${JSON.stringify(variable)} (§4.8/§3.3 fail-closed)`,
|
|
239
239
|
);
|
|
240
240
|
}
|
|
241
|
-
if (values.length === 0) return false;
|
|
241
|
+
if (values.length === 0) return { remaining: false, deferred: false };
|
|
242
242
|
clauses.push(
|
|
243
243
|
`${quoteIdent(column)} IN (${values.map(() => '?').join(', ')})`,
|
|
244
244
|
);
|
|
@@ -251,21 +251,25 @@ export function evictScopedRows(
|
|
|
251
251
|
pinnedClause = ` AND ${pk} NOT IN (${ids.map(() => '?').join(', ')})`;
|
|
252
252
|
params.push(...ids);
|
|
253
253
|
}
|
|
254
|
+
const target = quoteIdent(table.name);
|
|
254
255
|
db.exec(
|
|
255
|
-
`DELETE FROM ${
|
|
256
|
+
`DELETE FROM ${target} WHERE ${pk} IN (
|
|
257
|
+
SELECT ${pk} FROM ${target} WHERE ${clauses.join(' AND ')}${pinnedClause} LIMIT 1024
|
|
258
|
+
)`,
|
|
256
259
|
params,
|
|
257
260
|
);
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
261
|
+
const remaining =
|
|
262
|
+
db.query(
|
|
263
|
+
`SELECT 1 FROM ${target} WHERE ${clauses.join(' AND ')}${pinnedClause} LIMIT 1`,
|
|
264
|
+
params,
|
|
265
|
+
).length > 0;
|
|
266
|
+
const deferred =
|
|
267
|
+
remaining ||
|
|
268
|
+
db.query(
|
|
269
|
+
`SELECT 1 FROM ${target} WHERE ${clauses.join(' AND ')} LIMIT 1`,
|
|
270
|
+
params.slice(0, params.length - pinnedRowIds.size),
|
|
271
|
+
).length > 0;
|
|
272
|
+
return { remaining, deferred };
|
|
269
273
|
}
|
|
270
274
|
|
|
271
275
|
/** Descriptor fields a sqlite image is validated against (§5.3). */
|
|
@@ -286,14 +290,14 @@ function imageInvalid(detail: string): never {
|
|
|
286
290
|
}
|
|
287
291
|
|
|
288
292
|
/**
|
|
289
|
-
* Apply a §5.3 sqlite
|
|
293
|
+
* Apply a §5.3 sqlite image in committed chunks of at most 1,024 rows: validate
|
|
290
294
|
* the in-file metadata against the descriptor, validate the data table's
|
|
291
295
|
* column names/order against the generated schema, run the §5.6
|
|
292
|
-
* first-page clear when fresh, then copy
|
|
293
|
-
*
|
|
296
|
+
* first-page clear when fresh, then bulk-copy each chunk through a
|
|
297
|
+
* primary-key upsert — `_syncular_version` lands in
|
|
294
298
|
* `_sync_version` exactly like a rows segment's per-row `serverVersion`.
|
|
295
299
|
*/
|
|
296
|
-
export function applySqliteSegment(
|
|
300
|
+
export async function applySqliteSegment(
|
|
297
301
|
db: ClientDatabase,
|
|
298
302
|
schema: CompiledClientSchema,
|
|
299
303
|
table: CompiledClientTable,
|
|
@@ -303,8 +307,10 @@ export function applySqliteSegment(
|
|
|
303
307
|
readonly clearFirst: boolean;
|
|
304
308
|
readonly effective: ScopeMap;
|
|
305
309
|
readonly transaction?: ApplyTransaction;
|
|
310
|
+
readonly onProgress?: (rowsProcessed: number) => void;
|
|
311
|
+
readonly isCurrent?: () => boolean;
|
|
306
312
|
},
|
|
307
|
-
): number {
|
|
313
|
+
): Promise<number> {
|
|
308
314
|
const withImage = db.withSqliteImage?.bind(db);
|
|
309
315
|
if (withImage === undefined) {
|
|
310
316
|
throw new ClientSyncError(
|
|
@@ -315,7 +321,7 @@ export function applySqliteSegment(
|
|
|
315
321
|
if (descriptor.table !== table.name) {
|
|
316
322
|
imageInvalid(`descriptor table ${JSON.stringify(descriptor.table)}`);
|
|
317
323
|
}
|
|
318
|
-
return withImage(bytes, IMAGE_ALIAS, () => {
|
|
324
|
+
return withImage(bytes, IMAGE_ALIAS, async () => {
|
|
319
325
|
// 1. Metadata vs descriptor (§5.3 rule 2). A file that is not a
|
|
320
326
|
// SQLite database or lacks the metadata table fails right here.
|
|
321
327
|
let meta: ReturnType<ClientDatabase['query']>;
|
|
@@ -370,36 +376,61 @@ export function applySqliteSegment(
|
|
|
370
376
|
);
|
|
371
377
|
}
|
|
372
378
|
|
|
373
|
-
|
|
379
|
+
const primaryKeys = info.filter((column) => Number(column.pk) > 0);
|
|
380
|
+
if (primaryKeys.length !== 1 || primaryKeys[0]?.name !== table.primaryKey)
|
|
381
|
+
imageInvalid('image primary key does not match generated schema');
|
|
382
|
+
const source = `${IMAGE_ALIAS}.${quoteIdent(table.name)}`;
|
|
383
|
+
const count = Number(db.query(`SELECT count(*) AS n FROM ${source}`)[0]?.n);
|
|
384
|
+
if (count !== descriptor.rowCount)
|
|
385
|
+
imageInvalid('image row count does not match descriptor');
|
|
374
386
|
const names = table.columns.map((column) => quoteIdent(column.name));
|
|
375
387
|
const insertNames = [...names, quoteIdent(SYNC_VERSION_COLUMN)];
|
|
388
|
+
const primaryKey = quoteIdent(table.primaryKey);
|
|
376
389
|
const updates = insertNames
|
|
377
|
-
.filter((name) => name !==
|
|
390
|
+
.filter((name) => name !== primaryKey)
|
|
378
391
|
.map((name) => `${name}=excluded.${name}`)
|
|
379
392
|
.join(', ');
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
393
|
+
let after: SqlValue | undefined;
|
|
394
|
+
let applied = 0;
|
|
395
|
+
for (;;) {
|
|
396
|
+
if (options.isCurrent?.() === false) return applied;
|
|
397
|
+
const boundary = db.query(
|
|
398
|
+
`SELECT ${primaryKey} AS boundary FROM ${source}
|
|
399
|
+
${after === undefined ? '' : `WHERE ${primaryKey} > ?`}
|
|
400
|
+
ORDER BY ${primaryKey} LIMIT 1 OFFSET 1023`,
|
|
401
|
+
after === undefined ? [] : [after],
|
|
402
|
+
)[0]?.boundary;
|
|
403
|
+
if (boundary === null) imageInvalid('image primary key must not be null');
|
|
404
|
+
const predicates: string[] = [];
|
|
405
|
+
const params: SqlValue[] = [];
|
|
406
|
+
if (after !== undefined) {
|
|
407
|
+
predicates.push(`${primaryKey} > ?`);
|
|
408
|
+
params.push(after);
|
|
383
409
|
}
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
SELECT ${[...names, quoteIdent('_syncular_version')].join(', ')}
|
|
388
|
-
FROM ${IMAGE_ALIAS}.${quoteIdent(table.name)}
|
|
389
|
-
WHERE true
|
|
390
|
-
ON CONFLICT (${quoteIdent(table.primaryKey)}) DO UPDATE SET ${updates}`,
|
|
391
|
-
);
|
|
392
|
-
const counted = db.query(
|
|
393
|
-
`SELECT count(*) AS n FROM ${IMAGE_ALIAS}.${quoteIdent(table.name)}`,
|
|
394
|
-
)[0];
|
|
395
|
-
const applied = Number(counted?.n ?? 0);
|
|
396
|
-
if (applied !== descriptor.rowCount) {
|
|
397
|
-
imageInvalid(
|
|
398
|
-
`image holds ${applied} rows, descriptor says ${descriptor.rowCount}`,
|
|
399
|
-
);
|
|
410
|
+
if (boundary !== undefined) {
|
|
411
|
+
predicates.push(`${primaryKey} <= ?`);
|
|
412
|
+
params.push(boundary);
|
|
400
413
|
}
|
|
401
|
-
|
|
402
|
-
|
|
414
|
+
(options.transaction ?? ((fn) => db.transaction(fn)))(() => {
|
|
415
|
+
if (after === undefined && options.clearFirst)
|
|
416
|
+
deleteScopedRows(db, table, options.effective);
|
|
417
|
+
db.exec(
|
|
418
|
+
`INSERT INTO ${quoteIdent(table.name)} (${insertNames.join(', ')})
|
|
419
|
+
SELECT ${[...names, quoteIdent('_syncular_version')].join(', ')}
|
|
420
|
+
FROM ${source} WHERE ${predicates.join(' AND ') || 'true'}
|
|
421
|
+
ON CONFLICT (${primaryKey}) DO UPDATE SET ${updates}`,
|
|
422
|
+
params,
|
|
423
|
+
);
|
|
424
|
+
applied += Number(db.query('SELECT changes() AS n')[0]?.n ?? 0);
|
|
425
|
+
});
|
|
426
|
+
options.onProgress?.(applied);
|
|
427
|
+
await yieldToHost();
|
|
428
|
+
if (boundary === undefined || applied === count) break;
|
|
429
|
+
after = boundary;
|
|
430
|
+
}
|
|
431
|
+
if (applied !== count)
|
|
432
|
+
imageInvalid('image applied row count does not match descriptor');
|
|
433
|
+
return applied;
|
|
403
434
|
});
|
|
404
435
|
}
|
|
405
436
|
|
|
@@ -420,6 +451,8 @@ export async function applyRowsSegment(
|
|
|
420
451
|
readonly clearFirst: boolean;
|
|
421
452
|
readonly effective: ScopeMap;
|
|
422
453
|
readonly transaction?: ApplyTransaction;
|
|
454
|
+
readonly onProgress?: (rowsProcessed: number) => void;
|
|
455
|
+
readonly isCurrent?: () => boolean;
|
|
423
456
|
},
|
|
424
457
|
encryption?: EncryptionConfig,
|
|
425
458
|
): Promise<number> {
|
|
@@ -429,6 +462,7 @@ export async function applyRowsSegment(
|
|
|
429
462
|
const blocks: readonly (readonly SegmentRow[])[] =
|
|
430
463
|
segment.blocks.length > 0 ? segment.blocks : [[]];
|
|
431
464
|
for (const block of blocks) {
|
|
465
|
+
if (options.isCurrent?.() === false) break;
|
|
432
466
|
// §5.11: decrypt this block's rows before opening the sync transaction
|
|
433
467
|
// (WebCrypto is async; the SQLite transaction is not). Decrypt failure
|
|
434
468
|
// aborts before any write in this block.
|
|
@@ -443,6 +477,7 @@ export async function applyRowsSegment(
|
|
|
443
477
|
: row.values;
|
|
444
478
|
rows.push({ values, serverVersion: row.serverVersion });
|
|
445
479
|
}
|
|
480
|
+
if (options.isCurrent?.() === false) break;
|
|
446
481
|
const clearThisBlock = first && options.clearFirst;
|
|
447
482
|
first = false;
|
|
448
483
|
if (!clearThisBlock && rows.length === 0) continue;
|
|
@@ -453,8 +488,25 @@ export async function applyRowsSegment(
|
|
|
453
488
|
for (const row of rows) {
|
|
454
489
|
upsertLocalRow(db, table, row.values, row.serverVersion);
|
|
455
490
|
applied += 1;
|
|
491
|
+
if (applied % 1024 === 0) options.onProgress?.(applied);
|
|
456
492
|
}
|
|
457
493
|
});
|
|
494
|
+
options.onProgress?.(applied);
|
|
495
|
+
await yieldToHost();
|
|
458
496
|
}
|
|
497
|
+
options.onProgress?.(applied);
|
|
459
498
|
return applied;
|
|
460
499
|
}
|
|
500
|
+
|
|
501
|
+
/** Let worker messages run only after the current SQLite transaction commits. */
|
|
502
|
+
export function yieldToHost(): Promise<void> {
|
|
503
|
+
return new Promise((resolve) => {
|
|
504
|
+
const channel = new MessageChannel();
|
|
505
|
+
channel.port1.onmessage = () => {
|
|
506
|
+
channel.port1.close();
|
|
507
|
+
channel.port2.close();
|
|
508
|
+
resolve();
|
|
509
|
+
};
|
|
510
|
+
channel.port2.postMessage(null);
|
|
511
|
+
});
|
|
512
|
+
}
|
package/src/bun-database.ts
CHANGED
|
@@ -69,7 +69,11 @@ export class BunClientDatabase implements ClientDatabase {
|
|
|
69
69
|
* §5.3 image import: bun:sqlite attaches files, not buffers, so the
|
|
70
70
|
* image lands in a private temp file for the duration of the ATTACH.
|
|
71
71
|
*/
|
|
72
|
-
withSqliteImage<T>(
|
|
72
|
+
async withSqliteImage<T>(
|
|
73
|
+
bytes: Uint8Array,
|
|
74
|
+
alias: string,
|
|
75
|
+
fn: () => T | Promise<T>,
|
|
76
|
+
): Promise<T> {
|
|
73
77
|
assertImageAlias(alias);
|
|
74
78
|
const dir = mkdtempSync(join(tmpdir(), 'syncular-image-'));
|
|
75
79
|
const path = join(dir, 'segment.db');
|
|
@@ -77,7 +81,7 @@ export class BunClientDatabase implements ClientDatabase {
|
|
|
77
81
|
writeFileSync(path, bytes);
|
|
78
82
|
this.db.run(`ATTACH DATABASE ? AS ${alias}`, [path]);
|
|
79
83
|
try {
|
|
80
|
-
return fn();
|
|
84
|
+
return await fn();
|
|
81
85
|
} finally {
|
|
82
86
|
this.db.run(`DETACH DATABASE ${alias}`);
|
|
83
87
|
}
|