@syncular/client 0.20.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 +18 -1
- 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 +4 -4
- package/dist/client.js +64 -34
- package/dist/database.d.ts +3 -2
- package/dist/node-database.d.ts +1 -1
- package/dist/node-database.js +2 -2
- package/dist/reactive-store.js +49 -8
- package/dist/schema.js +15 -4
- 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 +81 -37
- package/src/database.ts +7 -2
- package/src/node-database.ts +6 -2
- package/src/reactive-store.ts +58 -8
- package/src/schema.ts +22 -4
- package/src/wasm-database.ts +7 -3
package/src/client.ts
CHANGED
|
@@ -44,6 +44,7 @@ import {
|
|
|
44
44
|
deleteLocalRow,
|
|
45
45
|
deleteScopedRows,
|
|
46
46
|
evictScopedRows,
|
|
47
|
+
yieldToHost,
|
|
47
48
|
upsertLocalRow,
|
|
48
49
|
} from './apply';
|
|
49
50
|
import {
|
|
@@ -286,8 +287,8 @@ export interface SyncClientLimits {
|
|
|
286
287
|
readonly maxSnapshotPages?: number;
|
|
287
288
|
/**
|
|
288
289
|
* §4.2 accept bitmask; defaults to inline + external rows (0b0011)
|
|
289
|
-
* plus
|
|
290
|
-
*
|
|
290
|
+
* plus SQLite images and signed URLs when supported. Both image chunks
|
|
291
|
+
* and rows blocks commit before yielding to local reads (§7.7).
|
|
291
292
|
*/
|
|
292
293
|
readonly accept?: number;
|
|
293
294
|
/**
|
|
@@ -748,6 +749,7 @@ export class SyncClient {
|
|
|
748
749
|
const startupWork =
|
|
749
750
|
this.#schemaFloor === undefined &&
|
|
750
751
|
(countOutbox(this.#db) > 0 ||
|
|
752
|
+
loadPendingEvictions(this.#db).length > 0 ||
|
|
751
753
|
subscriptions.some((sub) => sub.status === 'active'));
|
|
752
754
|
if (startupWork && this.#securityLifecycle === 'active') {
|
|
753
755
|
this.#needsPull = true;
|
|
@@ -1820,23 +1822,17 @@ export class SyncClient {
|
|
|
1820
1822
|
socket.send(encodePresencePublish(scopeKey, doc));
|
|
1821
1823
|
}
|
|
1822
1824
|
|
|
1823
|
-
/**
|
|
1824
|
-
* §4.2 accept mask: the configured override, or the rows baseline plus
|
|
1825
|
-
* bit 2 when the backend can import sqlite images (§5.3) and a segment
|
|
1826
|
-
* downloader exists (sqlite segments are never inline, §5.7), plus
|
|
1827
|
-
* bit 3 when the downloader exposes a direct URL fetch (§5.4
|
|
1828
|
-
* capability negotiation).
|
|
1829
|
-
*/
|
|
1825
|
+
/** Advertise supported segment formats; all imports yield between commits. */
|
|
1830
1826
|
#acceptMask(): number {
|
|
1831
1827
|
const configured = this.#config.limits?.accept;
|
|
1832
1828
|
if (configured !== undefined) return configured;
|
|
1833
1829
|
const segments = this.#config.segments;
|
|
1834
|
-
const sqliteCapable =
|
|
1835
|
-
typeof this.#db.withSqliteImage === 'function' && segments !== undefined;
|
|
1836
1830
|
const urlCapable = typeof segments?.fetchUrl === 'function';
|
|
1837
1831
|
return (
|
|
1838
1832
|
ACCEPT_ROWS_BASELINE |
|
|
1839
|
-
(
|
|
1833
|
+
(typeof this.#db.withSqliteImage === 'function' && segments !== undefined
|
|
1834
|
+
? ACCEPT_SQLITE
|
|
1835
|
+
: 0) |
|
|
1840
1836
|
(urlCapable ? ACCEPT_SIGNED_URLS : 0)
|
|
1841
1837
|
);
|
|
1842
1838
|
}
|
|
@@ -1909,8 +1905,8 @@ export class SyncClient {
|
|
|
1909
1905
|
* Set the live window units for a base (§4.8): a value-sharded family of
|
|
1910
1906
|
* subscriptions, one per unit. Computes the diff against the registry —
|
|
1911
1907
|
* added units get fresh subscriptions (image-lane bootstrap on the next
|
|
1912
|
-
* sync); removed units are unsubscribed
|
|
1913
|
-
* transaction (E1–E4). Idempotent: calling with the same units is a
|
|
1908
|
+
* sync); removed units are unsubscribed with their first eviction chunk
|
|
1909
|
+
* in one local transaction (E1–E4). Idempotent: calling with the same units is a
|
|
1914
1910
|
* no-op. Re-entry (a unit removed then re-added) cancels any deferred
|
|
1915
1911
|
* eviction and fresh-bootstraps.
|
|
1916
1912
|
*
|
|
@@ -1975,7 +1971,7 @@ export class SyncClient {
|
|
|
1975
1971
|
// Shrink: units live but not wanted → unsubscribe fused with eviction.
|
|
1976
1972
|
for (const { unit, subId } of live) {
|
|
1977
1973
|
if (wanted.has(unit)) continue;
|
|
1978
|
-
this.#evictUnit(baseKey, base, unit, subId);
|
|
1974
|
+
await this.#evictUnit(baseKey, base, unit, subId);
|
|
1979
1975
|
changed = true;
|
|
1980
1976
|
}
|
|
1981
1977
|
});
|
|
@@ -2018,8 +2014,8 @@ export class SyncClient {
|
|
|
2018
2014
|
}
|
|
2019
2015
|
|
|
2020
2016
|
/**
|
|
2021
|
-
* §4.8 E1–E4: evict one departing unit
|
|
2022
|
-
*
|
|
2017
|
+
* §4.8 E1–E4: evict one departing unit in committed chunks, fusing
|
|
2018
|
+
* its first chunk with unsubscription. Deletes the unit's rows EXCEPT those pinned by a
|
|
2023
2019
|
* pending outbox commit (E1); if any pin remains, records a deferred
|
|
2024
2020
|
* eviction retried on the next outbox drain. Discards the subscription's
|
|
2025
2021
|
* cursor/resume/effective-echo (E3) and its version state with the rows
|
|
@@ -2027,24 +2023,26 @@ export class SyncClient {
|
|
|
2027
2023
|
* with no local scope-column mapping, surfaces a configuration error and
|
|
2028
2024
|
* evicts nothing (§4.8/§3.3).
|
|
2029
2025
|
*/
|
|
2030
|
-
#evictUnit(
|
|
2026
|
+
async #evictUnit(
|
|
2031
2027
|
baseKey: string,
|
|
2032
2028
|
base: WindowBase,
|
|
2033
2029
|
unit: string,
|
|
2034
2030
|
subId: string,
|
|
2035
|
-
): void {
|
|
2031
|
+
): Promise<void> {
|
|
2036
2032
|
const table = this.#table(base.table);
|
|
2037
2033
|
const sub = getSubscription(this.#db, subId);
|
|
2038
2034
|
// The rows a unit holds live under its LAST effective scopes if it ever
|
|
2039
2035
|
// synced; before first sync, the requested unit scopes are the match.
|
|
2040
2036
|
const effective = sub?.effectiveScopes ?? unitScopes(base, unit);
|
|
2041
2037
|
const pinned = this.#pinnedRowIds(base.table);
|
|
2038
|
+
let remaining = false;
|
|
2042
2039
|
this.#applyBatch((batch) => {
|
|
2043
2040
|
this.#db.transaction(() => {
|
|
2044
|
-
const
|
|
2041
|
+
const result = evictScopedRows(this.#db, table, effective, pinned);
|
|
2042
|
+
remaining = result.remaining;
|
|
2045
2043
|
deleteWindowUnit(this.#db, baseKey, unit);
|
|
2046
2044
|
deleteSubscription(this.#db, subId);
|
|
2047
|
-
if (deferred) {
|
|
2045
|
+
if (result.deferred) {
|
|
2048
2046
|
savePendingEviction(this.#db, subId, base.table, effective);
|
|
2049
2047
|
} else {
|
|
2050
2048
|
deletePendingEviction(this.#db, subId);
|
|
@@ -2055,6 +2053,10 @@ export class SyncClient {
|
|
|
2055
2053
|
this.#needsPull = true;
|
|
2056
2054
|
batch.status();
|
|
2057
2055
|
});
|
|
2056
|
+
if (remaining) {
|
|
2057
|
+
await yieldToHost();
|
|
2058
|
+
await this.#drainPendingEvictions();
|
|
2059
|
+
}
|
|
2058
2060
|
}
|
|
2059
2061
|
|
|
2060
2062
|
/**
|
|
@@ -2062,7 +2064,7 @@ export class SyncClient {
|
|
|
2062
2064
|
* unit's rows are removed once no pending commit references them; a unit
|
|
2063
2065
|
* that re-entered the window in the meantime has no pending record left.
|
|
2064
2066
|
*/
|
|
2065
|
-
#drainPendingEvictions(): void {
|
|
2067
|
+
async #drainPendingEvictions(): Promise<void> {
|
|
2066
2068
|
const pending = loadPendingEvictions(this.#db);
|
|
2067
2069
|
if (pending.length === 0) return;
|
|
2068
2070
|
for (const entry of pending) {
|
|
@@ -2071,15 +2073,30 @@ export class SyncClient {
|
|
|
2071
2073
|
deletePendingEviction(this.#db, entry.subId);
|
|
2072
2074
|
continue;
|
|
2073
2075
|
}
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2076
|
+
let remaining: boolean;
|
|
2077
|
+
do {
|
|
2078
|
+
if (
|
|
2079
|
+
!loadPendingEvictions(this.#db).some(
|
|
2080
|
+
(item) => item.subId === entry.subId,
|
|
2081
|
+
)
|
|
2082
|
+
)
|
|
2083
|
+
break;
|
|
2084
|
+
// Local writes may add pins while the worker yields between chunks.
|
|
2085
|
+
const pinned = this.#pinnedRowIds(entry.table);
|
|
2086
|
+
remaining = false;
|
|
2087
|
+
this.#applyBatch((batch) => {
|
|
2088
|
+
const result = evictScopedRows(
|
|
2089
|
+
this.#db,
|
|
2090
|
+
table,
|
|
2091
|
+
entry.effective,
|
|
2092
|
+
pinned,
|
|
2093
|
+
);
|
|
2094
|
+
remaining = result.remaining;
|
|
2095
|
+
if (!result.deferred) deletePendingEviction(this.#db, entry.subId);
|
|
2096
|
+
batch.scopeMap(table, entry.effective);
|
|
2080
2097
|
});
|
|
2081
|
-
|
|
2082
|
-
});
|
|
2098
|
+
if (remaining) await yieldToHost();
|
|
2099
|
+
} while (remaining);
|
|
2083
2100
|
}
|
|
2084
2101
|
}
|
|
2085
2102
|
|
|
@@ -2753,6 +2770,7 @@ export class SyncClient {
|
|
|
2753
2770
|
// survive it — the reference server keeps no replay buffer (§8.2).
|
|
2754
2771
|
this.#setSyncNeeded(false);
|
|
2755
2772
|
try {
|
|
2773
|
+
await this.#drainPendingEvictions();
|
|
2756
2774
|
const logEpoch = getMeta(this.#db, LOG_EPOCH_META_KEY);
|
|
2757
2775
|
// §5.9.7 B4: upload pending blobs BEFORE pushing rows that reference
|
|
2758
2776
|
// them, so the server-side existence check (§6.6) passes.
|
|
@@ -2830,7 +2848,7 @@ export class SyncClient {
|
|
|
2830
2848
|
);
|
|
2831
2849
|
// §4.8 E1: the push half may have drained commits that pinned rows of
|
|
2832
2850
|
// a shrunk window unit — retry any deferred evictions now.
|
|
2833
|
-
this.#drainPendingEvictions();
|
|
2851
|
+
await this.#drainPendingEvictions();
|
|
2834
2852
|
this.#progress.update(
|
|
2835
2853
|
summary.failed.length > 0
|
|
2836
2854
|
? { state: 'failed', errorCode: 'sync.scope_revoked' }
|
|
@@ -2879,10 +2897,27 @@ export class SyncClient {
|
|
|
2879
2897
|
* Pull repeatedly until quiescent: no commits delivered, no bootstrap
|
|
2880
2898
|
* pages pending, no resets to recover (§4.5 "pull again" SHOULD).
|
|
2881
2899
|
*/
|
|
2882
|
-
async syncUntilIdle(maxRounds
|
|
2900
|
+
async syncUntilIdle(maxRounds?: number): Promise<SyncSummary> {
|
|
2901
|
+
const budget = maxRounds ?? 20;
|
|
2883
2902
|
let last: SyncSummary | undefined;
|
|
2884
|
-
for (let round = 0; round <
|
|
2903
|
+
for (let round = 0; round < budget; round++) {
|
|
2904
|
+
const before = JSON.stringify(
|
|
2905
|
+
loadSubscriptions(this.#db).map((sub) => [sub.id, sub.bootstrapState]),
|
|
2906
|
+
);
|
|
2885
2907
|
last = await this.sync();
|
|
2908
|
+
if (
|
|
2909
|
+
maxRounds === undefined &&
|
|
2910
|
+
last.segmentRowsApplied > 0 &&
|
|
2911
|
+
last.bootstrapping.length > 0 &&
|
|
2912
|
+
before !==
|
|
2913
|
+
JSON.stringify(
|
|
2914
|
+
loadSubscriptions(this.#db).map((sub) => [
|
|
2915
|
+
sub.id,
|
|
2916
|
+
sub.bootstrapState,
|
|
2917
|
+
]),
|
|
2918
|
+
)
|
|
2919
|
+
)
|
|
2920
|
+
round = -1;
|
|
2886
2921
|
if (last.schemaFloor !== undefined) return last;
|
|
2887
2922
|
if (
|
|
2888
2923
|
last.commitsApplied === 0 &&
|
|
@@ -2897,7 +2932,7 @@ export class SyncClient {
|
|
|
2897
2932
|
}
|
|
2898
2933
|
throw new ClientSyncError(
|
|
2899
2934
|
'sync.invalid_request',
|
|
2900
|
-
|
|
2935
|
+
'sync did not reach idle within the round budget',
|
|
2901
2936
|
);
|
|
2902
2937
|
}
|
|
2903
2938
|
|
|
@@ -3428,6 +3463,7 @@ export class SyncClient {
|
|
|
3428
3463
|
segment,
|
|
3429
3464
|
{
|
|
3430
3465
|
clearFirst,
|
|
3466
|
+
isCurrent: () => this.#localResetEpoch === resetEpoch,
|
|
3431
3467
|
onProgress: (rowsProcessed) =>
|
|
3432
3468
|
this.#progress.update({ rowsProcessed }),
|
|
3433
3469
|
effective,
|
|
@@ -3440,7 +3476,9 @@ export class SyncClient {
|
|
|
3440
3476
|
) {
|
|
3441
3477
|
batch.table(table.name);
|
|
3442
3478
|
}
|
|
3443
|
-
|
|
3479
|
+
const result = fn();
|
|
3480
|
+
this.#replayOutbox();
|
|
3481
|
+
return result;
|
|
3444
3482
|
}),
|
|
3445
3483
|
},
|
|
3446
3484
|
this.#encryption,
|
|
@@ -3499,6 +3537,7 @@ export class SyncClient {
|
|
|
3499
3537
|
},
|
|
3500
3538
|
{
|
|
3501
3539
|
clearFirst,
|
|
3540
|
+
isCurrent: () => this.#localResetEpoch === resetEpoch,
|
|
3502
3541
|
onProgress: (rowsProcessed) =>
|
|
3503
3542
|
this.#progress.update({ rowsProcessed }),
|
|
3504
3543
|
effective,
|
|
@@ -3511,7 +3550,9 @@ export class SyncClient {
|
|
|
3511
3550
|
) {
|
|
3512
3551
|
batch.table(table.name);
|
|
3513
3552
|
}
|
|
3514
|
-
|
|
3553
|
+
const result = fn();
|
|
3554
|
+
this.#replayOutbox();
|
|
3555
|
+
return result;
|
|
3515
3556
|
}),
|
|
3516
3557
|
},
|
|
3517
3558
|
),
|
|
@@ -3530,6 +3571,7 @@ export class SyncClient {
|
|
|
3530
3571
|
segment,
|
|
3531
3572
|
{
|
|
3532
3573
|
clearFirst,
|
|
3574
|
+
isCurrent: () => this.#localResetEpoch === resetEpoch,
|
|
3533
3575
|
onProgress: (rowsProcessed) =>
|
|
3534
3576
|
this.#progress.update({ rowsProcessed }),
|
|
3535
3577
|
effective,
|
|
@@ -3542,7 +3584,9 @@ export class SyncClient {
|
|
|
3542
3584
|
) {
|
|
3543
3585
|
batch.table(table.name);
|
|
3544
3586
|
}
|
|
3545
|
-
|
|
3587
|
+
const result = fn();
|
|
3588
|
+
this.#replayOutbox();
|
|
3589
|
+
return result;
|
|
3546
3590
|
}),
|
|
3547
3591
|
},
|
|
3548
3592
|
this.#encryption,
|
package/src/database.ts
CHANGED
|
@@ -22,11 +22,16 @@ export interface ClientDatabase {
|
|
|
22
22
|
/**
|
|
23
23
|
* Optional sqlite-image capability (SPEC §5.3): expose `bytes` as an
|
|
24
24
|
* attached read-only database schema named `alias` for the duration of
|
|
25
|
-
* `fn`,
|
|
25
|
+
* `fn`, awaiting its result before detaching. No transaction spans its awaits.
|
|
26
|
+
* Presence of this method is what makes the client
|
|
26
27
|
* advertise `accept` bit 2 (§4.2). MUST be called outside any open
|
|
27
28
|
* transaction (SQLite cannot ATTACH inside one); `fn` may open its own.
|
|
28
29
|
*/
|
|
29
|
-
withSqliteImage?<T>(
|
|
30
|
+
withSqliteImage?<T>(
|
|
31
|
+
bytes: Uint8Array,
|
|
32
|
+
alias: string,
|
|
33
|
+
fn: () => T | Promise<T>,
|
|
34
|
+
): Promise<T>;
|
|
30
35
|
close(): void;
|
|
31
36
|
}
|
|
32
37
|
|
package/src/node-database.ts
CHANGED
|
@@ -63,7 +63,11 @@ export class NodeClientDatabase implements ClientDatabase {
|
|
|
63
63
|
}
|
|
64
64
|
|
|
65
65
|
/** §5.3 image import through a private file attached for one callback. */
|
|
66
|
-
withSqliteImage<T>(
|
|
66
|
+
async withSqliteImage<T>(
|
|
67
|
+
bytes: Uint8Array,
|
|
68
|
+
alias: string,
|
|
69
|
+
fn: () => T | Promise<T>,
|
|
70
|
+
): Promise<T> {
|
|
67
71
|
assertImageAlias(alias);
|
|
68
72
|
const dir = mkdtempSync(join(tmpdir(), 'syncular-image-'));
|
|
69
73
|
const path = join(dir, 'segment.db');
|
|
@@ -71,7 +75,7 @@ export class NodeClientDatabase implements ClientDatabase {
|
|
|
71
75
|
writeFileSync(path, bytes);
|
|
72
76
|
this.db.prepare(`ATTACH DATABASE ? AS ${alias}`).run(path);
|
|
73
77
|
try {
|
|
74
|
-
return fn();
|
|
78
|
+
return await fn();
|
|
75
79
|
} finally {
|
|
76
80
|
this.db.prepare(`DETACH DATABASE ${alias}`).run();
|
|
77
81
|
}
|
package/src/reactive-store.ts
CHANGED
|
@@ -359,7 +359,8 @@ class QueryEntry<Row> implements ExternalStoreEntry<LiveQueryResult<Row>> {
|
|
|
359
359
|
#running = false;
|
|
360
360
|
#requested = false;
|
|
361
361
|
#desiredRevision = 0n;
|
|
362
|
-
#
|
|
362
|
+
#claimError: Error | undefined;
|
|
363
|
+
#claimPending = false;
|
|
363
364
|
#offStatus: (() => void) | undefined;
|
|
364
365
|
|
|
365
366
|
constructor(
|
|
@@ -397,10 +398,25 @@ class QueryEntry<Row> implements ExternalStoreEntry<LiveQueryResult<Row>> {
|
|
|
397
398
|
),
|
|
398
399
|
);
|
|
399
400
|
}
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
401
|
+
const generation = this.#generation;
|
|
402
|
+
this.#claimPending = claims.length > 0;
|
|
403
|
+
if (claims.length > 0)
|
|
404
|
+
void Promise.all(claims).then(
|
|
405
|
+
() => {
|
|
406
|
+
if (generation !== this.#generation || this.#listeners.size === 0)
|
|
407
|
+
return;
|
|
408
|
+
this.#claimPending = false;
|
|
409
|
+
if (!this.#running && this.#state.phase !== 'ready')
|
|
410
|
+
this.#requestRead();
|
|
411
|
+
},
|
|
412
|
+
(error: unknown) => {
|
|
413
|
+
if (generation !== this.#generation || this.#listeners.size === 0)
|
|
414
|
+
return;
|
|
415
|
+
this.#claimPending = false;
|
|
416
|
+
this.#claimError = errorOf(error);
|
|
417
|
+
this.#requestRead();
|
|
418
|
+
},
|
|
419
|
+
);
|
|
404
420
|
}
|
|
405
421
|
this.#requestRead();
|
|
406
422
|
}
|
|
@@ -426,7 +442,8 @@ class QueryEntry<Row> implements ExternalStoreEntry<LiveQueryResult<Row>> {
|
|
|
426
442
|
this.#running = false;
|
|
427
443
|
this.#requested = false;
|
|
428
444
|
this.#desiredRevision = 0n;
|
|
429
|
-
this.#
|
|
445
|
+
this.#claimError = undefined;
|
|
446
|
+
this.#claimPending = false;
|
|
430
447
|
this.#state = {
|
|
431
448
|
rows: [],
|
|
432
449
|
phase: 'loading',
|
|
@@ -520,9 +537,11 @@ class QueryEntry<Row> implements ExternalStoreEntry<LiveQueryResult<Row>> {
|
|
|
520
537
|
do {
|
|
521
538
|
this.#requested = false;
|
|
522
539
|
if (this.store.availabilitySnapshot().state === 'blocked') break;
|
|
523
|
-
|
|
540
|
+
|
|
524
541
|
if (generation !== this.#generation || this.#listeners.size === 0)
|
|
525
542
|
return;
|
|
543
|
+
if (this.#claimError !== undefined) throw this.#claimError;
|
|
544
|
+
const claimWasPending = this.#claimPending;
|
|
526
545
|
const snapshot = await this.store.client.querySnapshot<
|
|
527
546
|
Record<string, SqlValue>
|
|
528
547
|
>({
|
|
@@ -536,6 +555,7 @@ class QueryEntry<Row> implements ExternalStoreEntry<LiveQueryResult<Row>> {
|
|
|
536
555
|
});
|
|
537
556
|
if (generation !== this.#generation || this.#listeners.size === 0)
|
|
538
557
|
return;
|
|
558
|
+
if (this.#claimError !== undefined) throw this.#claimError;
|
|
539
559
|
const availability = this.store.availabilitySnapshot();
|
|
540
560
|
if (availability.state === 'blocked') {
|
|
541
561
|
this.#publish({
|
|
@@ -550,6 +570,12 @@ class QueryEntry<Row> implements ExternalStoreEntry<LiveQueryResult<Row>> {
|
|
|
550
570
|
this.#requested = true;
|
|
551
571
|
continue;
|
|
552
572
|
}
|
|
573
|
+
if (
|
|
574
|
+
!snapshot.coverage.complete &&
|
|
575
|
+
claimWasPending &&
|
|
576
|
+
!this.#claimPending
|
|
577
|
+
)
|
|
578
|
+
this.#requested = true;
|
|
553
579
|
const mappedRows =
|
|
554
580
|
this.spec.mapRow === undefined
|
|
555
581
|
? (snapshot.rows as readonly Row[])
|
|
@@ -625,6 +651,8 @@ interface WindowClaimGroup {
|
|
|
625
651
|
readonly base: WindowBase;
|
|
626
652
|
readonly claims: Map<symbol, ReadonlySet<string>>;
|
|
627
653
|
appliedKey: string;
|
|
654
|
+
appliedUnits: ReadonlySet<string>;
|
|
655
|
+
inFlightUnits?: ReadonlySet<string> | undefined;
|
|
628
656
|
scheduled: boolean;
|
|
629
657
|
running: boolean;
|
|
630
658
|
requested: boolean;
|
|
@@ -889,6 +917,7 @@ export class ReactiveClientStore {
|
|
|
889
917
|
base,
|
|
890
918
|
claims: new Map(),
|
|
891
919
|
appliedKey: '',
|
|
920
|
+
appliedUnits: new Set(),
|
|
892
921
|
scheduled: false,
|
|
893
922
|
running: false,
|
|
894
923
|
requested: false,
|
|
@@ -897,6 +926,17 @@ export class ReactiveClientStore {
|
|
|
897
926
|
this.#windowClaims.set(key, group);
|
|
898
927
|
}
|
|
899
928
|
group.claims.set(owner, new Set(units));
|
|
929
|
+
const alreadyHeld =
|
|
930
|
+
units.length > 0 &&
|
|
931
|
+
units.every(
|
|
932
|
+
(unit) =>
|
|
933
|
+
group.appliedUnits.has(unit) &&
|
|
934
|
+
(group.inFlightUnits === undefined || group.inFlightUnits.has(unit)),
|
|
935
|
+
);
|
|
936
|
+
if (alreadyHeld) {
|
|
937
|
+
this.#scheduleWindow(group);
|
|
938
|
+
return Promise.resolve();
|
|
939
|
+
}
|
|
900
940
|
const result = new Promise<void>((resolve, reject) => {
|
|
901
941
|
group?.waiters.push({ resolve, reject });
|
|
902
942
|
});
|
|
@@ -932,13 +972,23 @@ export class ReactiveClientStore {
|
|
|
932
972
|
].sort();
|
|
933
973
|
const key = canonicalValue(units);
|
|
934
974
|
if (key !== group.appliedKey) {
|
|
935
|
-
|
|
975
|
+
group.inFlightUnits = new Set(units);
|
|
976
|
+
try {
|
|
977
|
+
await this.client.setWindow(group.base, units);
|
|
978
|
+
} finally {
|
|
979
|
+
group.inFlightUnits = undefined;
|
|
980
|
+
}
|
|
936
981
|
if (this.#windowClaims.get(baseKey) !== group) return;
|
|
937
982
|
group.appliedKey = key;
|
|
983
|
+
group.appliedUnits = new Set(units);
|
|
938
984
|
}
|
|
939
985
|
}
|
|
940
986
|
for (const waiter of group.waiters.splice(0)) waiter.resolve();
|
|
941
987
|
} catch (error) {
|
|
988
|
+
// A failed window edit may already have committed its first eviction chunk.
|
|
989
|
+
// Reconcile through the core before acknowledging any later claim.
|
|
990
|
+
group.appliedKey = '';
|
|
991
|
+
group.appliedUnits = new Set();
|
|
942
992
|
for (const waiter of group.waiters.splice(0)) waiter.reject(error);
|
|
943
993
|
} finally {
|
|
944
994
|
group.running = false;
|
package/src/schema.ts
CHANGED
|
@@ -374,6 +374,14 @@ function createFtsProjection(
|
|
|
374
374
|
`CREATE VIRTUAL TABLE IF NOT EXISTS ${quoteIdent(index.name)} USING fts5(${quoteIdent(FTS_SOURCE_ID_COLUMN)} UNINDEXED, ${indexedColumns.join(', ')}, tokenize='${tokenizer}')`,
|
|
375
375
|
);
|
|
376
376
|
|
|
377
|
+
const mapping = quoteIdent(`_syncular_fts_${index.name}`);
|
|
378
|
+
const mapped =
|
|
379
|
+
db.query("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", [
|
|
380
|
+
`_syncular_fts_${index.name}`,
|
|
381
|
+
]).length > 0;
|
|
382
|
+
db.exec(
|
|
383
|
+
`CREATE TABLE IF NOT EXISTS ${mapping} (id INTEGER PRIMARY KEY, source_id TEXT NOT NULL UNIQUE)`,
|
|
384
|
+
);
|
|
377
385
|
const sourceId = `CAST(${quoteIdent(table.primaryKey)} AS TEXT)`;
|
|
378
386
|
const newSourceId = `CAST(new.${quoteIdent(table.primaryKey)} AS TEXT)`;
|
|
379
387
|
const oldSourceId = `CAST(old.${quoteIdent(table.primaryKey)} AS TEXT)`;
|
|
@@ -386,9 +394,10 @@ function createFtsProjection(
|
|
|
386
394
|
...index.columns.map((column) => `new.${quoteIdent(column)}`),
|
|
387
395
|
].join(', ');
|
|
388
396
|
const deleteFor = (value: string) =>
|
|
389
|
-
`DELETE FROM ${quoteIdent(index.name)} WHERE ${
|
|
397
|
+
`DELETE FROM ${quoteIdent(index.name)} WHERE rowid = (SELECT id FROM ${mapping} WHERE source_id = ${value}); DELETE FROM ${mapping} WHERE source_id = ${value}`;
|
|
390
398
|
const deleteDisplaced = (select: string) =>
|
|
391
|
-
`DELETE FROM ${quoteIdent(index.name)} WHERE ${
|
|
399
|
+
`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})`;
|
|
400
|
+
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})`;
|
|
392
401
|
|
|
393
402
|
// A clean insert cannot already have a projection row because the source
|
|
394
403
|
// primary key is unique. Keep clean inserts linear by moving replacement
|
|
@@ -441,13 +450,13 @@ function createFtsProjection(
|
|
|
441
450
|
`CREATE TRIGGER ${quoteIdent(`${index.name}_bi`)} BEFORE INSERT ON ${quoteIdent(table.name)} WHEN ${insertGuardCondition} BEGIN ${insertGuardBody}; END`,
|
|
442
451
|
);
|
|
443
452
|
db.exec(
|
|
444
|
-
`CREATE TRIGGER ${quoteIdent(`${index.name}_ai`)} AFTER INSERT ON ${quoteIdent(table.name)} BEGIN
|
|
453
|
+
`CREATE TRIGGER ${quoteIdent(`${index.name}_ai`)} AFTER INSERT ON ${quoteIdent(table.name)} BEGIN ${insertNew}; END`,
|
|
445
454
|
);
|
|
446
455
|
db.exec(
|
|
447
456
|
`CREATE TRIGGER ${quoteIdent(`${index.name}_ad`)} AFTER DELETE ON ${quoteIdent(table.name)} BEGIN ${deleteFor(oldSourceId)}; END`,
|
|
448
457
|
);
|
|
449
458
|
db.exec(
|
|
450
|
-
`CREATE TRIGGER ${quoteIdent(`${index.name}_au`)} AFTER UPDATE ON ${quoteIdent(table.name)} BEGIN ${deleteFor(oldSourceId)}; ${deleteFor(newSourceId)};
|
|
459
|
+
`CREATE TRIGGER ${quoteIdent(`${index.name}_au`)} AFTER UPDATE ON ${quoteIdent(table.name)} BEGIN ${deleteFor(oldSourceId)}; ${deleteFor(newSourceId)}; ${insertNew}; END`,
|
|
451
460
|
);
|
|
452
461
|
// BEFORE UPDATE guard for `UPDATE OR REPLACE`: clear the projection of any
|
|
453
462
|
// different-PK row about to be displaced through a secondary unique index by
|
|
@@ -468,6 +477,12 @@ function createFtsProjection(
|
|
|
468
477
|
`INSERT INTO ${quoteIdent(index.name)} (${projectionColumns}) SELECT ${sourceId}, ${indexedColumns.join(', ')} FROM ${quoteIdent(table.name)}`,
|
|
469
478
|
);
|
|
470
479
|
}
|
|
480
|
+
if (!mapped || !existed) {
|
|
481
|
+
db.exec(`DELETE FROM ${mapping}`);
|
|
482
|
+
db.exec(
|
|
483
|
+
`INSERT INTO ${mapping}(id, source_id) SELECT rowid, ${quoteIdent(FTS_SOURCE_ID_COLUMN)} FROM ${quoteIdent(index.name)}`,
|
|
484
|
+
);
|
|
485
|
+
}
|
|
471
486
|
}
|
|
472
487
|
|
|
473
488
|
/**
|
|
@@ -602,6 +617,9 @@ export function dropAndRecreateSyncedTables(
|
|
|
602
617
|
AND name NOT LIKE '${RESERVED_TABLE_PREFIX}%'`,
|
|
603
618
|
);
|
|
604
619
|
for (const row of virtualTables) {
|
|
620
|
+
db.exec(
|
|
621
|
+
`DROP TABLE IF EXISTS ${quoteIdent(`_syncular_fts_${String(row.name)}`)}`,
|
|
622
|
+
);
|
|
605
623
|
db.exec(`DROP TABLE IF EXISTS ${quoteIdent(String(row.name))}`);
|
|
606
624
|
}
|
|
607
625
|
const existing = db.query(
|
package/src/wasm-database.ts
CHANGED
|
@@ -151,9 +151,13 @@ class WasmClientDatabase implements ClientDatabase {
|
|
|
151
151
|
* §5.3 image import at near-file-copy speed: attach an empty in-memory
|
|
152
152
|
* schema, then `sqlite3_deserialize` the image bytes into it (the
|
|
153
153
|
* documented sqlite-wasm import path — no OPFS round-trip, no SQL-level
|
|
154
|
-
* row shuttling before the
|
|
154
|
+
* row shuttling before the chunked INSERT…SELECT statements the caller runs).
|
|
155
155
|
*/
|
|
156
|
-
withSqliteImage<T>(
|
|
156
|
+
async withSqliteImage<T>(
|
|
157
|
+
bytes: Uint8Array,
|
|
158
|
+
alias: string,
|
|
159
|
+
fn: () => T | Promise<T>,
|
|
160
|
+
): Promise<T> {
|
|
157
161
|
assertImageAlias(alias);
|
|
158
162
|
const pointer = this.#db.pointer;
|
|
159
163
|
if (pointer === undefined) {
|
|
@@ -182,7 +186,7 @@ class WasmClientDatabase implements ClientDatabase {
|
|
|
182
186
|
`sqlite3_deserialize failed with code ${rc} (§5.3)`,
|
|
183
187
|
);
|
|
184
188
|
}
|
|
185
|
-
return fn();
|
|
189
|
+
return await fn();
|
|
186
190
|
} finally {
|
|
187
191
|
this.exec(`DETACH ${alias}`);
|
|
188
192
|
}
|