@spooky-sync/core 0.0.1-canary.288 → 0.0.1-canary.289
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/AGENTS.md +1 -1
- package/dist/index.d.ts +8 -0
- package/dist/index.js +243 -26
- package/dist/types.d.ts +14 -0
- package/package.json +3 -3
- package/skills/sp00ky-core/SKILL.md +4 -0
- package/skills/sp00ky-core/references/config.md +6 -0
- package/src/boot/boot.saga.test.ts +1 -1
- package/src/boot/bucket-switch.saga.test.ts +1 -1
- package/src/client/router.test.ts +2 -0
- package/src/client/router.ts +5 -1
- package/src/client/services.ts +10 -2
- package/src/kernel/constants.ts +6 -0
- package/src/kernel/events.ts +2 -0
- package/src/mutation/pending-writes.integration.test.ts +175 -0
- package/src/mutation/push.saga.test.ts +2 -2
- package/src/mutation/push.saga.ts +2 -0
- package/src/mutation/rows.test.ts +30 -0
- package/src/mutation/rows.ts +63 -13
- package/src/mutation/write.saga.test.ts +154 -1
- package/src/mutation/write.saga.ts +108 -6
- package/src/services/database/local-migrator.integration.test.ts +110 -0
- package/src/services/database/local-migrator.ts +40 -0
- package/src/services/database/sqlite-cache-engine.ts +2 -0
- package/src/state/client-state.ts +2 -0
- package/src/state/reducers.test.ts +5 -3
- package/src/state/reducers.ts +1 -1
- package/src/state/selectors.test.ts +1 -1
- package/src/sync/tabs.saga.test.ts +1 -1
- package/src/types.ts +14 -0
package/AGENTS.md
CHANGED
|
@@ -49,7 +49,7 @@ re-materialized from state, never patched in place.
|
|
|
49
49
|
- **Use UI hooks, not the client directly.** In a Solid app, call `useDb()` from `@spooky-sync/client-solid`. Touch `Sp00kyClient` only inside `provider.client` for advanced flows.
|
|
50
50
|
- **Mutations are optimistic.** `db.create` / `db.update` / `db.delete` return immediately; the queue drains in the background. Inspect progress via `pendingMutationCount`.
|
|
51
51
|
- **Record IDs are full strings.** `db.create('thread:abc123', {...})` — *not* `db.create('thread', { id: 'abc123' })`. The first arg is `'<table>:<id>'`. Generate IDs with `Uuid` from `surrealdb` (re-exported by `client-solid`).
|
|
52
|
-
- **`db.update` takes `(table, id, payload, options?)`**. The `debounced` option (`{ debounced: true }` or `{ debounced: { delay, key } }`) coalesces rapid updates — use it for CRDT text/title fields.
|
|
52
|
+
- **`db.update` takes `(table, id, payload, options?)`**. The `debounced` option (`{ debounced: true }` or `{ debounced: { delay, key, flushOnHide } }`) coalesces rapid updates — use it for CRDT text/title fields. With `flushOnHide` (default `true`) the merged patch is also kept in the local `_00_pending_writes` table and pushed as soon as the page is hidden, so a reload or closed tab inside the delay does not lose it (the next boot moves a leftover patch into the outbox). `flushOnHide: false` keeps the patch in memory only.
|
|
53
53
|
- **`db.remoteQuery(sql, vars?)` is the one-shot remote read.** It runs one SurrealQL statement through the client's remote path (connect gate, per-statement timeout, concurrency limit) and returns the SurrealDB result array; results are not synced into the local cache. Use it for counts and other reads the query builder can't express.
|
|
54
54
|
- **`db.useRemote(fn)` hands out the bare SDK client.** Same cache bypass, but it skips the connect gate (a call during a reconnect fails with "Specify a namespace to use"). Reserve it for SDK features `remoteQuery` cannot reach (transactions, `live`).
|
|
55
55
|
- **Raw remote calls are gated by the query allowlist.** When the generated schema carries `policy.queryAllowlist: 'warn' | 'enforce'` (from `sync.queryAllowlist` in `sp00ky.yml`), `remoteQuery`, `useRemote` and `queryRaw` throw unless the client config sets `allowRawRemote: true`; builder queries are unaffected. A registration the SSP refuses as not allowlisted (403 `not_allowlisted`) is marked `remote: 'failed'` after one attempt with an error-level log carrying the surql and a `spky generate` hint; the view stays cold (`membershipKnown` false), it is never read as confirmed empty.
|
package/dist/index.d.ts
CHANGED
|
@@ -207,6 +207,8 @@ declare class LocalMigrator {
|
|
|
207
207
|
private logger;
|
|
208
208
|
constructor(localDb: LocalStore, logger: Logger$1);
|
|
209
209
|
provision(schemaSurql: string): Promise<void>;
|
|
210
|
+
private readKeptRows;
|
|
211
|
+
private restoreKeptRows;
|
|
210
212
|
private isSchemaUpToDate;
|
|
211
213
|
private recreateDatabase;
|
|
212
214
|
private splitStatements;
|
|
@@ -892,6 +894,10 @@ type RuntimeEvent = {
|
|
|
892
894
|
} | {
|
|
893
895
|
type: 'FlushWrite';
|
|
894
896
|
key: string;
|
|
897
|
+
} | {
|
|
898
|
+
type: 'FlushPendingWrites';
|
|
899
|
+
} | {
|
|
900
|
+
type: 'AdoptPendingWrites';
|
|
895
901
|
} | {
|
|
896
902
|
type: 'PollTick';
|
|
897
903
|
} | {
|
|
@@ -1278,6 +1284,8 @@ interface PendingWrite {
|
|
|
1278
1284
|
readonly data: Readonly<Record<string, unknown>>;
|
|
1279
1285
|
readonly before: Readonly<Record<string, unknown>> | null;
|
|
1280
1286
|
readonly firstAt: number;
|
|
1287
|
+
/** Its durable copy in `_00_pending_writes`; null when `flushOnHide` is off. */
|
|
1288
|
+
readonly mirrorId: string | null;
|
|
1281
1289
|
}
|
|
1282
1290
|
interface SyncSlice {
|
|
1283
1291
|
readonly health: SyncHealth;
|
package/dist/index.js
CHANGED
|
@@ -1752,8 +1752,8 @@ function selfAllowlistedVariant(flag, userId) {
|
|
|
1752
1752
|
|
|
1753
1753
|
//#endregion
|
|
1754
1754
|
//#region src/modules/devtools/index.ts
|
|
1755
|
-
const CORE_VERSION = "0.0.1-canary.
|
|
1756
|
-
const WASM_VERSION = "0.0.1-canary.
|
|
1755
|
+
const CORE_VERSION = "0.0.1-canary.289";
|
|
1756
|
+
const WASM_VERSION = "0.0.1-canary.289";
|
|
1757
1757
|
const SURREAL_VERSION = "3.0.3";
|
|
1758
1758
|
var DevToolsService = class DevToolsService {
|
|
1759
1759
|
eventsHistory = [];
|
|
@@ -2439,6 +2439,12 @@ const AUTH_READY_MAX_ATTEMPTS = 10;
|
|
|
2439
2439
|
/** Heartbeat at this fraction of the shortest ttl in state. */
|
|
2440
2440
|
const TTL_HEARTBEAT_FRACTION = .5;
|
|
2441
2441
|
/**
|
|
2442
|
+
* How long past its due flush another tab's `_00_pending_writes` row may sit
|
|
2443
|
+
* before it counts as abandoned (its tab died before the flush) and is moved
|
|
2444
|
+
* into the outbox here.
|
|
2445
|
+
*/
|
|
2446
|
+
const PENDING_WRITE_ORPHAN_MS = 3e3;
|
|
2447
|
+
/**
|
|
2442
2448
|
* Orphan body garbage collection: the first sweep waits this long after boot
|
|
2443
2449
|
* (off the first paint and the first sync round), then repeats on the interval.
|
|
2444
2450
|
*/
|
|
@@ -2840,7 +2846,8 @@ const mergePendingWrite = (write) => (s) => {
|
|
|
2840
2846
|
data: {
|
|
2841
2847
|
...prev.data,
|
|
2842
2848
|
...write.data
|
|
2843
|
-
}
|
|
2849
|
+
},
|
|
2850
|
+
mirrorId: prev.mirrorId ?? write.mirrorId
|
|
2844
2851
|
} : write);
|
|
2845
2852
|
return {
|
|
2846
2853
|
...s,
|
|
@@ -4451,6 +4458,8 @@ function* collectOrphans(bucket, relay) {
|
|
|
4451
4458
|
//#region src/mutation/rows.ts
|
|
4452
4459
|
const PENDING_TABLE = "_00_pending_mutations";
|
|
4453
4460
|
const FAILED_TABLE = "_00_failed_mutations";
|
|
4461
|
+
/** Debounced patches still inside their delay (see `DebounceOptions.flushOnHide`). */
|
|
4462
|
+
const PENDING_WRITES_TABLE = "_00_pending_writes";
|
|
4454
4463
|
const PENDING_ROW_VERSION = 2;
|
|
4455
4464
|
/**
|
|
4456
4465
|
* Parse a record id READ BACK FROM THE STORE, stripping SurrealDB's `⟨⟩`
|
|
@@ -4527,7 +4536,25 @@ const toOutboxItem = (row) => ({
|
|
|
4527
4536
|
ackedAt: null,
|
|
4528
4537
|
attempts: 0
|
|
4529
4538
|
});
|
|
4539
|
+
/** The `_00_pending_writes` id for a minted mutation id (same suffix). */
|
|
4540
|
+
const pendingWriteId = (mutationId) => `${PENDING_WRITES_TABLE}:${mutationId.includes(":") ? mutationId.slice(mutationId.indexOf(":") + 1) : mutationId}`;
|
|
4541
|
+
function parsePendingWriteRow(row) {
|
|
4542
|
+
if (!row || typeof row !== "object") return null;
|
|
4543
|
+
const r = row;
|
|
4544
|
+
if (typeof r.recordId !== "string" && !(r.recordId instanceof RecordId$1)) return null;
|
|
4545
|
+
if (!r.data || typeof r.data !== "object") return null;
|
|
4546
|
+
const recordId = typeof r.recordId === "string" ? r.recordId : storedIdString(r.recordId);
|
|
4547
|
+
return {
|
|
4548
|
+
id: storedIdString(r.id),
|
|
4549
|
+
recordId,
|
|
4550
|
+
tableName: typeof r.tableName === "string" ? r.tableName : extractTablePart(recordId),
|
|
4551
|
+
data: r.data,
|
|
4552
|
+
beforeRecord: r.beforeRecord && typeof r.beforeRecord === "object" ? r.beforeRecord : null,
|
|
4553
|
+
flushBy: typeof r.flushBy === "number" ? r.flushBy : 0
|
|
4554
|
+
};
|
|
4555
|
+
}
|
|
4530
4556
|
const loadPendingRows = () => `SELECT * FROM ${PENDING_TABLE} ORDER BY id ASC`;
|
|
4557
|
+
const loadPendingWriteRows = () => `SELECT * FROM ${PENDING_WRITES_TABLE}`;
|
|
4531
4558
|
const loadFailedRows = () => `SELECT * FROM ${FAILED_TABLE} ORDER BY failedAt ASC`;
|
|
4532
4559
|
const pendingInsert = (type, withData, withBefore) => {
|
|
4533
4560
|
const fields = [
|
|
@@ -4596,34 +4623,50 @@ function planDeleteTx(input) {
|
|
|
4596
4623
|
}
|
|
4597
4624
|
};
|
|
4598
4625
|
}
|
|
4599
|
-
/**
|
|
4626
|
+
/**
|
|
4627
|
+
* Debounced update: bump `_00_rv` and MERGE now; the outbox row comes later.
|
|
4628
|
+
* With a `mirror`, the merged patch so far is written to `_00_pending_writes`
|
|
4629
|
+
* in the same transaction, so the row never changes without a durable copy of
|
|
4630
|
+
* what still has to reach the server.
|
|
4631
|
+
*/
|
|
4600
4632
|
function planLocalOnlyUpdateTx(input) {
|
|
4633
|
+
const statements = [surql.updateSet("id", [{ statement: "_00_rv += 1" }]), surql.let("updated", surql.updateMerge("id", "data"))];
|
|
4634
|
+
if (input.mirror) statements.push(surql.upsert("wid", "wrow"));
|
|
4635
|
+
statements.push(surql.returnObject([{
|
|
4636
|
+
key: "target",
|
|
4637
|
+
variable: "updated"
|
|
4638
|
+
}]));
|
|
4601
4639
|
return {
|
|
4602
|
-
query: surql.seal(surql.tx(
|
|
4603
|
-
|
|
4604
|
-
|
|
4605
|
-
|
|
4606
|
-
|
|
4607
|
-
|
|
4608
|
-
|
|
4609
|
-
])),
|
|
4610
|
-
vars: {
|
|
4640
|
+
query: surql.seal(surql.tx(statements)),
|
|
4641
|
+
vars: input.mirror ? {
|
|
4642
|
+
id: input.recordId,
|
|
4643
|
+
data: input.data,
|
|
4644
|
+
wid: input.mirror.id,
|
|
4645
|
+
wrow: input.mirror.row
|
|
4646
|
+
} : {
|
|
4611
4647
|
id: input.recordId,
|
|
4612
4648
|
data: input.data
|
|
4613
4649
|
}
|
|
4614
4650
|
};
|
|
4615
4651
|
}
|
|
4616
|
-
/**
|
|
4652
|
+
/**
|
|
4653
|
+
* The outbox row of a flushed debounced update. With `mirrorId` the
|
|
4654
|
+
* `_00_pending_writes` row goes in the same transaction: the patch moves
|
|
4655
|
+
* from one table to the other, it is never in both or in neither.
|
|
4656
|
+
*/
|
|
4617
4657
|
function planDeferredOutboxRowTx(input) {
|
|
4658
|
+
const statements = [pendingInsert("update", true, true)];
|
|
4659
|
+
if (input.mirrorId) statements.push(surql.delete("wid"));
|
|
4618
4660
|
return {
|
|
4619
|
-
query: surql.seal(surql.tx(
|
|
4661
|
+
query: surql.seal(surql.tx(statements)),
|
|
4620
4662
|
vars: {
|
|
4621
4663
|
id: input.recordId,
|
|
4622
4664
|
mid: input.mutationId,
|
|
4623
4665
|
table: input.table,
|
|
4624
4666
|
createdAt: input.now,
|
|
4625
4667
|
data: input.data ?? {},
|
|
4626
|
-
before: input.before ?? null
|
|
4668
|
+
before: input.before ?? null,
|
|
4669
|
+
...input.mirrorId ? { wid: input.mirrorId } : {}
|
|
4627
4670
|
}
|
|
4628
4671
|
};
|
|
4629
4672
|
}
|
|
@@ -4777,6 +4820,10 @@ const debounceDelay = (input) => {
|
|
|
4777
4820
|
const d = input.options?.debounced;
|
|
4778
4821
|
return d && d !== true && typeof d.delay === "number" ? d.delay : 300;
|
|
4779
4822
|
};
|
|
4823
|
+
const flushOnHide = (input) => {
|
|
4824
|
+
const d = input.options?.debounced;
|
|
4825
|
+
return !(d && d !== true && d.flushOnHide === false);
|
|
4826
|
+
};
|
|
4780
4827
|
/**
|
|
4781
4828
|
* One optimistic write: read the previous row (update/delete), run the local
|
|
4782
4829
|
* transaction that writes the row AND its outbox entry, publish the outbox
|
|
@@ -4894,14 +4941,34 @@ function* commitWrite(kind, tx, ctx) {
|
|
|
4894
4941
|
record
|
|
4895
4942
|
};
|
|
4896
4943
|
}
|
|
4897
|
-
/**
|
|
4944
|
+
/**
|
|
4945
|
+
* Apply locally now, remember the merged patch, write the outbox row on flush.
|
|
4946
|
+
* With `flushOnHide` (the default) the merged patch is also mirrored to
|
|
4947
|
+
* `_00_pending_writes` in the same transaction, so a reload or a closed tab
|
|
4948
|
+
* inside the delay cannot lose it.
|
|
4949
|
+
*/
|
|
4898
4950
|
function* debouncedUpdate(input, key, rid, table, params) {
|
|
4899
4951
|
const existing = yield fx.state.read((s) => s.pendingWrites.get(key));
|
|
4900
4952
|
const now = yield fx.now();
|
|
4901
4953
|
const before = existing ? existing.before : yield* readBefore(rid);
|
|
4954
|
+
const mirrorId = existing?.mirrorId ?? (flushOnHide(input) ? pendingWriteId(yield fx.id("mutation")) : null);
|
|
4955
|
+
const delay = debounceDelay(input);
|
|
4902
4956
|
const local = planLocalOnlyUpdateTx({
|
|
4903
4957
|
recordId: rid,
|
|
4904
|
-
data: params
|
|
4958
|
+
data: params,
|
|
4959
|
+
mirror: mirrorId ? {
|
|
4960
|
+
id: parseRecordIdString(mirrorId),
|
|
4961
|
+
row: {
|
|
4962
|
+
recordId: rid,
|
|
4963
|
+
tableName: table,
|
|
4964
|
+
data: {
|
|
4965
|
+
...existing?.data,
|
|
4966
|
+
...params
|
|
4967
|
+
},
|
|
4968
|
+
beforeRecord: before,
|
|
4969
|
+
flushBy: now + delay
|
|
4970
|
+
}
|
|
4971
|
+
} : void 0
|
|
4905
4972
|
});
|
|
4906
4973
|
const record = (yield fx.local.execute(local.query, local.vars))?.target ?? null;
|
|
4907
4974
|
yield fx.state.update(mergePendingWrite({
|
|
@@ -4910,7 +4977,8 @@ function* debouncedUpdate(input, key, rid, table, params) {
|
|
|
4910
4977
|
recordId: input.recordId,
|
|
4911
4978
|
data: params,
|
|
4912
4979
|
before,
|
|
4913
|
-
firstAt: existing?.firstAt ?? now
|
|
4980
|
+
firstAt: existing?.firstAt ?? now,
|
|
4981
|
+
mirrorId
|
|
4914
4982
|
}));
|
|
4915
4983
|
yield fx.state.update(markTableDirty(table));
|
|
4916
4984
|
const version = typeof record?._00_rv === "number" ? record._00_rv : 0;
|
|
@@ -4944,7 +5012,7 @@ function* debouncedUpdate(input, key, rid, table, params) {
|
|
|
4944
5012
|
records: [tuple]
|
|
4945
5013
|
}
|
|
4946
5014
|
});
|
|
4947
|
-
yield fx.timer.set(`debounce:${key}`,
|
|
5015
|
+
yield fx.timer.set(`debounce:${key}`, delay, {
|
|
4948
5016
|
type: "FlushWrite",
|
|
4949
5017
|
key
|
|
4950
5018
|
});
|
|
@@ -4967,9 +5035,15 @@ function* flushWrite(env, key) {
|
|
|
4967
5035
|
table: pending.table,
|
|
4968
5036
|
data: { ...pending.data },
|
|
4969
5037
|
before: pending.before,
|
|
4970
|
-
now
|
|
5038
|
+
now,
|
|
5039
|
+
mirrorId: pending.mirrorId ? parseRecordIdString(pending.mirrorId) : void 0
|
|
4971
5040
|
});
|
|
4972
|
-
|
|
5041
|
+
try {
|
|
5042
|
+
yield fx.local.execute(insert.query, insert.vars);
|
|
5043
|
+
} catch (error) {
|
|
5044
|
+
if (pending.mirrorId) yield fx.timer.set("adopt-writes", PENDING_WRITE_ORPHAN_MS, { type: "AdoptPendingWrites" });
|
|
5045
|
+
throw error;
|
|
5046
|
+
}
|
|
4973
5047
|
yield fx.state.update(outboxPush({
|
|
4974
5048
|
id: mutationId,
|
|
4975
5049
|
type: "update",
|
|
@@ -4999,6 +5073,99 @@ function* flushWrite(env, key) {
|
|
|
4999
5073
|
});
|
|
5000
5074
|
else yield fx.dispatch({ type: "Drain" });
|
|
5001
5075
|
}
|
|
5076
|
+
/**
|
|
5077
|
+
* The page is being hidden (tab switch, reload, close, backgrounded): flush
|
|
5078
|
+
* every mirrored pending write now rather than when its timer fires. On a
|
|
5079
|
+
* reload or close this races the teardown; whatever does not make it stays
|
|
5080
|
+
* in `_00_pending_writes` for the next boot.
|
|
5081
|
+
*/
|
|
5082
|
+
function* flushPendingWrites() {
|
|
5083
|
+
const keys = yield fx.state.read((s) => [...s.pendingWrites.values()].filter((w) => w.mirrorId !== null).map((w) => w.key));
|
|
5084
|
+
for (const key of keys) {
|
|
5085
|
+
yield fx.timer.clear(`debounce:${key}`);
|
|
5086
|
+
yield fx.dispatch({
|
|
5087
|
+
type: "FlushWrite",
|
|
5088
|
+
key
|
|
5089
|
+
});
|
|
5090
|
+
}
|
|
5091
|
+
}
|
|
5092
|
+
/**
|
|
5093
|
+
* Move abandoned `_00_pending_writes` rows into the outbox: rows no pending
|
|
5094
|
+
* write of this tab holds, `PENDING_WRITE_ORPHAN_MS` past their due flush. That
|
|
5095
|
+
* is a tab that reloaded or closed inside the delay, or a flush of ours that
|
|
5096
|
+
* failed. A row not yet overdue may still be live in another tab and is looked
|
|
5097
|
+
* at again once it would be. A follower only nudges the leader, so a single
|
|
5098
|
+
* tab adopts and a patch is never queued twice.
|
|
5099
|
+
*/
|
|
5100
|
+
function* adoptPendingWrites() {
|
|
5101
|
+
const state = yield fx.state.read((s) => s);
|
|
5102
|
+
let raw = [];
|
|
5103
|
+
try {
|
|
5104
|
+
const res = yield fx.local.query(loadPendingWriteRows());
|
|
5105
|
+
raw = Array.isArray(res?.[0]) ? res[0] : [];
|
|
5106
|
+
} catch {
|
|
5107
|
+
return;
|
|
5108
|
+
}
|
|
5109
|
+
const held = new Set([...state.pendingWrites.values()].map((w) => w.mirrorId));
|
|
5110
|
+
const rows = raw.map(parsePendingWriteRow).filter((r) => r !== null && !held.has(r.id));
|
|
5111
|
+
if (rows.length === 0) return;
|
|
5112
|
+
if (state.tabRole === "follower") {
|
|
5113
|
+
yield fx.emit({
|
|
5114
|
+
type: "tabs:broadcast",
|
|
5115
|
+
message: {
|
|
5116
|
+
type: "outbox-changed",
|
|
5117
|
+
mutationId: ""
|
|
5118
|
+
}
|
|
5119
|
+
});
|
|
5120
|
+
return;
|
|
5121
|
+
}
|
|
5122
|
+
const now = yield fx.now();
|
|
5123
|
+
let recheckAt = Infinity;
|
|
5124
|
+
let adopted = 0;
|
|
5125
|
+
for (const row of rows) {
|
|
5126
|
+
const due = row.flushBy + PENDING_WRITE_ORPHAN_MS;
|
|
5127
|
+
if (now < due) {
|
|
5128
|
+
recheckAt = Math.min(recheckAt, due);
|
|
5129
|
+
continue;
|
|
5130
|
+
}
|
|
5131
|
+
const mutationId = yield fx.id("mutation");
|
|
5132
|
+
const move = planDeferredOutboxRowTx({
|
|
5133
|
+
recordId: parseRecordIdString(row.recordId),
|
|
5134
|
+
mutationId: parseRecordIdString(mutationId),
|
|
5135
|
+
table: row.tableName,
|
|
5136
|
+
data: row.data,
|
|
5137
|
+
before: row.beforeRecord,
|
|
5138
|
+
now,
|
|
5139
|
+
mirrorId: parseRecordIdString(row.id)
|
|
5140
|
+
});
|
|
5141
|
+
try {
|
|
5142
|
+
yield fx.local.execute(move.query, move.vars);
|
|
5143
|
+
} catch (error) {
|
|
5144
|
+
yield fx.emit({
|
|
5145
|
+
type: "log",
|
|
5146
|
+
level: "warn",
|
|
5147
|
+
message: "could not move an abandoned debounced write into the outbox",
|
|
5148
|
+
data: {
|
|
5149
|
+
id: row.id,
|
|
5150
|
+
error
|
|
5151
|
+
}
|
|
5152
|
+
});
|
|
5153
|
+
continue;
|
|
5154
|
+
}
|
|
5155
|
+
yield fx.state.update(outboxPush({
|
|
5156
|
+
id: mutationId,
|
|
5157
|
+
type: "update",
|
|
5158
|
+
recordId: row.recordId,
|
|
5159
|
+
table: row.tableName,
|
|
5160
|
+
status: "pending",
|
|
5161
|
+
ackedAt: null,
|
|
5162
|
+
attempts: 0
|
|
5163
|
+
}));
|
|
5164
|
+
adopted += 1;
|
|
5165
|
+
}
|
|
5166
|
+
if (adopted > 0) yield fx.dispatch({ type: "Drain" });
|
|
5167
|
+
if (recheckAt !== Infinity) yield fx.timer.set("adopt-writes", recheckAt - now, { type: "AdoptPendingWrites" });
|
|
5168
|
+
}
|
|
5002
5169
|
|
|
5003
5170
|
//#endregion
|
|
5004
5171
|
//#region src/mutation/tray.saga.ts
|
|
@@ -5119,6 +5286,7 @@ function mutationOwnerTabId(mutationId) {
|
|
|
5119
5286
|
/**
|
|
5120
5287
|
* Boot / bucket switch: mirror the outbox table into state. Rows that can
|
|
5121
5288
|
* never be replayed (a create without its payload) go straight to the tray.
|
|
5289
|
+
* Debounced patches a dead page left in `_00_pending_writes` are adopted next.
|
|
5122
5290
|
*/
|
|
5123
5291
|
function* loadOutbox(env) {
|
|
5124
5292
|
const res = yield fx.local.query(loadPendingRows());
|
|
@@ -5144,6 +5312,7 @@ function* loadOutbox(env) {
|
|
|
5144
5312
|
});
|
|
5145
5313
|
yield* refreshFailedCount();
|
|
5146
5314
|
if (items.length > 0) yield fx.dispatch({ type: "Drain" });
|
|
5315
|
+
yield fx.dispatch({ type: "AdoptPendingWrites" });
|
|
5147
5316
|
}
|
|
5148
5317
|
function* refreshFailedCount() {
|
|
5149
5318
|
try {
|
|
@@ -6686,6 +6855,11 @@ function route(env, event) {
|
|
|
6686
6855
|
saga: flushWrite(env, event.key),
|
|
6687
6856
|
lane: serial("outbox-write")
|
|
6688
6857
|
};
|
|
6858
|
+
case "FlushPendingWrites": return { saga: flushPendingWrites() };
|
|
6859
|
+
case "AdoptPendingWrites": return {
|
|
6860
|
+
saga: adoptPendingWrites(),
|
|
6861
|
+
lane: serial("outbox-write")
|
|
6862
|
+
};
|
|
6689
6863
|
case "PollTick": return {
|
|
6690
6864
|
saga: pollTick(env),
|
|
6691
6865
|
lane: dedupe("poll")
|
|
@@ -8141,6 +8315,16 @@ const sha1 = async (str) => {
|
|
|
8141
8315
|
const hash = await crypto.subtle.digest("SHA-1", enc.encode(str));
|
|
8142
8316
|
return Array.from(new Uint8Array(hash)).map((v) => v.toString(16).padStart(2, "0")).join("");
|
|
8143
8317
|
};
|
|
8318
|
+
/**
|
|
8319
|
+
* Local-only state a schema change must not throw away: the outbox, the
|
|
8320
|
+
* failed-mutation tray and debounced patches not yet in the outbox. Cached
|
|
8321
|
+
* rows refill from the server; these exist nowhere else.
|
|
8322
|
+
*/
|
|
8323
|
+
const KEPT_TABLES = [
|
|
8324
|
+
"_00_pending_mutations",
|
|
8325
|
+
"_00_failed_mutations",
|
|
8326
|
+
"_00_pending_writes"
|
|
8327
|
+
];
|
|
8144
8328
|
var LocalMigrator = class {
|
|
8145
8329
|
logger;
|
|
8146
8330
|
constructor(localDb, logger) {
|
|
@@ -8155,8 +8339,9 @@ var LocalMigrator = class {
|
|
|
8155
8339
|
this.logger.info({ Category: "sp00ky-client::LocalMigrator::provision" }, "[Provisioning] Schema is up to date, skipping migration");
|
|
8156
8340
|
return;
|
|
8157
8341
|
}
|
|
8342
|
+
const kept = await this.readKeptRows();
|
|
8158
8343
|
await this.recreateDatabase(database);
|
|
8159
|
-
const fullSchema = schemaSurql + "\n\n DEFINE TABLE IF NOT EXISTS _00_stream_processor_state SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;\n DEFINE TABLE IF NOT EXISTS _00_view SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;\n DEFINE TABLE IF NOT EXISTS _00_window SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;\n DEFINE TABLE IF NOT EXISTS _00_failed_mutations SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;\n DEFINE TABLE IF NOT EXISTS _00_schema SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;\n DEFINE TABLE IF NOT EXISTS _00_pending_mutations SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;\n DEFINE TABLE IF NOT EXISTS _00_blob SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;\n ";
|
|
8344
|
+
const fullSchema = schemaSurql + "\n\n DEFINE TABLE IF NOT EXISTS _00_stream_processor_state SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;\n DEFINE TABLE IF NOT EXISTS _00_view SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;\n DEFINE TABLE IF NOT EXISTS _00_window SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;\n DEFINE TABLE IF NOT EXISTS _00_failed_mutations SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;\n DEFINE TABLE IF NOT EXISTS _00_schema SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;\n DEFINE TABLE IF NOT EXISTS _00_pending_mutations SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;\n DEFINE TABLE IF NOT EXISTS _00_pending_writes SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;\n DEFINE TABLE IF NOT EXISTS _00_blob SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;\n ";
|
|
8160
8345
|
const statements = this.splitStatements(fullSchema);
|
|
8161
8346
|
for (let i = 0; i < statements.length; i++) {
|
|
8162
8347
|
const statement = statements[i];
|
|
@@ -8174,8 +8359,34 @@ var LocalMigrator = class {
|
|
|
8174
8359
|
throw e;
|
|
8175
8360
|
}
|
|
8176
8361
|
}
|
|
8362
|
+
await this.restoreKeptRows(kept);
|
|
8177
8363
|
await this.createHashRecord(hash);
|
|
8178
8364
|
}
|
|
8365
|
+
async readKeptRows() {
|
|
8366
|
+
const kept = [];
|
|
8367
|
+
for (const table of KEPT_TABLES) try {
|
|
8368
|
+
const [rows] = await this.localDb.queryUngated(`SELECT * FROM ${table}`);
|
|
8369
|
+
if (Array.isArray(rows) && rows.length > 0) kept.push([table, rows]);
|
|
8370
|
+
} catch {}
|
|
8371
|
+
return kept;
|
|
8372
|
+
}
|
|
8373
|
+
async restoreKeptRows(kept) {
|
|
8374
|
+
for (const [table, rows] of kept) try {
|
|
8375
|
+
await this.localDb.queryUngated(`INSERT INTO ${table} $rows`, { rows });
|
|
8376
|
+
this.logger.info({
|
|
8377
|
+
table,
|
|
8378
|
+
count: rows.length,
|
|
8379
|
+
Category: "sp00ky-client::LocalMigrator::provision"
|
|
8380
|
+
}, "[Provisioning] Kept local rows across the schema change");
|
|
8381
|
+
} catch (error) {
|
|
8382
|
+
this.logger.error({
|
|
8383
|
+
table,
|
|
8384
|
+
count: rows.length,
|
|
8385
|
+
error,
|
|
8386
|
+
Category: "sp00ky-client::LocalMigrator::provision"
|
|
8387
|
+
}, "[Provisioning] Could not restore local rows after the schema change; they are lost");
|
|
8388
|
+
}
|
|
8389
|
+
}
|
|
8179
8390
|
async isSchemaUpToDate(hash) {
|
|
8180
8391
|
try {
|
|
8181
8392
|
const [lastSchemaRecord] = await this.localDb.queryUngated(`SELECT hash, created_at FROM ONLY _00_schema ORDER BY created_at DESC LIMIT 1;`);
|
|
@@ -8924,6 +9135,7 @@ const SYSTEM_TABLES = [
|
|
|
8924
9135
|
"_00_failed_mutations",
|
|
8925
9136
|
"_00_schema",
|
|
8926
9137
|
"_00_pending_mutations",
|
|
9138
|
+
"_00_pending_writes",
|
|
8927
9139
|
"_00_blob",
|
|
8928
9140
|
"_00_user_feature",
|
|
8929
9141
|
"_00_app_release"
|
|
@@ -14013,7 +14225,7 @@ function createTabsCoordinator(config, s, host) {
|
|
|
14013
14225
|
return new TabsCoordinator({
|
|
14014
14226
|
tabId: s.tabId,
|
|
14015
14227
|
fingerprint: computeTabsFingerprint({
|
|
14016
|
-
coreVersion: "0.0.1-canary.
|
|
14228
|
+
coreVersion: "0.0.1-canary.289",
|
|
14017
14229
|
schemaHash: hash53(config.schemaSurql),
|
|
14018
14230
|
endpoint: config.database.endpoint ?? "",
|
|
14019
14231
|
namespace: config.database.namespace,
|
|
@@ -14185,6 +14397,7 @@ function createAdapters(config, s, host, lateModules) {
|
|
|
14185
14397
|
"window.attach": () => {
|
|
14186
14398
|
if (typeof window === "undefined" || typeof window.addEventListener !== "function") return;
|
|
14187
14399
|
const onPageHide = (event) => {
|
|
14400
|
+
host.dispatch({ type: "FlushPendingWrites" });
|
|
14188
14401
|
if (event.persisted) return;
|
|
14189
14402
|
host.dispatch({ type: "PageHide" });
|
|
14190
14403
|
};
|
|
@@ -14192,13 +14405,17 @@ function createAdapters(config, s, host, lateModules) {
|
|
|
14192
14405
|
if (typeof document !== "undefined" && document.visibilityState === "hidden") return;
|
|
14193
14406
|
host.dispatch({ type: "HeartbeatNow" });
|
|
14194
14407
|
};
|
|
14408
|
+
const onVisibility = () => {
|
|
14409
|
+
if (document.visibilityState === "hidden") host.dispatch({ type: "FlushPendingWrites" });
|
|
14410
|
+
else onWake();
|
|
14411
|
+
};
|
|
14195
14412
|
window.addEventListener("pagehide", onPageHide);
|
|
14196
14413
|
window.addEventListener("online", onWake);
|
|
14197
|
-
if (typeof document !== "undefined") document.addEventListener("visibilitychange",
|
|
14414
|
+
if (typeof document !== "undefined") document.addEventListener("visibilitychange", onVisibility);
|
|
14198
14415
|
host.setDetachWindow(() => {
|
|
14199
14416
|
window.removeEventListener("pagehide", onPageHide);
|
|
14200
14417
|
window.removeEventListener("online", onWake);
|
|
14201
|
-
if (typeof document !== "undefined") document.removeEventListener("visibilitychange",
|
|
14418
|
+
if (typeof document !== "undefined") document.removeEventListener("visibilitychange", onVisibility);
|
|
14202
14419
|
});
|
|
14203
14420
|
}
|
|
14204
14421
|
};
|
package/dist/types.d.ts
CHANGED
|
@@ -2002,6 +2002,20 @@ interface DebounceOptions {
|
|
|
2002
2002
|
key?: 'recordId' | 'recordId_x_fields';
|
|
2003
2003
|
/** The debounce delay in milliseconds. */
|
|
2004
2004
|
delay?: number;
|
|
2005
|
+
/**
|
|
2006
|
+
* Keep the pending patch across a reload or a closed tab. Default `true`.
|
|
2007
|
+
*
|
|
2008
|
+
* Every call also writes the merged patch to `_00_pending_writes`, in the
|
|
2009
|
+
* same local transaction as the row itself. When the page is hidden (tab
|
|
2010
|
+
* switch, reload, close, app sent to the background) every pending patch is
|
|
2011
|
+
* moved into the outbox and pushed at once instead of waiting out `delay`.
|
|
2012
|
+
* A page that dies before that finishes leaves the patch in the store, and
|
|
2013
|
+
* the next boot moves it into the outbox.
|
|
2014
|
+
*
|
|
2015
|
+
* `false` keeps the patch in memory only, so whatever is still inside its
|
|
2016
|
+
* delay is lost on reload. Meant for throwaway state like a cursor position.
|
|
2017
|
+
*/
|
|
2018
|
+
flushOnHide?: boolean;
|
|
2005
2019
|
}
|
|
2006
2020
|
//#endregion
|
|
2007
2021
|
export { WebPushSubscribeOptions as $, RunOptions as A, LocalStore as At, SyncHealthStatus as B, TokenClaims as Bt, QueryTimeToLive as C, SignOutHook as Ct, RecordVersionArray as D, createAuthEventSystem as Dt, ReconnectConfig as E, AuthEventTypes as Et, StorageHealth as F, ImpersonationInfo as Ft, WebPushConfig as G, UpdateOptions as H, impersonationFromToken as Ht, StorageHealthStatus as I, ImpersonationTimerHost as It, WebPushErrorCode as J, WebPushDevice as K, StoreType as L, ImpersonationTimers as Lt, Sp00kyConfig as M, EventDefinition as Mt, Sp00kyQueryResult as N, EventSystem as Nt, RecordVersionDiff as O, RemoteDatabaseService as Ot, Sp00kyQueryResultPromise as P, IMPERSONATION_ACCESS as Pt, WebPushModule as Q, SyncHealth as R, ORIGIN_TOKEN_KEY as Rt, QueryStatusCallback as S, SignOutContext as St, QueryUpdateCallback as T, AuthEventTypeMap as Tt, PUSH_ENDPOINT_KEY as U, renewDelayMs as Ut, TimingPhase as V, decodeTokenClaims as Vt, PushDeviceKind as W, sameImpersonation as Wt, WebPushMessage as X, WebPushInfo as Y, WebPushMessageInput as Z, QueryConfig as _, isPushPayload as _t, Level$1 as a, WebPushUpdateOptions as at, QueryState as b, ImpersonationCandidate as bt, MutationCallback as c, BridgeTokenMessage as ct, PersistenceClient as d, PushNotificationAction as dt, WebPushSupport as et, PhaseStat as f, PushOp as ft, QueryAuthorityCallback as g, WebPushClientMessage as gt, PreloadRefresh as h, PushUrgency as ht, InlineRow as i, WebPushUnsupportedReason as it, ServerViewMeta as j, SealedQuery as jt, RegistrationTimings as k, Logger$1 as kt, MutationEvent as l, PUSH_PAYLOAD_VERSION as lt, PreloadOptions as m, PushPayloadKind as mt, DebounceOptions as n, WebPushSyncStatus as nt, MATERIALIZATION_SAMPLE_WINDOW as o, detectWebPushSupport as ot, PinoTransmit as p, PushPayload as pt, WebPushError as q, EventSubscriptionOptions as r, WebPushUnsubscribeOptions as rt, MembershipOutcome as s, BridgeSignOutMessage as st, ConnectionState as t, WebPushSyncOptions as tt, MutationEventType as u, PushNotification as ut, QueryConfigRecord as v, ActiveImpersonation as vt, QueryTimings as w, AuthEventSystem as wt, QueryStatus as x, SIGN_OUT_HOOK_TIMEOUT_MS as xt, QueryHash as y, AuthService as yt, SyncHealthConfig as z, SESSION_POLL_MS as zt };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@spooky-sync/core",
|
|
3
|
-
"version": "0.0.1-canary.
|
|
3
|
+
"version": "0.0.1-canary.289",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"sideEffects": false,
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -75,8 +75,8 @@
|
|
|
75
75
|
}
|
|
76
76
|
},
|
|
77
77
|
"dependencies": {
|
|
78
|
-
"@spooky-sync/query-builder": "0.0.1-canary.
|
|
79
|
-
"@spooky-sync/ssp-wasm": "0.0.1-canary.
|
|
78
|
+
"@spooky-sync/query-builder": "0.0.1-canary.289",
|
|
79
|
+
"@spooky-sync/ssp-wasm": "0.0.1-canary.289",
|
|
80
80
|
"@sqlite.org/sqlite-wasm": "3.53.0-build1",
|
|
81
81
|
"@surrealdb/wasm": "^3.0.3",
|
|
82
82
|
"blurhash": "^2.0.5",
|
|
@@ -120,6 +120,10 @@ await client.delete('post', 'post:abc123');
|
|
|
120
120
|
Use `debounced` option for frequent updates (e.g., live typing):
|
|
121
121
|
- `key: 'recordId'` — Debounce by record ID only (latest write wins, no merge)
|
|
122
122
|
- `key: 'recordId_x_fields'` — Debounce by record ID + changed fields (recommended)
|
|
123
|
+
- `flushOnHide` (default `true`): also keep the merged patch in the local `_00_pending_writes`
|
|
124
|
+
table and push it as soon as the page is hidden (tab switch, reload, close). A reload or closed
|
|
125
|
+
tab inside the delay no longer loses the edit: the next boot moves the leftover patch into the
|
|
126
|
+
outbox. Set `false` for throwaway values (cursor position) to keep the patch in memory only.
|
|
123
127
|
|
|
124
128
|
### Failed writes
|
|
125
129
|
|
|
@@ -71,6 +71,12 @@ interface UpdateOptions {
|
|
|
71
71
|
key?: 'recordId' | 'recordId_x_fields';
|
|
72
72
|
/** Debounce delay in ms */
|
|
73
73
|
delay?: number;
|
|
74
|
+
/**
|
|
75
|
+
* Default true. Keep the merged patch in the local `_00_pending_writes` table and
|
|
76
|
+
* push it as soon as the page is hidden (tab switch, reload, close), so a reload
|
|
77
|
+
* inside the delay does not lose it. false: memory only.
|
|
78
|
+
*/
|
|
79
|
+
flushOnHide?: boolean;
|
|
74
80
|
};
|
|
75
81
|
}
|
|
76
82
|
```
|
|
@@ -43,7 +43,7 @@ describe('boot', () => {
|
|
|
43
43
|
]);
|
|
44
44
|
expect(svc.calls[1]).toEqual(['local.connect', ['u1']]);
|
|
45
45
|
expect(out.state).toMatchObject({ bucketId: 'u1', userId: 'user:u1', saltUserId: 'user:u1', sessionId: 'salt-1', localReady: true });
|
|
46
|
-
expect(out.dispatched.map((d) => d.type)).toEqual(['WarmBlobs', 'PrimeCircuit', 'LifecycleTick', 'StartRemote']);
|
|
46
|
+
expect(out.dispatched.map((d) => d.type)).toEqual(['WarmBlobs', 'PrimeCircuit', 'AdoptPendingWrites', 'LifecycleTick', 'StartRemote']);
|
|
47
47
|
// The view index is loaded before anything can paint; the first GC waits out the boot.
|
|
48
48
|
expect(out.state.views).toEqual(new Map([['k', ['t:1']]]));
|
|
49
49
|
expect(out.timers.get('gc')).toEqual({ ms: 30_000, event: { type: 'GcTick' } });
|
|
@@ -72,7 +72,7 @@ describe('bucketSwitch', () => {
|
|
|
72
72
|
expect(String(e.def.id.table)).toBe('_00_query');
|
|
73
73
|
// PollTick is load-bearing: the switch cleared the poll timer, and only the
|
|
74
74
|
// tick re-arms it, so a sign-in without it leaves the client on LIVE alone.
|
|
75
|
-
expect(out.dispatched.map((d) => d.type)).toEqual(['PrimeCircuit', 'EnsureRegistered', 'LiveStart', 'PollTick', 'Drain']);
|
|
75
|
+
expect(out.dispatched.map((d) => d.type)).toEqual(['PrimeCircuit', 'AdoptPendingWrites', 'EnsureRegistered', 'LiveStart', 'PollTick', 'Drain']);
|
|
76
76
|
});
|
|
77
77
|
it('shared tabs: moves namespaces, falls back to solo on failure; blob clear on sign-out; failures are logged and the gate always reopens', async () => {
|
|
78
78
|
let gateReleased = false;
|
|
@@ -22,6 +22,8 @@ describe('route', () => {
|
|
|
22
22
|
[{ type: 'GcTick' }, 'dedupe:gc'],
|
|
23
23
|
[{ type: 'Drain' }, 'serial:outbox'],
|
|
24
24
|
[{ type: 'FlushWrite', key: 'k' }, 'serial:outbox-write'],
|
|
25
|
+
[{ type: 'FlushPendingWrites' }, undefined],
|
|
26
|
+
[{ type: 'AdoptPendingWrites' }, 'serial:outbox-write'],
|
|
25
27
|
[{ type: 'PollTick' }, 'dedupe:poll'],
|
|
26
28
|
[{ type: 'SelfHealTick' }, 'dedupe:heal'],
|
|
27
29
|
[{ type: 'SyncOutcome', ok: true }, 'serial:health'],
|
package/src/client/router.ts
CHANGED
|
@@ -7,7 +7,7 @@ import { fetchRows } from '../query/fetch.saga';
|
|
|
7
7
|
import { materialize, streamUpdate } from '../query/materialize.saga';
|
|
8
8
|
import { ackPrune, gcTick, lifecycleTick } from '../query/lifecycle.saga';
|
|
9
9
|
import { drain } from '../mutation/push.saga';
|
|
10
|
-
import { flushWrite } from '../mutation/write.saga';
|
|
10
|
+
import { adoptPendingWrites, flushPendingWrites, flushWrite } from '../mutation/write.saga';
|
|
11
11
|
import { pollTick } from '../sync/poll.saga';
|
|
12
12
|
import { liveChange, liveStart } from '../sync/live.saga';
|
|
13
13
|
import { connectionChanged, selfHealTick, syncOutcome } from '../sync/connection.saga';
|
|
@@ -62,6 +62,10 @@ export function route(env: SagaEnv, event: RuntimeEvent): RouteTarget {
|
|
|
62
62
|
return { saga: drain(env), lane: serial('outbox') };
|
|
63
63
|
case 'FlushWrite':
|
|
64
64
|
return { saga: flushWrite(env, event.key), lane: serial('outbox-write') };
|
|
65
|
+
case 'FlushPendingWrites':
|
|
66
|
+
return { saga: flushPendingWrites() };
|
|
67
|
+
case 'AdoptPendingWrites':
|
|
68
|
+
return { saga: adoptPendingWrites(), lane: serial('outbox-write') };
|
|
65
69
|
case 'PollTick':
|
|
66
70
|
return { saga: pollTick(env), lane: dedupe('poll') };
|
|
67
71
|
case 'SelfHealTick':
|
package/src/client/services.ts
CHANGED
|
@@ -280,7 +280,11 @@ export function createAdapters<S extends SchemaStructure>(config: Sp00kyConfig<S
|
|
|
280
280
|
'releases.init': () => undefined,
|
|
281
281
|
'window.attach': () => {
|
|
282
282
|
if (typeof window === 'undefined' || typeof window.addEventListener !== 'function') return;
|
|
283
|
+
// Hidden is the last moment a page reliably gets to run: a reload or a
|
|
284
|
+
// closed tab follows it, and a backgrounded mobile tab may be killed
|
|
285
|
+
// without any further event. Pending debounced writes go out then.
|
|
283
286
|
const onPageHide = (event: PageTransitionEvent) => {
|
|
287
|
+
host.dispatch({ type: 'FlushPendingWrites' });
|
|
284
288
|
if (event.persisted) return;
|
|
285
289
|
host.dispatch({ type: 'PageHide' });
|
|
286
290
|
};
|
|
@@ -288,13 +292,17 @@ export function createAdapters<S extends SchemaStructure>(config: Sp00kyConfig<S
|
|
|
288
292
|
if (typeof document !== 'undefined' && document.visibilityState === 'hidden') return;
|
|
289
293
|
host.dispatch({ type: 'HeartbeatNow' });
|
|
290
294
|
};
|
|
295
|
+
const onVisibility = () => {
|
|
296
|
+
if (document.visibilityState === 'hidden') host.dispatch({ type: 'FlushPendingWrites' });
|
|
297
|
+
else onWake();
|
|
298
|
+
};
|
|
291
299
|
window.addEventListener('pagehide', onPageHide);
|
|
292
300
|
window.addEventListener('online', onWake);
|
|
293
|
-
if (typeof document !== 'undefined') document.addEventListener('visibilitychange',
|
|
301
|
+
if (typeof document !== 'undefined') document.addEventListener('visibilitychange', onVisibility);
|
|
294
302
|
host.setDetachWindow(() => {
|
|
295
303
|
window.removeEventListener('pagehide', onPageHide);
|
|
296
304
|
window.removeEventListener('online', onWake);
|
|
297
|
-
if (typeof document !== 'undefined') document.removeEventListener('visibilitychange',
|
|
305
|
+
if (typeof document !== 'undefined') document.removeEventListener('visibilitychange', onVisibility);
|
|
298
306
|
});
|
|
299
307
|
},
|
|
300
308
|
};
|
package/src/kernel/constants.ts
CHANGED
|
@@ -48,6 +48,12 @@ export const AUTH_READY_RETRY_MS = 500;
|
|
|
48
48
|
export const AUTH_READY_MAX_ATTEMPTS = 10;
|
|
49
49
|
/** Heartbeat at this fraction of the shortest ttl in state. */
|
|
50
50
|
export const TTL_HEARTBEAT_FRACTION = 0.5;
|
|
51
|
+
/**
|
|
52
|
+
* How long past its due flush another tab's `_00_pending_writes` row may sit
|
|
53
|
+
* before it counts as abandoned (its tab died before the flush) and is moved
|
|
54
|
+
* into the outbox here.
|
|
55
|
+
*/
|
|
56
|
+
export const PENDING_WRITE_ORPHAN_MS = 3_000;
|
|
51
57
|
/**
|
|
52
58
|
* Orphan body garbage collection: the first sweep waits this long after boot
|
|
53
59
|
* (off the first paint and the first sync round), then repeats on the interval.
|