@syncular/client 0.32.0 → 0.33.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/dist/apply.js CHANGED
@@ -9,7 +9,7 @@ import { retainedBaseWrite, restoreFailedBases } from './failed-overlay.js';
9
9
  */
10
10
  import { decodeRow, } from '@syncular/core';
11
11
  import { ClientSyncError, invalidRequest } from './errors.js';
12
- import { quoteIdent, matchesLocalScopes, SYNC_VERSION_COLUMN, toSqlValue, rowValueToJson, } from './schema.js';
12
+ import { quoteIdent, matchesLocalScopes, pkSqlValue, SYNC_VERSION_COLUMN, toSqlValue, rowValueToJson, } from './schema.js';
13
13
  function upsertSql(table) {
14
14
  const names = [
15
15
  ...table.columns.map((column) => quoteIdent(column.name)),
@@ -33,7 +33,7 @@ export function upsertLocalRow(db, table, values, syncVersion, serverWrite = tru
33
33
  export function deleteLocalRow(db, table, rowId, serverWrite = true) {
34
34
  if (serverWrite)
35
35
  retainedBaseWrite(db, table, rowId);
36
- db.exec(`DELETE FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)}=?`, [rowId]);
36
+ db.exec(`DELETE FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)}=?`, [pkSqlValue(table, rowId)]);
37
37
  }
38
38
  /**
39
39
  * Apply one `COMMIT` frame in one local transaction (§1.4 rule 4).
package/dist/client.d.ts CHANGED
@@ -639,6 +639,14 @@ export declare class SyncClient {
639
639
  /**
640
640
  * Pull repeatedly until quiescent: no commits delivered, no bootstrap
641
641
  * pages pending, no resets to recover (§4.5 "pull again" SHOULD).
642
+ *
643
+ * §8.4: every host trigger drives this same client — the scheduler, the
644
+ * realtime supervisor's connect-time catch-up, and app code — so a call
645
+ * that arrives while a drive is in flight waits for that drive and then
646
+ * runs one follow-up drive. The follow-up is required, not defensive: the
647
+ * caller may have mutated the outbox after the in-flight drive captured it.
648
+ * Callers that arrive during the same drive with the same budget share that
649
+ * single follow-up. `sync()` keeps rejecting a concurrent `sync()`.
642
650
  */
643
651
  syncUntilIdle(maxRounds?: number): Promise<SyncIdleResult>;
644
652
  connectRealtime(): Promise<void>;
package/dist/client.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { BLOB_COMMIT_REFS } from './blob.js';
2
- import { hasUniqueIndex } from './schema.js';
2
+ import { hasUniqueIndex, pkSqlValue } from './schema.js';
3
3
  import { OUTCOMES_TABLE } from './outcomes.js';
4
4
  import { OUTBOX_TABLE } from './outbox.js';
5
5
  import { RETAINED_ROWS } from './failed-overlay.js';
@@ -178,6 +178,14 @@ export class SyncClient {
178
178
  * (the fast-bail the delta path reads).
179
179
  */
180
180
  #syncOutstanding = false;
181
+ /**
182
+ * Idle drives run one after another (§8.4): `#syncIdleTail` settles when
183
+ * the last scheduled drive settles, and `#syncIdlePending` holds the
184
+ * scheduled drives that have not started, keyed by round budget, so a
185
+ * caller joins a waiting drive with its budget instead of queueing another.
186
+ */
187
+ #syncIdleTail;
188
+ #syncIdlePending = new Map();
181
189
  /**
182
190
  * Bumped by every local projection reset (`rebootstrapLocalData`). A sync
183
191
  * round captures this epoch together with its subscription state; a
@@ -756,6 +764,8 @@ export class SyncClient {
756
764
  this.#securityLifecycle = 'active';
757
765
  const startupWork = this.#schemaFloor === undefined &&
758
766
  (countOutbox(this.#db) > 0 ||
767
+ this.#db.query(`SELECT 1 FROM ${RETAINED_ROWS} WHERE commit_seq IS NOT NULL LIMIT 1`).length > 0 ||
768
+ loadPendingEvictions(this.#db).length > 0 ||
759
769
  loadSubscriptions(this.#db).some((sub) => sub.status === 'active'));
760
770
  if (startupWork) {
761
771
  this.#setSyncNeeded(true);
@@ -1496,6 +1506,7 @@ export class SyncClient {
1496
1506
  const task = Promise.resolve().then(fn);
1497
1507
  this.#protectedAsync.add(task);
1498
1508
  const settled = () => {
1509
+ this.#protectedAsync.delete(task);
1499
1510
  if (this.#realtimeConnectPromise === task)
1500
1511
  this.#realtimeConnectPromise = undefined;
1501
1512
  };
@@ -1826,7 +1837,12 @@ export class SyncClient {
1826
1837
  if (!sameIntent) {
1827
1838
  throw new ClientSyncError('client.subscription_intent_mismatch', 'subscription id already names different table, scopes, or params');
1828
1839
  }
1829
- return;
1840
+ // §3.3: a revoked registration keeps its id and intent but no progress.
1841
+ // Subscribing again with the same intent re-arms it, so the next pull
1842
+ // resolves its scopes from scratch. A `failed` registration is a
1843
+ // configuration error and stays put.
1844
+ if (existing.status !== 'revoked')
1845
+ return;
1830
1846
  }
1831
1847
  saveSubscription(this.#db, definedObject({
1832
1848
  id: input.id,
@@ -2077,7 +2093,7 @@ export class SyncClient {
2077
2093
  const scopeColumns = new Set(table.scopeColumnByVariable.values());
2078
2094
  const pkColumn = table.columns[table.primaryKeyIndex];
2079
2095
  const pkValue = requireRowId(normalized.get(pkColumn.name));
2080
- const local = this.#db.query(`SELECT * FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)}=?`, [pkValue])[0];
2096
+ const local = this.#db.query(`SELECT * FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)}=?`, [pkSqlValue(table, pkValue)])[0];
2081
2097
  if (!local)
2082
2098
  throw new ClientSyncError('sync.row_missing', 'a sparse patch requires a local row');
2083
2099
  for (const column of table.columns) {
@@ -2204,16 +2220,24 @@ export class SyncClient {
2204
2220
  const pkColumn = compiled.columns[compiled.primaryKeyIndex];
2205
2221
  const normalized = normalizeRecordKeys(compiled, partial);
2206
2222
  const pkOverride = normalized.get(pkColumn.name);
2207
- if (pkOverride !== undefined && pkOverride !== rowId) {
2223
+ if (pkOverride !== undefined && String(pkOverride) !== rowId) {
2208
2224
  throw invalidRequest(`table ${compiled.name}: patch cannot change the primary key`);
2209
2225
  }
2226
+ const pkType = localColumnType(pkColumn);
2227
+ const typedPk = pkType === 'integer' &&
2228
+ Number.isSafeInteger(Number(rowId)) &&
2229
+ String(Number(rowId)) === rowId
2230
+ ? Number(rowId)
2231
+ : pkType === 'boolean' && (rowId === 'true' || rowId === 'false')
2232
+ ? rowId === 'true'
2233
+ : rowId;
2210
2234
  return this.#recordMutations([
2211
2235
  definedObject({
2212
2236
  table,
2213
2237
  op: 'patch',
2214
2238
  values: {
2215
2239
  ...Object.fromEntries(normalized),
2216
- [pkColumn.name]: rowId,
2240
+ [pkColumn.name]: typedPk,
2217
2241
  },
2218
2242
  baseVersion: options?.baseVersion,
2219
2243
  }),
@@ -2524,7 +2548,7 @@ export class SyncClient {
2524
2548
  const selector = this.#encryption?.keyIdColumns?.[table.name];
2525
2549
  if (selector === undefined)
2526
2550
  return undefined;
2527
- const row = this.#db.query(`SELECT ${quoteIdent(selector)} AS value FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)}=?`, [rowId])[0];
2551
+ const row = this.#db.query(`SELECT ${quoteIdent(selector)} AS value FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)}=?`, [pkSqlValue(table, rowId)])[0];
2528
2552
  const value = row?.value;
2529
2553
  return typeof value === 'string' && value.length > 0
2530
2554
  ? value
@@ -2907,6 +2931,14 @@ export class SyncClient {
2907
2931
  /**
2908
2932
  * Pull repeatedly until quiescent: no commits delivered, no bootstrap
2909
2933
  * pages pending, no resets to recover (§4.5 "pull again" SHOULD).
2934
+ *
2935
+ * §8.4: every host trigger drives this same client — the scheduler, the
2936
+ * realtime supervisor's connect-time catch-up, and app code — so a call
2937
+ * that arrives while a drive is in flight waits for that drive and then
2938
+ * runs one follow-up drive. The follow-up is required, not defensive: the
2939
+ * caller may have mutated the outbox after the in-flight drive captured it.
2940
+ * Callers that arrive during the same drive with the same budget share that
2941
+ * single follow-up. `sync()` keeps rejecting a concurrent `sync()`.
2910
2942
  */
2911
2943
  async syncUntilIdle(maxRounds) {
2912
2944
  // §7.7: an explicit budget is a strict positive round count. Zero,
@@ -2914,6 +2946,31 @@ export class SyncClient {
2914
2946
  if (maxRounds !== undefined &&
2915
2947
  (!Number.isInteger(maxRounds) || maxRounds < 1 || maxRounds > 0xffffffff))
2916
2948
  throw invalidRequest('maxRounds must be a positive integer');
2949
+ const pending = this.#syncIdlePending.get(maxRounds);
2950
+ if (pending !== undefined)
2951
+ return pending;
2952
+ const previous = this.#syncIdleTail;
2953
+ let drive;
2954
+ if (previous === undefined) {
2955
+ drive = this.#syncIdleRounds(maxRounds);
2956
+ }
2957
+ else {
2958
+ drive = previous.then(() => {
2959
+ this.#syncIdlePending.delete(maxRounds);
2960
+ return this.#syncIdleRounds(maxRounds);
2961
+ });
2962
+ this.#syncIdlePending.set(maxRounds, drive);
2963
+ }
2964
+ const tail = drive.then(() => undefined, () => undefined);
2965
+ this.#syncIdleTail = tail;
2966
+ void tail.then(() => {
2967
+ if (this.#syncIdleTail === tail)
2968
+ this.#syncIdleTail = undefined;
2969
+ });
2970
+ return drive;
2971
+ }
2972
+ /** The rounds loop of one idle drive; `syncUntilIdle` owns coalescing. */
2973
+ async #syncIdleRounds(maxRounds) {
2917
2974
  const budget = maxRounds ?? 20;
2918
2975
  const aggregate = {
2919
2976
  pushed: 0,
@@ -3085,15 +3142,6 @@ export class SyncClient {
3085
3142
  this.#setRealtimeState('connecting');
3086
3143
  const task = this.#runProtectedAsync(() => this.#connectRealtime(generation));
3087
3144
  this.#realtimeConnectPromise = task;
3088
- void task.then(() => {
3089
- if (this.#realtimeConnectPromise === task) {
3090
- this.#realtimeConnectPromise = undefined;
3091
- }
3092
- }, () => {
3093
- if (this.#realtimeConnectPromise === task) {
3094
- this.#realtimeConnectPromise = undefined;
3095
- }
3096
- });
3097
3145
  return task;
3098
3146
  }
3099
3147
  async #connectRealtime(generation) {
@@ -3753,7 +3801,7 @@ export class SyncClient {
3753
3801
  const mappings = [...table.scopeColumnByVariable].filter(([variable]) => table.scopePrefixByVariable.has(variable));
3754
3802
  if (mappings.length === 0)
3755
3803
  return false;
3756
- const row = this.#db.query(`SELECT ${mappings.map(([, column]) => quoteIdent(column)).join(', ')} FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)}=?`, [rowId])[0];
3804
+ const row = this.#db.query(`SELECT ${mappings.map(([, column]) => quoteIdent(column)).join(', ')} FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)}=?`, [pkSqlValue(table, rowId)])[0];
3757
3805
  if (row === undefined)
3758
3806
  return false;
3759
3807
  let recorded = false;
@@ -4048,7 +4096,7 @@ export class SyncClient {
4048
4096
  ...table.columns.map((column) => quoteIdent(column.name)),
4049
4097
  quoteIdent(SYNC_VERSION_COLUMN),
4050
4098
  ];
4051
- const row = this.#db.query(`SELECT ${columns.join(', ')} FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)}=?`, [operation.rowId])[0];
4099
+ const row = this.#db.query(`SELECT ${columns.join(', ')} FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)}=?`, [pkSqlValue(table, operation.rowId)])[0];
4052
4100
  if (row === undefined)
4053
4101
  return { opIndex, existed: false };
4054
4102
  const values = {};
@@ -4088,7 +4136,7 @@ export class SyncClient {
4088
4136
  const table = this.#schema.tables.get(operation.table);
4089
4137
  if (table === undefined)
4090
4138
  continue;
4091
- const row = this.#db.query(`SELECT ${quoteIdent(SYNC_VERSION_COLUMN)} AS v FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)}=?`, [operation.rowId])[0];
4139
+ const row = this.#db.query(`SELECT ${quoteIdent(SYNC_VERSION_COLUMN)} AS v FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)}=?`, [pkSqlValue(table, operation.rowId)])[0];
4092
4140
  if (row !== undefined && row.v === OPTIMISTIC_VERSION) {
4093
4141
  batch.table(table.name);
4094
4142
  deleteLocalRow(this.#db, table, operation.rowId);
@@ -4164,7 +4212,7 @@ export class SyncClient {
4164
4212
  // sync.row_missing, and the local mirror never invents a base.
4165
4213
  const opValues = op.values ?? {};
4166
4214
  const full = table.columns.every((column) => column.name in opValues);
4167
- const local = this.#db.query(`SELECT * FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)}=?`, [op.rowId])[0];
4215
+ const local = this.#db.query(`SELECT * FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)}=?`, [pkSqlValue(table, op.rowId)])[0];
4168
4216
  if (local === undefined && !full) {
4169
4217
  if (batch !== undefined && !precise)
4170
4218
  batch.table(op.table);
@@ -4382,7 +4430,12 @@ function definedObject(record) {
4382
4430
  return record;
4383
4431
  }
4384
4432
  function requireRowId(value) {
4385
- if (typeof value !== 'string' || !value.length)
4386
- throw invalidRequest('a mutation requires a non-empty string primary key');
4387
- return value;
4433
+ if (typeof value === 'string' && value.length > 0)
4434
+ return value;
4435
+ if (typeof value === 'number' && Number.isSafeInteger(value)) {
4436
+ return String(value);
4437
+ }
4438
+ if (typeof value === 'boolean')
4439
+ return String(value);
4440
+ throw invalidRequest('a mutation requires a non-empty primary key');
4388
4441
  }
@@ -3,7 +3,7 @@ import { OUTCOMES_TABLE } from './outcomes.js';
3
3
  import { OUTBOX_TABLE } from './outbox.js';
4
4
  export const RETAINED_ROWS = '_syncular_failed_rows';
5
5
  import { ClientSyncError } from './errors.js';
6
- import { mapRowValues, fromSqlValue, jsonToRowValue, quoteIdent, rowValueToJson, SYNC_VERSION_COLUMN, toSqlValue, } from './schema.js';
6
+ import { mapRowValues, fromSqlValue, jsonToRowValue, pkSqlValue, quoteIdent, rowValueToJson, SYNC_VERSION_COLUMN, toSqlValue, } from './schema.js';
7
7
  import { deleteLocalRow, upsertLocalRow } from './apply.js';
8
8
  /** Protected failure state. The server base advances independently of intent. */
9
9
  export function ensureFailedOverlaySchema(db) {
@@ -131,7 +131,7 @@ export function failedOverlayCommits(db, acknowledged = false, scope, schema) {
131
131
  if (table === undefined)
132
132
  continue;
133
133
  if (acknowledged) {
134
- if (!db.query(`SELECT 1 FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)}=?`, [operation.rowId]).length)
134
+ if (!db.query(`SELECT 1 FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)}=?`, [pkSqlValue(table, operation.rowId)]).length)
135
135
  operation = {
136
136
  ...operation,
137
137
  values: JSON.parse(row.intent),
package/dist/http.js CHANGED
@@ -229,25 +229,30 @@ export function httpBlobTransport(blobsBaseUrl, options) {
229
229
  : response.status === 401
230
230
  ? 'sync.auth_required'
231
231
  : 'sync.transport_failed');
232
- // §5.9.5 always-issue: a JSON body with `url` means presigned delivery;
233
- // an octet-stream body is inline bytes.
232
+ // §5.9.5 always-issue: a JSON body with a `url` means presigned delivery;
233
+ // any other body is inline bytes. The body is read exactly once as bytes,
234
+ // so an inline blob stored as `application/json` stays readable.
234
235
  const contentType = response.headers.get('content-type') ?? '';
236
+ const bytes = new Uint8Array(await transfer('blob download body read failed', () => response.arrayBuffer(), response.status));
235
237
  if (contentType.includes('application/json')) {
236
- const body = (await transfer('blob download body read failed', () => response.json(), response.status));
237
- if (typeof body.url === 'string') {
238
+ let envelope = null;
239
+ try {
240
+ envelope = JSON.parse(new TextDecoder().decode(bytes));
241
+ }
242
+ catch {
243
+ // Not JSON: inline bytes.
244
+ }
245
+ if (typeof envelope?.url === 'string') {
238
246
  return {
239
247
  kind: 'url',
240
- url: body.url,
241
- ...(typeof body.urlExpiresAtMs === 'number'
242
- ? { urlExpiresAtMs: body.urlExpiresAtMs }
248
+ url: envelope.url,
249
+ ...(typeof envelope.urlExpiresAtMs === 'number'
250
+ ? { urlExpiresAtMs: envelope.urlExpiresAtMs }
243
251
  : {}),
244
252
  };
245
253
  }
246
254
  }
247
- return {
248
- kind: 'bytes',
249
- bytes: new Uint8Array(await transfer('blob download body read failed', () => response.arrayBuffer(), response.status)),
250
- };
255
+ return { kind: 'bytes', bytes };
251
256
  },
252
257
  // §5.9.5: bare GET of the signed URL — no host auth (the URL is the grant).
253
258
  fetchUrl: async (url) => {
package/dist/remote.js CHANGED
@@ -163,9 +163,15 @@ export class SyncRemoteClient {
163
163
  ? coerceSqlRepresentation(column, normalized.get(column.name))
164
164
  : undefined)
165
165
  : recordToRowValues(table, mutation.values);
166
- const rowId = values[table.primaryKeyIndex];
167
- if (typeof rowId !== 'string' || rowId.length === 0) {
168
- throw invalid('remote upsert requires a non-empty string primary key');
166
+ const rawRowId = values[table.primaryKeyIndex];
167
+ const rowId = typeof rawRowId === 'string' && rawRowId.length > 0
168
+ ? rawRowId
169
+ : (typeof rawRowId === 'number' && Number.isSafeInteger(rawRowId)) ||
170
+ typeof rawRowId === 'boolean'
171
+ ? String(rawRowId)
172
+ : undefined;
173
+ if (rowId === undefined) {
174
+ throw invalid('remote upsert requires a non-empty primary key');
169
175
  }
170
176
  if (this.#encryption !== undefined && table.hasEncryptedColumns) {
171
177
  values = await encryptRowValues(this.#encryption, table, rowId, values);
package/dist/schema.d.ts CHANGED
@@ -95,6 +95,12 @@ export declare const OPTIMISTIC_VERSION = -1;
95
95
  export declare function stripSyncColumns(rows: SqlRow[]): SqlRow[];
96
96
  export declare function hasUniqueIndex(table: CompiledClientTable): boolean;
97
97
  export declare function matchesLocalScopes(table: CompiledClientTable, scopes: Readonly<Record<string, readonly string[]>>, values: Readonly<Record<string, JsonRowValue>>): boolean;
98
+ /**
99
+ * §2.4: bind a rowId against the local primary-key column. An integer or
100
+ * boolean key matches only its canonical string form; any other rowId names
101
+ * no row, so it binds NULL instead of letting SQLite affinity coerce `042`.
102
+ */
103
+ export declare function pkSqlValue(table: CompiledClientTable, rowId: string): SqlValue;
98
104
  export declare function quoteIdent(name: string): string;
99
105
  /**
100
106
  * §5.11: the app-side type of a column for local (plaintext) storage. For an
package/dist/schema.js CHANGED
@@ -179,9 +179,30 @@ export function matchesLocalScopes(table, scopes, values) {
179
179
  return (entries.length > 0 &&
180
180
  entries.every(([variable, allowed]) => {
181
181
  const column = table.scopeColumnByVariable.get(variable);
182
- return column !== undefined && allowed.includes(String(values[column]));
182
+ if (column === undefined)
183
+ return false;
184
+ const value = values[column];
185
+ return (value !== undefined && value !== null && allowed.includes(String(value)));
183
186
  }));
184
187
  }
188
+ /**
189
+ * §2.4: bind a rowId against the local primary-key column. An integer or
190
+ * boolean key matches only its canonical string form; any other rowId names
191
+ * no row, so it binds NULL instead of letting SQLite affinity coerce `042`.
192
+ */
193
+ export function pkSqlValue(table, rowId) {
194
+ const pkType = localColumnType(table.columns[table.primaryKeyIndex]);
195
+ if (pkType === 'boolean') {
196
+ return rowId === 'true' ? 1 : rowId === 'false' ? 0 : null;
197
+ }
198
+ if (pkType === 'integer') {
199
+ const parsed = Number(rowId);
200
+ return Number.isSafeInteger(parsed) && String(parsed) === rowId
201
+ ? parsed
202
+ : null;
203
+ }
204
+ return rowId;
205
+ }
185
206
  export function quoteIdent(name) {
186
207
  return `"${name.replaceAll('"', '""')}"`;
187
208
  }
@@ -2,8 +2,10 @@ import type { WakeReason } from '@syncular/core';
2
2
  import type { SecurityLifecycle } from './client.js';
3
3
  import type { SyncIntent } from './invalidation.js';
4
4
  export interface SyncSchedulerClient {
5
- readonly syncNeeded: boolean;
6
- readonly securityLifecycle: SecurityLifecycle;
5
+ statusSnapshot(): {
6
+ readonly syncNeeded: boolean;
7
+ };
8
+ securityLifecycle(): SecurityLifecycle;
7
9
  syncUntilIdle(maxRounds?: number): Promise<unknown>;
8
10
  onSyncNeeded(listener: (reason: 'startup' | 'hello' | WakeReason) => void): () => void;
9
11
  onSyncIntent(listener: (intent: SyncIntent) => void): () => void;
@@ -47,7 +47,7 @@ export function installSyncScheduler(client, options = {}) {
47
47
  const run = () => {
48
48
  if (stopped || running)
49
49
  return;
50
- if (client.securityLifecycle === 'preflight') {
50
+ if (client.securityLifecycle() === 'preflight') {
51
51
  immediatePending = false;
52
52
  return;
53
53
  }
@@ -110,7 +110,8 @@ export function installSyncScheduler(client, options = {}) {
110
110
  consume({ kind: 'interactive' });
111
111
  });
112
112
  const unsubscribeIntent = client.onSyncIntent(consume);
113
- if (client.syncNeeded && client.securityLifecycle === 'active') {
113
+ if (client.statusSnapshot().syncNeeded &&
114
+ client.securityLifecycle() === 'active') {
114
115
  consume({ kind: 'interactive' });
115
116
  }
116
117
  return {
@@ -76,15 +76,21 @@ export function startSyncWorker(overrides = {}) {
76
76
  let backgroundTimer;
77
77
  let backgroundDue = Number.POSITIVE_INFINITY;
78
78
  function runAutoSync() {
79
- autoSyncScheduled = false;
80
79
  if (closed ||
81
80
  client === undefined ||
82
81
  offline ||
83
82
  client.securityLifecycle() === 'preflight') {
83
+ autoSyncScheduled = false;
84
84
  return;
85
85
  }
86
86
  const running = client;
87
- void serializedSync(() => running.syncUntilIdle())
87
+ void serializedSync(() => {
88
+ // The queued round owns the flag from here. Clearing it at entry would
89
+ // let every wake-up that arrives while this round waits behind the
90
+ // running one queue its own full drive (§8.4 coalescing).
91
+ autoSyncScheduled = false;
92
+ return running.syncUntilIdle();
93
+ })
88
94
  .then((summary) => {
89
95
  if (!closed)
90
96
  post({ t: 'event', event: { kind: 'synced', summary } });
@@ -453,7 +459,9 @@ export function startSyncWorker(overrides = {}) {
453
459
  if (message.t === 'call') {
454
460
  run(message.id, () => dispatch(message), message.method === 'query'
455
461
  ? (value) => queryTransferables(value)
456
- : undefined);
462
+ : message.method === 'querySnapshot'
463
+ ? (value) => queryTransferables(value.rows)
464
+ : undefined);
457
465
  }
458
466
  });
459
467
  post({ t: 'ready' });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syncular/client",
3
- "version": "0.32.0",
3
+ "version": "0.33.0",
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",
@@ -97,10 +97,10 @@
97
97
  },
98
98
  "dependencies": {
99
99
  "@sqlite.org/sqlite-wasm": "^3.53.0-build1",
100
- "@syncular/core": "0.32.0"
100
+ "@syncular/core": "0.33.0"
101
101
  },
102
102
  "devDependencies": {
103
- "@syncular/server": "0.32.0",
103
+ "@syncular/server": "0.33.0",
104
104
  "playwright": "1.63.0",
105
105
  "@electric-sql/pglite": "^0.5.4"
106
106
  }
package/src/apply.ts CHANGED
@@ -24,6 +24,7 @@ import {
24
24
  quoteIdent,
25
25
  matchesLocalScopes,
26
26
  type JsonRowValue,
27
+ pkSqlValue,
27
28
  SYNC_VERSION_COLUMN,
28
29
  toSqlValue,
29
30
  rowValueToJson,
@@ -77,7 +78,7 @@ export function deleteLocalRow(
77
78
  if (serverWrite) retainedBaseWrite(db, table, rowId);
78
79
  db.exec(
79
80
  `DELETE FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)}=?`,
80
- [rowId],
81
+ [pkSqlValue(table, rowId)],
81
82
  );
82
83
  }
83
84
 
package/src/client.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { BLOB_COMMIT_REFS } from './blob';
2
2
  import type { AuthorityReadPolicy, AuthoritySnapshot } from './authority';
3
- import { hasUniqueIndex } from './schema';
3
+ import { hasUniqueIndex, pkSqlValue } from './schema';
4
4
  import { OUTCOMES_TABLE } from './outcomes';
5
5
  import { OUTBOX_TABLE } from './outbox';
6
6
  import { RETAINED_ROWS } from './failed-overlay';
@@ -893,6 +893,17 @@ export class SyncClient {
893
893
  * (the fast-bail the delta path reads).
894
894
  */
895
895
  #syncOutstanding = false;
896
+ /**
897
+ * Idle drives run one after another (§8.4): `#syncIdleTail` settles when
898
+ * the last scheduled drive settles, and `#syncIdlePending` holds the
899
+ * scheduled drives that have not started, keyed by round budget, so a
900
+ * caller joins a waiting drive with its budget instead of queueing another.
901
+ */
902
+ #syncIdleTail: Promise<void> | undefined;
903
+ readonly #syncIdlePending = new Map<
904
+ number | undefined,
905
+ Promise<SyncIdleResult>
906
+ >();
896
907
  /**
897
908
  * Bumped by every local projection reset (`rebootstrapLocalData`). A sync
898
909
  * round captures this epoch together with its subscription state; a
@@ -1579,6 +1590,10 @@ export class SyncClient {
1579
1590
  const startupWork =
1580
1591
  this.#schemaFloor === undefined &&
1581
1592
  (countOutbox(this.#db) > 0 ||
1593
+ this.#db.query(
1594
+ `SELECT 1 FROM ${RETAINED_ROWS} WHERE commit_seq IS NOT NULL LIMIT 1`,
1595
+ ).length > 0 ||
1596
+ loadPendingEvictions(this.#db).length > 0 ||
1582
1597
  loadSubscriptions(this.#db).some((sub) => sub.status === 'active'));
1583
1598
  if (startupWork) {
1584
1599
  this.#setSyncNeeded(true);
@@ -2470,6 +2485,7 @@ export class SyncClient {
2470
2485
  const task = Promise.resolve().then(fn);
2471
2486
  this.#protectedAsync.add(task);
2472
2487
  const settled = () => {
2488
+ this.#protectedAsync.delete(task);
2473
2489
  if (this.#realtimeConnectPromise === task)
2474
2490
  this.#realtimeConnectPromise = undefined;
2475
2491
  };
@@ -2898,7 +2914,11 @@ export class SyncClient {
2898
2914
  'subscription id already names different table, scopes, or params',
2899
2915
  );
2900
2916
  }
2901
- return;
2917
+ // §3.3: a revoked registration keeps its id and intent but no progress.
2918
+ // Subscribing again with the same intent re-arms it, so the next pull
2919
+ // resolves its scopes from scratch. A `failed` registration is a
2920
+ // configuration error and stays put.
2921
+ if (existing.status !== 'revoked') return;
2902
2922
  }
2903
2923
  saveSubscription(
2904
2924
  this.#db,
@@ -3184,7 +3204,7 @@ export class SyncClient {
3184
3204
  const pkValue = requireRowId(normalized.get(pkColumn.name));
3185
3205
  const local = this.#db.query(
3186
3206
  `SELECT * FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)}=?`,
3187
- [pkValue],
3207
+ [pkSqlValue(table, pkValue)],
3188
3208
  )[0];
3189
3209
  if (!local)
3190
3210
  throw new ClientSyncError(
@@ -3351,18 +3371,27 @@ export class SyncClient {
3351
3371
  const pkColumn = compiled.columns[compiled.primaryKeyIndex] as RowColumn;
3352
3372
  const normalized = normalizeRecordKeys(compiled, partial);
3353
3373
  const pkOverride = normalized.get(pkColumn.name);
3354
- if (pkOverride !== undefined && pkOverride !== rowId) {
3374
+ if (pkOverride !== undefined && String(pkOverride) !== rowId) {
3355
3375
  throw invalidRequest(
3356
3376
  `table ${compiled.name}: patch cannot change the primary key`,
3357
3377
  );
3358
3378
  }
3379
+ const pkType = localColumnType(pkColumn);
3380
+ const typedPk =
3381
+ pkType === 'integer' &&
3382
+ Number.isSafeInteger(Number(rowId)) &&
3383
+ String(Number(rowId)) === rowId
3384
+ ? Number(rowId)
3385
+ : pkType === 'boolean' && (rowId === 'true' || rowId === 'false')
3386
+ ? rowId === 'true'
3387
+ : rowId;
3359
3388
  return this.#recordMutations([
3360
3389
  definedObject({
3361
3390
  table,
3362
3391
  op: 'patch',
3363
3392
  values: {
3364
3393
  ...Object.fromEntries(normalized),
3365
- [pkColumn.name]: rowId,
3394
+ [pkColumn.name]: typedPk,
3366
3395
  },
3367
3396
  baseVersion: options?.baseVersion,
3368
3397
  }),
@@ -3745,7 +3774,7 @@ export class SyncClient {
3745
3774
  if (selector === undefined) return undefined;
3746
3775
  const row = this.#db.query(
3747
3776
  `SELECT ${quoteIdent(selector)} AS value FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)}=?`,
3748
- [rowId],
3777
+ [pkSqlValue(table, rowId)],
3749
3778
  )[0];
3750
3779
  const value = row?.value;
3751
3780
  return typeof value === 'string' && value.length > 0
@@ -4213,6 +4242,14 @@ export class SyncClient {
4213
4242
  /**
4214
4243
  * Pull repeatedly until quiescent: no commits delivered, no bootstrap
4215
4244
  * pages pending, no resets to recover (§4.5 "pull again" SHOULD).
4245
+ *
4246
+ * §8.4: every host trigger drives this same client — the scheduler, the
4247
+ * realtime supervisor's connect-time catch-up, and app code — so a call
4248
+ * that arrives while a drive is in flight waits for that drive and then
4249
+ * runs one follow-up drive. The follow-up is required, not defensive: the
4250
+ * caller may have mutated the outbox after the in-flight drive captured it.
4251
+ * Callers that arrive during the same drive with the same budget share that
4252
+ * single follow-up. `sync()` keeps rejecting a concurrent `sync()`.
4216
4253
  */
4217
4254
  async syncUntilIdle(maxRounds?: number): Promise<SyncIdleResult> {
4218
4255
  // §7.7: an explicit budget is a strict positive round count. Zero,
@@ -4222,6 +4259,34 @@ export class SyncClient {
4222
4259
  (!Number.isInteger(maxRounds) || maxRounds < 1 || maxRounds > 0xffffffff)
4223
4260
  )
4224
4261
  throw invalidRequest('maxRounds must be a positive integer');
4262
+ const pending = this.#syncIdlePending.get(maxRounds);
4263
+ if (pending !== undefined) return pending;
4264
+ const previous = this.#syncIdleTail;
4265
+ let drive: Promise<SyncIdleResult>;
4266
+ if (previous === undefined) {
4267
+ drive = this.#syncIdleRounds(maxRounds);
4268
+ } else {
4269
+ drive = previous.then(() => {
4270
+ this.#syncIdlePending.delete(maxRounds);
4271
+ return this.#syncIdleRounds(maxRounds);
4272
+ });
4273
+ this.#syncIdlePending.set(maxRounds, drive);
4274
+ }
4275
+ const tail = drive.then(
4276
+ () => undefined,
4277
+ () => undefined,
4278
+ );
4279
+ this.#syncIdleTail = tail;
4280
+ void tail.then(() => {
4281
+ if (this.#syncIdleTail === tail) this.#syncIdleTail = undefined;
4282
+ });
4283
+ return drive;
4284
+ }
4285
+
4286
+ /** The rounds loop of one idle drive; `syncUntilIdle` owns coalescing. */
4287
+ async #syncIdleRounds(
4288
+ maxRounds: number | undefined,
4289
+ ): Promise<SyncIdleResult> {
4225
4290
  const budget = maxRounds ?? 20;
4226
4291
  const aggregate = {
4227
4292
  pushed: 0,
@@ -4428,18 +4493,6 @@ export class SyncClient {
4428
4493
  this.#connectRealtime(generation),
4429
4494
  );
4430
4495
  this.#realtimeConnectPromise = task;
4431
- void task.then(
4432
- () => {
4433
- if (this.#realtimeConnectPromise === task) {
4434
- this.#realtimeConnectPromise = undefined;
4435
- }
4436
- },
4437
- () => {
4438
- if (this.#realtimeConnectPromise === task) {
4439
- this.#realtimeConnectPromise = undefined;
4440
- }
4441
- },
4442
- );
4443
4496
  return task;
4444
4497
  }
4445
4498
 
@@ -5273,7 +5326,7 @@ export class SyncClient {
5273
5326
  if (mappings.length === 0) return false;
5274
5327
  const row = this.#db.query(
5275
5328
  `SELECT ${mappings.map(([, column]) => quoteIdent(column)).join(', ')} FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)}=?`,
5276
- [rowId],
5329
+ [pkSqlValue(table, rowId)],
5277
5330
  )[0];
5278
5331
  if (row === undefined) return false;
5279
5332
  let recorded = false;
@@ -5668,7 +5721,7 @@ export class SyncClient {
5668
5721
  ];
5669
5722
  const row = this.#db.query(
5670
5723
  `SELECT ${columns.join(', ')} FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)}=?`,
5671
- [operation.rowId],
5724
+ [pkSqlValue(table, operation.rowId)],
5672
5725
  )[0];
5673
5726
  if (row === undefined) return { opIndex, existed: false };
5674
5727
  const values: Record<string, ReturnType<typeof rowValueToJson>> = {};
@@ -5732,7 +5785,7 @@ export class SyncClient {
5732
5785
  if (table === undefined) continue;
5733
5786
  const row = this.#db.query(
5734
5787
  `SELECT ${quoteIdent(SYNC_VERSION_COLUMN)} AS v FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)}=?`,
5735
- [operation.rowId],
5788
+ [pkSqlValue(table, operation.rowId)],
5736
5789
  )[0];
5737
5790
  if (row !== undefined && row.v === OPTIMISTIC_VERSION) {
5738
5791
  batch.table(table.name);
@@ -5826,7 +5879,7 @@ export class SyncClient {
5826
5879
  const full = table.columns.every((column) => column.name in opValues);
5827
5880
  const local = this.#db.query(
5828
5881
  `SELECT * FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)}=?`,
5829
- [op.rowId],
5882
+ [pkSqlValue(table, op.rowId)],
5830
5883
  )[0];
5831
5884
  if (local === undefined && !full) {
5832
5885
  if (batch !== undefined && !precise) batch.table(op.table);
@@ -6110,7 +6163,10 @@ function definedObject<const T extends object>(record: T): DefinedObject<T> {
6110
6163
  }
6111
6164
 
6112
6165
  function requireRowId(value: unknown): string {
6113
- if (typeof value !== 'string' || !value.length)
6114
- throw invalidRequest('a mutation requires a non-empty string primary key');
6115
- return value;
6166
+ if (typeof value === 'string' && value.length > 0) return value;
6167
+ if (typeof value === 'number' && Number.isSafeInteger(value)) {
6168
+ return String(value);
6169
+ }
6170
+ if (typeof value === 'boolean') return String(value);
6171
+ throw invalidRequest('a mutation requires a non-empty primary key');
6116
6172
  }
@@ -13,6 +13,7 @@ import {
13
13
  mapRowValues,
14
14
  fromSqlValue,
15
15
  jsonToRowValue,
16
+ pkSqlValue,
16
17
  quoteIdent,
17
18
  rowValueToJson,
18
19
  SYNC_VERSION_COLUMN,
@@ -245,7 +246,7 @@ export function failedOverlayCommits(
245
246
  if (
246
247
  !db.query(
247
248
  `SELECT 1 FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)}=?`,
248
- [operation.rowId],
249
+ [pkSqlValue(table, operation.rowId)],
249
250
  ).length
250
251
  )
251
252
  operation = {
package/src/http.ts CHANGED
@@ -355,38 +355,35 @@ export function httpBlobTransport(
355
355
  ? 'sync.auth_required'
356
356
  : 'sync.transport_failed',
357
357
  );
358
- // §5.9.5 always-issue: a JSON body with `url` means presigned delivery;
359
- // an octet-stream body is inline bytes.
358
+ // §5.9.5 always-issue: a JSON body with a `url` means presigned delivery;
359
+ // any other body is inline bytes. The body is read exactly once as bytes,
360
+ // so an inline blob stored as `application/json` stays readable.
360
361
  const contentType = response.headers.get('content-type') ?? '';
361
- if (contentType.includes('application/json')) {
362
- const body = (await transfer(
362
+ const bytes = new Uint8Array(
363
+ await transfer(
363
364
  'blob download body read failed',
364
- () => response.json(),
365
+ () => response.arrayBuffer(),
365
366
  response.status,
366
- )) as {
367
- url?: string;
368
- urlExpiresAtMs?: number;
369
- };
370
- if (typeof body.url === 'string') {
367
+ ),
368
+ );
369
+ if (contentType.includes('application/json')) {
370
+ let envelope: { url?: unknown; urlExpiresAtMs?: unknown } | null = null;
371
+ try {
372
+ envelope = JSON.parse(new TextDecoder().decode(bytes));
373
+ } catch {
374
+ // Not JSON: inline bytes.
375
+ }
376
+ if (typeof envelope?.url === 'string') {
371
377
  return {
372
378
  kind: 'url',
373
- url: body.url,
374
- ...(typeof body.urlExpiresAtMs === 'number'
375
- ? { urlExpiresAtMs: body.urlExpiresAtMs }
379
+ url: envelope.url,
380
+ ...(typeof envelope.urlExpiresAtMs === 'number'
381
+ ? { urlExpiresAtMs: envelope.urlExpiresAtMs }
376
382
  : {}),
377
383
  };
378
384
  }
379
385
  }
380
- return {
381
- kind: 'bytes',
382
- bytes: new Uint8Array(
383
- await transfer(
384
- 'blob download body read failed',
385
- () => response.arrayBuffer(),
386
- response.status,
387
- ),
388
- ),
389
- };
386
+ return { kind: 'bytes', bytes };
390
387
  },
391
388
  // §5.9.5: bare GET of the signed URL — no host auth (the URL is the grant).
392
389
  fetchUrl: async (url) => {
package/src/remote.ts CHANGED
@@ -326,9 +326,16 @@ export class SyncRemoteClient {
326
326
  : undefined,
327
327
  )
328
328
  : recordToRowValues(table, mutation.values);
329
- const rowId = values[table.primaryKeyIndex];
330
- if (typeof rowId !== 'string' || rowId.length === 0) {
331
- throw invalid('remote upsert requires a non-empty string primary key');
329
+ const rawRowId = values[table.primaryKeyIndex];
330
+ const rowId =
331
+ typeof rawRowId === 'string' && rawRowId.length > 0
332
+ ? rawRowId
333
+ : (typeof rawRowId === 'number' && Number.isSafeInteger(rawRowId)) ||
334
+ typeof rawRowId === 'boolean'
335
+ ? String(rawRowId)
336
+ : undefined;
337
+ if (rowId === undefined) {
338
+ throw invalid('remote upsert requires a non-empty primary key');
332
339
  }
333
340
  if (this.#encryption !== undefined && table.hasEncryptedColumns) {
334
341
  values = await encryptRowValues(this.#encryption, table, rowId, values);
package/src/schema.ts CHANGED
@@ -298,11 +298,37 @@ export function matchesLocalScopes(
298
298
  entries.length > 0 &&
299
299
  entries.every(([variable, allowed]) => {
300
300
  const column = table.scopeColumnByVariable.get(variable);
301
- return column !== undefined && allowed.includes(String(values[column]));
301
+ if (column === undefined) return false;
302
+ const value = values[column];
303
+ return (
304
+ value !== undefined && value !== null && allowed.includes(String(value))
305
+ );
302
306
  })
303
307
  );
304
308
  }
305
309
 
310
+ /**
311
+ * §2.4: bind a rowId against the local primary-key column. An integer or
312
+ * boolean key matches only its canonical string form; any other rowId names
313
+ * no row, so it binds NULL instead of letting SQLite affinity coerce `042`.
314
+ */
315
+ export function pkSqlValue(
316
+ table: CompiledClientTable,
317
+ rowId: string,
318
+ ): SqlValue {
319
+ const pkType = localColumnType(table.columns[table.primaryKeyIndex]!);
320
+ if (pkType === 'boolean') {
321
+ return rowId === 'true' ? 1 : rowId === 'false' ? 0 : null;
322
+ }
323
+ if (pkType === 'integer') {
324
+ const parsed = Number(rowId);
325
+ return Number.isSafeInteger(parsed) && String(parsed) === rowId
326
+ ? parsed
327
+ : null;
328
+ }
329
+ return rowId;
330
+ }
331
+
306
332
  export function quoteIdent(name: string): string {
307
333
  return `"${name.replaceAll('"', '""')}"`;
308
334
  }
@@ -3,8 +3,8 @@ import type { SecurityLifecycle } from './client';
3
3
  import type { SyncIntent } from './invalidation';
4
4
 
5
5
  export interface SyncSchedulerClient {
6
- readonly syncNeeded: boolean;
7
- readonly securityLifecycle: SecurityLifecycle;
6
+ statusSnapshot(): { readonly syncNeeded: boolean };
7
+ securityLifecycle(): SecurityLifecycle;
8
8
  syncUntilIdle(maxRounds?: number): Promise<unknown>;
9
9
  onSyncNeeded(
10
10
  listener: (reason: 'startup' | 'hello' | WakeReason) => void,
@@ -79,7 +79,7 @@ export function installSyncScheduler(
79
79
 
80
80
  const run = (): void => {
81
81
  if (stopped || running) return;
82
- if (client.securityLifecycle === 'preflight') {
82
+ if (client.securityLifecycle() === 'preflight') {
83
83
  immediatePending = false;
84
84
  return;
85
85
  }
@@ -144,7 +144,10 @@ export function installSyncScheduler(
144
144
  consume({ kind: 'interactive' });
145
145
  });
146
146
  const unsubscribeIntent = client.onSyncIntent(consume);
147
- if (client.syncNeeded && client.securityLifecycle === 'active') {
147
+ if (
148
+ client.statusSnapshot().syncNeeded &&
149
+ client.securityLifecycle() === 'active'
150
+ ) {
148
151
  consume({ kind: 'interactive' });
149
152
  }
150
153
 
@@ -164,17 +164,23 @@ export function startSyncWorker(overrides: SyncWorkerOverrides = {}): void {
164
164
  let backgroundDue = Number.POSITIVE_INFINITY;
165
165
 
166
166
  function runAutoSync(): void {
167
- autoSyncScheduled = false;
168
167
  if (
169
168
  closed ||
170
169
  client === undefined ||
171
170
  offline ||
172
171
  client.securityLifecycle() === 'preflight'
173
172
  ) {
173
+ autoSyncScheduled = false;
174
174
  return;
175
175
  }
176
176
  const running = client;
177
- void serializedSync(() => running.syncUntilIdle())
177
+ void serializedSync(() => {
178
+ // The queued round owns the flag from here. Clearing it at entry would
179
+ // let every wake-up that arrives while this round waits behind the
180
+ // running one queue its own full drive (§8.4 coalescing).
181
+ autoSyncScheduled = false;
182
+ return running.syncUntilIdle();
183
+ })
178
184
  .then((summary) => {
179
185
  if (!closed) post({ t: 'event', event: { kind: 'synced', summary } });
180
186
  if (summary.budgetExhausted) consumeSyncIntent({ kind: 'interactive' });
@@ -584,7 +590,12 @@ export function startSyncWorker(overrides: SyncWorkerOverrides = {}): void {
584
590
  () => dispatch(message),
585
591
  message.method === 'query'
586
592
  ? (value) => queryTransferables(value as readonly unknown[])
587
- : undefined,
593
+ : message.method === 'querySnapshot'
594
+ ? (value) =>
595
+ queryTransferables(
596
+ (value as { readonly rows: readonly unknown[] }).rows,
597
+ )
598
+ : undefined,
588
599
  );
589
600
  }
590
601
  });