@syncular/client 0.32.0 → 0.32.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.
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.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';
@@ -756,6 +756,8 @@ export class SyncClient {
756
756
  this.#securityLifecycle = 'active';
757
757
  const startupWork = this.#schemaFloor === undefined &&
758
758
  (countOutbox(this.#db) > 0 ||
759
+ this.#db.query(`SELECT 1 FROM ${RETAINED_ROWS} WHERE commit_seq IS NOT NULL LIMIT 1`).length > 0 ||
760
+ loadPendingEvictions(this.#db).length > 0 ||
759
761
  loadSubscriptions(this.#db).some((sub) => sub.status === 'active'));
760
762
  if (startupWork) {
761
763
  this.#setSyncNeeded(true);
@@ -1496,6 +1498,7 @@ export class SyncClient {
1496
1498
  const task = Promise.resolve().then(fn);
1497
1499
  this.#protectedAsync.add(task);
1498
1500
  const settled = () => {
1501
+ this.#protectedAsync.delete(task);
1499
1502
  if (this.#realtimeConnectPromise === task)
1500
1503
  this.#realtimeConnectPromise = undefined;
1501
1504
  };
@@ -2077,7 +2080,7 @@ export class SyncClient {
2077
2080
  const scopeColumns = new Set(table.scopeColumnByVariable.values());
2078
2081
  const pkColumn = table.columns[table.primaryKeyIndex];
2079
2082
  const pkValue = requireRowId(normalized.get(pkColumn.name));
2080
- const local = this.#db.query(`SELECT * FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)}=?`, [pkValue])[0];
2083
+ const local = this.#db.query(`SELECT * FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)}=?`, [pkSqlValue(table, pkValue)])[0];
2081
2084
  if (!local)
2082
2085
  throw new ClientSyncError('sync.row_missing', 'a sparse patch requires a local row');
2083
2086
  for (const column of table.columns) {
@@ -2204,16 +2207,24 @@ export class SyncClient {
2204
2207
  const pkColumn = compiled.columns[compiled.primaryKeyIndex];
2205
2208
  const normalized = normalizeRecordKeys(compiled, partial);
2206
2209
  const pkOverride = normalized.get(pkColumn.name);
2207
- if (pkOverride !== undefined && pkOverride !== rowId) {
2210
+ if (pkOverride !== undefined && String(pkOverride) !== rowId) {
2208
2211
  throw invalidRequest(`table ${compiled.name}: patch cannot change the primary key`);
2209
2212
  }
2213
+ const pkType = localColumnType(pkColumn);
2214
+ const typedPk = pkType === 'integer' &&
2215
+ Number.isSafeInteger(Number(rowId)) &&
2216
+ String(Number(rowId)) === rowId
2217
+ ? Number(rowId)
2218
+ : pkType === 'boolean' && (rowId === 'true' || rowId === 'false')
2219
+ ? rowId === 'true'
2220
+ : rowId;
2210
2221
  return this.#recordMutations([
2211
2222
  definedObject({
2212
2223
  table,
2213
2224
  op: 'patch',
2214
2225
  values: {
2215
2226
  ...Object.fromEntries(normalized),
2216
- [pkColumn.name]: rowId,
2227
+ [pkColumn.name]: typedPk,
2217
2228
  },
2218
2229
  baseVersion: options?.baseVersion,
2219
2230
  }),
@@ -2524,7 +2535,7 @@ export class SyncClient {
2524
2535
  const selector = this.#encryption?.keyIdColumns?.[table.name];
2525
2536
  if (selector === undefined)
2526
2537
  return undefined;
2527
- const row = this.#db.query(`SELECT ${quoteIdent(selector)} AS value FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)}=?`, [rowId])[0];
2538
+ const row = this.#db.query(`SELECT ${quoteIdent(selector)} AS value FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)}=?`, [pkSqlValue(table, rowId)])[0];
2528
2539
  const value = row?.value;
2529
2540
  return typeof value === 'string' && value.length > 0
2530
2541
  ? value
@@ -3085,15 +3096,6 @@ export class SyncClient {
3085
3096
  this.#setRealtimeState('connecting');
3086
3097
  const task = this.#runProtectedAsync(() => this.#connectRealtime(generation));
3087
3098
  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
3099
  return task;
3098
3100
  }
3099
3101
  async #connectRealtime(generation) {
@@ -3753,7 +3755,7 @@ export class SyncClient {
3753
3755
  const mappings = [...table.scopeColumnByVariable].filter(([variable]) => table.scopePrefixByVariable.has(variable));
3754
3756
  if (mappings.length === 0)
3755
3757
  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];
3758
+ 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
3759
  if (row === undefined)
3758
3760
  return false;
3759
3761
  let recorded = false;
@@ -4048,7 +4050,7 @@ export class SyncClient {
4048
4050
  ...table.columns.map((column) => quoteIdent(column.name)),
4049
4051
  quoteIdent(SYNC_VERSION_COLUMN),
4050
4052
  ];
4051
- const row = this.#db.query(`SELECT ${columns.join(', ')} FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)}=?`, [operation.rowId])[0];
4053
+ const row = this.#db.query(`SELECT ${columns.join(', ')} FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)}=?`, [pkSqlValue(table, operation.rowId)])[0];
4052
4054
  if (row === undefined)
4053
4055
  return { opIndex, existed: false };
4054
4056
  const values = {};
@@ -4088,7 +4090,7 @@ export class SyncClient {
4088
4090
  const table = this.#schema.tables.get(operation.table);
4089
4091
  if (table === undefined)
4090
4092
  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];
4093
+ 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
4094
  if (row !== undefined && row.v === OPTIMISTIC_VERSION) {
4093
4095
  batch.table(table.name);
4094
4096
  deleteLocalRow(this.#db, table, operation.rowId);
@@ -4164,7 +4166,7 @@ export class SyncClient {
4164
4166
  // sync.row_missing, and the local mirror never invents a base.
4165
4167
  const opValues = op.values ?? {};
4166
4168
  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];
4169
+ const local = this.#db.query(`SELECT * FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)}=?`, [pkSqlValue(table, op.rowId)])[0];
4168
4170
  if (local === undefined && !full) {
4169
4171
  if (batch !== undefined && !precise)
4170
4172
  batch.table(op.table);
@@ -4382,7 +4384,12 @@ function definedObject(record) {
4382
4384
  return record;
4383
4385
  }
4384
4386
  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;
4387
+ if (typeof value === 'string' && value.length > 0)
4388
+ return value;
4389
+ if (typeof value === 'number' && Number.isSafeInteger(value)) {
4390
+ return String(value);
4391
+ }
4392
+ if (typeof value === 'boolean')
4393
+ return String(value);
4394
+ throw invalidRequest('a mutation requires a non-empty primary key');
4388
4395
  }
@@ -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/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
  }
@@ -453,7 +453,9 @@ export function startSyncWorker(overrides = {}) {
453
453
  if (message.t === 'call') {
454
454
  run(message.id, () => dispatch(message), message.method === 'query'
455
455
  ? (value) => queryTransferables(value)
456
- : undefined);
456
+ : message.method === 'querySnapshot'
457
+ ? (value) => queryTransferables(value.rows)
458
+ : undefined);
457
459
  }
458
460
  });
459
461
  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.32.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",
@@ -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.32.1"
101
101
  },
102
102
  "devDependencies": {
103
- "@syncular/server": "0.32.0",
103
+ "@syncular/server": "0.32.1",
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';
@@ -1579,6 +1579,10 @@ export class SyncClient {
1579
1579
  const startupWork =
1580
1580
  this.#schemaFloor === undefined &&
1581
1581
  (countOutbox(this.#db) > 0 ||
1582
+ this.#db.query(
1583
+ `SELECT 1 FROM ${RETAINED_ROWS} WHERE commit_seq IS NOT NULL LIMIT 1`,
1584
+ ).length > 0 ||
1585
+ loadPendingEvictions(this.#db).length > 0 ||
1582
1586
  loadSubscriptions(this.#db).some((sub) => sub.status === 'active'));
1583
1587
  if (startupWork) {
1584
1588
  this.#setSyncNeeded(true);
@@ -2470,6 +2474,7 @@ export class SyncClient {
2470
2474
  const task = Promise.resolve().then(fn);
2471
2475
  this.#protectedAsync.add(task);
2472
2476
  const settled = () => {
2477
+ this.#protectedAsync.delete(task);
2473
2478
  if (this.#realtimeConnectPromise === task)
2474
2479
  this.#realtimeConnectPromise = undefined;
2475
2480
  };
@@ -3184,7 +3189,7 @@ export class SyncClient {
3184
3189
  const pkValue = requireRowId(normalized.get(pkColumn.name));
3185
3190
  const local = this.#db.query(
3186
3191
  `SELECT * FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)}=?`,
3187
- [pkValue],
3192
+ [pkSqlValue(table, pkValue)],
3188
3193
  )[0];
3189
3194
  if (!local)
3190
3195
  throw new ClientSyncError(
@@ -3351,18 +3356,27 @@ export class SyncClient {
3351
3356
  const pkColumn = compiled.columns[compiled.primaryKeyIndex] as RowColumn;
3352
3357
  const normalized = normalizeRecordKeys(compiled, partial);
3353
3358
  const pkOverride = normalized.get(pkColumn.name);
3354
- if (pkOverride !== undefined && pkOverride !== rowId) {
3359
+ if (pkOverride !== undefined && String(pkOverride) !== rowId) {
3355
3360
  throw invalidRequest(
3356
3361
  `table ${compiled.name}: patch cannot change the primary key`,
3357
3362
  );
3358
3363
  }
3364
+ const pkType = localColumnType(pkColumn);
3365
+ const typedPk =
3366
+ pkType === 'integer' &&
3367
+ Number.isSafeInteger(Number(rowId)) &&
3368
+ String(Number(rowId)) === rowId
3369
+ ? Number(rowId)
3370
+ : pkType === 'boolean' && (rowId === 'true' || rowId === 'false')
3371
+ ? rowId === 'true'
3372
+ : rowId;
3359
3373
  return this.#recordMutations([
3360
3374
  definedObject({
3361
3375
  table,
3362
3376
  op: 'patch',
3363
3377
  values: {
3364
3378
  ...Object.fromEntries(normalized),
3365
- [pkColumn.name]: rowId,
3379
+ [pkColumn.name]: typedPk,
3366
3380
  },
3367
3381
  baseVersion: options?.baseVersion,
3368
3382
  }),
@@ -3745,7 +3759,7 @@ export class SyncClient {
3745
3759
  if (selector === undefined) return undefined;
3746
3760
  const row = this.#db.query(
3747
3761
  `SELECT ${quoteIdent(selector)} AS value FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)}=?`,
3748
- [rowId],
3762
+ [pkSqlValue(table, rowId)],
3749
3763
  )[0];
3750
3764
  const value = row?.value;
3751
3765
  return typeof value === 'string' && value.length > 0
@@ -4428,18 +4442,6 @@ export class SyncClient {
4428
4442
  this.#connectRealtime(generation),
4429
4443
  );
4430
4444
  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
4445
  return task;
4444
4446
  }
4445
4447
 
@@ -5273,7 +5275,7 @@ export class SyncClient {
5273
5275
  if (mappings.length === 0) return false;
5274
5276
  const row = this.#db.query(
5275
5277
  `SELECT ${mappings.map(([, column]) => quoteIdent(column)).join(', ')} FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)}=?`,
5276
- [rowId],
5278
+ [pkSqlValue(table, rowId)],
5277
5279
  )[0];
5278
5280
  if (row === undefined) return false;
5279
5281
  let recorded = false;
@@ -5668,7 +5670,7 @@ export class SyncClient {
5668
5670
  ];
5669
5671
  const row = this.#db.query(
5670
5672
  `SELECT ${columns.join(', ')} FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)}=?`,
5671
- [operation.rowId],
5673
+ [pkSqlValue(table, operation.rowId)],
5672
5674
  )[0];
5673
5675
  if (row === undefined) return { opIndex, existed: false };
5674
5676
  const values: Record<string, ReturnType<typeof rowValueToJson>> = {};
@@ -5732,7 +5734,7 @@ export class SyncClient {
5732
5734
  if (table === undefined) continue;
5733
5735
  const row = this.#db.query(
5734
5736
  `SELECT ${quoteIdent(SYNC_VERSION_COLUMN)} AS v FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)}=?`,
5735
- [operation.rowId],
5737
+ [pkSqlValue(table, operation.rowId)],
5736
5738
  )[0];
5737
5739
  if (row !== undefined && row.v === OPTIMISTIC_VERSION) {
5738
5740
  batch.table(table.name);
@@ -5826,7 +5828,7 @@ export class SyncClient {
5826
5828
  const full = table.columns.every((column) => column.name in opValues);
5827
5829
  const local = this.#db.query(
5828
5830
  `SELECT * FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)}=?`,
5829
- [op.rowId],
5831
+ [pkSqlValue(table, op.rowId)],
5830
5832
  )[0];
5831
5833
  if (local === undefined && !full) {
5832
5834
  if (batch !== undefined && !precise) batch.table(op.table);
@@ -6110,7 +6112,10 @@ function definedObject<const T extends object>(record: T): DefinedObject<T> {
6110
6112
  }
6111
6113
 
6112
6114
  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;
6115
+ if (typeof value === 'string' && value.length > 0) return value;
6116
+ if (typeof value === 'number' && Number.isSafeInteger(value)) {
6117
+ return String(value);
6118
+ }
6119
+ if (typeof value === 'boolean') return String(value);
6120
+ throw invalidRequest('a mutation requires a non-empty primary key');
6116
6121
  }
@@ -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/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
  }
@@ -584,7 +584,12 @@ export function startSyncWorker(overrides: SyncWorkerOverrides = {}): void {
584
584
  () => dispatch(message),
585
585
  message.method === 'query'
586
586
  ? (value) => queryTransferables(value as readonly unknown[])
587
- : undefined,
587
+ : message.method === 'querySnapshot'
588
+ ? (value) =>
589
+ queryTransferables(
590
+ (value as { readonly rows: readonly unknown[] }).rows,
591
+ )
592
+ : undefined,
588
593
  );
589
594
  }
590
595
  });