@syncular/client 0.31.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/README.md +1 -1
- package/dist/apply.js +2 -2
- package/dist/client.js +28 -21
- package/dist/failed-overlay.js +2 -2
- package/dist/remote.js +9 -3
- package/dist/schema.d.ts +6 -0
- package/dist/schema.js +22 -1
- package/dist/worker-entry.js +3 -1
- package/package.json +3 -3
- package/src/apply.ts +2 -1
- package/src/client.ts +29 -24
- package/src/failed-overlay.ts +2 -1
- package/src/remote.ts +10 -3
- package/src/schema.ts +27 -1
- package/src/worker-entry.ts +6 -1
package/README.md
CHANGED
|
@@ -284,7 +284,7 @@ stable code-like values (`A-Z`, `a-z`, `0-9`, dot, underscore, dash).
|
|
|
284
284
|
During Vite development, retain the React client resource only while its
|
|
285
285
|
captured generated schema version and published Syncular runtime identity both
|
|
286
286
|
match. The
|
|
287
|
-
[schema-and-runtime-aware Vite guide](https://syncular.dev/
|
|
287
|
+
[schema-and-runtime-aware Vite guide](https://syncular.dev/platform-web-install/#configure-vite) uses
|
|
288
288
|
`retainViteSyncClientResource` to close the old worker before constructing a
|
|
289
289
|
schema-bump or package-upgrade replacement; hot-reloading query code alone does
|
|
290
290
|
not migrate the worker-owned database.
|
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]:
|
|
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
|
|
4386
|
-
|
|
4387
|
-
|
|
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
|
}
|
package/dist/failed-overlay.js
CHANGED
|
@@ -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
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/worker-entry.js
CHANGED
|
@@ -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
|
-
:
|
|
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.
|
|
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.
|
|
100
|
+
"@syncular/core": "0.32.1"
|
|
101
101
|
},
|
|
102
102
|
"devDependencies": {
|
|
103
|
-
"@syncular/server": "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]:
|
|
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
|
|
6114
|
-
|
|
6115
|
-
|
|
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
|
}
|
package/src/failed-overlay.ts
CHANGED
|
@@ -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
|
|
330
|
-
|
|
331
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/worker-entry.ts
CHANGED
|
@@ -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
|
-
:
|
|
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
|
});
|