slopkit 0.1.2 → 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/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 +137 -14
- package/dist/sync.js +293 -41
- package/dist/web/replica.js +47 -12
- package/package.json +17 -1
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The server half of pull, over SQLite: the app's history table read newest first in pages by a keyset order, its
|
|
3
|
+
* other tables whole, and the receipts of the caller's commands with the rows they name, assembled in one read
|
|
4
|
+
* transaction at one revision and bounded in rows and encoded bytes. `sync` is the client half.
|
|
5
|
+
*
|
|
6
|
+
* Invariants it owns:
|
|
7
|
+
* - **One revision.** The delta, the page, the counts, the coverage boundary and the receipts' rows are read in one
|
|
8
|
+
* transaction (`pull` is synchronous and opens its own unless the caller has one open), so every row in a reply is
|
|
9
|
+
* the store's row at the reply's revision.
|
|
10
|
+
* - **Keyset cursors.** A cursor is the page's last row by its order values and unique key, with the dataset and the
|
|
11
|
+
* order's `version`: never an offset, and never looked up from that row again, so an anchor edited or tombstoned
|
|
12
|
+
* since skips nothing. A cursor of another dataset or order version, or one that doesn't parse, gets a paged reset.
|
|
13
|
+
* - **Truthful coverage.** `held` counts the live rows at or above the cursor and `total` every live row. `through` is
|
|
14
|
+
* the lowest order group held whole: a page ending inside a tie leaves that group out until it lands whole, and it
|
|
15
|
+
* is null while no group is whole.
|
|
16
|
+
* - **Every paging-aware reply is bounded** by `limits`: the history's rows, the other tables' and the receipts' rows
|
|
17
|
+
* together within `maxRows`, the reply as `respond` builds it within `maxBytes` encoded. A delta (with its receipts'
|
|
18
|
+
* rows and the other tables) that can't fit by itself is answered as a paged reset; otherwise the older page shrinks
|
|
19
|
+
* to the rows and bytes it leaves, and with no room at all the cursor stays where it was. A reset carries one row of history at least, then as many receipts as fit, then more rows; one
|
|
20
|
+
* that can't fit even so (the other tables alone are over the caps) is never sent: `pull` throws, a server fault
|
|
21
|
+
* the page sees as unavailable and retries.
|
|
22
|
+
* - **Receipts carry their rows.** A row a receipt names is in the reply that carries the receipt, even below the page,
|
|
23
|
+
* so a command settles only on a copy that holds its effects. A paging client names the commands it waits on (any of
|
|
24
|
+
* its pages', at most a push's worth); a receipt left out for room is asked for again by its next pull.
|
|
25
|
+
* - **Old clients keep the old protocol.** A request without `page` gets the whole reply as before: everything on a
|
|
26
|
+
* reset, the changes otherwise, without page metadata and unbounded.
|
|
27
|
+
*
|
|
28
|
+
* The history table has a `revision` column (the revision that last wrote the row), keeps tombstones (`live` tells
|
|
29
|
+
* them apart), and has an index on its order columns, which are never null. The order is descending in every column;
|
|
30
|
+
* its last column is the unique key that receipts name.
|
|
31
|
+
*
|
|
32
|
+
* A fingerprint for `verify` (asked only by a complete copy) is the app's: read what it covers in the same transaction,
|
|
33
|
+
* by opening one around `pull`, and keep its few bytes in mind under `maxBytes`, which measures `respond`'s reply.
|
|
34
|
+
*/
|
|
35
|
+
import { Buffer } from "node:buffer";
|
|
36
|
+
const marks = (columns) => columns.map(() => "?").join(", ");
|
|
37
|
+
const sizeOf = (tables) => Object.values(tables).reduce((sum, rows) => sum + rows.length, 0);
|
|
38
|
+
const tally = (row) => Number(row?.n ?? 0);
|
|
39
|
+
/** The largest whole number from `low` to `high` that `fits`, or undefined (`fits` holds for every one below it). */
|
|
40
|
+
function largest(low, high, fits) {
|
|
41
|
+
let found;
|
|
42
|
+
let from = low;
|
|
43
|
+
let to = high;
|
|
44
|
+
while (from <= to) {
|
|
45
|
+
const at = Math.floor((from + to) / 2);
|
|
46
|
+
if (fits(at)) {
|
|
47
|
+
found = at;
|
|
48
|
+
from = at + 1;
|
|
49
|
+
}
|
|
50
|
+
else {
|
|
51
|
+
to = at - 1;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return found;
|
|
55
|
+
}
|
|
56
|
+
/** A row's values in these order columns, which are never null: what a cursor holds. */
|
|
57
|
+
const values = (row, columns) => columns.map((column) => {
|
|
58
|
+
const value = row[column];
|
|
59
|
+
if (typeof value !== "string" && typeof value !== "number") {
|
|
60
|
+
throw new TypeError(`pull: order column ${column} must be text or a number`);
|
|
61
|
+
}
|
|
62
|
+
return value;
|
|
63
|
+
});
|
|
64
|
+
export function createPull(options) {
|
|
65
|
+
const { db, limits, paged } = options;
|
|
66
|
+
for (const [name, value] of Object.entries(limits)) {
|
|
67
|
+
if (!Number.isSafeInteger(value) || value < 1) {
|
|
68
|
+
throw new Error(`pull: ${name} must be a positive integer, got ${value}`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
if (limits.firstRows > limits.maxRows || limits.pageRows > limits.maxRows) {
|
|
72
|
+
throw new Error("pull: a page must fit in maxRows");
|
|
73
|
+
}
|
|
74
|
+
const order = paged.order;
|
|
75
|
+
const unique = order.at(-1);
|
|
76
|
+
if (unique === undefined) {
|
|
77
|
+
throw new Error("pull: the paged order needs at least its key column");
|
|
78
|
+
}
|
|
79
|
+
const key = unique;
|
|
80
|
+
const group = order.length > 1 ? order.slice(0, -1) : order;
|
|
81
|
+
const live = `from ${paged.table} where ${paged.live}`;
|
|
82
|
+
const newest = `order by ${order.map((column) => `${column} desc`).join(", ")} limit ?`;
|
|
83
|
+
const sql = {
|
|
84
|
+
changes: db.prepare(`select * from ${paged.table} where revision > ?`),
|
|
85
|
+
live: db.prepare(`select * ${live}`),
|
|
86
|
+
top: db.prepare(`select * ${live} ${newest}`),
|
|
87
|
+
older: db.prepare(`select * ${live} and (${order.join(", ")}) < (${marks(order)}) ${newest}`),
|
|
88
|
+
total: db.prepare(`select count(*) as n ${live}`),
|
|
89
|
+
held: db.prepare(`select count(*) as n ${live} and (${order.join(", ")}) >= (${marks(order)})`),
|
|
90
|
+
split: db.prepare(`select 1 as n ${live} and (${group.join(", ")}) = (${marks(group)}) and ${key} < ? limit 1`),
|
|
91
|
+
above: db.prepare(`select ${group.join(", ")} ${live} and (${group.join(", ")}) > (${marks(group)}) order by ${group.map((column) => `${column} asc`).join(", ")} limit 1`),
|
|
92
|
+
named: db.prepare(`select * from ${paged.table} where ${key} = ?`),
|
|
93
|
+
};
|
|
94
|
+
const encode = (dataset, row) => JSON.stringify([dataset, paged.version, ...values(row, order)]);
|
|
95
|
+
/** A cursor's order values, or undefined for one this history can't honour. */
|
|
96
|
+
function decode(text, dataset) {
|
|
97
|
+
let parsed;
|
|
98
|
+
try {
|
|
99
|
+
parsed = JSON.parse(text);
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
return undefined;
|
|
103
|
+
}
|
|
104
|
+
if (!Array.isArray(parsed) ||
|
|
105
|
+
parsed.length !== order.length + 2 ||
|
|
106
|
+
parsed[0] !== dataset ||
|
|
107
|
+
parsed[1] !== paged.version) {
|
|
108
|
+
return undefined;
|
|
109
|
+
}
|
|
110
|
+
const at = parsed.slice(2);
|
|
111
|
+
return at.every((value) => typeof value === "string" || (typeof value === "number" && Number.isFinite(value)))
|
|
112
|
+
? at.filter((value) => typeof value === "string" || typeof value === "number")
|
|
113
|
+
: undefined;
|
|
114
|
+
}
|
|
115
|
+
/** Where a page ending at `last` leaves the copy, at this revision. */
|
|
116
|
+
function place(dataset, from, last, more) {
|
|
117
|
+
const total = tally(sql.total.get());
|
|
118
|
+
if (last === undefined || !more) {
|
|
119
|
+
return { from, next: null, through: null, held: total, total };
|
|
120
|
+
}
|
|
121
|
+
const at = values(last, group);
|
|
122
|
+
const split = sql.split.get(...at, ...values(last, [key])) !== undefined;
|
|
123
|
+
const above = split ? sql.above.get(...at) : undefined;
|
|
124
|
+
return {
|
|
125
|
+
from,
|
|
126
|
+
next: encode(dataset, last),
|
|
127
|
+
through: split
|
|
128
|
+
? above === undefined
|
|
129
|
+
? null
|
|
130
|
+
: paged.through(Object.values(above))
|
|
131
|
+
: paged.through(at),
|
|
132
|
+
held: tally(sql.held.get(...values(last, order))),
|
|
133
|
+
total,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
function assemble(request, receipts) {
|
|
137
|
+
const dataset = options.dataset();
|
|
138
|
+
const revision = options.revision();
|
|
139
|
+
const { since } = request;
|
|
140
|
+
const cursor = typeof request.page === "string" ? decode(request.page, dataset) : null;
|
|
141
|
+
const reset = request.dataset !== dataset ||
|
|
142
|
+
since <= 0 ||
|
|
143
|
+
since > revision ||
|
|
144
|
+
options.known?.(since) === false ||
|
|
145
|
+
cursor === undefined;
|
|
146
|
+
const answer = (pulled) => {
|
|
147
|
+
const reply = options.respond(pulled);
|
|
148
|
+
return { reply, bytes: Buffer.byteLength(JSON.stringify(reply)), pulled };
|
|
149
|
+
};
|
|
150
|
+
const base = { dataset, revision };
|
|
151
|
+
// A client from before paging: the whole reply, as it always was.
|
|
152
|
+
if (request.page === undefined) {
|
|
153
|
+
const after = reset ? 0 : since;
|
|
154
|
+
return answer({
|
|
155
|
+
...base,
|
|
156
|
+
reset,
|
|
157
|
+
rows: (after === 0 ? sql.live.all() : sql.changes.all(after)).map(paged.read),
|
|
158
|
+
unpaged: options.unpaged(after),
|
|
159
|
+
receipts: [...receipts(after)],
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* A reply: `size` rows of `page` (fetched with one row more, to know if older ones are left), the first `count` of
|
|
164
|
+
* `kept` receipts with the rows they name, and `extra` history rows beside them; once each, as the store holds
|
|
165
|
+
* them now. Says whether it fits the caps, and how many rows it carries.
|
|
166
|
+
*/
|
|
167
|
+
const reply = (fixed, page, kept, extra) => {
|
|
168
|
+
const named = kept.map((each) => options.receipts.names(each).flatMap((name) => sql.named.all(name)));
|
|
169
|
+
return (size, count = kept.length) => {
|
|
170
|
+
const rows = new Map();
|
|
171
|
+
for (const row of [...page.slice(0, size), ...extra, ...named.slice(0, count).flat()]) {
|
|
172
|
+
rows.set(row[key], rows.get(row[key]) ?? row);
|
|
173
|
+
}
|
|
174
|
+
const made = answer({
|
|
175
|
+
...base,
|
|
176
|
+
...fixed,
|
|
177
|
+
rows: [...rows.values()].map(paged.read),
|
|
178
|
+
receipts: kept.slice(0, count),
|
|
179
|
+
page: place(dataset, fixed.from, page[size - 1] ?? fixed.at, page.length > size),
|
|
180
|
+
});
|
|
181
|
+
const carried = rows.size + sizeOf(fixed.unpaged);
|
|
182
|
+
return { made, carried, fits: carried <= limits.maxRows && made.bytes <= limits.maxBytes };
|
|
183
|
+
};
|
|
184
|
+
};
|
|
185
|
+
if (!reset) {
|
|
186
|
+
const unpaged = options.unpaged(since);
|
|
187
|
+
const room = cursor === null ? 0 : limits.pageRows;
|
|
188
|
+
const page = cursor === null ? [] : sql.older.all(...cursor, room + 1);
|
|
189
|
+
// The cursor as the row it stands for: a reply with no older row leaves the copy there.
|
|
190
|
+
const at = cursor === null
|
|
191
|
+
? {}
|
|
192
|
+
: {
|
|
193
|
+
at: Object.fromEntries(order.map((column, index) => [column, cursor[index] ?? null])),
|
|
194
|
+
};
|
|
195
|
+
const build = reply({ reset: false, from: request.page, unpaged, ...at }, page, receipts(since), sql.changes.all(since));
|
|
196
|
+
const alone = build(0);
|
|
197
|
+
// Only a delta (with its receipts' rows and the other tables) that can't fit by itself starts the copy over; the
|
|
198
|
+
// older page takes the rows and bytes it leaves.
|
|
199
|
+
if (alone.fits) {
|
|
200
|
+
const most = Math.min(room, page.length, limits.maxRows - alone.carried);
|
|
201
|
+
return build(largest(1, most, (size) => build(size).fits) ?? 0).made;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
// Everything, from the newest page: one row of it at least, then as many receipts (with their rows) as fit, then
|
|
205
|
+
// as many more of the first rows. A receipt left out is asked for again by the next pull.
|
|
206
|
+
const kept = receipts(0);
|
|
207
|
+
const page = sql.top.all(limits.firstRows + 1);
|
|
208
|
+
const least = Math.min(1, page.length);
|
|
209
|
+
const build = reply({ reset: true, from: null, unpaged: options.unpaged(0) }, page, kept, []);
|
|
210
|
+
const count = largest(0, kept.length, (at) => build(least, at).fits);
|
|
211
|
+
if (count === undefined) {
|
|
212
|
+
throw new Error(`pull: the reply can't fit its caps (${limits.maxRows} rows, ${limits.maxBytes} bytes), even with one row of history`);
|
|
213
|
+
}
|
|
214
|
+
const most = Math.min(limits.firstRows, page.length);
|
|
215
|
+
return build(largest(least, most, (at) => build(at, count).fits) ?? least, count).made;
|
|
216
|
+
}
|
|
217
|
+
/** The reply to one request, its encoded size, and what it carries before `respond` shaped it. */
|
|
218
|
+
function pull(request, caller) {
|
|
219
|
+
const own = !db.isTransaction;
|
|
220
|
+
if (own) {
|
|
221
|
+
db.exec("begin");
|
|
222
|
+
}
|
|
223
|
+
try {
|
|
224
|
+
return assemble(request, (since) => options.receipts.read(request.commands === undefined ? { since } : { since, commands: request.commands }, caller));
|
|
225
|
+
}
|
|
226
|
+
finally {
|
|
227
|
+
if (own) {
|
|
228
|
+
db.exec("commit");
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
return { pull };
|
|
233
|
+
}
|
package/dist/sync.d.ts
CHANGED
|
@@ -5,11 +5,22 @@
|
|
|
5
5
|
* headless simulation over real stores and in the browser alike.
|
|
6
6
|
*
|
|
7
7
|
* Invariants it owns:
|
|
8
|
-
* - **The replica only moves forward.** A
|
|
9
|
-
*
|
|
10
|
-
*
|
|
8
|
+
* - **The replica only moves forward.** A delta applies only to the same dataset with `since ≤ copy.revision ≤
|
|
9
|
+
* reply.revision`; only a reset replaces the copy (a late one is dropped), and a reply it can't follow asks for a
|
|
10
|
+
* real reset instead of leaving a gap. The disk commit is decided again against what the disk holds, inside its own
|
|
11
|
+
* transaction (`commitPlan`), so another tab or a failed write can't make the disk go back or skip a patch.
|
|
12
|
+
* - **A copy never claims more history than it holds.** It is complete or partial (`History`): every row it holds is
|
|
13
|
+
* the server's row at its revision, and a partial copy holds every row at or above its cursor, so every row at or
|
|
14
|
+
* above `through`. The first reply draws the newest rows at once; the cursor moves only when a reply answers the
|
|
15
|
+
* copy's own `next`, a reply without a page never changes it, and a row merged below `through` never deepens it.
|
|
16
|
+
* The disk takes a page's history only from the cursor it moved from, in the transaction that writes its rows.
|
|
17
|
+
* - **A partial copy owes its next page**, back to back, until it is complete; a reply that doesn't move it on counts
|
|
18
|
+
* as a failed pull, for backoff. An old server's reply to a partial copy asks for its whole copy instead.
|
|
19
|
+
* - **Only a complete copy is checked.** No fingerprint is asked for or compared while the copy is partial.
|
|
20
|
+
* - **A chunk costs its own size.** Merging a reply doesn't copy the tables it lands in (`Layered`).
|
|
11
21
|
* - **Observed is not saved.** `saved` is what the disk is known to hold; a failed commit is retried whole until the
|
|
12
|
-
* disk catches up, and until then the page
|
|
22
|
+
* disk catches up (a later page the disk takes as a delta under its own history doesn't), and until then the page
|
|
23
|
+
* says so.
|
|
13
24
|
* - **A pull is owed while the server is ahead.** `target` is the newest revision the server is known to have (pokes,
|
|
14
25
|
* which also beat every 20 s, push replies, receipts); while the replica is behind it a pull is in flight or
|
|
15
26
|
* scheduled, whatever the socket says. Liveness, the known head and the last reconciliation are separate facts.
|
|
@@ -17,7 +28,8 @@
|
|
|
17
28
|
* earlier page keep theirs.
|
|
18
29
|
* - **Nothing is sent before it is kept.** An intent the device can't keep fails at once, visibly, and is never sent.
|
|
19
30
|
* - **Every command reaches an outcome, and is settled only once the replica holds its effects** (a receipt names the
|
|
20
|
-
* revision that made it visible
|
|
31
|
+
* revision that made it visible; on a partial copy, only a pull reply that carried the receipt, with the rows it
|
|
32
|
+
* names, settles it); until then a pull is owed, and names the commands it waits on, any page's.
|
|
21
33
|
* - **Every waiting state ends.** Pulling, pushing, retrying and saving have deadlines or backoff; storage that is slow
|
|
22
34
|
* to load or keep is said (the page goes on from the server meanwhile), and its late answer still counts: a slow
|
|
23
35
|
* write is not a failed one.
|
|
@@ -65,12 +77,39 @@ export declare function table<T, P, R>(def: {
|
|
|
65
77
|
get: (tables: T) => ReadonlyMap<Key, R>;
|
|
66
78
|
set: (tables: T, rows: ReadonlyMap<Key, R>) => T;
|
|
67
79
|
}): Lens<T, P>;
|
|
80
|
+
/**
|
|
81
|
+
* How much of the server's history a copy holds. Complete: every row. Partial: every row at or above the keyset cursor
|
|
82
|
+
* `next` (the server's opaque cursor of the next older page), so every row at or above `through` (the fully covered
|
|
83
|
+
* boundary, as the app formats it; null while no order group is whole); `held` of `total` is progress, not a claim.
|
|
84
|
+
*/
|
|
85
|
+
export type History = {
|
|
86
|
+
kind: "complete";
|
|
87
|
+
} | {
|
|
88
|
+
kind: "partial";
|
|
89
|
+
next: string;
|
|
90
|
+
through: string | null;
|
|
91
|
+
held: number;
|
|
92
|
+
total: number;
|
|
93
|
+
};
|
|
94
|
+
/**
|
|
95
|
+
* A paged reply's place in the history: `from` is the cursor it answered (null on a reset's first page), `next` the
|
|
96
|
+
* next older one (null once complete), `through`, `held` and `total` as in `History`, at the reply's revision.
|
|
97
|
+
*/
|
|
98
|
+
export type Page = {
|
|
99
|
+
from: string | null;
|
|
100
|
+
next: string | null;
|
|
101
|
+
through: string | null;
|
|
102
|
+
held: number;
|
|
103
|
+
total: number;
|
|
104
|
+
};
|
|
68
105
|
export type Replica<T, S> = {
|
|
69
106
|
dataset: string | null;
|
|
70
107
|
revision: number;
|
|
71
108
|
tables: T;
|
|
72
109
|
/** What the server says about itself, read with every pull. */
|
|
73
110
|
server: S | null;
|
|
111
|
+
/** How much of the history it holds; absent (a copy from before paging) is complete. */
|
|
112
|
+
history?: History;
|
|
74
113
|
};
|
|
75
114
|
/** A pull reply as sync sees it: rows changed since the pull's `since`, or everything (`reset`). */
|
|
76
115
|
export type Patch<P, S> = {
|
|
@@ -80,12 +119,18 @@ export type Patch<P, S> = {
|
|
|
80
119
|
rows: P;
|
|
81
120
|
server: S;
|
|
82
121
|
checksum?: string;
|
|
122
|
+
/** A paging server's place in the history: absent from an old server, whose reset is the whole copy. */
|
|
123
|
+
page?: Page;
|
|
83
124
|
};
|
|
84
|
-
/**
|
|
125
|
+
/**
|
|
126
|
+
* A command's outcome as the server recorded it, and the revision at which its effects are visible. `client` names an
|
|
127
|
+
* earlier page's command (absent: the pull's or push's own client).
|
|
128
|
+
*/
|
|
85
129
|
export type Receipt<O> = {
|
|
86
130
|
id: number;
|
|
87
131
|
outcome: O;
|
|
88
132
|
revision: number;
|
|
133
|
+
client?: string;
|
|
89
134
|
};
|
|
90
135
|
export type CommandState<O> = {
|
|
91
136
|
kind: "saving";
|
|
@@ -160,8 +205,18 @@ export type Model<T, S, C, O> = {
|
|
|
160
205
|
writing: number;
|
|
161
206
|
failures: number;
|
|
162
207
|
nextAt: number;
|
|
208
|
+
/**
|
|
209
|
+
* A write failed and only a whole one (or one the disk took whole) repairs it: a later page landing as a delta
|
|
210
|
+
* doesn't, since the disk still lacks the failed page's rows.
|
|
211
|
+
*/
|
|
212
|
+
owed: boolean;
|
|
163
213
|
};
|
|
164
214
|
target: number;
|
|
215
|
+
/**
|
|
216
|
+
* A reset this copy asks for: `paged` after a reply it couldn't apply (another dataset without a reset, a gap),
|
|
217
|
+
* `whole` (no `page`, as an old server understands) after an old server answered a partial copy.
|
|
218
|
+
*/
|
|
219
|
+
restart: "paged" | "whole" | null;
|
|
165
220
|
/** When a pull last succeeded: freshness, apart from the socket's liveness. */
|
|
166
221
|
reconciledAt: number | null;
|
|
167
222
|
connection: Connection;
|
|
@@ -189,6 +244,8 @@ export type Model<T, S, C, O> = {
|
|
|
189
244
|
failures: number;
|
|
190
245
|
};
|
|
191
246
|
commands: readonly Pending<C, O>[];
|
|
247
|
+
/** Commands (`client:id`) whose receipt came in a pull reply this copy applied, so with the rows it names. */
|
|
248
|
+
carried: ReadonlySet<string>;
|
|
192
249
|
/** Commands an earlier page kept that this version can't read: left on disk, and said. */
|
|
193
250
|
unreadable: number;
|
|
194
251
|
visible: boolean;
|
|
@@ -242,11 +299,15 @@ export type Msg<T, P, S, C, O> = {
|
|
|
242
299
|
failure: Failure;
|
|
243
300
|
now: number;
|
|
244
301
|
}
|
|
245
|
-
/**
|
|
302
|
+
/**
|
|
303
|
+
* The disk holds the replica at `revision` (perhaps newer than asked, from another tab); `whole: false` when it took
|
|
304
|
+
* the patch as a delta under its own, other history.
|
|
305
|
+
*/
|
|
246
306
|
| {
|
|
247
307
|
type: "committed";
|
|
248
308
|
dataset: string;
|
|
249
309
|
revision: number;
|
|
310
|
+
whole?: boolean;
|
|
250
311
|
now: number;
|
|
251
312
|
} | {
|
|
252
313
|
type: "commitFailed";
|
|
@@ -311,6 +372,8 @@ export type Commit<T, S> = {
|
|
|
311
372
|
reset: boolean;
|
|
312
373
|
/** The revision the patch starts from: the disk takes it as a delta only if it is at least there. */
|
|
313
374
|
base: number;
|
|
375
|
+
/** The cursor the copy held before this patch: the disk adopts the patch's history only from that same cursor. */
|
|
376
|
+
from?: string | null;
|
|
314
377
|
/** The dataset this page last knew the disk to hold: the only one it may replace with another. */
|
|
315
378
|
replaces: string | null;
|
|
316
379
|
};
|
|
@@ -322,6 +385,13 @@ export type Effect<T, S, C, O> = {
|
|
|
322
385
|
dataset: string | null;
|
|
323
386
|
since: number;
|
|
324
387
|
verify: boolean;
|
|
388
|
+
/** The older page wanted (null: none, but a paged reset is understood); absent asks an old server's whole reply. */
|
|
389
|
+
page?: string | null;
|
|
390
|
+
/** Commands still waiting on an outcome or on their effects, any page's: their receipts come with their rows. */
|
|
391
|
+
commands: {
|
|
392
|
+
client: string;
|
|
393
|
+
id: number;
|
|
394
|
+
}[];
|
|
325
395
|
} | {
|
|
326
396
|
kind: "push";
|
|
327
397
|
client: string;
|
|
@@ -393,33 +463,86 @@ export declare const PUSH_MAX = 50;
|
|
|
393
463
|
export declare const DONE_KEEP_MS = 60000;
|
|
394
464
|
/** Whether the server can be asked now. */
|
|
395
465
|
export declare function reachable(connection: Connection): boolean;
|
|
396
|
-
/**
|
|
466
|
+
/**
|
|
467
|
+
* Whether the replica holds this command's effects: it is done, and the copy is at its revision and either complete or
|
|
468
|
+
* holding the rows its receipt names (a pull reply carried them). One that failed on the page has none.
|
|
469
|
+
*/
|
|
397
470
|
export declare function settled<T, S, C, O>(model: Model<T, S, C, O>, pending: Pending<C, O>): boolean;
|
|
398
|
-
/**
|
|
471
|
+
/**
|
|
472
|
+
* Whether what the page shows is also on the disk: by revision only, so tabs at different cursors never chase each
|
|
473
|
+
* other's writes. A failed write is owed again even at the same revision (a page that failed to land).
|
|
474
|
+
*/
|
|
399
475
|
export declare function saved<T, S, C, O>(model: Model<T, S, C, O>): boolean;
|
|
400
476
|
/**
|
|
401
477
|
* What a commit does to the disk, decided inside the disk's own transaction from what it holds: nothing when the disk
|
|
402
|
-
* already has this or newer (another tab got there
|
|
403
|
-
* whole replica, so a skipped or failed patch is never
|
|
478
|
+
* already has this or newer (another tab got there; a complete copy at the same revision is newer than any page), the
|
|
479
|
+
* patch alone when the disk is at least at its base, else the whole replica, so a skipped or failed patch is never
|
|
480
|
+
* covered by a later checkpoint. A delta keeps the disk's own history, and takes the patch's only when the disk is at
|
|
481
|
+
* the cursor the patch moved from (`adopt`): coverage never lands without its rows. A page may replace the disk's
|
|
404
482
|
* dataset only with one it moved to from that very dataset (`replaces`): a tab still on a dataset the server has since
|
|
405
483
|
* replaced is `stale` and never writes it back.
|
|
406
484
|
*/
|
|
407
485
|
export declare function commitPlan(stored: {
|
|
408
486
|
dataset: string | null;
|
|
409
487
|
revision: number;
|
|
488
|
+
history?: History;
|
|
410
489
|
}, commit: {
|
|
411
490
|
dataset: string | null;
|
|
412
491
|
revision: number;
|
|
413
492
|
reset: boolean;
|
|
414
493
|
base: number;
|
|
415
494
|
replaces: string | null;
|
|
416
|
-
|
|
495
|
+
from?: string | null;
|
|
496
|
+
}): "skip" | "delta" | "adopt" | "whole" | "stale";
|
|
497
|
+
/** How much history the page holds, as a closed subject for the app's state table. */
|
|
498
|
+
export type HistoryState = {
|
|
499
|
+
kind: "loading";
|
|
500
|
+
} | {
|
|
501
|
+
kind: "none";
|
|
502
|
+
} | {
|
|
503
|
+
kind: "complete";
|
|
504
|
+
} | {
|
|
505
|
+
kind: "partial";
|
|
506
|
+
through: string | null;
|
|
507
|
+
held: number;
|
|
508
|
+
total: number;
|
|
509
|
+
};
|
|
510
|
+
/**
|
|
511
|
+
* The copy's history: loading while storage loads, none without a dataset, else partial or complete. Partial is a claim
|
|
512
|
+
* beside the numbers ("history from <through>, loading older · held of total"), and totals cover only `through` and
|
|
513
|
+
* above. Complete says nothing about storage: that is `deriveDurabilityState`, its own subject.
|
|
514
|
+
*/
|
|
515
|
+
export declare function deriveHistoryState<T, S, C, O>(model: Model<T, S, C, O>): HistoryState;
|
|
516
|
+
/** Whether the page's copy is kept on this device, as a closed subject apart from how much history it holds. */
|
|
517
|
+
export type DurabilityState = {
|
|
518
|
+
kind: "loading";
|
|
519
|
+
} | {
|
|
520
|
+
kind: "unsaved";
|
|
521
|
+
} | {
|
|
522
|
+
kind: "lost";
|
|
523
|
+
reason: string;
|
|
524
|
+
} | {
|
|
525
|
+
kind: "failing";
|
|
526
|
+
reason: string;
|
|
527
|
+
} | {
|
|
528
|
+
kind: "replaced";
|
|
529
|
+
} | {
|
|
530
|
+
kind: "unreadable";
|
|
531
|
+
count: number;
|
|
532
|
+
} | {
|
|
533
|
+
kind: "ok";
|
|
534
|
+
};
|
|
535
|
+
/**
|
|
536
|
+
* The copy's durability at the model's clock: loading, replaced by another tab, a failing store (lost while the page
|
|
537
|
+
* shows more than the disk holds), unsaved past the storage deadline, commands kept that this version can't read, or ok.
|
|
538
|
+
*/
|
|
539
|
+
export declare function deriveDurabilityState<T, S, C, O>(model: Model<T, S, C, O>): DurabilityState;
|
|
417
540
|
export declare function createSync<T, P, S, C, O>(spec: Spec<T, P, S, C, O>): {
|
|
418
541
|
initial: (client: string, now: number) => Model<T, S, C, O>;
|
|
419
542
|
update: (model: Model<T, S, C, O>, msg: Msg<T, P, S, C, O>) => readonly [Model<T, S, C, O>, readonly Effect<T, S, C, O>[]];
|
|
420
|
-
merge: (replica: Replica<T, S>, patch: Patch<P, S
|
|
543
|
+
merge: (replica: Replica<T, S>, patch: Patch<P, S>, since: number) => {
|
|
421
544
|
replica: Replica<T, S>;
|
|
422
545
|
ops: Op[];
|
|
423
546
|
reset: boolean;
|
|
424
|
-
} | undefined;
|
|
547
|
+
} | "gap" | undefined;
|
|
425
548
|
};
|