@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/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
- * `true` iff any pinned row was left behind, so the caller knows to defer
219
- * the rest of the eviction until the outbox drains. Also removes the
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 ${quoteIdent(table.name)} WHERE ${clauses.join(' AND ')}${pinnedClause}`,
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
- if (pinnedRowIds.size === 0) return false;
259
- // A pin still matters only if a pinned row actually falls inside this
260
- // unit's effective scopes; check by re-selecting the survivors.
261
- const survivors = db.query(
262
- `SELECT ${pk} AS pk FROM ${quoteIdent(table.name)} WHERE ${clauses.join(' AND ')}`,
263
- params.slice(0, params.length - pinnedRowIds.size),
264
- );
265
- for (const row of survivors) {
266
- if (pinnedRowIds.has(String(row.pk))) return true;
267
- }
268
- return false;
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-image segment in ONE local transaction: validate
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 every row with a single
293
- * one primary-key upsert — `_syncular_version` lands in
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
- // 3. One transaction: fresh-bootstrap clear, then primary-key upsert.
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 !== quoteIdent(table.primaryKey))
390
+ .filter((name) => name !== primaryKey)
378
391
  .map((name) => `${name}=excluded.${name}`)
379
392
  .join(', ');
380
- return (options.transaction ?? ((fn) => db.transaction(fn)))(() => {
381
- if (options.clearFirst) {
382
- deleteScopedRows(db, table, options.effective);
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
- db.exec(
385
- `INSERT INTO ${quoteIdent(table.name)}
386
- (${insertNames.join(', ')})
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
- return applied;
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
+ }
@@ -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>(bytes: Uint8Array, alias: string, fn: () => T): 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
  }