@syncular/client 0.20.0 → 0.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +22 -5
- package/dist/apply.d.ts +14 -7
- package/dist/apply.js +75 -53
- package/dist/bun-database.d.ts +1 -1
- package/dist/bun-database.js +2 -2
- package/dist/client.d.ts +10 -10
- package/dist/client.js +162 -64
- package/dist/database.d.ts +3 -2
- package/dist/encryption.d.ts +1 -1
- package/dist/encryption.js +4 -2
- package/dist/node-database.d.ts +1 -1
- package/dist/node-database.js +2 -2
- package/dist/outbox.d.ts +5 -5
- package/dist/outbox.js +5 -7
- package/dist/outcomes.d.ts +6 -0
- package/dist/outcomes.js +2 -0
- package/dist/reactive-store.js +49 -8
- package/dist/remote.js +40 -6
- package/dist/schema.d.ts +8 -0
- package/dist/schema.js +19 -5
- package/dist/wasm-database.js +3 -3
- package/package.json +3 -3
- package/src/apply.ts +86 -60
- package/src/bun-database.ts +6 -2
- package/src/client.ts +195 -72
- package/src/database.ts +7 -2
- package/src/encryption.ts +13 -6
- package/src/node-database.ts +6 -2
- package/src/outbox.ts +12 -12
- package/src/outcomes.ts +14 -1
- package/src/reactive-store.ts +58 -8
- package/src/remote.ts +54 -7
- package/src/schema.ts +29 -5
- package/src/wasm-database.ts +7 -3
package/dist/reactive-store.js
CHANGED
|
@@ -244,7 +244,8 @@ class QueryEntry {
|
|
|
244
244
|
#running = false;
|
|
245
245
|
#requested = false;
|
|
246
246
|
#desiredRevision = 0n;
|
|
247
|
-
#
|
|
247
|
+
#claimError;
|
|
248
|
+
#claimPending = false;
|
|
248
249
|
#offStatus;
|
|
249
250
|
constructor(store, spec, key, cache) {
|
|
250
251
|
this.store = store;
|
|
@@ -270,10 +271,22 @@ class QueryEntry {
|
|
|
270
271
|
for (const coverage of this.spec.coverage ?? []) {
|
|
271
272
|
claims.push(this.store.setWindowClaim(this.#owner, coverage.base, coverage.units));
|
|
272
273
|
}
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
274
|
+
const generation = this.#generation;
|
|
275
|
+
this.#claimPending = claims.length > 0;
|
|
276
|
+
if (claims.length > 0)
|
|
277
|
+
void Promise.all(claims).then(() => {
|
|
278
|
+
if (generation !== this.#generation || this.#listeners.size === 0)
|
|
279
|
+
return;
|
|
280
|
+
this.#claimPending = false;
|
|
281
|
+
if (!this.#running && this.#state.phase !== 'ready')
|
|
282
|
+
this.#requestRead();
|
|
283
|
+
}, (error) => {
|
|
284
|
+
if (generation !== this.#generation || this.#listeners.size === 0)
|
|
285
|
+
return;
|
|
286
|
+
this.#claimPending = false;
|
|
287
|
+
this.#claimError = errorOf(error);
|
|
288
|
+
this.#requestRead();
|
|
289
|
+
});
|
|
277
290
|
}
|
|
278
291
|
this.#requestRead();
|
|
279
292
|
}
|
|
@@ -300,7 +313,8 @@ class QueryEntry {
|
|
|
300
313
|
this.#running = false;
|
|
301
314
|
this.#requested = false;
|
|
302
315
|
this.#desiredRevision = 0n;
|
|
303
|
-
this.#
|
|
316
|
+
this.#claimError = undefined;
|
|
317
|
+
this.#claimPending = false;
|
|
304
318
|
this.#state = {
|
|
305
319
|
rows: [],
|
|
306
320
|
phase: 'loading',
|
|
@@ -393,9 +407,11 @@ class QueryEntry {
|
|
|
393
407
|
this.#requested = false;
|
|
394
408
|
if (this.store.availabilitySnapshot().state === 'blocked')
|
|
395
409
|
break;
|
|
396
|
-
await this.#claimReady;
|
|
397
410
|
if (generation !== this.#generation || this.#listeners.size === 0)
|
|
398
411
|
return;
|
|
412
|
+
if (this.#claimError !== undefined)
|
|
413
|
+
throw this.#claimError;
|
|
414
|
+
const claimWasPending = this.#claimPending;
|
|
399
415
|
const snapshot = await this.store.client.querySnapshot({
|
|
400
416
|
sql: this.spec.sql,
|
|
401
417
|
...(this.spec.params !== undefined
|
|
@@ -407,6 +423,8 @@ class QueryEntry {
|
|
|
407
423
|
});
|
|
408
424
|
if (generation !== this.#generation || this.#listeners.size === 0)
|
|
409
425
|
return;
|
|
426
|
+
if (this.#claimError !== undefined)
|
|
427
|
+
throw this.#claimError;
|
|
410
428
|
const availability = this.store.availabilitySnapshot();
|
|
411
429
|
if (availability.state === 'blocked') {
|
|
412
430
|
this.#publish({
|
|
@@ -421,6 +439,10 @@ class QueryEntry {
|
|
|
421
439
|
this.#requested = true;
|
|
422
440
|
continue;
|
|
423
441
|
}
|
|
442
|
+
if (!snapshot.coverage.complete &&
|
|
443
|
+
claimWasPending &&
|
|
444
|
+
!this.#claimPending)
|
|
445
|
+
this.#requested = true;
|
|
424
446
|
const mappedRows = this.spec.mapRow === undefined
|
|
425
447
|
? snapshot.rows
|
|
426
448
|
: snapshot.rows.map(this.spec.mapRow);
|
|
@@ -736,6 +758,7 @@ export class ReactiveClientStore {
|
|
|
736
758
|
base,
|
|
737
759
|
claims: new Map(),
|
|
738
760
|
appliedKey: '',
|
|
761
|
+
appliedUnits: new Set(),
|
|
739
762
|
scheduled: false,
|
|
740
763
|
running: false,
|
|
741
764
|
requested: false,
|
|
@@ -744,6 +767,13 @@ export class ReactiveClientStore {
|
|
|
744
767
|
this.#windowClaims.set(key, group);
|
|
745
768
|
}
|
|
746
769
|
group.claims.set(owner, new Set(units));
|
|
770
|
+
const alreadyHeld = units.length > 0 &&
|
|
771
|
+
units.every((unit) => group.appliedUnits.has(unit) &&
|
|
772
|
+
(group.inFlightUnits === undefined || group.inFlightUnits.has(unit)));
|
|
773
|
+
if (alreadyHeld) {
|
|
774
|
+
this.#scheduleWindow(group);
|
|
775
|
+
return Promise.resolve();
|
|
776
|
+
}
|
|
747
777
|
const result = new Promise((resolve, reject) => {
|
|
748
778
|
group?.waiters.push({ resolve, reject });
|
|
749
779
|
});
|
|
@@ -779,16 +809,27 @@ export class ReactiveClientStore {
|
|
|
779
809
|
].sort();
|
|
780
810
|
const key = canonicalValue(units);
|
|
781
811
|
if (key !== group.appliedKey) {
|
|
782
|
-
|
|
812
|
+
group.inFlightUnits = new Set(units);
|
|
813
|
+
try {
|
|
814
|
+
await this.client.setWindow(group.base, units);
|
|
815
|
+
}
|
|
816
|
+
finally {
|
|
817
|
+
group.inFlightUnits = undefined;
|
|
818
|
+
}
|
|
783
819
|
if (this.#windowClaims.get(baseKey) !== group)
|
|
784
820
|
return;
|
|
785
821
|
group.appliedKey = key;
|
|
822
|
+
group.appliedUnits = new Set(units);
|
|
786
823
|
}
|
|
787
824
|
}
|
|
788
825
|
for (const waiter of group.waiters.splice(0))
|
|
789
826
|
waiter.resolve();
|
|
790
827
|
}
|
|
791
828
|
catch (error) {
|
|
829
|
+
// A failed window edit may already have committed its first eviction chunk.
|
|
830
|
+
// Reconcile through the core before acknowledging any later claim.
|
|
831
|
+
group.appliedKey = '';
|
|
832
|
+
group.appliedUnits = new Set();
|
|
792
833
|
for (const waiter of group.waiters.splice(0))
|
|
793
834
|
waiter.reject(error);
|
|
794
835
|
}
|
package/dist/remote.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Database-less SSP2 producer (§6.10). It prepares and sends ordinary commits
|
|
3
3
|
* through the existing push path without creating a local replica or outbox.
|
|
4
4
|
*/
|
|
5
|
-
import { decodeMessage, decodeRemoteOperationResponse, decodeRemoteOperationRealtimeMessage, encodeMessage, encodeRemoteOperationRequest, encodeRemoteOperationRealtimeMessage,
|
|
5
|
+
import { decodeMessage, decodeRemoteOperationResponse, decodeRemoteOperationRealtimeMessage, encodeMessage, encodeRemoteOperationRequest, encodeRemoteOperationRealtimeMessage, encodeSparseRow, PROTOCOL_WIRE_VERSION, } from '@syncular/core';
|
|
6
6
|
import { encryptRowValues } from './encryption.js';
|
|
7
7
|
import { ClientSyncError } from './errors.js';
|
|
8
8
|
import { compileClientSchema, recordToRowValues, } from './schema.js';
|
|
@@ -71,6 +71,7 @@ export class SyncRemoteClient {
|
|
|
71
71
|
#operationSocketGeneration = 0;
|
|
72
72
|
#watches = new Map();
|
|
73
73
|
#encryption;
|
|
74
|
+
/** Pinned by config, or acquired on the first prepared commit (§2.1). */
|
|
74
75
|
#logEpoch;
|
|
75
76
|
constructor(config) {
|
|
76
77
|
if (config.clientId.length === 0) {
|
|
@@ -101,6 +102,41 @@ export class SyncRemoteClient {
|
|
|
101
102
|
if (input.mutations.length === 0) {
|
|
102
103
|
throw new ClientSyncError('sync.empty_commit', 'a remote commit must carry at least one mutation (§6.1)');
|
|
103
104
|
}
|
|
105
|
+
let logEpoch = this.#logEpoch;
|
|
106
|
+
if (logEpoch === undefined) {
|
|
107
|
+
// §2.1: a push round binds to the partition log epoch; an acquisition
|
|
108
|
+
// round carries no push commits.
|
|
109
|
+
if (this.#transport === undefined) {
|
|
110
|
+
throw new ClientSyncError('client.remote_sync_unconfigured', 'SyncRemoteClient has no sync transport for ordinary commits');
|
|
111
|
+
}
|
|
112
|
+
const acquisition = decodeMessage(await this.#transport(encodeMessage({
|
|
113
|
+
wireVersion: PROTOCOL_WIRE_VERSION,
|
|
114
|
+
msgKind: 'request',
|
|
115
|
+
frames: [
|
|
116
|
+
{
|
|
117
|
+
type: 'REQ_HEADER',
|
|
118
|
+
clientId: this.#clientId,
|
|
119
|
+
schemaVersion: schema.version,
|
|
120
|
+
},
|
|
121
|
+
// A pull that asks for nothing: the round exists for its epoch.
|
|
122
|
+
{
|
|
123
|
+
type: 'PULL_HEADER',
|
|
124
|
+
limitCommits: 0,
|
|
125
|
+
limitSnapshotRows: 0,
|
|
126
|
+
maxSnapshotPages: 0,
|
|
127
|
+
accept: 0b0011,
|
|
128
|
+
},
|
|
129
|
+
],
|
|
130
|
+
})));
|
|
131
|
+
const acquisitionHeader = acquisition.frames[0];
|
|
132
|
+
if (acquisition.msgKind !== 'response' ||
|
|
133
|
+
acquisitionHeader?.type !== 'RESP_HEADER' ||
|
|
134
|
+
acquisitionHeader.logEpoch === undefined) {
|
|
135
|
+
throw new ClientSyncError('client.invalid_host_response', 'remote epoch acquisition returned an invalid response');
|
|
136
|
+
}
|
|
137
|
+
logEpoch = acquisitionHeader.logEpoch;
|
|
138
|
+
this.#logEpoch = logEpoch;
|
|
139
|
+
}
|
|
104
140
|
const operations = [];
|
|
105
141
|
for (const mutation of input.mutations) {
|
|
106
142
|
const table = schema.tables.get(mutation.table);
|
|
@@ -136,22 +172,20 @@ export class SyncRemoteClient {
|
|
|
136
172
|
...(mutation.baseVersion !== undefined
|
|
137
173
|
? { baseVersion: mutation.baseVersion }
|
|
138
174
|
: {}),
|
|
139
|
-
payload:
|
|
175
|
+
payload: encodeSparseRow(table.columns, table.primaryKeyIndex, values),
|
|
140
176
|
});
|
|
141
177
|
}
|
|
142
178
|
return {
|
|
143
179
|
requestId: input.requestId,
|
|
144
180
|
bytes: encodeMessage({
|
|
145
|
-
wireVersion:
|
|
181
|
+
wireVersion: PROTOCOL_WIRE_VERSION,
|
|
146
182
|
msgKind: 'request',
|
|
147
183
|
frames: [
|
|
148
184
|
{
|
|
149
185
|
type: 'REQ_HEADER',
|
|
150
186
|
clientId: this.#clientId,
|
|
151
187
|
schemaVersion: schema.version,
|
|
152
|
-
|
|
153
|
-
? { logEpoch: this.#logEpoch }
|
|
154
|
-
: {}),
|
|
188
|
+
logEpoch,
|
|
155
189
|
},
|
|
156
190
|
{
|
|
157
191
|
type: 'PUSH_COMMIT',
|
package/dist/schema.d.ts
CHANGED
|
@@ -138,6 +138,14 @@ export declare function fromSqlValue(column: RowColumn, value: SqlValue): RowVal
|
|
|
138
138
|
* reserved `_sync_*` names); giving one column in both casings is an error.
|
|
139
139
|
*/
|
|
140
140
|
export declare function normalizeRecordKeys(table: CompiledClientTable, record: Readonly<Record<string, unknown>>): Map<string, unknown>;
|
|
141
|
+
/**
|
|
142
|
+
* Accept the local SQL representation of a value alongside the app-facing
|
|
143
|
+
* one, so a row read straight off the mirror (`SELECT *`) feeds back into
|
|
144
|
+
* `mutate()` without per-column fixups: SQLite stores booleans as 0/1 and
|
|
145
|
+
* may surface integers as bigint. Anything else passes through untouched —
|
|
146
|
+
* the codec still fails loud on genuine type garbage at encode time.
|
|
147
|
+
*/
|
|
148
|
+
export declare function coerceSqlRepresentation(column: RowColumn, value: unknown): unknown;
|
|
141
149
|
/**
|
|
142
150
|
* App-facing record → schema-ordered row values for the codec and the
|
|
143
151
|
* local mirror. Missing keys become NULL; unknown keys fail loud (see
|
package/dist/schema.js
CHANGED
|
@@ -195,6 +195,9 @@ function sqlType(column) {
|
|
|
195
195
|
/** §7.4.1 persisted local schema-version marker (`_syncular_meta` key). */
|
|
196
196
|
export const LOCAL_SCHEMA_VERSION_KEY = 'localSchemaVersion';
|
|
197
197
|
function createSyncedTable(db, table) {
|
|
198
|
+
// §6.11: a declared reference is deliberately NOT emitted here. Windowing
|
|
199
|
+
// evicts a parent independently of its children, so local SQLite never
|
|
200
|
+
// enforces a reference; the server enforces it.
|
|
198
201
|
const columns = table.columns.map((column) => {
|
|
199
202
|
const notNull = column.nullable ? '' : ' NOT NULL';
|
|
200
203
|
const pk = column.name === table.primaryKey ? ' PRIMARY KEY' : '';
|
|
@@ -234,6 +237,11 @@ function createFtsProjection(db, table, index) {
|
|
|
234
237
|
const indexedColumns = index.columns.map(quoteIdent);
|
|
235
238
|
const tokenizer = index.tokenize.replaceAll("'", "''");
|
|
236
239
|
db.exec(`CREATE VIRTUAL TABLE IF NOT EXISTS ${quoteIdent(index.name)} USING fts5(${quoteIdent(FTS_SOURCE_ID_COLUMN)} UNINDEXED, ${indexedColumns.join(', ')}, tokenize='${tokenizer}')`);
|
|
240
|
+
const mapping = quoteIdent(`_syncular_fts_${index.name}`);
|
|
241
|
+
const mapped = db.query("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", [
|
|
242
|
+
`_syncular_fts_${index.name}`,
|
|
243
|
+
]).length > 0;
|
|
244
|
+
db.exec(`CREATE TABLE IF NOT EXISTS ${mapping} (id INTEGER PRIMARY KEY, source_id TEXT NOT NULL UNIQUE)`);
|
|
237
245
|
const sourceId = `CAST(${quoteIdent(table.primaryKey)} AS TEXT)`;
|
|
238
246
|
const newSourceId = `CAST(new.${quoteIdent(table.primaryKey)} AS TEXT)`;
|
|
239
247
|
const oldSourceId = `CAST(old.${quoteIdent(table.primaryKey)} AS TEXT)`;
|
|
@@ -245,8 +253,9 @@ function createFtsProjection(db, table, index) {
|
|
|
245
253
|
newSourceId,
|
|
246
254
|
...index.columns.map((column) => `new.${quoteIdent(column)}`),
|
|
247
255
|
].join(', ');
|
|
248
|
-
const deleteFor = (value) => `DELETE FROM ${quoteIdent(index.name)} WHERE ${
|
|
249
|
-
const deleteDisplaced = (select) => `DELETE FROM ${quoteIdent(index.name)} WHERE ${
|
|
256
|
+
const deleteFor = (value) => `DELETE FROM ${quoteIdent(index.name)} WHERE rowid = (SELECT id FROM ${mapping} WHERE source_id = ${value}); DELETE FROM ${mapping} WHERE source_id = ${value}`;
|
|
257
|
+
const deleteDisplaced = (select) => `DELETE FROM ${quoteIdent(index.name)} WHERE rowid IN (SELECT id FROM ${mapping} WHERE source_id IN (${select})); DELETE FROM ${mapping} WHERE source_id IN (${select})`;
|
|
258
|
+
const insertNew = `INSERT INTO ${mapping}(source_id) VALUES (${newSourceId}); INSERT INTO ${quoteIdent(index.name)} (rowid, ${projectionColumns}) VALUES ((SELECT id FROM ${mapping} WHERE source_id = ${newSourceId}), ${newValues})`;
|
|
250
259
|
// A clean insert cannot already have a projection row because the source
|
|
251
260
|
// primary key is unique. Keep clean inserts linear by moving replacement
|
|
252
261
|
// cleanup behind indexed source-table existence checks. SQLite does not
|
|
@@ -294,9 +303,9 @@ function createFtsProjection(db, table, index) {
|
|
|
294
303
|
...displacedByUnique.map(deleteDisplaced),
|
|
295
304
|
].join('; ');
|
|
296
305
|
db.exec(`CREATE TRIGGER ${quoteIdent(`${index.name}_bi`)} BEFORE INSERT ON ${quoteIdent(table.name)} WHEN ${insertGuardCondition} BEGIN ${insertGuardBody}; END`);
|
|
297
|
-
db.exec(`CREATE TRIGGER ${quoteIdent(`${index.name}_ai`)} AFTER INSERT ON ${quoteIdent(table.name)} BEGIN
|
|
306
|
+
db.exec(`CREATE TRIGGER ${quoteIdent(`${index.name}_ai`)} AFTER INSERT ON ${quoteIdent(table.name)} BEGIN ${insertNew}; END`);
|
|
298
307
|
db.exec(`CREATE TRIGGER ${quoteIdent(`${index.name}_ad`)} AFTER DELETE ON ${quoteIdent(table.name)} BEGIN ${deleteFor(oldSourceId)}; END`);
|
|
299
|
-
db.exec(`CREATE TRIGGER ${quoteIdent(`${index.name}_au`)} AFTER UPDATE ON ${quoteIdent(table.name)} BEGIN ${deleteFor(oldSourceId)}; ${deleteFor(newSourceId)};
|
|
308
|
+
db.exec(`CREATE TRIGGER ${quoteIdent(`${index.name}_au`)} AFTER UPDATE ON ${quoteIdent(table.name)} BEGIN ${deleteFor(oldSourceId)}; ${deleteFor(newSourceId)}; ${insertNew}; END`);
|
|
300
309
|
// BEFORE UPDATE guard for `UPDATE OR REPLACE`: clear the projection of any
|
|
301
310
|
// different-PK row about to be displaced through a secondary unique index by
|
|
302
311
|
// the new values. Only meaningful when the table has a unique index; with
|
|
@@ -311,6 +320,10 @@ function createFtsProjection(db, table, index) {
|
|
|
311
320
|
if (!existed) {
|
|
312
321
|
db.exec(`INSERT INTO ${quoteIdent(index.name)} (${projectionColumns}) SELECT ${sourceId}, ${indexedColumns.join(', ')} FROM ${quoteIdent(table.name)}`);
|
|
313
322
|
}
|
|
323
|
+
if (!mapped || !existed) {
|
|
324
|
+
db.exec(`DELETE FROM ${mapping}`);
|
|
325
|
+
db.exec(`INSERT INTO ${mapping}(id, source_id) SELECT rowid, ${quoteIdent(FTS_SOURCE_ID_COLUMN)} FROM ${quoteIdent(index.name)}`);
|
|
326
|
+
}
|
|
314
327
|
}
|
|
315
328
|
/**
|
|
316
329
|
* Create only the application-owned synced tables, indexes, and FTS
|
|
@@ -424,6 +437,7 @@ export function dropAndRecreateSyncedTables(db, schema) {
|
|
|
424
437
|
AND sql LIKE 'CREATE VIRTUAL TABLE%'
|
|
425
438
|
AND name NOT LIKE '${RESERVED_TABLE_PREFIX}%'`);
|
|
426
439
|
for (const row of virtualTables) {
|
|
440
|
+
db.exec(`DROP TABLE IF EXISTS ${quoteIdent(`_syncular_fts_${String(row.name)}`)}`);
|
|
427
441
|
db.exec(`DROP TABLE IF EXISTS ${quoteIdent(String(row.name))}`);
|
|
428
442
|
}
|
|
429
443
|
const existing = db.query(`SELECT name FROM sqlite_master WHERE type = 'table'
|
|
@@ -502,7 +516,7 @@ export function normalizeRecordKeys(table, record) {
|
|
|
502
516
|
* may surface integers as bigint. Anything else passes through untouched —
|
|
503
517
|
* the codec still fails loud on genuine type garbage at encode time.
|
|
504
518
|
*/
|
|
505
|
-
function coerceSqlRepresentation(column, value) {
|
|
519
|
+
export function coerceSqlRepresentation(column, value) {
|
|
506
520
|
switch (localColumnType(column)) {
|
|
507
521
|
case 'boolean':
|
|
508
522
|
return value === 0 ? false : value === 1 ? true : value;
|
package/dist/wasm-database.js
CHANGED
|
@@ -65,9 +65,9 @@ class WasmClientDatabase {
|
|
|
65
65
|
* §5.3 image import at near-file-copy speed: attach an empty in-memory
|
|
66
66
|
* schema, then `sqlite3_deserialize` the image bytes into it (the
|
|
67
67
|
* documented sqlite-wasm import path — no OPFS round-trip, no SQL-level
|
|
68
|
-
* row shuttling before the
|
|
68
|
+
* row shuttling before the chunked INSERT…SELECT statements the caller runs).
|
|
69
69
|
*/
|
|
70
|
-
withSqliteImage(bytes, alias, fn) {
|
|
70
|
+
async withSqliteImage(bytes, alias, fn) {
|
|
71
71
|
assertImageAlias(alias);
|
|
72
72
|
const pointer = this.#db.pointer;
|
|
73
73
|
if (pointer === undefined) {
|
|
@@ -84,7 +84,7 @@ class WasmClientDatabase {
|
|
|
84
84
|
if (rc !== 0) {
|
|
85
85
|
throw new ClientSyncError('sync.invalid_request', `sqlite3_deserialize failed with code ${rc} (§5.3)`);
|
|
86
86
|
}
|
|
87
|
-
return fn();
|
|
87
|
+
return await fn();
|
|
88
88
|
}
|
|
89
89
|
finally {
|
|
90
90
|
this.exec(`DETACH ${alias}`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@syncular/client",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.21.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",
|
|
@@ -89,10 +89,10 @@
|
|
|
89
89
|
},
|
|
90
90
|
"dependencies": {
|
|
91
91
|
"@sqlite.org/sqlite-wasm": "^3.53.0-build1",
|
|
92
|
-
"@syncular/core": "0.
|
|
92
|
+
"@syncular/core": "0.21.0"
|
|
93
93
|
},
|
|
94
94
|
"devDependencies": {
|
|
95
|
-
"@syncular/server": "0.
|
|
95
|
+
"@syncular/server": "0.21.0",
|
|
96
96
|
"playwright": "1.63.0"
|
|
97
97
|
}
|
|
98
98
|
}
|
package/src/apply.ts
CHANGED
|
@@ -215,8 +215,8 @@ export function deleteScopedRows(
|
|
|
215
215
|
* scopes (same local-scope-column rule and fail-closed clause as
|
|
216
216
|
* {@link deleteScopedRows}) EXCEPT rows whose primary key is in
|
|
217
217
|
* `pinnedRowIds` (E1 — pinned by a still-pending outbox commit). Returns
|
|
218
|
-
*
|
|
219
|
-
*
|
|
218
|
+
* whether unpinned work remains and whether any scoped rows remain. Each
|
|
219
|
+
* call removes at most 1,024 rows; the caller commits before yielding. Also removes the
|
|
220
220
|
* evicted rows' `server_version` with them (E2 — no residual version
|
|
221
221
|
* cache), which is automatic since the version column is per-row.
|
|
222
222
|
*/
|
|
@@ -225,9 +225,9 @@ export function evictScopedRows(
|
|
|
225
225
|
table: CompiledClientTable,
|
|
226
226
|
effective: ScopeMap,
|
|
227
227
|
pinnedRowIds: ReadonlySet<string>,
|
|
228
|
-
): boolean {
|
|
228
|
+
): { remaining: boolean; deferred: boolean } {
|
|
229
229
|
const entries = Object.entries(effective);
|
|
230
|
-
if (entries.length === 0) return false;
|
|
230
|
+
if (entries.length === 0) return { remaining: false, deferred: false };
|
|
231
231
|
const clauses: string[] = [];
|
|
232
232
|
const params: string[] = [];
|
|
233
233
|
for (const [variable, values] of entries) {
|
|
@@ -238,7 +238,7 @@ export function evictScopedRows(
|
|
|
238
238
|
`table ${JSON.stringify(table.name)} has no local scope-column mapping for ${JSON.stringify(variable)} (§4.8/§3.3 fail-closed)`,
|
|
239
239
|
);
|
|
240
240
|
}
|
|
241
|
-
if (values.length === 0) return false;
|
|
241
|
+
if (values.length === 0) return { remaining: false, deferred: false };
|
|
242
242
|
clauses.push(
|
|
243
243
|
`${quoteIdent(column)} IN (${values.map(() => '?').join(', ')})`,
|
|
244
244
|
);
|
|
@@ -251,21 +251,25 @@ export function evictScopedRows(
|
|
|
251
251
|
pinnedClause = ` AND ${pk} NOT IN (${ids.map(() => '?').join(', ')})`;
|
|
252
252
|
params.push(...ids);
|
|
253
253
|
}
|
|
254
|
+
const target = quoteIdent(table.name);
|
|
254
255
|
db.exec(
|
|
255
|
-
`DELETE FROM ${
|
|
256
|
+
`DELETE FROM ${target} WHERE ${pk} IN (
|
|
257
|
+
SELECT ${pk} FROM ${target} WHERE ${clauses.join(' AND ')}${pinnedClause} LIMIT 1024
|
|
258
|
+
)`,
|
|
256
259
|
params,
|
|
257
260
|
);
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
261
|
+
const remaining =
|
|
262
|
+
db.query(
|
|
263
|
+
`SELECT 1 FROM ${target} WHERE ${clauses.join(' AND ')}${pinnedClause} LIMIT 1`,
|
|
264
|
+
params,
|
|
265
|
+
).length > 0;
|
|
266
|
+
const deferred =
|
|
267
|
+
remaining ||
|
|
268
|
+
db.query(
|
|
269
|
+
`SELECT 1 FROM ${target} WHERE ${clauses.join(' AND ')} LIMIT 1`,
|
|
270
|
+
params.slice(0, params.length - pinnedRowIds.size),
|
|
271
|
+
).length > 0;
|
|
272
|
+
return { remaining, deferred };
|
|
269
273
|
}
|
|
270
274
|
|
|
271
275
|
/** Descriptor fields a sqlite image is validated against (§5.3). */
|
|
@@ -286,14 +290,14 @@ function imageInvalid(detail: string): never {
|
|
|
286
290
|
}
|
|
287
291
|
|
|
288
292
|
/**
|
|
289
|
-
* Apply a §5.3 sqlite
|
|
293
|
+
* Apply a §5.3 sqlite image in committed chunks of at most 1,024 rows: validate
|
|
290
294
|
* the in-file metadata against the descriptor, validate the data table's
|
|
291
295
|
* column names/order against the generated schema, run the §5.6
|
|
292
|
-
* first-page clear when fresh, then copy
|
|
293
|
-
*
|
|
296
|
+
* first-page clear when fresh, then bulk-copy each chunk through a
|
|
297
|
+
* primary-key upsert — `_syncular_version` lands in
|
|
294
298
|
* `_sync_version` exactly like a rows segment's per-row `serverVersion`.
|
|
295
299
|
*/
|
|
296
|
-
export function applySqliteSegment(
|
|
300
|
+
export async function applySqliteSegment(
|
|
297
301
|
db: ClientDatabase,
|
|
298
302
|
schema: CompiledClientSchema,
|
|
299
303
|
table: CompiledClientTable,
|
|
@@ -304,8 +308,9 @@ export function applySqliteSegment(
|
|
|
304
308
|
readonly effective: ScopeMap;
|
|
305
309
|
readonly transaction?: ApplyTransaction;
|
|
306
310
|
readonly onProgress?: (rowsProcessed: number) => void;
|
|
311
|
+
readonly isCurrent?: () => boolean;
|
|
307
312
|
},
|
|
308
|
-
): number {
|
|
313
|
+
): Promise<number> {
|
|
309
314
|
const withImage = db.withSqliteImage?.bind(db);
|
|
310
315
|
if (withImage === undefined) {
|
|
311
316
|
throw new ClientSyncError(
|
|
@@ -316,7 +321,7 @@ export function applySqliteSegment(
|
|
|
316
321
|
if (descriptor.table !== table.name) {
|
|
317
322
|
imageInvalid(`descriptor table ${JSON.stringify(descriptor.table)}`);
|
|
318
323
|
}
|
|
319
|
-
return withImage(bytes, IMAGE_ALIAS, () => {
|
|
324
|
+
return withImage(bytes, IMAGE_ALIAS, async () => {
|
|
320
325
|
// 1. Metadata vs descriptor (§5.3 rule 2). A file that is not a
|
|
321
326
|
// SQLite database or lacks the metadata table fails right here.
|
|
322
327
|
let meta: ReturnType<ClientDatabase['query']>;
|
|
@@ -371,38 +376,44 @@ export function applySqliteSegment(
|
|
|
371
376
|
);
|
|
372
377
|
}
|
|
373
378
|
|
|
374
|
-
|
|
379
|
+
const primaryKeys = info.filter((column) => Number(column.pk) > 0);
|
|
380
|
+
if (primaryKeys.length !== 1 || primaryKeys[0]?.name !== table.primaryKey)
|
|
381
|
+
imageInvalid('image primary key does not match generated schema');
|
|
382
|
+
const source = `${IMAGE_ALIAS}.${quoteIdent(table.name)}`;
|
|
383
|
+
const count = Number(db.query(`SELECT count(*) AS n FROM ${source}`)[0]?.n);
|
|
384
|
+
if (count !== descriptor.rowCount)
|
|
385
|
+
imageInvalid('image row count does not match descriptor');
|
|
375
386
|
const names = table.columns.map((column) => quoteIdent(column.name));
|
|
376
387
|
const insertNames = [...names, quoteIdent(SYNC_VERSION_COLUMN)];
|
|
388
|
+
const primaryKey = quoteIdent(table.primaryKey);
|
|
377
389
|
const updates = insertNames
|
|
378
|
-
.filter((name) => name !==
|
|
390
|
+
.filter((name) => name !== primaryKey)
|
|
379
391
|
.map((name) => `${name}=excluded.${name}`)
|
|
380
392
|
.join(', ');
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
393
|
+
let after: SqlValue | undefined;
|
|
394
|
+
let applied = 0;
|
|
395
|
+
for (;;) {
|
|
396
|
+
if (options.isCurrent?.() === false) return applied;
|
|
397
|
+
const boundary = db.query(
|
|
398
|
+
`SELECT ${primaryKey} AS boundary FROM ${source}
|
|
399
|
+
${after === undefined ? '' : `WHERE ${primaryKey} > ?`}
|
|
400
|
+
ORDER BY ${primaryKey} LIMIT 1 OFFSET 1023`,
|
|
401
|
+
after === undefined ? [] : [after],
|
|
402
|
+
)[0]?.boundary;
|
|
403
|
+
if (boundary === null) imageInvalid('image primary key must not be null');
|
|
404
|
+
const predicates: string[] = [];
|
|
405
|
+
const params: SqlValue[] = [];
|
|
406
|
+
if (after !== undefined) {
|
|
407
|
+
predicates.push(`${primaryKey} > ?`);
|
|
408
|
+
params.push(after);
|
|
384
409
|
}
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
${after === undefined ? '' : `WHERE ${primaryKey} > ?`}
|
|
393
|
-
ORDER BY ${primaryKey} LIMIT 1 OFFSET 1023`,
|
|
394
|
-
after === undefined ? [] : [after],
|
|
395
|
-
)[0]?.boundary;
|
|
396
|
-
const predicates: string[] = [];
|
|
397
|
-
const params: SqlValue[] = [];
|
|
398
|
-
if (after !== undefined) {
|
|
399
|
-
predicates.push(`${primaryKey} > ?`);
|
|
400
|
-
params.push(after);
|
|
401
|
-
}
|
|
402
|
-
if (boundary !== undefined) {
|
|
403
|
-
predicates.push(`${primaryKey} <= ?`);
|
|
404
|
-
params.push(boundary);
|
|
405
|
-
}
|
|
410
|
+
if (boundary !== undefined) {
|
|
411
|
+
predicates.push(`${primaryKey} <= ?`);
|
|
412
|
+
params.push(boundary);
|
|
413
|
+
}
|
|
414
|
+
(options.transaction ?? ((fn) => db.transaction(fn)))(() => {
|
|
415
|
+
if (after === undefined && options.clearFirst)
|
|
416
|
+
deleteScopedRows(db, table, options.effective);
|
|
406
417
|
db.exec(
|
|
407
418
|
`INSERT INTO ${quoteIdent(table.name)} (${insertNames.join(', ')})
|
|
408
419
|
SELECT ${[...names, quoteIdent('_syncular_version')].join(', ')}
|
|
@@ -411,18 +422,15 @@ export function applySqliteSegment(
|
|
|
411
422
|
params,
|
|
412
423
|
);
|
|
413
424
|
applied += Number(db.query('SELECT changes() AS n')[0]?.n ?? 0);
|
|
414
|
-
|
|
415
|
-
options.onProgress?.(applied);
|
|
416
|
-
after = boundary;
|
|
417
|
-
}
|
|
418
|
-
if (applied !== descriptor.rowCount) {
|
|
419
|
-
imageInvalid(
|
|
420
|
-
`image holds ${applied} rows, descriptor says ${descriptor.rowCount}`,
|
|
421
|
-
);
|
|
422
|
-
}
|
|
425
|
+
});
|
|
423
426
|
options.onProgress?.(applied);
|
|
424
|
-
|
|
425
|
-
|
|
427
|
+
await yieldToHost();
|
|
428
|
+
if (boundary === undefined || applied === count) break;
|
|
429
|
+
after = boundary;
|
|
430
|
+
}
|
|
431
|
+
if (applied !== count)
|
|
432
|
+
imageInvalid('image applied row count does not match descriptor');
|
|
433
|
+
return applied;
|
|
426
434
|
});
|
|
427
435
|
}
|
|
428
436
|
|
|
@@ -444,6 +452,7 @@ export async function applyRowsSegment(
|
|
|
444
452
|
readonly effective: ScopeMap;
|
|
445
453
|
readonly transaction?: ApplyTransaction;
|
|
446
454
|
readonly onProgress?: (rowsProcessed: number) => void;
|
|
455
|
+
readonly isCurrent?: () => boolean;
|
|
447
456
|
},
|
|
448
457
|
encryption?: EncryptionConfig,
|
|
449
458
|
): Promise<number> {
|
|
@@ -453,6 +462,7 @@ export async function applyRowsSegment(
|
|
|
453
462
|
const blocks: readonly (readonly SegmentRow[])[] =
|
|
454
463
|
segment.blocks.length > 0 ? segment.blocks : [[]];
|
|
455
464
|
for (const block of blocks) {
|
|
465
|
+
if (options.isCurrent?.() === false) break;
|
|
456
466
|
// §5.11: decrypt this block's rows before opening the sync transaction
|
|
457
467
|
// (WebCrypto is async; the SQLite transaction is not). Decrypt failure
|
|
458
468
|
// aborts before any write in this block.
|
|
@@ -467,6 +477,7 @@ export async function applyRowsSegment(
|
|
|
467
477
|
: row.values;
|
|
468
478
|
rows.push({ values, serverVersion: row.serverVersion });
|
|
469
479
|
}
|
|
480
|
+
if (options.isCurrent?.() === false) break;
|
|
470
481
|
const clearThisBlock = first && options.clearFirst;
|
|
471
482
|
first = false;
|
|
472
483
|
if (!clearThisBlock && rows.length === 0) continue;
|
|
@@ -480,7 +491,22 @@ export async function applyRowsSegment(
|
|
|
480
491
|
if (applied % 1024 === 0) options.onProgress?.(applied);
|
|
481
492
|
}
|
|
482
493
|
});
|
|
494
|
+
options.onProgress?.(applied);
|
|
495
|
+
await yieldToHost();
|
|
483
496
|
}
|
|
484
497
|
options.onProgress?.(applied);
|
|
485
498
|
return applied;
|
|
486
499
|
}
|
|
500
|
+
|
|
501
|
+
/** Let worker messages run only after the current SQLite transaction commits. */
|
|
502
|
+
export function yieldToHost(): Promise<void> {
|
|
503
|
+
return new Promise((resolve) => {
|
|
504
|
+
const channel = new MessageChannel();
|
|
505
|
+
channel.port1.onmessage = () => {
|
|
506
|
+
channel.port1.close();
|
|
507
|
+
channel.port2.close();
|
|
508
|
+
resolve();
|
|
509
|
+
};
|
|
510
|
+
channel.port2.postMessage(null);
|
|
511
|
+
});
|
|
512
|
+
}
|
package/src/bun-database.ts
CHANGED
|
@@ -69,7 +69,11 @@ export class BunClientDatabase implements ClientDatabase {
|
|
|
69
69
|
* §5.3 image import: bun:sqlite attaches files, not buffers, so the
|
|
70
70
|
* image lands in a private temp file for the duration of the ATTACH.
|
|
71
71
|
*/
|
|
72
|
-
withSqliteImage<T>(
|
|
72
|
+
async withSqliteImage<T>(
|
|
73
|
+
bytes: Uint8Array,
|
|
74
|
+
alias: string,
|
|
75
|
+
fn: () => T | Promise<T>,
|
|
76
|
+
): Promise<T> {
|
|
73
77
|
assertImageAlias(alias);
|
|
74
78
|
const dir = mkdtempSync(join(tmpdir(), 'syncular-image-'));
|
|
75
79
|
const path = join(dir, 'segment.db');
|
|
@@ -77,7 +81,7 @@ export class BunClientDatabase implements ClientDatabase {
|
|
|
77
81
|
writeFileSync(path, bytes);
|
|
78
82
|
this.db.run(`ATTACH DATABASE ? AS ${alias}`, [path]);
|
|
79
83
|
try {
|
|
80
|
-
return fn();
|
|
84
|
+
return await fn();
|
|
81
85
|
} finally {
|
|
82
86
|
this.db.run(`DETACH DATABASE ${alias}`);
|
|
83
87
|
}
|