@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.
@@ -94,6 +94,7 @@ export function startSyncWorker(overrides = {}) {
94
94
  autoSyncScheduled = false;
95
95
  if (closed ||
96
96
  client === undefined ||
97
+ offline ||
97
98
  client.securityLifecycle() === 'preflight') {
98
99
  return;
99
100
  }
@@ -116,6 +117,7 @@ export function startSyncWorker(overrides = {}) {
116
117
  if (!autoSync ||
117
118
  closed ||
118
119
  client === undefined ||
120
+ offline ||
119
121
  client.securityLifecycle() === 'preflight' ||
120
122
  intent.kind === 'none') {
121
123
  return;
@@ -156,31 +158,6 @@ export function startSyncWorker(overrides = {}) {
156
158
  }
157
159
  return client;
158
160
  }
159
- function assertOnline() {
160
- if (offline) {
161
- throw new ClientSyncError('sync.transport_failed', 'the worker transport is offline (setOffline(true))', true);
162
- }
163
- }
164
- function gateOffline(inner) {
165
- return (request) => {
166
- assertOnline();
167
- return inner(request);
168
- };
169
- }
170
- /** Offline gate that preserves the §5.4 `fetchUrl` capability marker. */
171
- function gateSegmentsOffline(inner) {
172
- const fetchUrl = inner.fetchUrl;
173
- return Object.assign(gateOffline(inner), {
174
- ...(fetchUrl !== undefined
175
- ? {
176
- fetchUrl: (url, onProgress) => {
177
- assertOnline();
178
- return fetchUrl(url, onProgress);
179
- },
180
- }
181
- : {}),
182
- });
183
- }
184
161
  async function init(config) {
185
162
  if (client !== undefined) {
186
163
  throw new ClientSyncError(WORKER_FAILED_CODE, 'the worker is already initialized');
@@ -215,9 +192,9 @@ export function startSyncWorker(overrides = {}) {
215
192
  }
216
193
  }
217
194
  }
218
- const transport = gateOffline(overrides.createTransport !== undefined
195
+ const transport = overrides.createTransport !== undefined
219
196
  ? overrides.createTransport(config)
220
- : httpSyncTransport(config.endpoints.syncUrl, http));
197
+ : httpSyncTransport(config.endpoints.syncUrl, http);
221
198
  const segments = overrides.createSegments !== undefined
222
199
  ? overrides.createSegments(config)
223
200
  : config.endpoints.segmentsUrl !== undefined
@@ -231,18 +208,15 @@ export function startSyncWorker(overrides = {}) {
231
208
  // `{clientId}` URL placeholder — start the client first, then attach
232
209
  // the connector through a mutable slot read at connect time.
233
210
  let realtimeConnector;
211
+ offline = config.transportEnabled === false;
234
212
  const started = new SyncClient({
213
+ transportEnabled: !offline,
235
214
  database,
236
215
  schema: config.schema,
237
216
  transport,
238
- ...(segments !== undefined
239
- ? { segments: gateSegmentsOffline(segments) }
240
- : {}),
217
+ ...(segments !== undefined ? { segments } : {}),
241
218
  ...(blobs !== undefined ? { blobs } : {}),
242
219
  realtime: (handlers) => {
243
- if (offline) {
244
- throw new ClientSyncError('sync.transport_failed', 'the worker realtime channel is offline (setOffline(true))', true);
245
- }
246
220
  if (realtimeConnector === undefined) {
247
221
  throw new ClientSyncError('sync.invalid_request', 'no realtime connector configured');
248
222
  }
@@ -399,8 +373,13 @@ export function startSyncWorker(overrides = {}) {
399
373
  },
400
374
  setOffline: (value) => {
401
375
  offline = value;
402
- if (offline)
403
- client?.disconnectRealtime();
376
+ requireClient().setTransportEnabled(!value);
377
+ if (offline) {
378
+ if (backgroundTimer !== undefined)
379
+ clearTimeout(backgroundTimer);
380
+ backgroundTimer = undefined;
381
+ backgroundDue = Number.POSITIVE_INFINITY;
382
+ }
404
383
  else if (client !== undefined) {
405
384
  const snapshot = client.diagnosticsSnapshot();
406
385
  post({
@@ -85,6 +85,7 @@ export interface SyncClientHandleConfig {
85
85
  readonly encryption?: EncryptionKeyringConfig;
86
86
  /** Open the worker-owned replica behind the fail-closed security gate. */
87
87
  readonly securityPreflight?: boolean;
88
+ readonly transportEnabled?: boolean;
88
89
  readonly clientId?: string;
89
90
  readonly limits?: SyncClientLimits;
90
91
  readonly retainFailedCommits?: boolean;
@@ -566,6 +566,9 @@ function buildInitConfig(config, headers = config.headers) {
566
566
  ...(config.encryption !== undefined
567
567
  ? { encryption: config.encryption }
568
568
  : {}),
569
+ ...(config.transportEnabled !== undefined
570
+ ? { transportEnabled: config.transportEnabled }
571
+ : {}),
569
572
  ...(config.securityPreflight !== undefined
570
573
  ? { securityPreflight: config.securityPreflight }
571
574
  : {}),
@@ -78,6 +78,7 @@ export interface WorkerInitConfig {
78
78
  readonly encryption?: EncryptionKeyringConfig;
79
79
  /** Open the worker-owned replica behind the fail-closed security gate. */
80
80
  readonly securityPreflight?: boolean;
81
+ readonly transportEnabled?: boolean;
81
82
  readonly clientId?: string;
82
83
  readonly limits?: SyncClientLimits;
83
84
  readonly retainFailedCommits?: boolean;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syncular/client",
3
- "version": "0.30.12",
3
+ "version": "0.30.14",
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.12"
92
+ "@syncular/core": "0.30.14"
93
93
  },
94
94
  "devDependencies": {
95
- "@syncular/server": "0.30.12",
95
+ "@syncular/server": "0.30.14",
96
96
  "playwright": "1.63.0",
97
97
  "@electric-sql/pglite": "^0.5.4"
98
98
  }
package/src/apply.ts CHANGED
@@ -1,4 +1,6 @@
1
- import { RETAINED_ROWS } from './failed-overlay';
1
+ import { hasUniqueIndex } from './schema';
2
+ import { OUTBOX_TABLE, OUTBOX_IMAGES, countOutbox } from './outbox';
3
+ import { RETAINED_ROWS, type OverlayScope } from './failed-overlay';
2
4
  import { retainedBaseWrite, restoreFailedBases } from './failed-overlay';
3
5
  /**
4
6
  * Local application of server data: `COMMIT` frames (§4.5), rows segments
@@ -28,7 +30,7 @@ import {
28
30
  } from './schema';
29
31
 
30
32
  /** Lets the client wrap the physical write in its revision transaction. */
31
- export type ApplyTransaction = <T>(fn: () => T) => T;
33
+ export type ApplyTransaction = (fn: () => OverlayScope) => unknown;
32
34
 
33
35
  function upsertSql(table: CompiledClientTable): string {
34
36
  const names = [
@@ -96,8 +98,7 @@ export async function applyCommitFrame(
96
98
  schema: CompiledClientSchema,
97
99
  frame: CommitFrame,
98
100
  encryption?: EncryptionConfig,
99
- transaction: (fn: () => readonly string[] | undefined) => unknown = (fn) =>
100
- db.transaction(fn),
101
+ transaction: (fn: () => OverlayScope) => unknown = (fn) => db.transaction(fn),
101
102
  ): Promise<void> {
102
103
  type Resolved =
103
104
  | { op: 'delete'; table: CompiledClientTable; rowId: string }
@@ -142,18 +143,18 @@ export async function applyCommitFrame(
142
143
  });
143
144
  }
144
145
  transaction(() => {
145
- const rowKeys = frame.tables.some((name) =>
146
- schema.tables.get(name)?.indexes.some((index) => index.unique),
147
- )
148
- ? undefined
149
- : frame.changes.map((change) =>
150
- JSON.stringify([frame.tables[change.tableIndex], change.rowId]),
151
- );
152
- restoreFailedBases(db, schema, false, rowKeys);
146
+ const scope = resolved.map((change, index) =>
147
+ JSON.stringify(
148
+ hasUniqueIndex(change.table)
149
+ ? [change.table.name]
150
+ : [change.table.name, frame.changes[index]!.rowId],
151
+ ),
152
+ );
153
+ restoreFailedBases(db, schema, scope);
153
154
  for (const [index, change] of resolved.entries()) {
154
155
  const rowId = frame.changes[index]!.rowId;
155
156
  db.exec(
156
- "UPDATE _syncular_outbox_before_images SET delivery_seq=MAX(COALESCE(delivery_seq,0),?) WHERE (client_commit_id,op_index) IN(SELECT client_commit_id,json_each.key FROM _syncular_outbox,json_each(operations) WHERE json_extract(value,'$.table')=? AND json_extract(value,'$.rowId')=?)",
157
+ `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')=?)`,
157
158
  [frame.commitSeq, change.table.name, rowId],
158
159
  );
159
160
  db.exec(
@@ -166,7 +167,7 @@ export async function applyCommitFrame(
166
167
  upsertLocalRow(db, change.table, change.values, change.rowVersion);
167
168
  }
168
169
  }
169
- return rowKeys;
170
+ return scope;
170
171
  });
171
172
  }
172
173
 
@@ -238,6 +239,41 @@ export function localScopePredicate(
238
239
  return { sql, params };
239
240
  }
240
241
 
242
+ function scopedRows(
243
+ db: ClientDatabase,
244
+ table: CompiledClientTable,
245
+ scope: ReturnType<typeof localScopePredicate>,
246
+ ) {
247
+ return scope
248
+ ? db.query(
249
+ `SELECT ${quoteIdent(table.primaryKey)} AS id FROM ${quoteIdent(table.name)} WHERE ${scope.sql}`,
250
+ scope.params,
251
+ )
252
+ : [];
253
+ }
254
+
255
+ function bootstrapScope(
256
+ db: ClientDatabase,
257
+ table: CompiledClientTable,
258
+ effective: ScopeMap,
259
+ ): string[] {
260
+ const rows = scopedRows(db, table, localScopePredicate(table, effective));
261
+ rows.push(
262
+ ...db
263
+ .query(`SELECT id,base,intent FROM ${RETAINED_ROWS} WHERE tbl=?`, [
264
+ table.name,
265
+ ])
266
+ .filter((row) =>
267
+ [row.base, row.intent].some(
268
+ (json) =>
269
+ json !== null &&
270
+ matchesLocalScopes(table, effective, JSON.parse(json as string)),
271
+ ),
272
+ ),
273
+ );
274
+ return rows.map((row) => JSON.stringify([table.name, String(row.id)]));
275
+ }
276
+
241
277
  export function deleteScopedRows(
242
278
  db: ClientDatabase,
243
279
  table: CompiledClientTable,
@@ -246,10 +282,7 @@ export function deleteScopedRows(
246
282
  const scope = localScopePredicate(table, effective);
247
283
  if (!scope) return;
248
284
  const { sql, params } = scope;
249
- for (const row of db.query(
250
- `SELECT ${quoteIdent(table.primaryKey)} AS id FROM ${quoteIdent(table.name)} WHERE ${sql}`,
251
- params,
252
- )) {
285
+ for (const row of scopedRows(db, table, scope)) {
253
286
  retainedBaseWrite(db, table, String(row.id));
254
287
  }
255
288
  db.exec(`DELETE FROM ${quoteIdent(table.name)} WHERE ${sql}`, params);
@@ -451,8 +484,31 @@ export async function applySqliteSegment(
451
484
  predicates.push(`${primaryKey} <= ?`);
452
485
  params.push(boundary);
453
486
  }
487
+ const replay =
488
+ countOutbox(db) ||
489
+ db.query(`SELECT 1 FROM ${RETAINED_ROWS} WHERE tbl=? LIMIT 1`, [
490
+ table.name,
491
+ ]).length;
492
+ const rows = replay
493
+ ? db.query(
494
+ `SELECT * FROM ${source} WHERE ${predicates.join(' AND ') || 'true'}`,
495
+ params,
496
+ )
497
+ : [];
454
498
  (options.transaction ?? ((fn) => db.transaction(fn)))(() => {
455
- restoreFailedBases(db, schema);
499
+ const scope = !replay
500
+ ? []
501
+ : hasUniqueIndex(table)
502
+ ? [JSON.stringify([table.name])]
503
+ : [
504
+ ...(after === undefined && options.clearFirst
505
+ ? bootstrapScope(db, table, options.effective)
506
+ : []),
507
+ ...rows.map((row) =>
508
+ JSON.stringify([table.name, String(row[table.primaryKey])]),
509
+ ),
510
+ ];
511
+ const protectedRows = restoreFailedBases(db, schema, scope);
456
512
  if (after === undefined && options.clearFirst)
457
513
  deleteScopedRows(db, table, options.effective);
458
514
  db.exec(
@@ -460,23 +516,22 @@ export async function applySqliteSegment(
460
516
  params,
461
517
  );
462
518
  applied += Number(db.query('SELECT changes()AS n')[0]?.n ?? 0);
463
- for (const row of db.query(
464
- `SELECT * FROM ${source} WHERE ${predicates.join(' AND ') || 'true'}`,
465
- params,
466
- )) {
467
- retainedBaseWrite(
468
- db,
469
- table,
470
- String(row[table.primaryKey]),
471
- Object.fromEntries(
472
- table.columns.map((column) => [
473
- column.name,
474
- rowValueToJson(row[column.name] as RowValue),
475
- ]),
476
- ),
477
- Number(row._syncular_version),
478
- );
479
- }
519
+ if (protectedRows)
520
+ for (const row of rows) {
521
+ retainedBaseWrite(
522
+ db,
523
+ table,
524
+ String(row[table.primaryKey]),
525
+ Object.fromEntries(
526
+ table.columns.map((column) => [
527
+ column.name,
528
+ rowValueToJson(row[column.name] as RowValue),
529
+ ]),
530
+ ),
531
+ Number(row._syncular_version),
532
+ );
533
+ }
534
+ return scope;
480
535
  });
481
536
  options.onProgress?.(applied);
482
537
  await yieldToHost();
@@ -537,7 +592,20 @@ export async function applyRowsSegment(
537
592
  first = false;
538
593
  if (!clearThisBlock && rows.length === 0) continue;
539
594
  (options.transaction ?? ((fn) => db.transaction(fn)))(() => {
540
- const protectedRows = restoreFailedBases(db, schema);
595
+ const scope: OverlayScope = hasUniqueIndex(table)
596
+ ? [JSON.stringify([table.name])]
597
+ : [
598
+ ...(clearThisBlock
599
+ ? bootstrapScope(db, table, options.effective)
600
+ : []),
601
+ ...rows.map((row) =>
602
+ JSON.stringify([
603
+ table.name,
604
+ String(row.values[table.primaryKeyIndex]),
605
+ ]),
606
+ ),
607
+ ];
608
+ const protectedRows = restoreFailedBases(db, schema, scope);
541
609
  if (clearThisBlock) {
542
610
  deleteScopedRows(db, table, options.effective);
543
611
  }
@@ -546,6 +614,7 @@ export async function applyRowsSegment(
546
614
  applied += 1;
547
615
  if (applied % 1024 === 0) options.onProgress?.(applied);
548
616
  }
617
+ return scope;
549
618
  });
550
619
  options.onProgress?.(applied);
551
620
  await yieldToHost();
package/src/blob.ts 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
  *
@@ -187,7 +188,7 @@ export function enforceBlobCacheCap(
187
188
  if (total <= maxBytes) return [];
188
189
  // Oldest eligible body first, with blob ID as a stable tie.
189
190
  const candidates = db.query(
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`,
191
+ `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`,
191
192
  );
192
193
  const evicted: string[] = [];
193
194
  db.transaction(() => {
@@ -207,7 +208,7 @@ export function listPendingUploads(
207
208
  ): { blobId: string; mediaType?: string }[] {
208
209
  return db
209
210
  .query(
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',
211
+ `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`,
211
212
  )
212
213
  .map((row) => {
213
214
  if (
@@ -285,7 +286,7 @@ export function deleteUnreferencedCachedBlobs(
285
286
  schema: CompiledClientSchema,
286
287
  ): void {
287
288
  db.exec(
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)})`,
289
+ `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)})`,
289
290
  );
290
291
  }
291
292