slopkit 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/DEPS.md +2 -1
- package/README.md +41 -0
- package/dist/client.d.ts +15 -1
- package/dist/client.js +4 -0
- package/dist/conformance/fixtures.d.ts +54 -3
- package/dist/conformance/fixtures.js +4 -4
- package/dist/conformance/legacy/replica-012.d.ts +52 -0
- package/dist/conformance/legacy/replica-012.js +403 -0
- package/dist/conformance/paging-budget.test.d.ts +1 -0
- package/dist/conformance/paging-budget.test.js +101 -0
- package/dist/conformance/paging-fixtures.d.ts +80 -0
- package/dist/conformance/paging-fixtures.js +135 -0
- package/dist/conformance/paging-network.test.d.ts +1 -0
- package/dist/conformance/paging-network.test.js +123 -0
- package/dist/conformance/paging-state.test.d.ts +1 -0
- package/dist/conformance/paging-state.test.js +180 -0
- package/dist/conformance/paging-storage.test.d.ts +1 -0
- package/dist/conformance/paging-storage.test.js +255 -0
- package/dist/conformance/paging.test.d.ts +1 -0
- package/dist/conformance/paging.test.js +209 -0
- package/dist/conformance/pull.d.ts +74 -0
- package/dist/conformance/pull.js +400 -0
- package/dist/conformance/push-batch.test.d.ts +2 -0
- package/dist/conformance/push-batch.test.js +66 -0
- package/dist/server/ledger.d.ts +13 -0
- package/dist/server/ledger.js +18 -0
- package/dist/server/pull.d.ts +85 -0
- package/dist/server/pull.js +233 -0
- package/dist/sync.d.ts +141 -14
- package/dist/sync.js +301 -41
- package/dist/web/replica.js +47 -12
- package/package.json +17 -1
package/DEPS.md
CHANGED
|
@@ -29,7 +29,8 @@ seeded network's PRNG and scheduler.
|
|
|
29
29
|
## Budgets
|
|
30
30
|
|
|
31
31
|
- **Kit client core**, gated by `pack:check` on the packed package: `schema`, `contract`, `sync`, `client`, `machine`,
|
|
32
|
-
`money`, `digest`, `states` and `web/replica`, minified and gzipped together, ≤
|
|
32
|
+
`money`, `digest`, `states` and `web/replica`, minified and gzipped together, ≤ 12,196 bytes (12,196 measured; the
|
|
33
|
+
paged first copy added 1,470).
|
|
33
34
|
`cron` is browser-safe but outside the core; an app that ships it counts it in its own budget. The budget grows only
|
|
34
35
|
with a new entry or guarantee, by its measured size.
|
|
35
36
|
- **Hello app** (preact, signals, and `sync`, `client`, `web/replica`, `schema`, `contract`, `states`): ≤ 17 KB gzip
|
package/README.md
CHANGED
|
@@ -94,6 +94,47 @@ export default defineConfig({
|
|
|
94
94
|
});
|
|
95
95
|
```
|
|
96
96
|
|
|
97
|
+
### sync in pages
|
|
98
|
+
|
|
99
|
+
a device with no copy draws its newest rows after one round trip, whatever the history's size, then pulls older pages
|
|
100
|
+
back to back until it holds everything. the server half is `slopkit/server/pull`: one history table paged newest
|
|
101
|
+
first by a keyset order, the other tables whole, receipts with the rows they name, all in one read transaction and
|
|
102
|
+
within your caps.
|
|
103
|
+
|
|
104
|
+
```ts
|
|
105
|
+
import { createPull } from "slopkit/server/pull";
|
|
106
|
+
|
|
107
|
+
const pulls = createPull({
|
|
108
|
+
db, dataset: () => store.dataset(), revision: () => store.revision(),
|
|
109
|
+
// newest first by (date, id), descending; id is unique. bump version when the order changes
|
|
110
|
+
paged: { table: "transactions", order: ["date", "id"], version: 1, live: "deleted = 0", read: toTransaction,
|
|
111
|
+
through: ([date]) => String(date) },
|
|
112
|
+
unpaged: (since) => ({ accounts: store.accounts(since) }), // whole on every reset
|
|
113
|
+
// the commands the page names (any of its pages'), or an old page's own receipts since `since`
|
|
114
|
+
receipts: { read: ({ since, commands }, viewer: Viewer) => commands === undefined
|
|
115
|
+
? ledger.receipts(viewer.principal, viewer.client, since).map((each) => ({ ...each, client: viewer.client }))
|
|
116
|
+
: ledger.receiptsOf(viewer.principal, commands),
|
|
117
|
+
names: (each) => [each.receipt.transaction] },
|
|
118
|
+
limits: { firstRows: 200, pageRows: 2000, maxRows: 5000, maxBytes: 2_000_000 },
|
|
119
|
+
// your endpoint's reply: the caps measure its json
|
|
120
|
+
respond: ({ rows, unpaged, receipts, ...rest }) => ({ ...rest, transactions: rows, ...unpaged, commands: receipts }),
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
route(pull, (request, caller) => pulls.pull(request, viewerOf(request, caller)).reply);
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
the page's pull request carries `page` (the engine fills it in; a request without it gets the old whole reply), and
|
|
127
|
+
the copy's history is its own subject: place `deriveHistoryState(model)` (loading, none, partial, complete) and
|
|
128
|
+
`deriveDurabilityState(model)` (the storage states) in two tables and compose both. partial is a claim beside the
|
|
129
|
+
numbers, "history from <through>, loading older · held of total", and totals cover only `through` and above. run the
|
|
130
|
+
pull contract against your own pull in your tests:
|
|
131
|
+
|
|
132
|
+
```ts
|
|
133
|
+
import { pullConformance } from "slopkit/conformance/pull";
|
|
134
|
+
|
|
135
|
+
pullConformance({ name: "my app", create: async (limits) => fixtureOverMyRealStore(limits) });
|
|
136
|
+
```
|
|
137
|
+
|
|
97
138
|
the shared lint rules go on top of ultracite's presets (`oxlint.config.ts`):
|
|
98
139
|
|
|
99
140
|
```ts
|
package/dist/client.d.ts
CHANGED
|
@@ -19,10 +19,14 @@ export type StoragePort<T, S, C, O> = {
|
|
|
19
19
|
commands: Pending<C, O>[];
|
|
20
20
|
unreadable: number;
|
|
21
21
|
}>;
|
|
22
|
-
/**
|
|
22
|
+
/**
|
|
23
|
+
* Commit a patch, decided against the disk inside its transaction; returns what the disk now holds, and `whole:
|
|
24
|
+
* false` when it took the patch as a delta under its own history (so it lacks rows of this page's copy).
|
|
25
|
+
*/
|
|
23
26
|
commit: (commit: Commit<T, S>) => Promise<{
|
|
24
27
|
dataset: string;
|
|
25
28
|
revision: number;
|
|
29
|
+
whole?: boolean;
|
|
26
30
|
}>;
|
|
27
31
|
/** Write one command (an intent, or its outcome) under its key. */
|
|
28
32
|
keep: (pending: Pending<C, O>) => Promise<void>;
|
|
@@ -37,6 +41,16 @@ export type NetworkPort<P, S, C, O> = {
|
|
|
37
41
|
dataset: string | null;
|
|
38
42
|
since: number;
|
|
39
43
|
verify: boolean;
|
|
44
|
+
/**
|
|
45
|
+
* The older page wanted: null for none (a paged reset is understood), the copy's `next` while partial. Absent asks
|
|
46
|
+
* for an old server's whole reply.
|
|
47
|
+
*/
|
|
48
|
+
page?: string | null;
|
|
49
|
+
/** Commands waiting on an outcome or on their effects, any page's: their receipts come with the rows they name. */
|
|
50
|
+
commands?: {
|
|
51
|
+
client: string;
|
|
52
|
+
id: number;
|
|
53
|
+
}[];
|
|
40
54
|
}) => Promise<Result<{
|
|
41
55
|
patch: Patch<P, S>;
|
|
42
56
|
receipts: Receipt<O>[];
|
package/dist/client.js
CHANGED
|
@@ -37,6 +37,10 @@ export function createClient(options) {
|
|
|
37
37
|
dataset: effect.dataset,
|
|
38
38
|
since,
|
|
39
39
|
verify: effect.verify,
|
|
40
|
+
// An old server's whole copy is asked as an old client asks it: with neither.
|
|
41
|
+
...(effect.page === undefined
|
|
42
|
+
? {}
|
|
43
|
+
: { page: effect.page, commands: effect.commands }),
|
|
40
44
|
})
|
|
41
45
|
.then((result) => result.ok
|
|
42
46
|
? { type: "pulled", since, ...result.value, now: now() }
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { NetworkPort } from "../client.ts";
|
|
2
2
|
import type { Infer, Schema } from "../schema.ts";
|
|
3
3
|
import type { Key, Pending, Replica } from "../sync.ts";
|
|
4
|
-
declare const rowSchema: Schema<{
|
|
4
|
+
export declare const rowSchema: Schema<{
|
|
5
5
|
deleted: boolean;
|
|
6
6
|
id: string;
|
|
7
7
|
revision: number;
|
|
@@ -14,7 +14,7 @@ export type Tables = {
|
|
|
14
14
|
export type PatchRows = {
|
|
15
15
|
rows: Row[];
|
|
16
16
|
};
|
|
17
|
-
declare const serverSchema: Schema<{
|
|
17
|
+
export declare const serverSchema: Schema<{
|
|
18
18
|
build: string;
|
|
19
19
|
}>;
|
|
20
20
|
export type Server = Infer<typeof serverSchema>;
|
|
@@ -40,6 +40,57 @@ export declare const outcomeSchema: Schema<({
|
|
|
40
40
|
kind: "unknown";
|
|
41
41
|
})>;
|
|
42
42
|
export type Outcome = Infer<typeof outcomeSchema>;
|
|
43
|
+
export declare const pendingSchema: Schema<{
|
|
44
|
+
at: number;
|
|
45
|
+
client: string;
|
|
46
|
+
command: {
|
|
47
|
+
name: "put";
|
|
48
|
+
value: number;
|
|
49
|
+
};
|
|
50
|
+
dataset: string | null;
|
|
51
|
+
id: number;
|
|
52
|
+
since?: number;
|
|
53
|
+
state: ({
|
|
54
|
+
kind: "saving";
|
|
55
|
+
} & {
|
|
56
|
+
kind: "saving";
|
|
57
|
+
}) | ({
|
|
58
|
+
kind: "queued";
|
|
59
|
+
} & {
|
|
60
|
+
kind: "queued";
|
|
61
|
+
}) | ({
|
|
62
|
+
kind: "sending";
|
|
63
|
+
} & {
|
|
64
|
+
kind: "sending";
|
|
65
|
+
}) | ({
|
|
66
|
+
failure: string;
|
|
67
|
+
kind: "unknown";
|
|
68
|
+
} & {
|
|
69
|
+
kind: "unknown";
|
|
70
|
+
}) | ({
|
|
71
|
+
kind: "done";
|
|
72
|
+
outcome: ({
|
|
73
|
+
kind: "ok";
|
|
74
|
+
resource: string;
|
|
75
|
+
} & {
|
|
76
|
+
kind: "ok";
|
|
77
|
+
}) | ({
|
|
78
|
+
kind: "failed";
|
|
79
|
+
reason: string;
|
|
80
|
+
} & {
|
|
81
|
+
kind: "failed";
|
|
82
|
+
}) | ({
|
|
83
|
+
kind: "unknown";
|
|
84
|
+
reason: string;
|
|
85
|
+
} & {
|
|
86
|
+
kind: "unknown";
|
|
87
|
+
});
|
|
88
|
+
revision: number;
|
|
89
|
+
} & {
|
|
90
|
+
kind: "done";
|
|
91
|
+
});
|
|
92
|
+
}>;
|
|
93
|
+
export declare const rowsLens: import("../sync.ts").Lens<Tables, PatchRows>;
|
|
43
94
|
export declare const sync: {
|
|
44
95
|
initial: (client: string, now: number) => import("../sync.ts").Model<Tables, {
|
|
45
96
|
build: string;
|
|
@@ -147,7 +198,7 @@ export declare const sync: {
|
|
|
147
198
|
build: string;
|
|
148
199
|
}>, patch: import("../sync.ts").Patch<PatchRows, {
|
|
149
200
|
build: string;
|
|
150
|
-
}
|
|
201
|
+
}>, since: number) => "gap" | {
|
|
151
202
|
replica: Replica<Tables, {
|
|
152
203
|
build: string;
|
|
153
204
|
}>;
|
|
@@ -3,20 +3,20 @@ import { IDBFactory } from "fake-indexeddb";
|
|
|
3
3
|
import { boolean, literal, nullable, number, object, optional, string, variant, } from "../schema.js";
|
|
4
4
|
import { createSync, table } from "../sync.js";
|
|
5
5
|
import { indexedReplica } from "../web/replica.js";
|
|
6
|
-
const rowSchema = object({
|
|
6
|
+
export const rowSchema = object({
|
|
7
7
|
id: string(),
|
|
8
8
|
value: number({ integer: true }),
|
|
9
9
|
deleted: boolean(),
|
|
10
10
|
revision: number({ integer: true, min: 0 }),
|
|
11
11
|
}, { strict: true });
|
|
12
|
-
const serverSchema = object({ build: string() }, { strict: true });
|
|
12
|
+
export const serverSchema = object({ build: string() }, { strict: true });
|
|
13
13
|
const commandSchema = object({ name: literal("put"), value: number({ integer: true }) }, { strict: true });
|
|
14
14
|
export const outcomeSchema = variant("kind", {
|
|
15
15
|
ok: object({ kind: literal("ok"), resource: string() }, { strict: true }),
|
|
16
16
|
failed: object({ kind: literal("failed"), reason: string() }, { strict: true }),
|
|
17
17
|
unknown: object({ kind: literal("unknown"), reason: string() }, { strict: true }),
|
|
18
18
|
});
|
|
19
|
-
const pendingSchema = object({
|
|
19
|
+
export const pendingSchema = object({
|
|
20
20
|
client: string(),
|
|
21
21
|
id: number({ integer: true, min: 1 }),
|
|
22
22
|
dataset: nullable(string()),
|
|
@@ -36,7 +36,7 @@ const pendingSchema = object({
|
|
|
36
36
|
}),
|
|
37
37
|
});
|
|
38
38
|
const empty = { rows: new Map() };
|
|
39
|
-
const rowsLens = table({
|
|
39
|
+
export const rowsLens = table({
|
|
40
40
|
name: "rows",
|
|
41
41
|
schema: rowSchema,
|
|
42
42
|
rows: (patch) => patch.rows,
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { Schema } from "../../schema.ts";
|
|
2
|
+
import type { Lens, Op, Pending } from "../../sync.ts";
|
|
3
|
+
type Replica<T, S> = {
|
|
4
|
+
dataset: string | null;
|
|
5
|
+
revision: number;
|
|
6
|
+
tables: T;
|
|
7
|
+
server: S | null;
|
|
8
|
+
};
|
|
9
|
+
type StoragePort<T, S, C, O> = {
|
|
10
|
+
load: () => Promise<{
|
|
11
|
+
replica: Replica<T, S>;
|
|
12
|
+
commands: Pending<C, O>[];
|
|
13
|
+
unreadable: number;
|
|
14
|
+
}>;
|
|
15
|
+
commit: (commit: {
|
|
16
|
+
replica: Replica<T, S>;
|
|
17
|
+
ops: Op[];
|
|
18
|
+
reset: boolean;
|
|
19
|
+
base: number;
|
|
20
|
+
replaces: string | null;
|
|
21
|
+
}) => Promise<{
|
|
22
|
+
dataset: string;
|
|
23
|
+
revision: number;
|
|
24
|
+
}>;
|
|
25
|
+
keep: (pending: Pending<C, O>) => Promise<void>;
|
|
26
|
+
drop: (keys: {
|
|
27
|
+
client: string;
|
|
28
|
+
id: number;
|
|
29
|
+
}[]) => Promise<void>;
|
|
30
|
+
};
|
|
31
|
+
export declare function indexedReplica<T, P, S, C, O>(options: {
|
|
32
|
+
factory: IDBFactory;
|
|
33
|
+
name: string;
|
|
34
|
+
/** The object stores' layout: bump when a table is added or renamed. Row shapes are checked on their own. */
|
|
35
|
+
version: number;
|
|
36
|
+
tables: readonly Lens<T, P>[];
|
|
37
|
+
empty: T;
|
|
38
|
+
server: Schema<S>;
|
|
39
|
+
pending: Schema<Pending<C, O>>;
|
|
40
|
+
interfere?: (what: "commit" | "keep" | "drop", transaction: IDBTransaction) => void;
|
|
41
|
+
/**
|
|
42
|
+
* An outbox an earlier release kept in another store: on upgrade each record is moved into the outbox, converted to
|
|
43
|
+
* a command (or kept as it was, under its own key, to be counted unreadable). Never dropped.
|
|
44
|
+
*/
|
|
45
|
+
legacy?: {
|
|
46
|
+
store: string;
|
|
47
|
+
convert: (value: unknown) => Pending<C, O> | undefined;
|
|
48
|
+
};
|
|
49
|
+
}): StoragePort<T, S, C, O> & {
|
|
50
|
+
forget: () => Promise<void>;
|
|
51
|
+
};
|
|
52
|
+
export {};
|
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
/* oxlint-disable anti-slop/no-unknown-parameters, anti-slop/no-runtime-typeof, anti-slop/no-reflect-get -- frozen released storage boundary, not a new decoder */
|
|
2
|
+
// Released 0.1.2 adapter and commit decision, kept unchanged for cross-release read/write checks.
|
|
3
|
+
/**
|
|
4
|
+
* The sync engine's storage port over IndexedDB: the replica (one store per table, out-of-line keys, and a checkpoint)
|
|
5
|
+
* and the outbox (one record per command, keyed by client and number), in one database shared by every tab.
|
|
6
|
+
*
|
|
7
|
+
* - A commit is decided inside its own read-write transaction from the checkpoint the disk holds (`commitPlan`): a
|
|
8
|
+
* stale tab can't make the disk go back, and a patch whose base the disk never reached is written as the whole
|
|
9
|
+
* replica instead, so the checkpoint never covers rows the disk doesn't have.
|
|
10
|
+
* - Every write checks the database's generation, read at load: after a forget in another tab this page's writes
|
|
11
|
+
* fail as `Replaced` instead of bringing the forgotten copy back.
|
|
12
|
+
* - The outbox is written per command, never replaced whole, so tabs can't overwrite each other's intents.
|
|
13
|
+
* - Rows are read back only under the row shapes that wrote them (each table's name and schema `shape`, kept with the
|
|
14
|
+
* checkpoint): any change to a table or its schema starts the rows over, with no number to bump by hand. The next
|
|
15
|
+
* commit then writes the whole replica, so the disk is repaired, not just the memory. Rows that don't parse are
|
|
16
|
+
* treated the same. The server's facts are not rows: facts an older page kept that no longer fit load as null and
|
|
17
|
+
* never cost the copy; facts that change at the same revision are written too.
|
|
18
|
+
* - A version upgrade starts the replica over (it comes back from the server) but keeps the outbox and the generation:
|
|
19
|
+
* an intent on disk is never erased by an upgrade. One this version can't read stays on disk and is counted.
|
|
20
|
+
* - A newer version opening in another tab closes this page's connection; its writes then fail as `Replaced`.
|
|
21
|
+
*
|
|
22
|
+
* `factory` is the browser's `indexedDB`, or any implementation of the same API (the headless simulation passes an
|
|
23
|
+
* in-memory one). `interfere` is for tests: it sees each write transaction before it commits and may abort it.
|
|
24
|
+
*/
|
|
25
|
+
import { Replaced } from "../../client.js";
|
|
26
|
+
import { check } from "../../schema.js";
|
|
27
|
+
import { committed, openDatabase, request } from "../../web/idb.js";
|
|
28
|
+
function commitPlan(stored, commit) {
|
|
29
|
+
const same = stored.dataset === commit.dataset;
|
|
30
|
+
if (!same && stored.dataset !== null && stored.dataset !== commit.replaces) {
|
|
31
|
+
return "stale";
|
|
32
|
+
}
|
|
33
|
+
if (same && stored.revision >= commit.revision) {
|
|
34
|
+
return "skip";
|
|
35
|
+
}
|
|
36
|
+
if (!commit.reset && same && stored.revision >= commit.base) {
|
|
37
|
+
return "delta";
|
|
38
|
+
}
|
|
39
|
+
return "whole";
|
|
40
|
+
}
|
|
41
|
+
export function indexedReplica(options) {
|
|
42
|
+
const names = options.tables.map((lens) => lens.name);
|
|
43
|
+
const shapes = options.tables.map((lens) => lens.shape).join("\n");
|
|
44
|
+
/** The checkpoint a load found whose rows didn't parse: the next commit at it rewrites the whole replica. */
|
|
45
|
+
let damaged;
|
|
46
|
+
/** The dataset of a copy this page loaded but couldn't read (other row shapes, rows that don't parse): its own to replace. */
|
|
47
|
+
let discarded = null;
|
|
48
|
+
/**
|
|
49
|
+
* The checkpoint this page loaded, as written: a copy under other row shapes is this page's to replace only while it
|
|
50
|
+
* is still that one. Once another release's tab writes its own, that release owns the copy on this device.
|
|
51
|
+
*/
|
|
52
|
+
let loaded;
|
|
53
|
+
let opened;
|
|
54
|
+
/** The generation this page loaded; undefined until it has. */
|
|
55
|
+
let generation;
|
|
56
|
+
/** A newer version took the database over: this page must reload. */
|
|
57
|
+
let superseded = false;
|
|
58
|
+
/**
|
|
59
|
+
* The version to open: `version`, or past what the browser has when that lacks a store these tables need (a table
|
|
60
|
+
* added without a bump) or is already newer (another release's tab moved it): the layout follows the tables, so a
|
|
61
|
+
* forgotten bump can't leave the copy unopenable.
|
|
62
|
+
*/
|
|
63
|
+
const layout = async () => {
|
|
64
|
+
const probe = await new Promise((resolve, reject) => {
|
|
65
|
+
const opening = options.factory.open(options.name);
|
|
66
|
+
opening.onsuccess = () => resolve(opening.result);
|
|
67
|
+
opening.onerror = () => reject(opening.error ?? new Error(`can't open ${options.name}`));
|
|
68
|
+
});
|
|
69
|
+
const missing = [...names, "meta", "outbox"].some((name) => !probe.objectStoreNames.contains(name));
|
|
70
|
+
const current = probe.version;
|
|
71
|
+
probe.close();
|
|
72
|
+
if (current >= options.version && !missing) {
|
|
73
|
+
return current;
|
|
74
|
+
}
|
|
75
|
+
return Math.max(current + (missing ? 1 : 0), options.version);
|
|
76
|
+
};
|
|
77
|
+
const open = () => {
|
|
78
|
+
opened ??= layout()
|
|
79
|
+
.then((version) => openDatabase(options.factory, options.name, version, (database, _from, upgrade) => {
|
|
80
|
+
// Replica tables start over (the server has them); every other store an earlier version kept may hold work
|
|
81
|
+
// nobody else has, so its records move into the outbox: converted by `legacy`, else kept as they were under
|
|
82
|
+
// their own key, counted unreadable. Nothing is dropped unseen.
|
|
83
|
+
const legacy = options.legacy;
|
|
84
|
+
const own = new Set(["meta", "outbox"]);
|
|
85
|
+
if (!database.objectStoreNames.contains("outbox")) {
|
|
86
|
+
database.createObjectStore("outbox");
|
|
87
|
+
}
|
|
88
|
+
if (database.objectStoreNames.contains("meta")) {
|
|
89
|
+
upgrade.objectStore("meta").delete("checkpoint");
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
database.createObjectStore("meta");
|
|
93
|
+
}
|
|
94
|
+
const outbox = upgrade.objectStore("outbox");
|
|
95
|
+
// A snapshot: deleting a store changes the live list being walked.
|
|
96
|
+
const existing = [...database.objectStoreNames];
|
|
97
|
+
for (const name of existing) {
|
|
98
|
+
if (own.has(name)) {
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
if (names.includes(name)) {
|
|
102
|
+
database.deleteObjectStore(name);
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
const cursor = upgrade.objectStore(name).openCursor();
|
|
106
|
+
let at = 0;
|
|
107
|
+
cursor.onsuccess = () => {
|
|
108
|
+
const current = cursor.result;
|
|
109
|
+
if (current === null) {
|
|
110
|
+
database.deleteObjectStore(name);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
const value = current.value;
|
|
114
|
+
const command = legacy?.store === name ? legacy.convert(value) : undefined;
|
|
115
|
+
if (command === undefined) {
|
|
116
|
+
outbox.put(value, ["legacy", name, at++]);
|
|
117
|
+
}
|
|
118
|
+
else {
|
|
119
|
+
outbox.put(command, [command.client, command.id]);
|
|
120
|
+
}
|
|
121
|
+
current.continue();
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
for (const name of names) {
|
|
125
|
+
database.createObjectStore(name);
|
|
126
|
+
}
|
|
127
|
+
}))
|
|
128
|
+
.then((database) => {
|
|
129
|
+
// The browser closed it under us (iOS Safari, cleared site data): the next call opens it again.
|
|
130
|
+
database.onclose = () => {
|
|
131
|
+
opened = undefined;
|
|
132
|
+
};
|
|
133
|
+
database.onversionchange = () => {
|
|
134
|
+
superseded = true;
|
|
135
|
+
database.close();
|
|
136
|
+
};
|
|
137
|
+
return database;
|
|
138
|
+
});
|
|
139
|
+
// A failed open may succeed later (a blocked upgrade): try again next time.
|
|
140
|
+
opened.catch(() => (opened = undefined));
|
|
141
|
+
return opened;
|
|
142
|
+
};
|
|
143
|
+
const db = async () => {
|
|
144
|
+
if (superseded) {
|
|
145
|
+
throw new Replaced();
|
|
146
|
+
}
|
|
147
|
+
return await open();
|
|
148
|
+
};
|
|
149
|
+
/** Inside a write transaction: refuse if the database was forgotten since this page loaded. */
|
|
150
|
+
async function guard(transaction) {
|
|
151
|
+
const stored = await request(transaction.objectStore("meta").get("generation"));
|
|
152
|
+
if ((typeof stored === "number" ? stored : 0) !== (generation ?? 0)) {
|
|
153
|
+
transaction.abort();
|
|
154
|
+
throw new Replaced();
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
/** The checkpoint on disk as this version may trust it: one of other row shapes (or none) holds nothing. */
|
|
158
|
+
const checkpointOf = (value) => {
|
|
159
|
+
if (typeof value !== "object" || value === null) {
|
|
160
|
+
return { dataset: null, revision: 0 };
|
|
161
|
+
}
|
|
162
|
+
const dataset = Reflect.get(value, "dataset");
|
|
163
|
+
const revision = Reflect.get(value, "revision");
|
|
164
|
+
if (Reflect.get(value, "shapes") !== shapes) {
|
|
165
|
+
return { dataset: null, revision: 0 };
|
|
166
|
+
}
|
|
167
|
+
const at = {
|
|
168
|
+
dataset: typeof dataset === "string" ? dataset : null,
|
|
169
|
+
revision: typeof revision === "number" ? revision : 0,
|
|
170
|
+
};
|
|
171
|
+
if (damaged !== undefined &&
|
|
172
|
+
damaged.dataset === at.dataset &&
|
|
173
|
+
damaged.revision === at.revision) {
|
|
174
|
+
return { dataset: null, revision: 0 };
|
|
175
|
+
}
|
|
176
|
+
return at;
|
|
177
|
+
};
|
|
178
|
+
return {
|
|
179
|
+
async load() {
|
|
180
|
+
const database = await db();
|
|
181
|
+
const transaction = database.transaction([...names, "meta", "outbox"], "readonly");
|
|
182
|
+
const entries = (name) => Promise.all([
|
|
183
|
+
request(transaction.objectStore(name).getAllKeys()),
|
|
184
|
+
request(transaction.objectStore(name).getAll()),
|
|
185
|
+
]).then(([keys, rows]) => keys.flatMap((key, at) => typeof key === "string" || typeof key === "number" ? [{ key, row: rows[at] }] : []));
|
|
186
|
+
const [stored, point, outbox, ...tables] = await Promise.all([
|
|
187
|
+
request(transaction.objectStore("meta").get("generation")),
|
|
188
|
+
request(transaction.objectStore("meta").get("checkpoint")),
|
|
189
|
+
request(transaction.objectStore("outbox").getAll()),
|
|
190
|
+
...names.map(entries),
|
|
191
|
+
]);
|
|
192
|
+
generation = typeof stored === "number" ? stored : 0;
|
|
193
|
+
// A tap kept before taps recorded their copy's revision was made on the copy beside it on the disk: it takes
|
|
194
|
+
// that checkpoint's revision now, before the rows can be discarded (other row shapes, rows that don't parse),
|
|
195
|
+
// so a copy started over never makes it look like it was made on nothing.
|
|
196
|
+
const disk = typeof point === "object" && point !== null ? Reflect.get(point, "revision") : 0;
|
|
197
|
+
const commands = outbox.flatMap((each) => {
|
|
198
|
+
const parsed = check(options.pending, each);
|
|
199
|
+
if (!parsed.ok) {
|
|
200
|
+
return [];
|
|
201
|
+
}
|
|
202
|
+
return [
|
|
203
|
+
parsed.value.since === undefined && typeof disk === "number" && disk > 0
|
|
204
|
+
? { ...parsed.value, since: disk }
|
|
205
|
+
: parsed.value,
|
|
206
|
+
];
|
|
207
|
+
});
|
|
208
|
+
const unreadable = outbox.length - commands.length;
|
|
209
|
+
const empty = {
|
|
210
|
+
dataset: null,
|
|
211
|
+
revision: 0,
|
|
212
|
+
tables: options.empty,
|
|
213
|
+
server: null,
|
|
214
|
+
};
|
|
215
|
+
const at = checkpointOf(point);
|
|
216
|
+
const raw = typeof point === "object" && point !== null ? Reflect.get(point, "dataset") : null;
|
|
217
|
+
discarded = typeof raw === "string" && raw !== at.dataset ? raw : null;
|
|
218
|
+
loaded =
|
|
219
|
+
typeof point === "object" && point !== null
|
|
220
|
+
? {
|
|
221
|
+
dataset: raw,
|
|
222
|
+
revision: disk,
|
|
223
|
+
shapes: Reflect.get(point, "shapes"),
|
|
224
|
+
}
|
|
225
|
+
: undefined;
|
|
226
|
+
const server = typeof point === "object" && point !== null
|
|
227
|
+
? check(options.server, Reflect.get(point, "server"))
|
|
228
|
+
: undefined;
|
|
229
|
+
let restored = options.empty;
|
|
230
|
+
for (const [index, lens] of options.tables.entries()) {
|
|
231
|
+
restored = restored === undefined ? undefined : lens.restore(restored, tables[index] ?? []);
|
|
232
|
+
}
|
|
233
|
+
// Rows written under other shapes, or that no longer fit: start over rather than show wrong data, and let the
|
|
234
|
+
// next commit rewrite the disk. The server's facts are not rows (every pull brings them again), so facts an
|
|
235
|
+
// older page kept that no longer fit wait for the next pull instead of costing the copy.
|
|
236
|
+
if (restored === undefined && at.dataset !== null) {
|
|
237
|
+
damaged = at;
|
|
238
|
+
discarded = at.dataset;
|
|
239
|
+
}
|
|
240
|
+
if (restored === undefined || at.dataset === null) {
|
|
241
|
+
return { replica: empty, commands, unreadable };
|
|
242
|
+
}
|
|
243
|
+
return {
|
|
244
|
+
replica: {
|
|
245
|
+
dataset: at.dataset,
|
|
246
|
+
revision: at.revision,
|
|
247
|
+
tables: restored,
|
|
248
|
+
server: server?.ok === true ? server.value : null,
|
|
249
|
+
},
|
|
250
|
+
commands,
|
|
251
|
+
unreadable,
|
|
252
|
+
};
|
|
253
|
+
},
|
|
254
|
+
async commit(commit) {
|
|
255
|
+
const database = await db();
|
|
256
|
+
const transaction = database.transaction([...names, "meta"], "readwrite");
|
|
257
|
+
const done = committed(transaction);
|
|
258
|
+
const meta = transaction.objectStore("meta");
|
|
259
|
+
const target = {
|
|
260
|
+
dataset: commit.replica.dataset,
|
|
261
|
+
revision: commit.replica.revision,
|
|
262
|
+
server: commit.replica.server,
|
|
263
|
+
shapes,
|
|
264
|
+
};
|
|
265
|
+
try {
|
|
266
|
+
await guard(transaction);
|
|
267
|
+
const point = await request(meta.get("checkpoint"));
|
|
268
|
+
// The disk holds another dataset this page never saw (the server replaced the one it is on): refused,
|
|
269
|
+
// whatever row shapes wrote it, so a tab of another release can't write a replaced dataset back.
|
|
270
|
+
const held = typeof point === "object" && point !== null ? Reflect.get(point, "dataset") : null;
|
|
271
|
+
if (typeof held === "string" &&
|
|
272
|
+
held !== commit.replica.dataset &&
|
|
273
|
+
held !== commit.replaces &&
|
|
274
|
+
held !== discarded) {
|
|
275
|
+
transaction.abort();
|
|
276
|
+
throw new Replaced();
|
|
277
|
+
}
|
|
278
|
+
const stored = checkpointOf(point);
|
|
279
|
+
const plan = commitPlan(stored, {
|
|
280
|
+
...target,
|
|
281
|
+
reset: commit.reset,
|
|
282
|
+
base: commit.base,
|
|
283
|
+
replaces: commit.replaces,
|
|
284
|
+
});
|
|
285
|
+
// The disk holds a dataset this page never saw: the server replaced the one this page is on.
|
|
286
|
+
if (plan === "stale") {
|
|
287
|
+
transaction.abort();
|
|
288
|
+
throw new Replaced();
|
|
289
|
+
}
|
|
290
|
+
const revision = typeof point === "object" && point !== null ? Reflect.get(point, "revision") : 0;
|
|
291
|
+
const written = typeof point === "object" && point !== null ? Reflect.get(point, "shapes") : undefined;
|
|
292
|
+
// Another release's tab wrote this dataset under its own row shapes since this page loaded: that release owns
|
|
293
|
+
// the copy now, at any revision, and this page stops writing until it reloads.
|
|
294
|
+
if (held === commit.replica.dataset &&
|
|
295
|
+
written !== shapes &&
|
|
296
|
+
(loaded === undefined ||
|
|
297
|
+
loaded.dataset !== held ||
|
|
298
|
+
loaded.revision !== revision ||
|
|
299
|
+
loaded.shapes !== written)) {
|
|
300
|
+
transaction.abort();
|
|
301
|
+
throw new Replaced();
|
|
302
|
+
}
|
|
303
|
+
// The copy this page loaded and couldn't read (other row shapes, rows that don't parse) still fences it: never
|
|
304
|
+
// rewritten below its revision. The page catches up from the server and writes it then.
|
|
305
|
+
if (plan === "whole" &&
|
|
306
|
+
held === commit.replica.dataset &&
|
|
307
|
+
typeof revision === "number" &&
|
|
308
|
+
revision > commit.replica.revision) {
|
|
309
|
+
transaction.abort();
|
|
310
|
+
throw new Error("this device holds a newer copy than this page");
|
|
311
|
+
}
|
|
312
|
+
const write = (ops) => {
|
|
313
|
+
for (const op of ops) {
|
|
314
|
+
if ("drop" in op) {
|
|
315
|
+
transaction.objectStore(op.table).delete(op.key);
|
|
316
|
+
}
|
|
317
|
+
else {
|
|
318
|
+
transaction.objectStore(op.table).put(op.row, op.key);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
};
|
|
322
|
+
if (plan === "delta") {
|
|
323
|
+
write(commit.ops);
|
|
324
|
+
}
|
|
325
|
+
if (plan === "whole") {
|
|
326
|
+
for (const name of names) {
|
|
327
|
+
transaction.objectStore(name).clear();
|
|
328
|
+
}
|
|
329
|
+
for (const lens of options.tables) {
|
|
330
|
+
write(lens.snapshot(commit.replica.tables));
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
// At the same revision the rows are already there, but the server's facts may have moved: they are kept too.
|
|
334
|
+
const facts = plan === "skip" && stored.revision === target.revision;
|
|
335
|
+
if (plan !== "skip" || facts) {
|
|
336
|
+
meta.put(target, "checkpoint");
|
|
337
|
+
}
|
|
338
|
+
options.interfere?.("commit", transaction);
|
|
339
|
+
await done;
|
|
340
|
+
if (plan === "whole") {
|
|
341
|
+
damaged = undefined;
|
|
342
|
+
discarded = null;
|
|
343
|
+
loaded = undefined;
|
|
344
|
+
}
|
|
345
|
+
const now = plan === "skip" ? stored : target;
|
|
346
|
+
if (now.dataset === null) {
|
|
347
|
+
throw new Error("the replica has no dataset");
|
|
348
|
+
}
|
|
349
|
+
return { dataset: now.dataset, revision: now.revision };
|
|
350
|
+
}
|
|
351
|
+
catch (error) {
|
|
352
|
+
await done.catch(() => undefined);
|
|
353
|
+
throw error;
|
|
354
|
+
}
|
|
355
|
+
},
|
|
356
|
+
async keep(pending) {
|
|
357
|
+
const database = await db();
|
|
358
|
+
const transaction = database.transaction(["meta", "outbox"], "readwrite");
|
|
359
|
+
const done = committed(transaction);
|
|
360
|
+
try {
|
|
361
|
+
await guard(transaction);
|
|
362
|
+
transaction.objectStore("outbox").put(pending, [pending.client, pending.id]);
|
|
363
|
+
options.interfere?.("keep", transaction);
|
|
364
|
+
await done;
|
|
365
|
+
}
|
|
366
|
+
catch (error) {
|
|
367
|
+
await done.catch(() => undefined);
|
|
368
|
+
throw error;
|
|
369
|
+
}
|
|
370
|
+
},
|
|
371
|
+
async drop(keys) {
|
|
372
|
+
const database = await db();
|
|
373
|
+
const transaction = database.transaction(["meta", "outbox"], "readwrite");
|
|
374
|
+
const done = committed(transaction);
|
|
375
|
+
try {
|
|
376
|
+
await guard(transaction);
|
|
377
|
+
for (const key of keys) {
|
|
378
|
+
transaction.objectStore("outbox").delete([key.client, key.id]);
|
|
379
|
+
}
|
|
380
|
+
options.interfere?.("drop", transaction);
|
|
381
|
+
await done;
|
|
382
|
+
}
|
|
383
|
+
catch (error) {
|
|
384
|
+
await done.catch(() => undefined);
|
|
385
|
+
throw error;
|
|
386
|
+
}
|
|
387
|
+
},
|
|
388
|
+
/** Drop this device's copy and its outbox, and move to a new generation so no open tab writes it back. */
|
|
389
|
+
async forget() {
|
|
390
|
+
const database = await db();
|
|
391
|
+
const transaction = database.transaction([...names, "meta", "outbox"], "readwrite");
|
|
392
|
+
const done = committed(transaction);
|
|
393
|
+
const meta = transaction.objectStore("meta");
|
|
394
|
+
const stored = await request(meta.get("generation"));
|
|
395
|
+
for (const name of [...names, "outbox"]) {
|
|
396
|
+
transaction.objectStore(name).clear();
|
|
397
|
+
}
|
|
398
|
+
meta.delete("checkpoint");
|
|
399
|
+
meta.put((typeof stored === "number" ? stored : 0) + 1, "generation");
|
|
400
|
+
await done;
|
|
401
|
+
},
|
|
402
|
+
};
|
|
403
|
+
}
|