@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 +2 -2
- package/dist/client.d.ts +8 -0
- package/dist/client.js +75 -22
- package/dist/failed-overlay.js +2 -2
- package/dist/http.js +16 -11
- package/dist/remote.js +9 -3
- package/dist/schema.d.ts +6 -0
- package/dist/schema.js +22 -1
- package/dist/sync-scheduler.d.ts +4 -2
- package/dist/sync-scheduler.js +3 -2
- package/dist/worker-entry.js +11 -3
- package/package.json +3 -3
- package/src/apply.ts +2 -1
- package/src/client.ts +81 -25
- package/src/failed-overlay.ts +2 -1
- package/src/http.ts +20 -23
- package/src/remote.ts +10 -3
- package/src/schema.ts +27 -1
- package/src/sync-scheduler.ts +7 -4
- package/src/worker-entry.ts +14 -3
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
|
-
|
|
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]:
|
|
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
|
|
4386
|
-
|
|
4387
|
-
|
|
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
|
}
|
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/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
|
-
//
|
|
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
|
-
|
|
237
|
-
|
|
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:
|
|
241
|
-
...(typeof
|
|
242
|
-
? { 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
|
|
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/sync-scheduler.d.ts
CHANGED
|
@@ -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
|
-
|
|
6
|
-
|
|
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;
|
package/dist/sync-scheduler.js
CHANGED
|
@@ -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 &&
|
|
113
|
+
if (client.statusSnapshot().syncNeeded &&
|
|
114
|
+
client.securityLifecycle() === 'active') {
|
|
114
115
|
consume({ kind: 'interactive' });
|
|
115
116
|
}
|
|
116
117
|
return {
|
package/dist/worker-entry.js
CHANGED
|
@@ -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(() =>
|
|
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
|
-
:
|
|
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.
|
|
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.
|
|
100
|
+
"@syncular/core": "0.33.0"
|
|
101
101
|
},
|
|
102
102
|
"devDependencies": {
|
|
103
|
-
"@syncular/server": "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
|
-
|
|
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]:
|
|
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
|
|
6114
|
-
|
|
6115
|
-
|
|
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
|
}
|
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/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
|
-
//
|
|
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
|
-
|
|
362
|
-
|
|
362
|
+
const bytes = new Uint8Array(
|
|
363
|
+
await transfer(
|
|
363
364
|
'blob download body read failed',
|
|
364
|
-
() => response.
|
|
365
|
+
() => response.arrayBuffer(),
|
|
365
366
|
response.status,
|
|
366
|
-
)
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
};
|
|
370
|
-
|
|
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:
|
|
374
|
-
...(typeof
|
|
375
|
-
? { 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
|
|
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/sync-scheduler.ts
CHANGED
|
@@ -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
|
-
|
|
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 (
|
|
147
|
+
if (
|
|
148
|
+
client.statusSnapshot().syncNeeded &&
|
|
149
|
+
client.securityLifecycle() === 'active'
|
|
150
|
+
) {
|
|
148
151
|
consume({ kind: 'interactive' });
|
|
149
152
|
}
|
|
150
153
|
|
package/src/worker-entry.ts
CHANGED
|
@@ -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(() =>
|
|
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
|
-
:
|
|
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
|
});
|