@syncular/client 0.20.0 → 0.21.0

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/outcomes.ts CHANGED
@@ -22,6 +22,12 @@ export interface ConflictRecord {
22
22
  readonly serverVersion: number;
23
23
  /** The current server row, decoded — resolve without a round-trip. */
24
24
  readonly serverRow: Readonly<Record<string, RowValue>>;
25
+ /**
26
+ * §6.3: the present columns whose `column_version` exceeded the losing
27
+ * operation's `baseVersion`. A custom merge recomputes exactly these;
28
+ * keep-server and keep-local ignore them (§6.5).
29
+ */
30
+ readonly conflictColumns: readonly string[];
25
31
  /** The losing local operation (absent only for malformed op indexes). */
26
32
  readonly operation?: OutboxOperation;
27
33
  }
@@ -94,8 +100,13 @@ export interface ResolveCommitOutcomeInput {
94
100
  readonly replacementClientCommitId?: string;
95
101
  }
96
102
 
97
- interface StoredConflictRecord extends Omit<ConflictRecord, 'serverRow'> {
103
+ interface StoredConflictRecord extends Omit<
104
+ ConflictRecord,
105
+ 'serverRow' | 'conflictColumns'
106
+ > {
98
107
  readonly serverRow: Readonly<Record<string, JsonRowValue>>;
108
+ /** Absent in journal entries written before wire version 3. */
109
+ readonly conflictColumns?: readonly string[];
99
110
  }
100
111
 
101
112
  type StoredCommitOperationOutcome =
@@ -130,6 +141,8 @@ function decodeResults(raw: string): CommitOperationOutcome[] {
130
141
  status: 'conflict',
131
142
  conflict: {
132
143
  ...result.conflict,
144
+ // Pre-column-version journal entries carry no conflictColumns.
145
+ conflictColumns: result.conflict.conflictColumns ?? [],
133
146
  serverRow: Object.fromEntries(
134
147
  Object.entries(result.conflict.serverRow).map(([key, value]) => [
135
148
  key,
@@ -359,7 +359,8 @@ class QueryEntry<Row> implements ExternalStoreEntry<LiveQueryResult<Row>> {
359
359
  #running = false;
360
360
  #requested = false;
361
361
  #desiredRevision = 0n;
362
- #claimReady: Promise<void> = Promise.resolve();
362
+ #claimError: Error | undefined;
363
+ #claimPending = false;
363
364
  #offStatus: (() => void) | undefined;
364
365
 
365
366
  constructor(
@@ -397,10 +398,25 @@ class QueryEntry<Row> implements ExternalStoreEntry<LiveQueryResult<Row>> {
397
398
  ),
398
399
  );
399
400
  }
400
- this.#claimReady = Promise.all(claims).then(() => undefined);
401
- // A render can lose its last subscriber before the read loop starts.
402
- // The loop still observes the original rejection while it owns the claim.
403
- void this.#claimReady.catch(() => undefined);
401
+ const generation = this.#generation;
402
+ this.#claimPending = claims.length > 0;
403
+ if (claims.length > 0)
404
+ void Promise.all(claims).then(
405
+ () => {
406
+ if (generation !== this.#generation || this.#listeners.size === 0)
407
+ return;
408
+ this.#claimPending = false;
409
+ if (!this.#running && this.#state.phase !== 'ready')
410
+ this.#requestRead();
411
+ },
412
+ (error: unknown) => {
413
+ if (generation !== this.#generation || this.#listeners.size === 0)
414
+ return;
415
+ this.#claimPending = false;
416
+ this.#claimError = errorOf(error);
417
+ this.#requestRead();
418
+ },
419
+ );
404
420
  }
405
421
  this.#requestRead();
406
422
  }
@@ -426,7 +442,8 @@ class QueryEntry<Row> implements ExternalStoreEntry<LiveQueryResult<Row>> {
426
442
  this.#running = false;
427
443
  this.#requested = false;
428
444
  this.#desiredRevision = 0n;
429
- this.#claimReady = Promise.resolve();
445
+ this.#claimError = undefined;
446
+ this.#claimPending = false;
430
447
  this.#state = {
431
448
  rows: [],
432
449
  phase: 'loading',
@@ -520,9 +537,11 @@ class QueryEntry<Row> implements ExternalStoreEntry<LiveQueryResult<Row>> {
520
537
  do {
521
538
  this.#requested = false;
522
539
  if (this.store.availabilitySnapshot().state === 'blocked') break;
523
- await this.#claimReady;
540
+
524
541
  if (generation !== this.#generation || this.#listeners.size === 0)
525
542
  return;
543
+ if (this.#claimError !== undefined) throw this.#claimError;
544
+ const claimWasPending = this.#claimPending;
526
545
  const snapshot = await this.store.client.querySnapshot<
527
546
  Record<string, SqlValue>
528
547
  >({
@@ -536,6 +555,7 @@ class QueryEntry<Row> implements ExternalStoreEntry<LiveQueryResult<Row>> {
536
555
  });
537
556
  if (generation !== this.#generation || this.#listeners.size === 0)
538
557
  return;
558
+ if (this.#claimError !== undefined) throw this.#claimError;
539
559
  const availability = this.store.availabilitySnapshot();
540
560
  if (availability.state === 'blocked') {
541
561
  this.#publish({
@@ -550,6 +570,12 @@ class QueryEntry<Row> implements ExternalStoreEntry<LiveQueryResult<Row>> {
550
570
  this.#requested = true;
551
571
  continue;
552
572
  }
573
+ if (
574
+ !snapshot.coverage.complete &&
575
+ claimWasPending &&
576
+ !this.#claimPending
577
+ )
578
+ this.#requested = true;
553
579
  const mappedRows =
554
580
  this.spec.mapRow === undefined
555
581
  ? (snapshot.rows as readonly Row[])
@@ -625,6 +651,8 @@ interface WindowClaimGroup {
625
651
  readonly base: WindowBase;
626
652
  readonly claims: Map<symbol, ReadonlySet<string>>;
627
653
  appliedKey: string;
654
+ appliedUnits: ReadonlySet<string>;
655
+ inFlightUnits?: ReadonlySet<string> | undefined;
628
656
  scheduled: boolean;
629
657
  running: boolean;
630
658
  requested: boolean;
@@ -889,6 +917,7 @@ export class ReactiveClientStore {
889
917
  base,
890
918
  claims: new Map(),
891
919
  appliedKey: '',
920
+ appliedUnits: new Set(),
892
921
  scheduled: false,
893
922
  running: false,
894
923
  requested: false,
@@ -897,6 +926,17 @@ export class ReactiveClientStore {
897
926
  this.#windowClaims.set(key, group);
898
927
  }
899
928
  group.claims.set(owner, new Set(units));
929
+ const alreadyHeld =
930
+ units.length > 0 &&
931
+ units.every(
932
+ (unit) =>
933
+ group.appliedUnits.has(unit) &&
934
+ (group.inFlightUnits === undefined || group.inFlightUnits.has(unit)),
935
+ );
936
+ if (alreadyHeld) {
937
+ this.#scheduleWindow(group);
938
+ return Promise.resolve();
939
+ }
900
940
  const result = new Promise<void>((resolve, reject) => {
901
941
  group?.waiters.push({ resolve, reject });
902
942
  });
@@ -932,13 +972,23 @@ export class ReactiveClientStore {
932
972
  ].sort();
933
973
  const key = canonicalValue(units);
934
974
  if (key !== group.appliedKey) {
935
- await this.client.setWindow(group.base, units);
975
+ group.inFlightUnits = new Set(units);
976
+ try {
977
+ await this.client.setWindow(group.base, units);
978
+ } finally {
979
+ group.inFlightUnits = undefined;
980
+ }
936
981
  if (this.#windowClaims.get(baseKey) !== group) return;
937
982
  group.appliedKey = key;
983
+ group.appliedUnits = new Set(units);
938
984
  }
939
985
  }
940
986
  for (const waiter of group.waiters.splice(0)) waiter.resolve();
941
987
  } catch (error) {
988
+ // A failed window edit may already have committed its first eviction chunk.
989
+ // Reconcile through the core before acknowledging any later claim.
990
+ group.appliedKey = '';
991
+ group.appliedUnits = new Set();
942
992
  for (const waiter of group.waiters.splice(0)) waiter.reject(error);
943
993
  } finally {
944
994
  group.running = false;
package/src/remote.ts CHANGED
@@ -9,7 +9,8 @@ import {
9
9
  encodeMessage,
10
10
  encodeRemoteOperationRequest,
11
11
  encodeRemoteOperationRealtimeMessage,
12
- encodeRow,
12
+ encodeSparseRow,
13
+ PROTOCOL_WIRE_VERSION,
13
14
  type PushOperation,
14
15
  type PushOperationResult,
15
16
  type PushResultDetailsFrame,
@@ -204,7 +205,8 @@ export class SyncRemoteClient {
204
205
  }
205
206
  >();
206
207
  readonly #encryption: EncryptionConfig | undefined;
207
- readonly #logEpoch: string | undefined;
208
+ /** Pinned by config, or acquired on the first prepared commit (§2.1). */
209
+ #logEpoch: string | undefined;
208
210
 
209
211
  constructor(config: SyncRemoteClientConfig) {
210
212
  if (config.clientId.length === 0) {
@@ -242,6 +244,53 @@ export class SyncRemoteClient {
242
244
  'a remote commit must carry at least one mutation (§6.1)',
243
245
  );
244
246
  }
247
+ let logEpoch = this.#logEpoch;
248
+ if (logEpoch === undefined) {
249
+ // §2.1: a push round binds to the partition log epoch; an acquisition
250
+ // round carries no push commits.
251
+ if (this.#transport === undefined) {
252
+ throw new ClientSyncError(
253
+ 'client.remote_sync_unconfigured',
254
+ 'SyncRemoteClient has no sync transport for ordinary commits',
255
+ );
256
+ }
257
+ const acquisition = decodeMessage(
258
+ await this.#transport(
259
+ encodeMessage({
260
+ wireVersion: PROTOCOL_WIRE_VERSION,
261
+ msgKind: 'request',
262
+ frames: [
263
+ {
264
+ type: 'REQ_HEADER',
265
+ clientId: this.#clientId,
266
+ schemaVersion: schema.version,
267
+ },
268
+ // A pull that asks for nothing: the round exists for its epoch.
269
+ {
270
+ type: 'PULL_HEADER',
271
+ limitCommits: 0,
272
+ limitSnapshotRows: 0,
273
+ maxSnapshotPages: 0,
274
+ accept: 0b0011,
275
+ },
276
+ ],
277
+ }),
278
+ ),
279
+ );
280
+ const acquisitionHeader = acquisition.frames[0];
281
+ if (
282
+ acquisition.msgKind !== 'response' ||
283
+ acquisitionHeader?.type !== 'RESP_HEADER' ||
284
+ acquisitionHeader.logEpoch === undefined
285
+ ) {
286
+ throw new ClientSyncError(
287
+ 'client.invalid_host_response',
288
+ 'remote epoch acquisition returned an invalid response',
289
+ );
290
+ }
291
+ logEpoch = acquisitionHeader.logEpoch;
292
+ this.#logEpoch = logEpoch;
293
+ }
245
294
  const operations: PushOperation[] = [];
246
295
  for (const mutation of input.mutations) {
247
296
  const table = schema.tables.get(mutation.table);
@@ -277,22 +326,20 @@ export class SyncRemoteClient {
277
326
  ...(mutation.baseVersion !== undefined
278
327
  ? { baseVersion: mutation.baseVersion }
279
328
  : {}),
280
- payload: encodeRow(table.columns, values),
329
+ payload: encodeSparseRow(table.columns, table.primaryKeyIndex, values),
281
330
  });
282
331
  }
283
332
  return {
284
333
  requestId: input.requestId,
285
334
  bytes: encodeMessage({
286
- wireVersion: this.#logEpoch === undefined ? 1 : 2,
335
+ wireVersion: PROTOCOL_WIRE_VERSION,
287
336
  msgKind: 'request',
288
337
  frames: [
289
338
  {
290
339
  type: 'REQ_HEADER',
291
340
  clientId: this.#clientId,
292
341
  schemaVersion: schema.version,
293
- ...(this.#logEpoch !== undefined
294
- ? { logEpoch: this.#logEpoch }
295
- : {}),
342
+ logEpoch,
296
343
  },
297
344
  {
298
345
  type: 'PUSH_COMMIT',
package/src/schema.ts CHANGED
@@ -318,6 +318,9 @@ function createSyncedTable(
318
318
  db: ClientDatabase,
319
319
  table: CompiledClientTable,
320
320
  ): void {
321
+ // §6.11: a declared reference is deliberately NOT emitted here. Windowing
322
+ // evicts a parent independently of its children, so local SQLite never
323
+ // enforces a reference; the server enforces it.
321
324
  const columns = table.columns.map((column) => {
322
325
  const notNull = column.nullable ? '' : ' NOT NULL';
323
326
  const pk = column.name === table.primaryKey ? ' PRIMARY KEY' : '';
@@ -374,6 +377,14 @@ function createFtsProjection(
374
377
  `CREATE VIRTUAL TABLE IF NOT EXISTS ${quoteIdent(index.name)} USING fts5(${quoteIdent(FTS_SOURCE_ID_COLUMN)} UNINDEXED, ${indexedColumns.join(', ')}, tokenize='${tokenizer}')`,
375
378
  );
376
379
 
380
+ const mapping = quoteIdent(`_syncular_fts_${index.name}`);
381
+ const mapped =
382
+ db.query("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", [
383
+ `_syncular_fts_${index.name}`,
384
+ ]).length > 0;
385
+ db.exec(
386
+ `CREATE TABLE IF NOT EXISTS ${mapping} (id INTEGER PRIMARY KEY, source_id TEXT NOT NULL UNIQUE)`,
387
+ );
377
388
  const sourceId = `CAST(${quoteIdent(table.primaryKey)} AS TEXT)`;
378
389
  const newSourceId = `CAST(new.${quoteIdent(table.primaryKey)} AS TEXT)`;
379
390
  const oldSourceId = `CAST(old.${quoteIdent(table.primaryKey)} AS TEXT)`;
@@ -386,9 +397,10 @@ function createFtsProjection(
386
397
  ...index.columns.map((column) => `new.${quoteIdent(column)}`),
387
398
  ].join(', ');
388
399
  const deleteFor = (value: string) =>
389
- `DELETE FROM ${quoteIdent(index.name)} WHERE ${quoteIdent(FTS_SOURCE_ID_COLUMN)} = ${value}`;
400
+ `DELETE FROM ${quoteIdent(index.name)} WHERE rowid = (SELECT id FROM ${mapping} WHERE source_id = ${value}); DELETE FROM ${mapping} WHERE source_id = ${value}`;
390
401
  const deleteDisplaced = (select: string) =>
391
- `DELETE FROM ${quoteIdent(index.name)} WHERE ${quoteIdent(FTS_SOURCE_ID_COLUMN)} IN (${select})`;
402
+ `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})`;
403
+ 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})`;
392
404
 
393
405
  // A clean insert cannot already have a projection row because the source
394
406
  // primary key is unique. Keep clean inserts linear by moving replacement
@@ -441,13 +453,13 @@ function createFtsProjection(
441
453
  `CREATE TRIGGER ${quoteIdent(`${index.name}_bi`)} BEFORE INSERT ON ${quoteIdent(table.name)} WHEN ${insertGuardCondition} BEGIN ${insertGuardBody}; END`,
442
454
  );
443
455
  db.exec(
444
- `CREATE TRIGGER ${quoteIdent(`${index.name}_ai`)} AFTER INSERT ON ${quoteIdent(table.name)} BEGIN INSERT INTO ${quoteIdent(index.name)} (${projectionColumns}) VALUES (${newValues}); END`,
456
+ `CREATE TRIGGER ${quoteIdent(`${index.name}_ai`)} AFTER INSERT ON ${quoteIdent(table.name)} BEGIN ${insertNew}; END`,
445
457
  );
446
458
  db.exec(
447
459
  `CREATE TRIGGER ${quoteIdent(`${index.name}_ad`)} AFTER DELETE ON ${quoteIdent(table.name)} BEGIN ${deleteFor(oldSourceId)}; END`,
448
460
  );
449
461
  db.exec(
450
- `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`,
462
+ `CREATE TRIGGER ${quoteIdent(`${index.name}_au`)} AFTER UPDATE ON ${quoteIdent(table.name)} BEGIN ${deleteFor(oldSourceId)}; ${deleteFor(newSourceId)}; ${insertNew}; END`,
451
463
  );
452
464
  // BEFORE UPDATE guard for `UPDATE OR REPLACE`: clear the projection of any
453
465
  // different-PK row about to be displaced through a secondary unique index by
@@ -468,6 +480,12 @@ function createFtsProjection(
468
480
  `INSERT INTO ${quoteIdent(index.name)} (${projectionColumns}) SELECT ${sourceId}, ${indexedColumns.join(', ')} FROM ${quoteIdent(table.name)}`,
469
481
  );
470
482
  }
483
+ if (!mapped || !existed) {
484
+ db.exec(`DELETE FROM ${mapping}`);
485
+ db.exec(
486
+ `INSERT INTO ${mapping}(id, source_id) SELECT rowid, ${quoteIdent(FTS_SOURCE_ID_COLUMN)} FROM ${quoteIdent(index.name)}`,
487
+ );
488
+ }
471
489
  }
472
490
 
473
491
  /**
@@ -602,6 +620,9 @@ export function dropAndRecreateSyncedTables(
602
620
  AND name NOT LIKE '${RESERVED_TABLE_PREFIX}%'`,
603
621
  );
604
622
  for (const row of virtualTables) {
623
+ db.exec(
624
+ `DROP TABLE IF EXISTS ${quoteIdent(`_syncular_fts_${String(row.name)}`)}`,
625
+ );
605
626
  db.exec(`DROP TABLE IF EXISTS ${quoteIdent(String(row.name))}`);
606
627
  }
607
628
  const existing = db.query(
@@ -698,7 +719,10 @@ export function normalizeRecordKeys(
698
719
  * may surface integers as bigint. Anything else passes through untouched —
699
720
  * the codec still fails loud on genuine type garbage at encode time.
700
721
  */
701
- function coerceSqlRepresentation(column: RowColumn, value: unknown): unknown {
722
+ export function coerceSqlRepresentation(
723
+ column: RowColumn,
724
+ value: unknown,
725
+ ): unknown {
702
726
  switch (localColumnType(column)) {
703
727
  case 'boolean':
704
728
  return value === 0 ? false : value === 1 ? true : value;
@@ -151,9 +151,13 @@ class WasmClientDatabase implements ClientDatabase {
151
151
  * §5.3 image import at near-file-copy speed: attach an empty in-memory
152
152
  * schema, then `sqlite3_deserialize` the image bytes into it (the
153
153
  * documented sqlite-wasm import path — no OPFS round-trip, no SQL-level
154
- * row shuttling before the single INSERT…SELECT the caller runs).
154
+ * row shuttling before the chunked INSERT…SELECT statements the caller runs).
155
155
  */
156
- withSqliteImage<T>(bytes: Uint8Array, alias: string, fn: () => T): T {
156
+ async withSqliteImage<T>(
157
+ bytes: Uint8Array,
158
+ alias: string,
159
+ fn: () => T | Promise<T>,
160
+ ): Promise<T> {
157
161
  assertImageAlias(alias);
158
162
  const pointer = this.#db.pointer;
159
163
  if (pointer === undefined) {
@@ -182,7 +186,7 @@ class WasmClientDatabase implements ClientDatabase {
182
186
  `sqlite3_deserialize failed with code ${rc} (§5.3)`,
183
187
  );
184
188
  }
185
- return fn();
189
+ return await fn();
186
190
  } finally {
187
191
  this.exec(`DETACH ${alias}`);
188
192
  }