@syncular/client 0.19.0 → 0.20.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 +118 -6
- package/dist/apply.d.ts +16 -7
- package/dist/apply.js +83 -37
- package/dist/bun-database.d.ts +1 -1
- package/dist/bun-database.js +2 -2
- package/dist/client.d.ts +7 -4
- package/dist/client.js +121 -35
- package/dist/database.d.ts +3 -2
- package/dist/http.d.ts +0 -8
- package/dist/http.js +36 -3
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/node-database.d.ts +1 -1
- package/dist/node-database.js +2 -2
- package/dist/progress.d.ts +24 -0
- package/dist/progress.js +42 -0
- package/dist/reactive-store.js +49 -8
- package/dist/schema.js +15 -4
- package/dist/transport.d.ts +2 -1
- package/dist/wasm-database.js +44 -4
- package/dist/worker-entry.d.ts +2 -0
- package/dist/worker-entry.js +34 -7
- package/dist/worker-host.d.ts +3 -0
- package/dist/worker-host.js +13 -1
- package/dist/worker-protocol.d.ts +4 -0
- package/package.json +4 -3
- package/src/apply.ts +99 -47
- package/src/bun-database.ts +6 -2
- package/src/client.ts +153 -38
- package/src/database.ts +7 -2
- package/src/http.ts +41 -3
- package/src/index.ts +6 -0
- package/src/node-database.ts +6 -2
- package/src/progress.ts +62 -0
- package/src/reactive-store.ts +58 -8
- package/src/schema.ts +22 -4
- package/src/transport.ts +5 -1
- package/src/wasm-database.ts +86 -4
- package/src/worker-entry.ts +38 -7
- package/src/worker-host.ts +17 -1
- package/src/worker-protocol.ts +2 -0
package/README.md
CHANGED
|
@@ -457,10 +457,17 @@ still happen during rapid hot-module replacement, or in an embedded/test host
|
|
|
457
457
|
that shares OPFS data without sharing the same Web Locks and BroadcastChannel
|
|
458
458
|
coordination domain.
|
|
459
459
|
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
460
|
+
Persistent worker startup retries retryable `client.storage_busy` failures
|
|
461
|
+
while retaining the leader lease. It opens the same directory at most seven
|
|
462
|
+
times, waiting 50, 100, 200, 400, 800, and 1000 ms between attempts. These delays
|
|
463
|
+
total 2550 ms; browser scheduling and storage operations can extend elapsed time.
|
|
464
|
+
Other errors fail immediately. Direct `openPersistentWasmDatabase` calls remain
|
|
465
|
+
single attempts.
|
|
466
|
+
|
|
467
|
+
If ownership remains unavailable, handle creation rejects with `ClientSyncError`,
|
|
468
|
+
code `client.storage_busy`, and `retryable === true`, then releases the worker
|
|
469
|
+
and leader lease. Close the competing instance or let it finish shutting down,
|
|
470
|
+
then create the handle again. **Do not delete, rename, or silently replace the database with an
|
|
464
471
|
in-memory one**; the local database and pending outbox may be perfectly healthy.
|
|
465
472
|
Missing or obsolete OPFS APIs instead use the non-retryable
|
|
466
473
|
`client.storage_unavailable` code.
|
|
@@ -584,8 +591,97 @@ they own their buffer.
|
|
|
584
591
|
|
|
585
592
|
Tests drive the real worker entry in a bun `Worker` with bun:sqlite
|
|
586
593
|
injected through the bootstrap's database-factory override
|
|
587
|
-
(`test/worker-rpc.test.ts`)
|
|
588
|
-
|
|
594
|
+
(`test/worker-rpc.test.ts`). The separate Chromium gate exercises actual
|
|
595
|
+
sqlite-wasm, OPFS SAH pools and FTS through the production worker and HTTP paths:
|
|
596
|
+
|
|
597
|
+
```sh
|
|
598
|
+
cd packages/web-client
|
|
599
|
+
bunx --no-install playwright install chromium
|
|
600
|
+
cd ../..
|
|
601
|
+
bun run test:browser
|
|
602
|
+
```
|
|
603
|
+
|
|
604
|
+
`test/opfs-bootstrap.browser.test.ts` covers five interruption boundaries:
|
|
605
|
+
|
|
606
|
+
- Partial segment download: hold the asynchronous transfer, then terminate the worker.
|
|
607
|
+
- Before image import: inject an exception before applying the verified image.
|
|
608
|
+
- Mid-import: terminate the worker after an actual OPFS database page write during
|
|
609
|
+
`INSERT SELECT`. A commit notification must not precede termination.
|
|
610
|
+
- After import: inject an exception after the transaction commits, before `SUB_END`.
|
|
611
|
+
- After checkpoint: complete the round before terminating the worker.
|
|
612
|
+
|
|
613
|
+
Each case reloads the page using the same replica identity and requires successful
|
|
614
|
+
startup in the same browser session. It checks SQLite and FTS integrity, search
|
|
615
|
+
results, exact rows, persisted client identity, and subscription checkpoints.
|
|
616
|
+
Incomplete sections must download again; completed sections must not. A held
|
|
617
|
+
synchronous test request after a physical page write keeps the mid-import crash
|
|
618
|
+
boundary stable until worker termination. Two additional ownership cases verify
|
|
619
|
+
recovery when a competing owner closes during retry and bounded failure while
|
|
620
|
+
that owner remains live, including pending outbox preservation.
|
|
621
|
+
|
|
622
|
+
Each case owns a temporary browser profile and a loopback server. A small SQLite
|
|
623
|
+
page cache forces dirty pages to disk before COMMIT. Tests use no timing sleeps,
|
|
624
|
+
COOP/COEP requirement, existing profiles or application replicas. Failed profiles
|
|
625
|
+
and `recovery-test.json` remain at the path printed by the test; CI retains them
|
|
626
|
+
as an artifact. Successful profiles are removed. `bun run check` keeps its
|
|
627
|
+
Bun-only test lane; CI runs the browser gate separately.
|
|
628
|
+
|
|
629
|
+
`openPersistentWasmDatabase` corrects the SAH-pool VFS's reserved-lock callback
|
|
630
|
+
before the first SQL statement. sqlite-wasm 3.53 reports a reserved lock
|
|
631
|
+
unconditionally, suppressing SQLite's hot-journal recovery after an interrupted
|
|
632
|
+
write. The pool already excludes other owners. Syncular reports no competing
|
|
633
|
+
writer through SQLite's I/O method binding, matching the other OPFS VFS, and
|
|
634
|
+
retains that callback across database closes and pool opens. The correction ships
|
|
635
|
+
in the client package and preserves the existing database format, DELETE journal
|
|
636
|
+
mode and FULL synchronous mode.
|
|
637
|
+
|
|
638
|
+
On 2026-09-12, Chromium 153 with sqlite-wasm 3.53.0 failed the mid-import case
|
|
639
|
+
without this correction and passed all five cases with it. The fixture serves
|
|
640
|
+
the unmodified dependency. Bounded worker startup retries handle transient
|
|
641
|
+
`client.storage_busy` while the browser releases old handles. This correction enables
|
|
642
|
+
rollback from an intact hot journal; it does not repair existing corruption
|
|
643
|
+
whose recovery journal has already been lost or overwritten.
|
|
644
|
+
|
|
645
|
+
## Live sync progress
|
|
646
|
+
|
|
647
|
+
`onProgress` observes work during a sync round. It immediately delivers the latest
|
|
648
|
+
snapshot when one exists and returns an unsubscribe function. `progressSnapshot()`
|
|
649
|
+
reads the cached snapshot synchronously, including on worker handles. Worker
|
|
650
|
+
leaders and followers forward events while download and import are running.
|
|
651
|
+
|
|
652
|
+
```ts
|
|
653
|
+
const stop = client.onProgress((progress) => {
|
|
654
|
+
if (progress.phase === 'download') {
|
|
655
|
+
renderDownload(progress.bytesReceived, progress.bytesTotal);
|
|
656
|
+
} else if (progress.phase === 'import') {
|
|
657
|
+
renderImport(progress.rowsProcessed, progress.rowsTotal);
|
|
658
|
+
}
|
|
659
|
+
if (progress.state === 'failed') showError(progress.errorCode);
|
|
660
|
+
});
|
|
661
|
+
// Stop listening when the view is disposed. Sync continues.
|
|
662
|
+
stop();
|
|
663
|
+
```
|
|
664
|
+
|
|
665
|
+
Each snapshot identifies the round with `attempt`, and the current payload with
|
|
666
|
+
`subscriptionId`, `table`, and optional `segmentId`. Counters reset for each
|
|
667
|
+
payload; `syncUntilIdle` starts a new attempt for each round. `phase` is `request`,
|
|
668
|
+
`download`, or `import`; `state` is `running`, `complete`, or `failed`. Unknown
|
|
669
|
+
totals remain absent. A new attempt clears prior errors and counters.
|
|
670
|
+
|
|
671
|
+
HTTP downloads report decoded body bytes at 64 KiB intervals or larger network
|
|
672
|
+
chunks. Custom segment downloaders receive `request.onProgress`; signed-URL
|
|
673
|
+
implementations receive an optional second callback argument. Call it with the
|
|
674
|
+
cumulative received byte count. Buffered custom transports report a final count
|
|
675
|
+
through the core, without intermediate download updates.
|
|
676
|
+
|
|
677
|
+
Import reports every 1,024 rows and its final count. `rowsProcessed` includes work
|
|
678
|
+
inside the current transaction; a failed import can roll those rows back. SQLite
|
|
679
|
+
images commit in chunks of at most 1,024 rows and yield after each commit.
|
|
680
|
+
Earlier committed chunks survive a later interruption. `complete` follows checkpoint persistence
|
|
681
|
+
and optimistic-state reconciliation for the round. More bootstrap pages can
|
|
682
|
+
remain; use subscription coverage to determine whether a query is complete.
|
|
683
|
+
Listener exceptions do not fail sync. Listeners should hand UI work off and avoid
|
|
684
|
+
reentering database commands during an import.
|
|
589
685
|
|
|
590
686
|
## Snapshot API migration
|
|
591
687
|
|
|
@@ -628,3 +724,19 @@ request. Status and diagnostics use `COUNT(*)` without parsing pending bodies.
|
|
|
628
724
|
Optimistic replay still reads the remaining outbox after each response. The
|
|
629
725
|
100/1,000/10,000-commit workload and measured limits are recorded in
|
|
630
726
|
[the reliability RFC](../../docs/RFC-RELIABILITY-DX.md#9-implementation-evidence-2026-09-05).
|
|
727
|
+
|
|
728
|
+
## Reads during bootstrap and eviction
|
|
729
|
+
|
|
730
|
+
Reactive queries read cached snapshots while window registration runs. Coverage
|
|
731
|
+
continues to distinguish complete, pending, and missing windows; registration
|
|
732
|
+
errors remain observable. Retained owners reuse acknowledged units immediately.
|
|
733
|
+
|
|
734
|
+
Both segment formats yield between committed import chunks automatically.
|
|
735
|
+
Window eviction deletes at most 1,024 unpinned rows per chunk and resumes from a
|
|
736
|
+
durable pending record after interruption. Security purges remain atomic.
|
|
737
|
+
See [windowing](https://syncular.dev/concepts-windowing/) and
|
|
738
|
+
[bootstrap](https://syncular.dev/concepts-bootstrap/) for coverage and recovery.
|
|
739
|
+
|
|
740
|
+
Custom database adapters implementing `withSqliteImage` must return a promise and
|
|
741
|
+
await their callback before detaching the image. The callback can await between
|
|
742
|
+
committed import chunks; no write transaction spans that await.
|
package/dist/apply.d.ts
CHANGED
|
@@ -43,12 +43,15 @@ export declare function deleteScopedRows(db: ClientDatabase, table: CompiledClie
|
|
|
43
43
|
* scopes (same local-scope-column rule and fail-closed clause as
|
|
44
44
|
* {@link deleteScopedRows}) EXCEPT rows whose primary key is in
|
|
45
45
|
* `pinnedRowIds` (E1 — pinned by a still-pending outbox commit). Returns
|
|
46
|
-
*
|
|
47
|
-
*
|
|
46
|
+
* whether unpinned work remains and whether any scoped rows remain. Each
|
|
47
|
+
* call removes at most 1,024 rows; the caller commits before yielding. Also removes the
|
|
48
48
|
* evicted rows' `server_version` with them (E2 — no residual version
|
|
49
49
|
* cache), which is automatic since the version column is per-row.
|
|
50
50
|
*/
|
|
51
|
-
export declare function evictScopedRows(db: ClientDatabase, table: CompiledClientTable, effective: ScopeMap, pinnedRowIds: ReadonlySet<string>):
|
|
51
|
+
export declare function evictScopedRows(db: ClientDatabase, table: CompiledClientTable, effective: ScopeMap, pinnedRowIds: ReadonlySet<string>): {
|
|
52
|
+
remaining: boolean;
|
|
53
|
+
deferred: boolean;
|
|
54
|
+
};
|
|
52
55
|
/** Descriptor fields a sqlite image is validated against (§5.3). */
|
|
53
56
|
export interface SqliteSegmentDescriptor {
|
|
54
57
|
readonly table: string;
|
|
@@ -57,18 +60,20 @@ export interface SqliteSegmentDescriptor {
|
|
|
57
60
|
readonly scopeDigest: string;
|
|
58
61
|
}
|
|
59
62
|
/**
|
|
60
|
-
* Apply a §5.3 sqlite
|
|
63
|
+
* Apply a §5.3 sqlite image in committed chunks of at most 1,024 rows: validate
|
|
61
64
|
* the in-file metadata against the descriptor, validate the data table's
|
|
62
65
|
* column names/order against the generated schema, run the §5.6
|
|
63
|
-
* first-page clear when fresh, then copy
|
|
64
|
-
*
|
|
66
|
+
* first-page clear when fresh, then bulk-copy each chunk through a
|
|
67
|
+
* primary-key upsert — `_syncular_version` lands in
|
|
65
68
|
* `_sync_version` exactly like a rows segment's per-row `serverVersion`.
|
|
66
69
|
*/
|
|
67
70
|
export declare function applySqliteSegment(db: ClientDatabase, schema: CompiledClientSchema, table: CompiledClientTable, bytes: Uint8Array, descriptor: SqliteSegmentDescriptor, options: {
|
|
68
71
|
readonly clearFirst: boolean;
|
|
69
72
|
readonly effective: ScopeMap;
|
|
70
73
|
readonly transaction?: ApplyTransaction;
|
|
71
|
-
|
|
74
|
+
readonly onProgress?: (rowsProcessed: number) => void;
|
|
75
|
+
readonly isCurrent?: () => boolean;
|
|
76
|
+
}): Promise<number>;
|
|
72
77
|
/**
|
|
73
78
|
* Apply a decoded rows segment: each block in one local transaction
|
|
74
79
|
* (§5.2/§1.4); `clearFirst` implements the §5.6 fresh-bootstrap first-page
|
|
@@ -81,4 +86,8 @@ export declare function applyRowsSegment(db: ClientDatabase, schema: CompiledCli
|
|
|
81
86
|
readonly clearFirst: boolean;
|
|
82
87
|
readonly effective: ScopeMap;
|
|
83
88
|
readonly transaction?: ApplyTransaction;
|
|
89
|
+
readonly onProgress?: (rowsProcessed: number) => void;
|
|
90
|
+
readonly isCurrent?: () => boolean;
|
|
84
91
|
}, encryption?: EncryptionConfig): Promise<number>;
|
|
92
|
+
/** Let worker messages run only after the current SQLite transaction commits. */
|
|
93
|
+
export declare function yieldToHost(): Promise<void>;
|
package/dist/apply.js
CHANGED
|
@@ -138,15 +138,15 @@ export function deleteScopedRows(db, table, effective) {
|
|
|
138
138
|
* scopes (same local-scope-column rule and fail-closed clause as
|
|
139
139
|
* {@link deleteScopedRows}) EXCEPT rows whose primary key is in
|
|
140
140
|
* `pinnedRowIds` (E1 — pinned by a still-pending outbox commit). Returns
|
|
141
|
-
*
|
|
142
|
-
*
|
|
141
|
+
* whether unpinned work remains and whether any scoped rows remain. Each
|
|
142
|
+
* call removes at most 1,024 rows; the caller commits before yielding. Also removes the
|
|
143
143
|
* evicted rows' `server_version` with them (E2 — no residual version
|
|
144
144
|
* cache), which is automatic since the version column is per-row.
|
|
145
145
|
*/
|
|
146
146
|
export function evictScopedRows(db, table, effective, pinnedRowIds) {
|
|
147
147
|
const entries = Object.entries(effective);
|
|
148
148
|
if (entries.length === 0)
|
|
149
|
-
return false;
|
|
149
|
+
return { remaining: false, deferred: false };
|
|
150
150
|
const clauses = [];
|
|
151
151
|
const params = [];
|
|
152
152
|
for (const [variable, values] of entries) {
|
|
@@ -155,7 +155,7 @@ export function evictScopedRows(db, table, effective, pinnedRowIds) {
|
|
|
155
155
|
throw new ClientSyncError('sync.scope_revoked', `table ${JSON.stringify(table.name)} has no local scope-column mapping for ${JSON.stringify(variable)} (§4.8/§3.3 fail-closed)`);
|
|
156
156
|
}
|
|
157
157
|
if (values.length === 0)
|
|
158
|
-
return false;
|
|
158
|
+
return { remaining: false, deferred: false };
|
|
159
159
|
clauses.push(`${quoteIdent(column)} IN (${values.map(() => '?').join(', ')})`);
|
|
160
160
|
params.push(...values);
|
|
161
161
|
}
|
|
@@ -166,31 +166,28 @@ export function evictScopedRows(db, table, effective, pinnedRowIds) {
|
|
|
166
166
|
pinnedClause = ` AND ${pk} NOT IN (${ids.map(() => '?').join(', ')})`;
|
|
167
167
|
params.push(...ids);
|
|
168
168
|
}
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
const
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
return true;
|
|
178
|
-
}
|
|
179
|
-
return false;
|
|
169
|
+
const target = quoteIdent(table.name);
|
|
170
|
+
db.exec(`DELETE FROM ${target} WHERE ${pk} IN (
|
|
171
|
+
SELECT ${pk} FROM ${target} WHERE ${clauses.join(' AND ')}${pinnedClause} LIMIT 1024
|
|
172
|
+
)`, params);
|
|
173
|
+
const remaining = db.query(`SELECT 1 FROM ${target} WHERE ${clauses.join(' AND ')}${pinnedClause} LIMIT 1`, params).length > 0;
|
|
174
|
+
const deferred = remaining ||
|
|
175
|
+
db.query(`SELECT 1 FROM ${target} WHERE ${clauses.join(' AND ')} LIMIT 1`, params.slice(0, params.length - pinnedRowIds.size)).length > 0;
|
|
176
|
+
return { remaining, deferred };
|
|
180
177
|
}
|
|
181
178
|
const IMAGE_ALIAS = 'syncular_image';
|
|
182
179
|
function imageInvalid(detail) {
|
|
183
180
|
throw new ClientSyncError('sync.invalid_request', `sqlite segment rejected: ${detail} (§5.3)`);
|
|
184
181
|
}
|
|
185
182
|
/**
|
|
186
|
-
* Apply a §5.3 sqlite
|
|
183
|
+
* Apply a §5.3 sqlite image in committed chunks of at most 1,024 rows: validate
|
|
187
184
|
* the in-file metadata against the descriptor, validate the data table's
|
|
188
185
|
* column names/order against the generated schema, run the §5.6
|
|
189
|
-
* first-page clear when fresh, then copy
|
|
190
|
-
*
|
|
186
|
+
* first-page clear when fresh, then bulk-copy each chunk through a
|
|
187
|
+
* primary-key upsert — `_syncular_version` lands in
|
|
191
188
|
* `_sync_version` exactly like a rows segment's per-row `serverVersion`.
|
|
192
189
|
*/
|
|
193
|
-
export function applySqliteSegment(db, schema, table, bytes, descriptor, options) {
|
|
190
|
+
export async function applySqliteSegment(db, schema, table, bytes, descriptor, options) {
|
|
194
191
|
const withImage = db.withSqliteImage?.bind(db);
|
|
195
192
|
if (withImage === undefined) {
|
|
196
193
|
throw new ClientSyncError('sync.invalid_request', 'received a sqlite segment but the database backend cannot import images (§4.2: do not advertise accept bit 2)');
|
|
@@ -198,7 +195,7 @@ export function applySqliteSegment(db, schema, table, bytes, descriptor, options
|
|
|
198
195
|
if (descriptor.table !== table.name) {
|
|
199
196
|
imageInvalid(`descriptor table ${JSON.stringify(descriptor.table)}`);
|
|
200
197
|
}
|
|
201
|
-
return withImage(bytes, IMAGE_ALIAS, () => {
|
|
198
|
+
return withImage(bytes, IMAGE_ALIAS, async () => {
|
|
202
199
|
// 1. Metadata vs descriptor (§5.3 rule 2). A file that is not a
|
|
203
200
|
// SQLite database or lacks the metadata table fails right here.
|
|
204
201
|
let meta;
|
|
@@ -244,30 +241,58 @@ export function applySqliteSegment(db, schema, table, bytes, descriptor, options
|
|
|
244
241
|
expected.some((name, index) => actual[index] !== name)) {
|
|
245
242
|
throw new ClientSyncError('sync.schema_mismatch', `sqlite segment for ${JSON.stringify(table.name)} does not match the generated schema: columns [${actual.join(', ')}] (§5.3)`);
|
|
246
243
|
}
|
|
247
|
-
|
|
244
|
+
const primaryKeys = info.filter((column) => Number(column.pk) > 0);
|
|
245
|
+
if (primaryKeys.length !== 1 || primaryKeys[0]?.name !== table.primaryKey)
|
|
246
|
+
imageInvalid('image primary key does not match generated schema');
|
|
247
|
+
const source = `${IMAGE_ALIAS}.${quoteIdent(table.name)}`;
|
|
248
|
+
const count = Number(db.query(`SELECT count(*) AS n FROM ${source}`)[0]?.n);
|
|
249
|
+
if (count !== descriptor.rowCount)
|
|
250
|
+
imageInvalid('image row count does not match descriptor');
|
|
248
251
|
const names = table.columns.map((column) => quoteIdent(column.name));
|
|
249
252
|
const insertNames = [...names, quoteIdent(SYNC_VERSION_COLUMN)];
|
|
253
|
+
const primaryKey = quoteIdent(table.primaryKey);
|
|
250
254
|
const updates = insertNames
|
|
251
|
-
.filter((name) => name !==
|
|
255
|
+
.filter((name) => name !== primaryKey)
|
|
252
256
|
.map((name) => `${name}=excluded.${name}`)
|
|
253
257
|
.join(', ');
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
258
|
+
let after;
|
|
259
|
+
let applied = 0;
|
|
260
|
+
for (;;) {
|
|
261
|
+
if (options.isCurrent?.() === false)
|
|
262
|
+
return applied;
|
|
263
|
+
const boundary = db.query(`SELECT ${primaryKey} AS boundary FROM ${source}
|
|
264
|
+
${after === undefined ? '' : `WHERE ${primaryKey} > ?`}
|
|
265
|
+
ORDER BY ${primaryKey} LIMIT 1 OFFSET 1023`, after === undefined ? [] : [after])[0]?.boundary;
|
|
266
|
+
if (boundary === null)
|
|
267
|
+
imageInvalid('image primary key must not be null');
|
|
268
|
+
const predicates = [];
|
|
269
|
+
const params = [];
|
|
270
|
+
if (after !== undefined) {
|
|
271
|
+
predicates.push(`${primaryKey} > ?`);
|
|
272
|
+
params.push(after);
|
|
257
273
|
}
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
FROM ${IMAGE_ALIAS}.${quoteIdent(table.name)}
|
|
262
|
-
WHERE true
|
|
263
|
-
ON CONFLICT (${quoteIdent(table.primaryKey)}) DO UPDATE SET ${updates}`);
|
|
264
|
-
const counted = db.query(`SELECT count(*) AS n FROM ${IMAGE_ALIAS}.${quoteIdent(table.name)}`)[0];
|
|
265
|
-
const applied = Number(counted?.n ?? 0);
|
|
266
|
-
if (applied !== descriptor.rowCount) {
|
|
267
|
-
imageInvalid(`image holds ${applied} rows, descriptor says ${descriptor.rowCount}`);
|
|
274
|
+
if (boundary !== undefined) {
|
|
275
|
+
predicates.push(`${primaryKey} <= ?`);
|
|
276
|
+
params.push(boundary);
|
|
268
277
|
}
|
|
269
|
-
|
|
270
|
-
|
|
278
|
+
(options.transaction ?? ((fn) => db.transaction(fn)))(() => {
|
|
279
|
+
if (after === undefined && options.clearFirst)
|
|
280
|
+
deleteScopedRows(db, table, options.effective);
|
|
281
|
+
db.exec(`INSERT INTO ${quoteIdent(table.name)} (${insertNames.join(', ')})
|
|
282
|
+
SELECT ${[...names, quoteIdent('_syncular_version')].join(', ')}
|
|
283
|
+
FROM ${source} WHERE ${predicates.join(' AND ') || 'true'}
|
|
284
|
+
ON CONFLICT (${primaryKey}) DO UPDATE SET ${updates}`, params);
|
|
285
|
+
applied += Number(db.query('SELECT changes() AS n')[0]?.n ?? 0);
|
|
286
|
+
});
|
|
287
|
+
options.onProgress?.(applied);
|
|
288
|
+
await yieldToHost();
|
|
289
|
+
if (boundary === undefined || applied === count)
|
|
290
|
+
break;
|
|
291
|
+
after = boundary;
|
|
292
|
+
}
|
|
293
|
+
if (applied !== count)
|
|
294
|
+
imageInvalid('image applied row count does not match descriptor');
|
|
295
|
+
return applied;
|
|
271
296
|
});
|
|
272
297
|
}
|
|
273
298
|
/**
|
|
@@ -284,6 +309,8 @@ export async function applyRowsSegment(db, schema, table, segment, options, encr
|
|
|
284
309
|
let first = true;
|
|
285
310
|
const blocks = segment.blocks.length > 0 ? segment.blocks : [[]];
|
|
286
311
|
for (const block of blocks) {
|
|
312
|
+
if (options.isCurrent?.() === false)
|
|
313
|
+
break;
|
|
287
314
|
// §5.11: decrypt this block's rows before opening the sync transaction
|
|
288
315
|
// (WebCrypto is async; the SQLite transaction is not). Decrypt failure
|
|
289
316
|
// aborts before any write in this block.
|
|
@@ -297,6 +324,8 @@ export async function applyRowsSegment(db, schema, table, segment, options, encr
|
|
|
297
324
|
: row.values;
|
|
298
325
|
rows.push({ values, serverVersion: row.serverVersion });
|
|
299
326
|
}
|
|
327
|
+
if (options.isCurrent?.() === false)
|
|
328
|
+
break;
|
|
300
329
|
const clearThisBlock = first && options.clearFirst;
|
|
301
330
|
first = false;
|
|
302
331
|
if (!clearThisBlock && rows.length === 0)
|
|
@@ -308,8 +337,25 @@ export async function applyRowsSegment(db, schema, table, segment, options, encr
|
|
|
308
337
|
for (const row of rows) {
|
|
309
338
|
upsertLocalRow(db, table, row.values, row.serverVersion);
|
|
310
339
|
applied += 1;
|
|
340
|
+
if (applied % 1024 === 0)
|
|
341
|
+
options.onProgress?.(applied);
|
|
311
342
|
}
|
|
312
343
|
});
|
|
344
|
+
options.onProgress?.(applied);
|
|
345
|
+
await yieldToHost();
|
|
313
346
|
}
|
|
347
|
+
options.onProgress?.(applied);
|
|
314
348
|
return applied;
|
|
315
349
|
}
|
|
350
|
+
/** Let worker messages run only after the current SQLite transaction commits. */
|
|
351
|
+
export function yieldToHost() {
|
|
352
|
+
return new Promise((resolve) => {
|
|
353
|
+
const channel = new MessageChannel();
|
|
354
|
+
channel.port1.onmessage = () => {
|
|
355
|
+
channel.port1.close();
|
|
356
|
+
channel.port2.close();
|
|
357
|
+
resolve();
|
|
358
|
+
};
|
|
359
|
+
channel.port2.postMessage(null);
|
|
360
|
+
});
|
|
361
|
+
}
|
package/dist/bun-database.d.ts
CHANGED
|
@@ -21,7 +21,7 @@ export declare class BunClientDatabase implements ClientDatabase {
|
|
|
21
21
|
* §5.3 image import: bun:sqlite attaches files, not buffers, so the
|
|
22
22
|
* image lands in a private temp file for the duration of the ATTACH.
|
|
23
23
|
*/
|
|
24
|
-
withSqliteImage<T>(bytes: Uint8Array, alias: string, fn: () => T): T
|
|
24
|
+
withSqliteImage<T>(bytes: Uint8Array, alias: string, fn: () => T | Promise<T>): Promise<T>;
|
|
25
25
|
close(): void;
|
|
26
26
|
}
|
|
27
27
|
export declare function openBunDatabase(path?: string): ClientDatabase;
|
package/dist/bun-database.js
CHANGED
|
@@ -50,7 +50,7 @@ export class BunClientDatabase {
|
|
|
50
50
|
* §5.3 image import: bun:sqlite attaches files, not buffers, so the
|
|
51
51
|
* image lands in a private temp file for the duration of the ATTACH.
|
|
52
52
|
*/
|
|
53
|
-
withSqliteImage(bytes, alias, fn) {
|
|
53
|
+
async withSqliteImage(bytes, alias, fn) {
|
|
54
54
|
assertImageAlias(alias);
|
|
55
55
|
const dir = mkdtempSync(join(tmpdir(), 'syncular-image-'));
|
|
56
56
|
const path = join(dir, 'segment.db');
|
|
@@ -58,7 +58,7 @@ export class BunClientDatabase {
|
|
|
58
58
|
writeFileSync(path, bytes);
|
|
59
59
|
this.db.run(`ATTACH DATABASE ? AS ${alias}`, [path]);
|
|
60
60
|
try {
|
|
61
|
-
return fn();
|
|
61
|
+
return await fn();
|
|
62
62
|
}
|
|
63
63
|
finally {
|
|
64
64
|
this.db.run(`DETACH DATABASE ${alias}`);
|
package/dist/client.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type SyncProgress, type SyncProgressListener } from './progress.js';
|
|
1
2
|
/**
|
|
2
3
|
* SyncClient implements the client side of SPEC.md §§3–8.
|
|
3
4
|
*
|
|
@@ -90,8 +91,8 @@ export interface SyncClientLimits {
|
|
|
90
91
|
readonly maxSnapshotPages?: number;
|
|
91
92
|
/**
|
|
92
93
|
* §4.2 accept bitmask; defaults to inline + external rows (0b0011)
|
|
93
|
-
* plus
|
|
94
|
-
*
|
|
94
|
+
* plus SQLite images and signed URLs when supported. Both image chunks
|
|
95
|
+
* and rows blocks commit before yielding to local reads (§7.7).
|
|
95
96
|
*/
|
|
96
97
|
readonly accept?: number;
|
|
97
98
|
/**
|
|
@@ -294,6 +295,8 @@ export declare class SyncClient {
|
|
|
294
295
|
*/
|
|
295
296
|
onInvalidate(listener: InvalidationListener): () => void;
|
|
296
297
|
/** Subscribe to exact revisioned observer transactions (SPEC §7.5). */
|
|
298
|
+
onProgress(listener: SyncProgressListener): () => void;
|
|
299
|
+
progressSnapshot(): SyncProgress | undefined;
|
|
297
300
|
onChange(listener: ClientChangeListener): () => void;
|
|
298
301
|
/** Subscribe to host wake signals raised by startup and realtime. */
|
|
299
302
|
onSyncNeeded(listener: (reason: 'startup' | 'hello' | WakeReason) => void): () => void;
|
|
@@ -380,8 +383,8 @@ export declare class SyncClient {
|
|
|
380
383
|
* Set the live window units for a base (§4.8): a value-sharded family of
|
|
381
384
|
* subscriptions, one per unit. Computes the diff against the registry —
|
|
382
385
|
* added units get fresh subscriptions (image-lane bootstrap on the next
|
|
383
|
-
* sync); removed units are unsubscribed
|
|
384
|
-
* transaction (E1–E4). Idempotent: calling with the same units is a
|
|
386
|
+
* sync); removed units are unsubscribed with their first eviction chunk
|
|
387
|
+
* in one local transaction (E1–E4). Idempotent: calling with the same units is a
|
|
385
388
|
* no-op. Re-entry (a unit removed then re-added) cancels any deferred
|
|
386
389
|
* eviction and fresh-bootstraps.
|
|
387
390
|
*
|