@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.
@@ -437,7 +437,29 @@ async function startWorkerCore(options) {
437
437
  const worker = config.worker();
438
438
  const pending = new Map();
439
439
  const nextId = { value: 1 };
440
+ let terminated = false;
441
+ let rejectReady;
442
+ const closedError = new ClientSyncError(WORKER_FAILED_CODE, 'the handle was closed');
443
+ const terminate = () => {
444
+ if (terminated)
445
+ return;
446
+ terminated = true;
447
+ globalThis.removeEventListener?.('pagehide', terminate);
448
+ bridge?.close();
449
+ worker.terminate();
450
+ rejectReady?.(closedError);
451
+ for (const entry of pending.values())
452
+ entry.reject(closedError);
453
+ pending.clear();
454
+ void lease.release();
455
+ };
456
+ // Installed before bootstrap starts: pagehide must also terminate a worker
457
+ // whose first open/import is still pending. Termination closes its SAHs and
458
+ // releases its physical Web Lock; the next document waits for that lock.
459
+ globalThis.addEventListener?.('pagehide', terminate);
440
460
  const invoke = (method, args) => {
461
+ if (terminated)
462
+ return Promise.reject(closedError);
441
463
  const id = nextId.value++;
442
464
  return new Promise((resolve, reject) => {
443
465
  pending.set(id, { resolve, reject });
@@ -451,6 +473,7 @@ async function startWorkerCore(options) {
451
473
  };
452
474
  let bridge;
453
475
  const ready = new Promise((resolve, reject) => {
476
+ rejectReady = reject;
454
477
  const onMessage = (event) => {
455
478
  const message = event.data;
456
479
  switch (message.t) {
@@ -507,27 +530,30 @@ async function startWorkerCore(options) {
507
530
  invoke,
508
531
  bridge,
509
532
  lease,
510
- close: async (terminate) => {
533
+ close: async (terminateWorker) => {
534
+ if (terminated)
535
+ return;
511
536
  bridge?.close();
512
537
  try {
513
538
  await invoke('close', []);
514
539
  }
515
540
  catch {
516
- // Closing a wedged worker still terminates it below.
541
+ // A terminated or failed worker has no further close RPC to service.
542
+ }
543
+ if (terminateWorker)
544
+ terminate();
545
+ else {
546
+ globalThis.removeEventListener?.('pagehide', terminate);
547
+ for (const entry of pending.values())
548
+ entry.reject(closedError);
549
+ pending.clear();
550
+ await lease.release();
517
551
  }
518
- if (terminate)
519
- worker.terminate();
520
- const closedError = new ClientSyncError(WORKER_FAILED_CODE, 'the handle was closed');
521
- for (const entry of pending.values())
522
- entry.reject(closedError);
523
- pending.clear();
524
- await lease.release();
525
552
  },
526
553
  };
527
554
  }
528
555
  catch (error) {
529
- worker.terminate();
530
- await lease.release();
556
+ terminate();
531
557
  throw error;
532
558
  }
533
559
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syncular/client",
3
- "version": "0.30.10",
3
+ "version": "0.30.11",
4
4
  "description": "Syncular TypeScript client core — offline-first sync over SQLite (WASM/OPFS, Bun, Node)",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Benjamin Kniffler",
@@ -89,10 +89,10 @@
89
89
  },
90
90
  "dependencies": {
91
91
  "@sqlite.org/sqlite-wasm": "^3.53.0-build1",
92
- "@syncular/core": "0.30.10"
92
+ "@syncular/core": "0.30.11"
93
93
  },
94
94
  "devDependencies": {
95
- "@syncular/server": "0.30.10",
95
+ "@syncular/server": "0.30.11",
96
96
  "playwright": "1.63.0",
97
97
  "@electric-sql/pglite": "^0.5.4"
98
98
  }
package/src/apply.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { retainedBaseWrite } from './failed-overlay';
1
+ import { retainedBaseWrite, restoreFailedBases } from './failed-overlay';
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
@@ -37,8 +37,7 @@ function upsertSql(table: CompiledClientTable): string {
37
37
  .filter((name) => name !== quoteIdent(table.primaryKey))
38
38
  .map((name) => `${name}=excluded.${name}`)
39
39
  .join(', ');
40
- return `INSERT INTO ${quoteIdent(table.name)} (${names.join(', ')}) VALUES (${placeholders})
41
- ON CONFLICT (${quoteIdent(table.primaryKey)}) DO UPDATE SET ${updates}`;
40
+ return `INSERT INTO ${quoteIdent(table.name)}(${names.join(', ')})VALUES(${placeholders})ON CONFLICT(${quoteIdent(table.primaryKey)})DO UPDATE SET ${updates}`;
42
41
  }
43
42
 
44
43
  export function upsertLocalRow(
@@ -48,10 +47,11 @@ export function upsertLocalRow(
48
47
  syncVersion: number,
49
48
  serverWrite = true,
50
49
  ): void {
50
+ db.exec(upsertSql(table), [...values.map(toSqlValue), syncVersion]);
51
51
  if (serverWrite)
52
52
  retainedBaseWrite(
53
53
  db,
54
- table.name,
54
+ table,
55
55
  String(values[table.primaryKeyIndex]),
56
56
  Object.fromEntries(
57
57
  table.columns.map((column, index) => [
@@ -61,7 +61,6 @@ export function upsertLocalRow(
61
61
  ),
62
62
  syncVersion,
63
63
  );
64
- db.exec(upsertSql(table), [...values.map(toSqlValue), syncVersion]);
65
64
  }
66
65
 
67
66
  export function deleteLocalRow(
@@ -70,9 +69,9 @@ export function deleteLocalRow(
70
69
  rowId: string,
71
70
  serverWrite = true,
72
71
  ): void {
73
- if (serverWrite) retainedBaseWrite(db, table.name, rowId);
72
+ if (serverWrite) retainedBaseWrite(db, table, rowId);
74
73
  db.exec(
75
- `DELETE FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)} = ?`,
74
+ `DELETE FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)}=?`,
76
75
  [rowId],
77
76
  );
78
77
  }
@@ -139,6 +138,7 @@ export async function applyCommitFrame(
139
138
  });
140
139
  }
141
140
  transaction(() => {
141
+ restoreFailedBases(db, schema);
142
142
  for (const change of resolved) {
143
143
  if (change.op === 'delete') {
144
144
  deleteLocalRow(db, change.table, change.rowId);
@@ -222,7 +222,7 @@ export function deleteScopedRows(
222
222
  `SELECT ${quoteIdent(table.primaryKey)} AS id FROM ${quoteIdent(table.name)} WHERE ${clauses.join(' AND ')}`,
223
223
  params,
224
224
  )) {
225
- retainedBaseWrite(db, table.name, String(row.id));
225
+ retainedBaseWrite(db, table, String(row.id));
226
226
  }
227
227
  db.exec(
228
228
  `DELETE FROM ${quoteIdent(table.name)} WHERE ${clauses.join(' AND ')}`,
@@ -273,9 +273,7 @@ export function evictScopedRows(
273
273
  }
274
274
  const target = quoteIdent(table.name);
275
275
  db.exec(
276
- `DELETE FROM ${target} WHERE ${pk} IN (
277
- SELECT ${pk} FROM ${target} WHERE ${clauses.join(' AND ')}${pinnedClause} LIMIT 1024
278
- )`,
276
+ `DELETE FROM ${target} WHERE ${pk} IN(SELECT ${pk} FROM ${target} WHERE ${clauses.join(' AND ')}${pinnedClause} LIMIT 1024)`,
279
277
  params,
280
278
  );
281
279
  const remaining =
@@ -343,10 +341,7 @@ export async function applySqliteSegment(
343
341
  let meta: ReturnType<ClientDatabase['query']>;
344
342
  try {
345
343
  meta = db.query(
346
- `SELECT format, "table" AS tbl, "schemaVersion" AS sv,
347
- "asOfCommitSeq" AS pin, "scopeDigest" AS sd,
348
- "rowCount" AS rc
349
- FROM ${IMAGE_ALIAS}."_syncular_segment"`,
344
+ `SELECT format,"table" AS tbl,"schemaVersion" AS sv,"asOfCommitSeq" AS pin,"scopeDigest" AS sd,"rowCount" AS rc FROM ${IMAGE_ALIAS}."_syncular_segment"`,
350
345
  );
351
346
  } catch {
352
347
  imageInvalid('bytes are not a SQLite image with _syncular_segment');
@@ -396,7 +391,7 @@ export async function applySqliteSegment(
396
391
  if (primaryKeys.length !== 1 || primaryKeys[0]?.name !== table.primaryKey)
397
392
  imageInvalid('image primary key does not match generated schema');
398
393
  const source = `${IMAGE_ALIAS}.${quoteIdent(table.name)}`;
399
- const count = Number(db.query(`SELECT count(*) AS n FROM ${source}`)[0]?.n);
394
+ const count = Number(db.query(`SELECT count(*)AS n FROM ${source}`)[0]?.n);
400
395
  if (count !== descriptor.rowCount)
401
396
  imageInvalid('image row count does not match descriptor');
402
397
  const names = table.columns.map((column) => quoteIdent(column.name));
@@ -411,9 +406,7 @@ export async function applySqliteSegment(
411
406
  for (;;) {
412
407
  if (options.isCurrent?.() === false) return applied;
413
408
  const boundary = db.query(
414
- `SELECT ${primaryKey} AS boundary FROM ${source}
415
- ${after === undefined ? '' : `WHERE ${primaryKey} > ?`}
416
- ORDER BY ${primaryKey} LIMIT 1 OFFSET 1023`,
409
+ `SELECT ${primaryKey} AS boundary FROM ${source} ${after === undefined ? '' : `WHERE ${primaryKey} > ?`} ORDER BY ${primaryKey} LIMIT 1 OFFSET 1023`,
417
410
  after === undefined ? [] : [after],
418
411
  )[0]?.boundary;
419
412
  if (boundary === null) imageInvalid('image primary key must not be null');
@@ -428,23 +421,21 @@ export async function applySqliteSegment(
428
421
  params.push(boundary);
429
422
  }
430
423
  (options.transaction ?? ((fn) => db.transaction(fn)))(() => {
424
+ restoreFailedBases(db, schema);
431
425
  if (after === undefined && options.clearFirst)
432
426
  deleteScopedRows(db, table, options.effective);
433
427
  db.exec(
434
- `INSERT INTO ${quoteIdent(table.name)} (${insertNames.join(', ')})
435
- SELECT ${[...names, quoteIdent('_syncular_version')].join(', ')}
436
- FROM ${source} WHERE ${predicates.join(' AND ') || 'true'}
437
- ON CONFLICT (${primaryKey}) DO UPDATE SET ${updates}`,
428
+ `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}`,
438
429
  params,
439
430
  );
440
- applied += Number(db.query('SELECT changes() AS n')[0]?.n ?? 0);
431
+ applied += Number(db.query('SELECT changes()AS n')[0]?.n ?? 0);
441
432
  for (const row of db.query(
442
433
  `SELECT * FROM ${source} WHERE ${predicates.join(' AND ') || 'true'}`,
443
434
  params,
444
435
  )) {
445
436
  retainedBaseWrite(
446
437
  db,
447
- table.name,
438
+ table,
448
439
  String(row[table.primaryKey]),
449
440
  Object.fromEntries(
450
441
  table.columns.map((column) => [
@@ -515,6 +506,7 @@ export async function applyRowsSegment(
515
506
  first = false;
516
507
  if (!clearThisBlock && rows.length === 0) continue;
517
508
  (options.transaction ?? ((fn) => db.transaction(fn)))(() => {
509
+ restoreFailedBases(db, schema);
518
510
  if (clearThisBlock) {
519
511
  deleteScopedRows(db, table, options.effective);
520
512
  }
package/src/blob.ts CHANGED
@@ -105,7 +105,7 @@ export async function computeBlobId(bytes: Uint8Array): Promise<string> {
105
105
 
106
106
  export function ensureBlobSchema(db: ClientDatabase): void {
107
107
  const existing = db.query(
108
- "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = '_syncular_blobs'",
108
+ "SELECT 1 FROM sqlite_master WHERE type='table' AND name='_syncular_blobs'",
109
109
  );
110
110
  if (existing.length > 0) {
111
111
  const columns = db
@@ -129,15 +129,13 @@ export function ensureBlobSchema(db: ClientDatabase): void {
129
129
  );
130
130
  }
131
131
  } else {
132
- db.exec(`CREATE TABLE _syncular_blobs(
133
- blob_id TEXT PRIMARY KEY,
134
- bytes BLOB NOT NULL,
135
- byte_length INTEGER NOT NULL,
136
- media_type TEXT,
137
- created_at_ms INTEGER NOT NULL)`);
132
+ db.exec(
133
+ '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)',
134
+ );
138
135
  }
139
- db.exec(`CREATE TABLE IF NOT EXISTS _syncular_blob_uploads(
140
- blob_id TEXT PRIMARY KEY, media_type TEXT, created_at_ms INTEGER NOT NULL)`);
136
+ db.exec(
137
+ 'CREATE TABLE IF NOT EXISTS _syncular_blob_uploads(blob_id TEXT PRIMARY KEY,media_type TEXT,created_at_ms INTEGER NOT NULL)',
138
+ );
141
139
  }
142
140
 
143
141
  /** Put bytes into the content-addressed cache without rewriting an existing body. */
@@ -149,9 +147,7 @@ export function putCachedBlob(
149
147
  mediaType?: string,
150
148
  ): void {
151
149
  db.exec(
152
- `INSERT INTO _syncular_blobs(
153
- blob_id, bytes, byte_length, media_type, created_at_ms)
154
- VALUES (?,?,?,?,?) ON CONFLICT(blob_id) DO NOTHING`,
150
+ 'INSERT INTO _syncular_blobs(blob_id,bytes,byte_length,media_type,created_at_ms)VALUES(?,?,?,?,?)ON CONFLICT(blob_id)DO NOTHING',
155
151
  [blobId, bytes, bytes.length, mediaType ?? null, nowMs],
156
152
  );
157
153
  }
@@ -161,7 +157,7 @@ export function getCachedBlob(
161
157
  blobId: string,
162
158
  ): CachedBlob | undefined {
163
159
  const rows = db.query(
164
- 'SELECT bytes, byte_length, media_type FROM _syncular_blobs WHERE blob_id = ?',
160
+ 'SELECT bytes,byte_length,media_type FROM _syncular_blobs WHERE blob_id=?',
165
161
  [blobId],
166
162
  );
167
163
  const row = rows[0];
@@ -185,24 +181,20 @@ export function enforceBlobCacheCap(
185
181
  maxBytes: number,
186
182
  ): string[] {
187
183
  const totalRow = db.query(
188
- 'SELECT COALESCE(SUM(byte_length), 0) AS total FROM _syncular_blobs',
184
+ 'SELECT COALESCE(SUM(byte_length),0)AS total FROM _syncular_blobs',
189
185
  )[0];
190
186
  let total = Number(totalRow?.total ?? 0);
191
187
  if (total <= maxBytes) return [];
192
188
  // Oldest eligible body first, with blob ID as a stable tie.
193
189
  const candidates = db.query(
194
- `SELECT blob_id, byte_length FROM _syncular_blobs
195
- WHERE blob_id NOT IN (SELECT blob_id FROM _syncular_blob_uploads)
196
- AND blob_id NOT IN (SELECT blob_id FROM _syncular_blob_commit_refs)
197
- AND blob_id NOT IN (${visibleBlobIdsSql(schema)})
198
- ORDER BY created_at_ms ASC, blob_id ASC`,
190
+ `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`,
199
191
  );
200
192
  const evicted: string[] = [];
201
193
  db.transaction(() => {
202
194
  for (const row of candidates) {
203
195
  if (total <= maxBytes) break;
204
196
  const blobId = row.blob_id as string;
205
- db.exec('DELETE FROM _syncular_blobs WHERE blob_id = ?', [blobId]);
197
+ db.exec('DELETE FROM _syncular_blobs WHERE blob_id=?', [blobId]);
206
198
  total -= Number(row.byte_length);
207
199
  evicted.push(blobId);
208
200
  }
@@ -215,13 +207,7 @@ export function listPendingUploads(
215
207
  ): { blobId: string; mediaType?: string }[] {
216
208
  return db
217
209
  .query(
218
- `SELECT blob_id, media_type FROM (
219
- SELECT blob_id, media_type, created_at_ms FROM _syncular_blob_uploads
220
- UNION ALL
221
- SELECT DISTINCT r.blob_id, b.media_type, 9223372036854775807 AS created_at_ms
222
- FROM _syncular_blob_commit_refs r LEFT JOIN _syncular_blobs b ON b.blob_id = r.blob_id
223
- WHERE NOT EXISTS (SELECT 1 FROM _syncular_blob_uploads u WHERE u.blob_id = r.blob_id)
224
- ) ORDER BY created_at_ms, blob_id`,
210
+ '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',
225
211
  )
226
212
  .map((row) => {
227
213
  if (
@@ -247,14 +233,13 @@ export function recordPendingUpload(
247
233
  mediaType?: string,
248
234
  ): void {
249
235
  db.exec(
250
- `INSERT OR IGNORE INTO _syncular_blob_uploads(blob_id, media_type, created_at_ms)
251
- VALUES (?,?,?)`,
236
+ 'INSERT OR IGNORE INTO _syncular_blob_uploads(blob_id,media_type,created_at_ms)VALUES(?,?,?)',
252
237
  [blobId, mediaType ?? null, nowMs],
253
238
  );
254
239
  }
255
240
 
256
241
  export function clearPendingUpload(db: ClientDatabase, blobId: string): void {
257
- db.exec('DELETE FROM _syncular_blob_uploads WHERE blob_id = ?', [blobId]);
242
+ db.exec('DELETE FROM _syncular_blob_uploads WHERE blob_id=?', [blobId]);
258
243
  }
259
244
 
260
245
  /**
@@ -286,11 +271,7 @@ function visibleBlobIdsSql(schema: CompiledClientSchema): string {
286
271
  for (const column of columns) {
287
272
  const identifier = quoteIdent(column);
288
273
  selects.push(
289
- `SELECT json_extract(${identifier}, '$.blobId') AS blob_id
290
- FROM ${quoteIdent(tableName)}
291
- WHERE typeof(${identifier}) = 'text'
292
- AND json_valid(${identifier})
293
- AND json_type(${identifier}, '$.blobId') = 'text'`,
274
+ `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'`,
294
275
  );
295
276
  }
296
277
  }
@@ -304,10 +285,7 @@ export function deleteUnreferencedCachedBlobs(
304
285
  schema: CompiledClientSchema,
305
286
  ): void {
306
287
  db.exec(
307
- `DELETE FROM _syncular_blobs
308
- WHERE blob_id NOT IN (SELECT blob_id FROM _syncular_blob_uploads)
309
- AND blob_id NOT IN (SELECT blob_id FROM _syncular_blob_commit_refs)
310
- AND blob_id NOT IN (${visibleBlobIdsSql(schema)})`,
288
+ `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)})`,
311
289
  );
312
290
  }
313
291