@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.
@@ -244,7 +244,8 @@ class QueryEntry {
244
244
  #running = false;
245
245
  #requested = false;
246
246
  #desiredRevision = 0n;
247
- #claimReady = Promise.resolve();
247
+ #claimError;
248
+ #claimPending = false;
248
249
  #offStatus;
249
250
  constructor(store, spec, key, cache) {
250
251
  this.store = store;
@@ -270,10 +271,22 @@ class QueryEntry {
270
271
  for (const coverage of this.spec.coverage ?? []) {
271
272
  claims.push(this.store.setWindowClaim(this.#owner, coverage.base, coverage.units));
272
273
  }
273
- this.#claimReady = Promise.all(claims).then(() => undefined);
274
- // A render can lose its last subscriber before the read loop starts.
275
- // The loop still observes the original rejection while it owns the claim.
276
- void this.#claimReady.catch(() => undefined);
274
+ const generation = this.#generation;
275
+ this.#claimPending = claims.length > 0;
276
+ if (claims.length > 0)
277
+ void Promise.all(claims).then(() => {
278
+ if (generation !== this.#generation || this.#listeners.size === 0)
279
+ return;
280
+ this.#claimPending = false;
281
+ if (!this.#running && this.#state.phase !== 'ready')
282
+ this.#requestRead();
283
+ }, (error) => {
284
+ if (generation !== this.#generation || this.#listeners.size === 0)
285
+ return;
286
+ this.#claimPending = false;
287
+ this.#claimError = errorOf(error);
288
+ this.#requestRead();
289
+ });
277
290
  }
278
291
  this.#requestRead();
279
292
  }
@@ -300,7 +313,8 @@ class QueryEntry {
300
313
  this.#running = false;
301
314
  this.#requested = false;
302
315
  this.#desiredRevision = 0n;
303
- this.#claimReady = Promise.resolve();
316
+ this.#claimError = undefined;
317
+ this.#claimPending = false;
304
318
  this.#state = {
305
319
  rows: [],
306
320
  phase: 'loading',
@@ -393,9 +407,11 @@ class QueryEntry {
393
407
  this.#requested = false;
394
408
  if (this.store.availabilitySnapshot().state === 'blocked')
395
409
  break;
396
- await this.#claimReady;
397
410
  if (generation !== this.#generation || this.#listeners.size === 0)
398
411
  return;
412
+ if (this.#claimError !== undefined)
413
+ throw this.#claimError;
414
+ const claimWasPending = this.#claimPending;
399
415
  const snapshot = await this.store.client.querySnapshot({
400
416
  sql: this.spec.sql,
401
417
  ...(this.spec.params !== undefined
@@ -407,6 +423,8 @@ class QueryEntry {
407
423
  });
408
424
  if (generation !== this.#generation || this.#listeners.size === 0)
409
425
  return;
426
+ if (this.#claimError !== undefined)
427
+ throw this.#claimError;
410
428
  const availability = this.store.availabilitySnapshot();
411
429
  if (availability.state === 'blocked') {
412
430
  this.#publish({
@@ -421,6 +439,10 @@ class QueryEntry {
421
439
  this.#requested = true;
422
440
  continue;
423
441
  }
442
+ if (!snapshot.coverage.complete &&
443
+ claimWasPending &&
444
+ !this.#claimPending)
445
+ this.#requested = true;
424
446
  const mappedRows = this.spec.mapRow === undefined
425
447
  ? snapshot.rows
426
448
  : snapshot.rows.map(this.spec.mapRow);
@@ -736,6 +758,7 @@ export class ReactiveClientStore {
736
758
  base,
737
759
  claims: new Map(),
738
760
  appliedKey: '',
761
+ appliedUnits: new Set(),
739
762
  scheduled: false,
740
763
  running: false,
741
764
  requested: false,
@@ -744,6 +767,13 @@ export class ReactiveClientStore {
744
767
  this.#windowClaims.set(key, group);
745
768
  }
746
769
  group.claims.set(owner, new Set(units));
770
+ const alreadyHeld = units.length > 0 &&
771
+ units.every((unit) => group.appliedUnits.has(unit) &&
772
+ (group.inFlightUnits === undefined || group.inFlightUnits.has(unit)));
773
+ if (alreadyHeld) {
774
+ this.#scheduleWindow(group);
775
+ return Promise.resolve();
776
+ }
747
777
  const result = new Promise((resolve, reject) => {
748
778
  group?.waiters.push({ resolve, reject });
749
779
  });
@@ -779,16 +809,27 @@ export class ReactiveClientStore {
779
809
  ].sort();
780
810
  const key = canonicalValue(units);
781
811
  if (key !== group.appliedKey) {
782
- await this.client.setWindow(group.base, units);
812
+ group.inFlightUnits = new Set(units);
813
+ try {
814
+ await this.client.setWindow(group.base, units);
815
+ }
816
+ finally {
817
+ group.inFlightUnits = undefined;
818
+ }
783
819
  if (this.#windowClaims.get(baseKey) !== group)
784
820
  return;
785
821
  group.appliedKey = key;
822
+ group.appliedUnits = new Set(units);
786
823
  }
787
824
  }
788
825
  for (const waiter of group.waiters.splice(0))
789
826
  waiter.resolve();
790
827
  }
791
828
  catch (error) {
829
+ // A failed window edit may already have committed its first eviction chunk.
830
+ // Reconcile through the core before acknowledging any later claim.
831
+ group.appliedKey = '';
832
+ group.appliedUnits = new Set();
792
833
  for (const waiter of group.waiters.splice(0))
793
834
  waiter.reject(error);
794
835
  }
package/dist/schema.js CHANGED
@@ -234,6 +234,11 @@ function createFtsProjection(db, table, index) {
234
234
  const indexedColumns = index.columns.map(quoteIdent);
235
235
  const tokenizer = index.tokenize.replaceAll("'", "''");
236
236
  db.exec(`CREATE VIRTUAL TABLE IF NOT EXISTS ${quoteIdent(index.name)} USING fts5(${quoteIdent(FTS_SOURCE_ID_COLUMN)} UNINDEXED, ${indexedColumns.join(', ')}, tokenize='${tokenizer}')`);
237
+ const mapping = quoteIdent(`_syncular_fts_${index.name}`);
238
+ const mapped = db.query("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", [
239
+ `_syncular_fts_${index.name}`,
240
+ ]).length > 0;
241
+ db.exec(`CREATE TABLE IF NOT EXISTS ${mapping} (id INTEGER PRIMARY KEY, source_id TEXT NOT NULL UNIQUE)`);
237
242
  const sourceId = `CAST(${quoteIdent(table.primaryKey)} AS TEXT)`;
238
243
  const newSourceId = `CAST(new.${quoteIdent(table.primaryKey)} AS TEXT)`;
239
244
  const oldSourceId = `CAST(old.${quoteIdent(table.primaryKey)} AS TEXT)`;
@@ -245,8 +250,9 @@ function createFtsProjection(db, table, index) {
245
250
  newSourceId,
246
251
  ...index.columns.map((column) => `new.${quoteIdent(column)}`),
247
252
  ].join(', ');
248
- const deleteFor = (value) => `DELETE FROM ${quoteIdent(index.name)} WHERE ${quoteIdent(FTS_SOURCE_ID_COLUMN)} = ${value}`;
249
- const deleteDisplaced = (select) => `DELETE FROM ${quoteIdent(index.name)} WHERE ${quoteIdent(FTS_SOURCE_ID_COLUMN)} IN (${select})`;
253
+ const deleteFor = (value) => `DELETE FROM ${quoteIdent(index.name)} WHERE rowid = (SELECT id FROM ${mapping} WHERE source_id = ${value}); DELETE FROM ${mapping} WHERE source_id = ${value}`;
254
+ const deleteDisplaced = (select) => `DELETE FROM ${quoteIdent(index.name)} WHERE rowid IN (SELECT id FROM ${mapping} WHERE source_id IN (${select})); DELETE FROM ${mapping} WHERE source_id IN (${select})`;
255
+ const insertNew = `INSERT INTO ${mapping}(source_id) VALUES (${newSourceId}); INSERT INTO ${quoteIdent(index.name)} (rowid, ${projectionColumns}) VALUES ((SELECT id FROM ${mapping} WHERE source_id = ${newSourceId}), ${newValues})`;
250
256
  // A clean insert cannot already have a projection row because the source
251
257
  // primary key is unique. Keep clean inserts linear by moving replacement
252
258
  // cleanup behind indexed source-table existence checks. SQLite does not
@@ -294,9 +300,9 @@ function createFtsProjection(db, table, index) {
294
300
  ...displacedByUnique.map(deleteDisplaced),
295
301
  ].join('; ');
296
302
  db.exec(`CREATE TRIGGER ${quoteIdent(`${index.name}_bi`)} BEFORE INSERT ON ${quoteIdent(table.name)} WHEN ${insertGuardCondition} BEGIN ${insertGuardBody}; END`);
297
- db.exec(`CREATE TRIGGER ${quoteIdent(`${index.name}_ai`)} AFTER INSERT ON ${quoteIdent(table.name)} BEGIN INSERT INTO ${quoteIdent(index.name)} (${projectionColumns}) VALUES (${newValues}); END`);
303
+ db.exec(`CREATE TRIGGER ${quoteIdent(`${index.name}_ai`)} AFTER INSERT ON ${quoteIdent(table.name)} BEGIN ${insertNew}; END`);
298
304
  db.exec(`CREATE TRIGGER ${quoteIdent(`${index.name}_ad`)} AFTER DELETE ON ${quoteIdent(table.name)} BEGIN ${deleteFor(oldSourceId)}; END`);
299
- db.exec(`CREATE TRIGGER ${quoteIdent(`${index.name}_au`)} AFTER UPDATE ON ${quoteIdent(table.name)} BEGIN ${deleteFor(oldSourceId)}; ${deleteFor(newSourceId)}; INSERT INTO ${quoteIdent(index.name)} (${projectionColumns}) VALUES (${newValues}); END`);
305
+ db.exec(`CREATE TRIGGER ${quoteIdent(`${index.name}_au`)} AFTER UPDATE ON ${quoteIdent(table.name)} BEGIN ${deleteFor(oldSourceId)}; ${deleteFor(newSourceId)}; ${insertNew}; END`);
300
306
  // BEFORE UPDATE guard for `UPDATE OR REPLACE`: clear the projection of any
301
307
  // different-PK row about to be displaced through a secondary unique index by
302
308
  // the new values. Only meaningful when the table has a unique index; with
@@ -311,6 +317,10 @@ function createFtsProjection(db, table, index) {
311
317
  if (!existed) {
312
318
  db.exec(`INSERT INTO ${quoteIdent(index.name)} (${projectionColumns}) SELECT ${sourceId}, ${indexedColumns.join(', ')} FROM ${quoteIdent(table.name)}`);
313
319
  }
320
+ if (!mapped || !existed) {
321
+ db.exec(`DELETE FROM ${mapping}`);
322
+ db.exec(`INSERT INTO ${mapping}(id, source_id) SELECT rowid, ${quoteIdent(FTS_SOURCE_ID_COLUMN)} FROM ${quoteIdent(index.name)}`);
323
+ }
314
324
  }
315
325
  /**
316
326
  * Create only the application-owned synced tables, indexes, and FTS
@@ -424,6 +434,7 @@ export function dropAndRecreateSyncedTables(db, schema) {
424
434
  AND sql LIKE 'CREATE VIRTUAL TABLE%'
425
435
  AND name NOT LIKE '${RESERVED_TABLE_PREFIX}%'`);
426
436
  for (const row of virtualTables) {
437
+ db.exec(`DROP TABLE IF EXISTS ${quoteIdent(`_syncular_fts_${String(row.name)}`)}`);
427
438
  db.exec(`DROP TABLE IF EXISTS ${quoteIdent(String(row.name))}`);
428
439
  }
429
440
  const existing = db.query(`SELECT name FROM sqlite_master WHERE type = 'table'
@@ -22,6 +22,7 @@ export interface SegmentFetchRequest {
22
22
  readonly table: string;
23
23
  /** Canonical JSON (§11.2) of the requested scope map (§5.5 header). */
24
24
  readonly requestedScopesJson: string;
25
+ readonly onProgress?: (bytesReceived: number) => void;
25
26
  }
26
27
  /**
27
28
  * Fetch segment bytes from the direct endpoint (§5.5). `fetchUrl`, when
@@ -33,7 +34,7 @@ export interface SegmentFetchRequest {
33
34
  */
34
35
  export interface SegmentDownloader {
35
36
  (request: SegmentFetchRequest): Promise<Uint8Array>;
36
- readonly fetchUrl?: (url: string) => Promise<Uint8Array>;
37
+ readonly fetchUrl?: (url: string, onProgress?: (bytesReceived: number) => void) => Promise<Uint8Array>;
37
38
  }
38
39
  export interface RealtimeHandlers {
39
40
  /** JSON control frame (§8.1): hello / sync / heartbeat / unknown. */
@@ -65,9 +65,9 @@ class WasmClientDatabase {
65
65
  * §5.3 image import at near-file-copy speed: attach an empty in-memory
66
66
  * schema, then `sqlite3_deserialize` the image bytes into it (the
67
67
  * documented sqlite-wasm import path — no OPFS round-trip, no SQL-level
68
- * row shuttling before the single INSERT…SELECT the caller runs).
68
+ * row shuttling before the chunked INSERT…SELECT statements the caller runs).
69
69
  */
70
- withSqliteImage(bytes, alias, fn) {
70
+ async withSqliteImage(bytes, alias, fn) {
71
71
  assertImageAlias(alias);
72
72
  const pointer = this.#db.pointer;
73
73
  if (pointer === undefined) {
@@ -84,7 +84,7 @@ class WasmClientDatabase {
84
84
  if (rc !== 0) {
85
85
  throw new ClientSyncError('sync.invalid_request', `sqlite3_deserialize failed with code ${rc} (§5.3)`);
86
86
  }
87
- return fn();
87
+ return await fn();
88
88
  }
89
89
  finally {
90
90
  this.exec(`DETACH ${alias}`);
@@ -122,6 +122,9 @@ function inWorkerContext() {
122
122
  }
123
123
  /** One registered VFS per pool directory, reused across opens. */
124
124
  const sahPools = new Map();
125
+ // SAH pools share an I/O method table within the WASM runtime. Keep the
126
+ // installed callback alive for that runtime, including across database closes.
127
+ const sahIoMethods = new Map();
125
128
  function opfsSahPoolError(error, directory) {
126
129
  const detail = error instanceof Error ? error.message : String(error);
127
130
  const normalized = detail.toLowerCase();
@@ -187,5 +190,42 @@ export async function openPersistentWasmDatabase(name, options) {
187
190
  sahPools.set(directory, pool);
188
191
  }
189
192
  const util = await pool;
190
- return new WasmClientDatabase(new util.OpfsSAHPoolDb(`/${name}.db`), sqlite3);
193
+ const db = new util.OpfsSAHPoolDb(`/${name}.db`);
194
+ const { capi, wasm } = sqlite3;
195
+ const stack = wasm.pstack.pointer;
196
+ try {
197
+ // Do this before the first SQL statement, when SQLite checks for a hot
198
+ // journal. The SAH-pool callback in sqlite-wasm 3.53 reports a reserved
199
+ // lock unconditionally, which suppresses crash rollback. The pool already
200
+ // excludes other owners; report no competing writer, as the other OPFS
201
+ // VFS does. Keep DELETE/FULL and the existing database/journal files.
202
+ const output = wasm.pstack.allocPtr();
203
+ if (db.pointer === undefined ||
204
+ capi.sqlite3_file_control(db.pointer, 'main', capi.SQLITE_FCNTL_FILE_POINTER, output) !== 0 ||
205
+ !wasm.peekPtr(output)) {
206
+ throw new ClientSyncError(STORAGE_UNAVAILABLE_CODE, 'Could not configure OPFS crash recovery');
207
+ }
208
+ const file = new capi.sqlite3_file(wasm.peekPtr(output));
209
+ const methodsPointer = file.$pMethods;
210
+ file.dispose(); // Borrowed struct view; SQLite still owns the file.
211
+ if (!methodsPointer) {
212
+ throw new ClientSyncError(STORAGE_UNAVAILABLE_CODE, 'Could not configure OPFS crash recovery');
213
+ }
214
+ if (!sahIoMethods.has(methodsPointer)) {
215
+ const methods = new capi.sqlite3_io_methods(methodsPointer);
216
+ methods.installMethod('xCheckReservedLock', (_file, reserved) => {
217
+ wasm.poke32(reserved, 0);
218
+ return 0;
219
+ });
220
+ sahIoMethods.set(methodsPointer, methods);
221
+ }
222
+ return new WasmClientDatabase(db, sqlite3);
223
+ }
224
+ catch (error) {
225
+ db.close();
226
+ throw error;
227
+ }
228
+ finally {
229
+ wasm.pstack.restore(stack);
230
+ }
191
231
  }
@@ -4,6 +4,8 @@ import { type WorkerInitConfig } from './worker-protocol.js';
4
4
  export interface SyncWorkerOverrides {
5
5
  /** Database factory indirection: tests inject bun:sqlite here. */
6
6
  readonly openDatabase?: (config: WorkerInitConfig) => Promise<ClientDatabase> | ClientDatabase;
7
+ /** Retry deadline indirection for deterministic worker-host tests. */
8
+ readonly waitForStorageRetry?: (delayMs: number) => Promise<void>;
7
9
  readonly createTransport?: (config: WorkerInitConfig) => SyncTransport;
8
10
  readonly createSegments?: (config: WorkerInitConfig) => SegmentDownloader | undefined;
9
11
  readonly createRealtime?: (config: WorkerInitConfig, clientId: string) => RealtimeConnector | undefined;
@@ -16,7 +16,7 @@
16
16
  import { SyncClient } from './client.js';
17
17
  import { withClientDiagnosticsHost, } from './diagnostics.js';
18
18
  import { encryptionConfigFromKeyring } from './encryption.js';
19
- import { ClientSyncError } from './errors.js';
19
+ import { ClientSyncError, STORAGE_BUSY_CODE } from './errors.js';
20
20
  import { httpBlobTransport, httpSegmentDownloader, httpSyncTransport, webSocketRealtimeConnector, } from './http.js';
21
21
  import { openPersistentWasmDatabase } from './wasm-database.js';
22
22
  import { WORKER_FAILED_CODE, } from './worker-protocol.js';
@@ -170,9 +170,9 @@ export function startSyncWorker(overrides = {}) {
170
170
  return Object.assign(gateOffline(inner), {
171
171
  ...(fetchUrl !== undefined
172
172
  ? {
173
- fetchUrl: (url) => {
173
+ fetchUrl: (url, onProgress) => {
174
174
  assertOnline();
175
- return fetchUrl(url);
175
+ return fetchUrl(url, onProgress);
176
176
  },
177
177
  }
178
178
  : {}),
@@ -183,10 +183,33 @@ export function startSyncWorker(overrides = {}) {
183
183
  throw new ClientSyncError(WORKER_FAILED_CODE, 'the worker is already initialized');
184
184
  }
185
185
  autoSync = config.autoSync ?? true;
186
- database =
187
- overrides.openDatabase !== undefined
188
- ? await overrides.openDatabase(config)
189
- : await defaultOpenDatabase(config);
186
+ for (let attempt = 0;; attempt++) {
187
+ try {
188
+ database =
189
+ overrides.openDatabase !== undefined
190
+ ? await overrides.openDatabase(config)
191
+ : await defaultOpenDatabase(config);
192
+ break;
193
+ }
194
+ catch (error) {
195
+ if (config.database.mode !== 'persistent' ||
196
+ !(error instanceof ClientSyncError) ||
197
+ error.code !== STORAGE_BUSY_CODE ||
198
+ !error.retryable ||
199
+ attempt === 6) {
200
+ throw error;
201
+ }
202
+ // Retain the leader lease while the previous worker's OPFS handles
203
+ // drain. Only opening is retried; core initialization has not started.
204
+ const delayMs = Math.min(50 * 2 ** attempt, 1000);
205
+ if (overrides.waitForStorageRetry !== undefined) {
206
+ await overrides.waitForStorageRetry(delayMs);
207
+ }
208
+ else {
209
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
210
+ }
211
+ }
212
+ }
190
213
  const transport = gateOffline(overrides.createTransport !== undefined
191
214
  ? overrides.createTransport(config)
192
215
  : httpSyncTransport(config.endpoints.syncUrl));
@@ -245,6 +268,10 @@ export function startSyncWorker(overrides = {}) {
245
268
  post({ t: 'event', event: { kind: 'presence', scopeKey } });
246
269
  },
247
270
  });
271
+ started.onProgress((progress) => {
272
+ if (!closed)
273
+ post({ t: 'event', event: { kind: 'progress', progress } });
274
+ });
248
275
  started.onChange((batch) => {
249
276
  if (!closed)
250
277
  post({ t: 'event', event: { kind: 'change', batch } });
@@ -1,3 +1,4 @@
1
+ import { type SyncProgress, type SyncProgressListener } from './progress.js';
1
2
  import type { PromiseMethods } from './client.js';
2
3
  /**
3
4
  * Main-thread side of the worker mode and the
@@ -179,6 +180,8 @@ export declare class SyncClientHandle implements PromiseMethods<WorkerApi> {
179
180
  * unsubscribe function.
180
181
  */
181
182
  onInvalidate(listener: InvalidationListener): () => void;
183
+ onProgress(listener: SyncProgressListener): () => void;
184
+ progressSnapshot(): SyncProgress | undefined;
182
185
  onChange(listener: ClientChangeListener): () => void;
183
186
  onDiagnostics(listener: ClientDiagnosticsListener): () => void;
184
187
  /**
@@ -1,3 +1,4 @@
1
+ import { ProgressEmitter, } from './progress.js';
1
2
  import { registerDevtools } from './devtools.js';
2
3
  import { ClientDiagnosticsEmitter, withClientDiagnosticsHost, } from './diagnostics.js';
3
4
  import { ClientSyncError } from './errors.js';
@@ -68,6 +69,7 @@ export class SyncClientHandle {
68
69
  #core;
69
70
  #follower;
70
71
  #invalidation;
72
+ #progress = new ProgressEmitter();
71
73
  #changes;
72
74
  #presence;
73
75
  #diagnostics;
@@ -156,7 +158,10 @@ export class SyncClientHandle {
156
158
  }
157
159
  /** @internal — dispatch a worker/relayed event to handle-local listeners. */
158
160
  __dispatchEvent(event) {
159
- if (event.kind === 'presence') {
161
+ if (event.kind === 'progress') {
162
+ this.#progress.emit(event.progress);
163
+ }
164
+ else if (event.kind === 'presence') {
160
165
  for (const listener of this.#presence) {
161
166
  try {
162
167
  listener(event.scopeKey);
@@ -189,6 +194,12 @@ export class SyncClientHandle {
189
194
  onInvalidate(listener) {
190
195
  return this.#invalidation.on(listener);
191
196
  }
197
+ onProgress(listener) {
198
+ return this.#progress.on(listener);
199
+ }
200
+ progressSnapshot() {
201
+ return this.#progress.snapshot();
202
+ }
192
203
  onChange(listener) {
193
204
  return this.#changes.on(listener);
194
205
  }
@@ -349,6 +360,7 @@ export class SyncClientHandle {
349
360
  if (this.#closed)
350
361
  return;
351
362
  this.#closed = true;
363
+ this.#progress.clear();
352
364
  this.#devtoolsUnregister();
353
365
  if (this.#follower !== undefined) {
354
366
  this.#follower.close();
@@ -1,3 +1,4 @@
1
+ import type { SyncProgress } from './progress.js';
1
2
  /**
2
3
  * The worker RPC protocol: the whole
3
4
  * client core runs in a Web Worker; the UI thread talks to it through
@@ -151,6 +152,9 @@ export interface WorkerErrorShape {
151
152
  readonly retryable: boolean;
152
153
  }
153
154
  export type SyncWorkerEvent = {
155
+ readonly kind: 'progress';
156
+ readonly progress: SyncProgress;
157
+ } | {
154
158
  readonly kind: 'sync-needed';
155
159
  readonly reason: 'startup' | 'hello' | WakeReason;
156
160
  } | {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syncular/client",
3
- "version": "0.19.0",
3
+ "version": "0.20.1",
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,9 +89,10 @@
89
89
  },
90
90
  "dependencies": {
91
91
  "@sqlite.org/sqlite-wasm": "^3.53.0-build1",
92
- "@syncular/core": "0.19.0"
92
+ "@syncular/core": "0.20.1"
93
93
  },
94
94
  "devDependencies": {
95
- "@syncular/server": "0.19.0"
95
+ "@syncular/server": "0.20.1",
96
+ "playwright": "1.63.0"
96
97
  }
97
98
  }