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.
@@ -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 pull applies only if newer (or a reset for another dataset). The disk commit
9
- * is decided again against what the disk holds, inside its own transaction (`commitPlan`), so another tab or a
10
- * failed write can't make the disk go back or skip a patch.
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 says so.
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); until then a pull is owed.
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
- /** A command's outcome as the server recorded it, and the revision at which its effects are visible. */
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
- /** The disk holds the replica at `revision` (perhaps newer than asked, from another tab). */
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;
@@ -378,6 +448,8 @@ export type Spec<T, P, S, C, O> = {
378
448
  * until it resolves or the person dismisses it. Default: every outcome is resolved.
379
449
  */
380
450
  resolved?: (pending: Pending<C, O>, model: Model<T, S, C, O>) => boolean;
451
+ /** The most commands one push carries: the push endpoint's own bound. Default `PUSH_MAX`. */
452
+ pushMax?: number;
381
453
  };
382
454
  /** The socket must be heard this often (the server pokes every 20 s); quieter, the page polls. */
383
455
  export declare const SOCKET_DEADLINE_MS = 45000;
@@ -385,37 +457,92 @@ export declare const SOCKET_DEADLINE_MS = 45000;
385
457
  export declare const POLL_MS = 4000;
386
458
  /** Storage gets this long to load, or to keep an intent, before the page goes on without it. */
387
459
  export declare const STORAGE_DEADLINE_MS = 3000;
460
+ /** Commands per push unless the spec says otherwise; the rest wait for the next push. */
461
+ export declare const PUSH_MAX = 50;
388
462
  /** Settled commands stay drawn this long. */
389
463
  export declare const DONE_KEEP_MS = 60000;
390
464
  /** Whether the server can be asked now. */
391
465
  export declare function reachable(connection: Connection): boolean;
392
- /** Whether the replica holds this command's effects. */
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
+ */
393
470
  export declare function settled<T, S, C, O>(model: Model<T, S, C, O>, pending: Pending<C, O>): boolean;
394
- /** Whether what the page shows is also on the disk. */
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
+ */
395
475
  export declare function saved<T, S, C, O>(model: Model<T, S, C, O>): boolean;
396
476
  /**
397
477
  * What a commit does to the disk, decided inside the disk's own transaction from what it holds: nothing when the disk
398
- * already has this or newer (another tab got there), the patch alone when the disk is at least at its base, else the
399
- * whole replica, so a skipped or failed patch is never covered by a later checkpoint. A page may replace the disk's
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
400
482
  * dataset only with one it moved to from that very dataset (`replaces`): a tab still on a dataset the server has since
401
483
  * replaced is `stale` and never writes it back.
402
484
  */
403
485
  export declare function commitPlan(stored: {
404
486
  dataset: string | null;
405
487
  revision: number;
488
+ history?: History;
406
489
  }, commit: {
407
490
  dataset: string | null;
408
491
  revision: number;
409
492
  reset: boolean;
410
493
  base: number;
411
494
  replaces: string | null;
412
- }): "skip" | "delta" | "whole" | "stale";
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;
413
540
  export declare function createSync<T, P, S, C, O>(spec: Spec<T, P, S, C, O>): {
414
541
  initial: (client: string, now: number) => Model<T, S, C, O>;
415
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>[]];
416
- merge: (replica: Replica<T, S>, patch: Patch<P, S>) => {
543
+ merge: (replica: Replica<T, S>, patch: Patch<P, S>, since: number) => {
417
544
  replica: Replica<T, S>;
418
545
  ops: Op[];
419
546
  reset: boolean;
420
- } | undefined;
547
+ } | "gap" | undefined;
421
548
  };