slopkit 0.1.2 → 0.3.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.
Files changed (41) hide show
  1. package/DEPS.md +2 -1
  2. package/README.md +51 -0
  3. package/dist/client.d.ts +31 -4
  4. package/dist/client.js +25 -2
  5. package/dist/conformance/client.test.js +7 -7
  6. package/dist/conformance/fixtures.d.ts +55 -3
  7. package/dist/conformance/fixtures.js +9 -5
  8. package/dist/conformance/legacy/replica-012.d.ts +52 -0
  9. package/dist/conformance/legacy/replica-012.js +403 -0
  10. package/dist/conformance/outbox-bound.test.d.ts +1 -0
  11. package/dist/conformance/outbox-bound.test.js +268 -0
  12. package/dist/conformance/paging-budget.test.d.ts +1 -0
  13. package/dist/conformance/paging-budget.test.js +101 -0
  14. package/dist/conformance/paging-fixtures.d.ts +80 -0
  15. package/dist/conformance/paging-fixtures.js +135 -0
  16. package/dist/conformance/paging-network.test.d.ts +1 -0
  17. package/dist/conformance/paging-network.test.js +123 -0
  18. package/dist/conformance/paging-state.test.d.ts +1 -0
  19. package/dist/conformance/paging-state.test.js +180 -0
  20. package/dist/conformance/paging-storage.test.d.ts +1 -0
  21. package/dist/conformance/paging-storage.test.js +255 -0
  22. package/dist/conformance/paging.test.d.ts +1 -0
  23. package/dist/conformance/paging.test.js +209 -0
  24. package/dist/conformance/pull.d.ts +74 -0
  25. package/dist/conformance/pull.js +400 -0
  26. package/dist/conformance/push-bytes.test.d.ts +1 -0
  27. package/dist/conformance/push-bytes.test.js +174 -0
  28. package/dist/conformance/push-refusal.test.d.ts +1 -0
  29. package/dist/conformance/push-refusal.test.js +644 -0
  30. package/dist/contract.d.ts +19 -6
  31. package/dist/contract.js +76 -9
  32. package/dist/server/ledger.d.ts +13 -0
  33. package/dist/server/ledger.js +18 -0
  34. package/dist/server/pull.d.ts +85 -0
  35. package/dist/server/pull.js +324 -0
  36. package/dist/server/web.d.ts +9 -5
  37. package/dist/server/web.js +29 -12
  38. package/dist/sync.d.ts +221 -16
  39. package/dist/sync.js +565 -96
  40. package/dist/web/replica.js +93 -16
  41. package/package.json +17 -1
package/dist/contract.js CHANGED
@@ -3,7 +3,7 @@
3
3
  * server (which checks requests and its own replies) and the page (which checks replies, since a proxy in between can
4
4
  * answer anything, such as an HTML login page). Failures are a closed set of kinds; a caller handles each by kind.
5
5
  */
6
- import { check, object, string } from "./schema.js";
6
+ import { check, object, optional, string } from "./schema.js";
7
7
  export function endpoint(method, path, request, response, options = {}) {
8
8
  return { method, path, request, response, person: options.person ?? false };
9
9
  }
@@ -16,8 +16,25 @@ export const STATUS = {
16
16
  unavailable: 503,
17
17
  offline: 503,
18
18
  };
19
+ const KINDS = [
20
+ "signed-out",
21
+ "forbidden",
22
+ "invalid",
23
+ "conflict",
24
+ "upstream",
25
+ "unavailable",
26
+ "offline",
27
+ ];
28
+ /** A body that was due as JSON and wasn't. */
29
+ const UNREADABLE = Symbol("unreadable");
19
30
  /** A failure's body, as `serveWeb` answers it: its message is shown when there is one. */
20
31
  const ERROR_BODY = object({ message: string() });
32
+ /** The kit's own error body: a kind it can send, its message, and the schema path of a refused request. */
33
+ const KIT_BODY = object({ kind: string(), message: string(), path: optional(string()) });
34
+ /**
35
+ * A status's kind when no kit body says otherwise. A wrong endpoint or method and a body past the server's limit are
36
+ * `invalid` (no command path: incompatible); a timeout or rate limit is `unavailable`, retried.
37
+ */
21
38
  function kindOf(status) {
22
39
  if (status === 401) {
23
40
  return "signed-out";
@@ -28,6 +45,9 @@ function kindOf(status) {
28
45
  if (status === 409) {
29
46
  return "conflict";
30
47
  }
48
+ if (status === 408 || status === 429) {
49
+ return "unavailable";
50
+ }
31
51
  if (status === 502 || status === 504) {
32
52
  return "upstream";
33
53
  }
@@ -36,6 +56,21 @@ function kindOf(status) {
36
56
  }
37
57
  return "invalid";
38
58
  }
59
+ /** The longest a `Retry-After` defers the next try: a bad header can delay sync, never stop it. */
60
+ export const RETRY_AFTER_MAX_MS = 3_600_000;
61
+ /**
62
+ * `Retry-After` as milliseconds from now, capped at `RETRY_AFTER_MAX_MS`: delay seconds or an HTTP date; undefined
63
+ * when absent or unreadable.
64
+ */
65
+ function retryAfter(value) {
66
+ if (value === null) {
67
+ return undefined;
68
+ }
69
+ const at = /^\d+$/.test(value.trim())
70
+ ? Number(value.trim()) * 1000
71
+ : Date.parse(value) - Date.now();
72
+ return Number.isNaN(at) ? undefined : Math.min(RETRY_AFTER_MAX_MS, Math.max(0, at));
73
+ }
39
74
  /** GET requests carry their input as one `q` query parameter of JSON; POST as the body. */
40
75
  export function encodeUrl(route, request) {
41
76
  if (route.method === "POST") {
@@ -44,8 +79,9 @@ export function encodeUrl(route, request) {
44
79
  return `${route.path}?q=${encodeURIComponent(JSON.stringify(request))}`;
45
80
  }
46
81
  /**
47
- * Call an endpoint from a browser (or any fetch). Every outcome is a `Result`: thrown errors, non-JSON answers (a
48
- * proxy's login page means signed out), error statuses and replies that break the schema all become a `Failure`.
82
+ * Call an endpoint from a browser (or any fetch). Every outcome is a `Result`: thrown errors, redirects and successful
83
+ * HTML pages (a proxy's login page means signed out), error statuses, and successes that aren't JSON or break the
84
+ * schema (`unreadable`) all become a `Failure`. An error is `refused` only with the kit's own body.
49
85
  */
50
86
  export async function call(route, request, options = {}) {
51
87
  const send = options.fetch ?? fetch;
@@ -70,29 +106,60 @@ export async function call(route, request, options = {}) {
70
106
  failure: { kind: "offline", message: error instanceof Error ? error.message : String(error) },
71
107
  };
72
108
  }
73
- // A redirect (an opaque one, in a browser) or an HTML page is a proxy sending the browser to sign in.
109
+ // A redirect (an opaque one, in a browser) or a successful HTML page is a proxy sending the browser to sign in. An
110
+ // HTML error page keeps its status's kind.
74
111
  const type = response.headers.get("content-type") ?? "";
75
112
  if (response.type === "opaqueredirect" ||
76
113
  (response.status >= 300 && response.status < 400) ||
77
- type.includes("text/html")) {
114
+ (response.ok && type.includes("text/html"))) {
78
115
  return { ok: false, failure: { kind: "signed-out", message: "the login expired" } };
79
116
  }
80
117
  const body = type.includes("application/json")
81
- ? await response.json().catch(() => undefined)
82
- : undefined;
118
+ ? await response.json().catch(() => UNREADABLE)
119
+ : UNREADABLE;
83
120
  if (!response.ok) {
121
+ const kit = check(KIT_BODY, body);
122
+ const named = kit.ok ? KINDS.find((each) => each === kit.value.kind) : undefined;
123
+ // Only the kit's own body, naming a kind its status carries, is the server's refusal; anything else is a status.
124
+ const own = kit.ok && named !== undefined && STATUS[named] === response.status;
125
+ const kind = own ? named : kindOf(response.status);
126
+ const after = kind === "unavailable" ? retryAfter(response.headers.get("retry-after")) : undefined;
127
+ if (own) {
128
+ const path = kit.value.path;
129
+ return {
130
+ ok: false,
131
+ failure: {
132
+ kind,
133
+ message: kit.value.message,
134
+ refused: true,
135
+ ...(path === undefined ? {} : { path }),
136
+ ...(after === undefined ? {} : { retryAfterMs: after }),
137
+ },
138
+ };
139
+ }
84
140
  const error = check(ERROR_BODY, body);
85
141
  return {
86
142
  ok: false,
87
143
  failure: {
88
- kind: kindOf(response.status),
144
+ kind,
89
145
  message: error.ok ? error.value.message : `HTTP ${response.status}`,
146
+ ...(after === undefined ? {} : { retryAfterMs: after }),
90
147
  },
91
148
  };
92
149
  }
150
+ // A success the page can't read may still have acted: unreadable, never a refusal.
151
+ if (body === UNREADABLE) {
152
+ return {
153
+ ok: false,
154
+ failure: { kind: "unreadable", message: `reply: not JSON (HTTP ${response.status})` },
155
+ };
156
+ }
93
157
  const parsed = check(route.response, body);
94
158
  if (!parsed.ok) {
95
- return { ok: false, failure: { kind: "invalid", message: `reply: ${parsed.error.message}` } };
159
+ return {
160
+ ok: false,
161
+ failure: { kind: "unreadable", message: `reply: ${parsed.error.message}` },
162
+ };
96
163
  }
97
164
  return { ok: true, value: parsed.value };
98
165
  }
@@ -115,6 +115,19 @@ export declare class Ledger<Receipt> {
115
115
  receipt: Receipt;
116
116
  revision: number;
117
117
  }[];
118
+ /**
119
+ * The receipts of these commands (any of the principal's clients, so an earlier page's too), in the order asked: for
120
+ * a pull that names the commands it still waits on. One with no receipt yet (or retired) is left out.
121
+ */
122
+ receiptsOf(principal: string, keys: readonly {
123
+ client: string;
124
+ id: number;
125
+ }[]): {
126
+ client: string;
127
+ id: number;
128
+ receipt: Receipt;
129
+ revision: number;
130
+ }[];
118
131
  /** Commands accepted longer than `ms` ago and still unfinished: a stuck effect, for the invariant probe. */
119
132
  stuck(ms: number): number;
120
133
  }
@@ -150,6 +150,24 @@ export class Ledger {
150
150
  return { id: Number(row.id), receipt: decode(row.outcome), revision: Number(row.revision) };
151
151
  });
152
152
  }
153
+ /**
154
+ * The receipts of these commands (any of the principal's clients, so an earlier page's too), in the order asked: for
155
+ * a pull that names the commands it still waits on. One with no receipt yet (or retired) is left out.
156
+ */
157
+ receiptsOf(principal, keys) {
158
+ const { db, decode } = this.options;
159
+ const find = db.prepare("select outcome, revision from commands where principal = ? and client = ? and id = ? and state = 'done'");
160
+ return keys.flatMap(({ client, id }) => {
161
+ const row = find.get(principal, client, id);
162
+ if (row === undefined) {
163
+ return [];
164
+ }
165
+ if (typeof row.outcome !== "string") {
166
+ throw new TypeError("Corrupt row in commands.outcome");
167
+ }
168
+ return [{ client, id, receipt: decode(row.outcome), revision: Number(row.revision) }];
169
+ });
170
+ }
153
171
  /** Commands accepted longer than `ms` ago and still unfinished: a stuck effect, for the invariant probe. */
154
172
  stuck(ms) {
155
173
  const before = new Date(Date.now() - ms).toISOString();
@@ -0,0 +1,85 @@
1
+ import type { DatabaseSync, SQLOutputValue } from "node:sqlite";
2
+ import type { Page } from "../sync.ts";
3
+ /** The caps of a paging-aware reply: rows of the first and later pages, and the whole reply in rows and bytes. */
4
+ export type PullLimits = {
5
+ firstRows: number;
6
+ pageRows: number;
7
+ maxRows: number;
8
+ maxBytes: number;
9
+ };
10
+ /** A command by the page that sent it and its number. */
11
+ export type CommandRef = {
12
+ client: string;
13
+ id: number;
14
+ };
15
+ /**
16
+ * What `pull` reads of a request: the copy's dataset and revision, the older page it wants, and the commands it still
17
+ * waits on (any of its pages', at most a push's worth).
18
+ */
19
+ export type PullAsk = {
20
+ dataset: string | null;
21
+ since: number;
22
+ page?: string | null;
23
+ commands?: readonly CommandRef[];
24
+ };
25
+ /** A reply before the app shapes it: the history's rows, the other tables, the receipts and the page. */
26
+ export type Pulled<R, U, Rc> = {
27
+ dataset: string;
28
+ revision: number;
29
+ /** Everything (of the history, from its newest page), not changes: the copy starts over. */
30
+ reset: boolean;
31
+ /** The history table's rows: the page newest first, then the changes and the receipts' rows. */
32
+ rows: R[];
33
+ unpaged: U;
34
+ receipts: Rc[];
35
+ /** Absent for a request without `page`. */
36
+ page?: Page;
37
+ };
38
+ type Row = Record<string, SQLOutputValue>;
39
+ export type PullOptions<R, U extends Record<string, readonly unknown[]>, Rc, Caller, Res> = {
40
+ db: DatabaseSync;
41
+ dataset: () => string;
42
+ revision: () => number;
43
+ /** Whether a delta can follow from `since` (a revision this history still has). Default: any up to the head. */
44
+ known?: (since: number) => boolean;
45
+ /** The history: the table paged newest first. */
46
+ paged: {
47
+ table: string;
48
+ /** Order columns, newest first, all descending; the last one is the row's unique key. */
49
+ order: readonly string[];
50
+ /** Changes whenever the order does, so no cursor of another order is honoured. */
51
+ version: string | number;
52
+ /** SQL condition true for a row that isn't a tombstone. */
53
+ live: string;
54
+ /** A table row, as the reply carries it. */
55
+ read: (row: Row) => R;
56
+ /** A fully covered order group's values (the order without its key, or the key alone), as the app shows them. */
57
+ through: (values: readonly SQLOutputValue[]) => string;
58
+ };
59
+ /** The other tables' rows changed after `since`, or every live row for 0: always whole on a reset. */
60
+ unpaged: (since: number) => U;
61
+ /** The caller's receipts stamped after `since`, and the history rows (by key) each one names. */
62
+ /**
63
+ * The receipts a reply carries, and the history rows (by key) each one names. With `commands` (a paging client's),
64
+ * those commands' receipts, scoped to the caller's principal; without, the caller's client's receipts stamped after
65
+ * `since`, as before.
66
+ */
67
+ receipts: {
68
+ read: (ask: {
69
+ since: number;
70
+ commands?: readonly CommandRef[];
71
+ }, caller: Caller) => readonly Rc[];
72
+ names: (receipt: Rc) => readonly (string | number)[];
73
+ };
74
+ limits: PullLimits;
75
+ /** The reply the endpoint answers: the bounds measure its JSON. */
76
+ respond: (pulled: Pulled<R, U, Rc>) => Res;
77
+ };
78
+ export declare function createPull<R, U extends Record<string, readonly unknown[]>, Rc, Caller, Res>(options: PullOptions<R, U, Rc, Caller, Res>): {
79
+ pull: (request: PullAsk, caller: Caller) => {
80
+ reply: Res;
81
+ bytes: number;
82
+ pulled: Pulled<R, U, Rc>;
83
+ };
84
+ };
85
+ export {};
@@ -0,0 +1,324 @@
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
+ * - **A reply costs about one encoding.** Each candidate is measured, not built: the reply as `respond` makes it with no
23
+ * rows, plus each row's own encoding (every row read and encoded once) and a comma between rows; counts run once per
24
+ * reply, and a candidate's coverage is read from the rows it fetched. The chosen reply is built once, and checked: one
25
+ * whose bytes aren't its rows' (a `respond` that reshapes rows) is chosen again by building each candidate whole.
26
+ * - **Receipts carry their rows.** A row a receipt names is in the reply that carries the receipt, even below the page,
27
+ * so a command settles only on a copy that holds its effects. A paging client names the commands it waits on (any of
28
+ * its pages', at most a push's worth); a receipt left out for room is asked for again by its next pull.
29
+ * - **Old clients keep the old protocol.** A request without `page` gets the whole reply as before: everything on a
30
+ * reset, the changes otherwise, without page metadata and unbounded.
31
+ *
32
+ * The history table has a `revision` column (the revision that last wrote the row), keeps tombstones (`live` tells
33
+ * them apart), and has an index on its order columns, which are never null. The order is descending in every column;
34
+ * its last column is the unique key that receipts name.
35
+ *
36
+ * A fingerprint for `verify` (asked only by a complete copy) is the app's: read what it covers in the same transaction,
37
+ * by opening one around `pull`, and keep its few bytes in mind under `maxBytes`, which measures `respond`'s reply.
38
+ */
39
+ import { Buffer } from "node:buffer";
40
+ const marks = (columns) => columns.map(() => "?").join(", ");
41
+ const sizeOf = (tables) => Object.values(tables).reduce((sum, rows) => sum + rows.length, 0);
42
+ const tally = (row) => Number(row?.n ?? 0);
43
+ /** The largest whole number from `low` to `high` that `fits`, or undefined (`fits` holds for every one below it). */
44
+ function largest(low, high, fits) {
45
+ let found;
46
+ let from = low;
47
+ let to = high;
48
+ while (from <= to) {
49
+ const at = Math.floor((from + to) / 2);
50
+ if (fits(at)) {
51
+ found = at;
52
+ from = at + 1;
53
+ }
54
+ else {
55
+ to = at - 1;
56
+ }
57
+ }
58
+ return found;
59
+ }
60
+ /** A row's values in these order columns, which are never null: what a cursor holds. */
61
+ const values = (row, columns) => columns.map((column) => {
62
+ const value = row[column];
63
+ if (typeof value !== "string" && typeof value !== "number") {
64
+ throw new TypeError(`pull: order column ${column} must be text or a number`);
65
+ }
66
+ return value;
67
+ });
68
+ export function createPull(options) {
69
+ const { db, limits, paged } = options;
70
+ for (const [name, value] of Object.entries(limits)) {
71
+ if (!Number.isSafeInteger(value) || value < 1) {
72
+ throw new Error(`pull: ${name} must be a positive integer, got ${value}`);
73
+ }
74
+ }
75
+ if (limits.firstRows > limits.maxRows || limits.pageRows > limits.maxRows) {
76
+ throw new Error("pull: a page must fit in maxRows");
77
+ }
78
+ const order = paged.order;
79
+ const unique = order.at(-1);
80
+ if (unique === undefined) {
81
+ throw new Error("pull: the paged order needs at least its key column");
82
+ }
83
+ const key = unique;
84
+ const group = order.length > 1 ? order.slice(0, -1) : order;
85
+ const live = `from ${paged.table} where ${paged.live}`;
86
+ const newest = `order by ${order.map((column) => `${column} desc`).join(", ")} limit ?`;
87
+ const sql = {
88
+ changes: db.prepare(`select * from ${paged.table} where revision > ?`),
89
+ live: db.prepare(`select * ${live}`),
90
+ top: db.prepare(`select * ${live} ${newest}`),
91
+ older: db.prepare(`select * ${live} and (${order.join(", ")}) < (${marks(order)}) ${newest}`),
92
+ total: db.prepare(`select count(*) as n ${live}`),
93
+ held: db.prepare(`select count(*) as n ${live} and (${order.join(", ")}) >= (${marks(order)})`),
94
+ split: db.prepare(`select 1 as n ${live} and (${group.join(", ")}) = (${marks(group)}) and ${key} < ? limit 1`),
95
+ above: db.prepare(`select ${group.join(", ")} ${live} and (${group.join(", ")}) > (${marks(group)}) order by ${group.map((column) => `${column} asc`).join(", ")} limit 1`),
96
+ named: db.prepare(`select * from ${paged.table} where ${key} = ?`),
97
+ };
98
+ const encode = (dataset, row) => JSON.stringify([dataset, paged.version, ...values(row, order)]);
99
+ /** Whether a row is in the order group `at`. */
100
+ const same = (row, at) => values(row, group).every((value, index) => value === at[index]);
101
+ /** A cursor's order values, or undefined for one this history can't honour. */
102
+ function decode(text, dataset) {
103
+ let parsed;
104
+ try {
105
+ parsed = JSON.parse(text);
106
+ }
107
+ catch {
108
+ return undefined;
109
+ }
110
+ if (!Array.isArray(parsed) ||
111
+ parsed.length !== order.length + 2 ||
112
+ parsed[0] !== dataset ||
113
+ parsed[1] !== paged.version) {
114
+ return undefined;
115
+ }
116
+ const at = parsed.slice(2);
117
+ return at.every((value) => typeof value === "string" || (typeof value === "number" && Number.isFinite(value)))
118
+ ? at.filter((value) => typeof value === "string" || typeof value === "number")
119
+ : undefined;
120
+ }
121
+ function assemble(request, receipts) {
122
+ const dataset = options.dataset();
123
+ const revision = options.revision();
124
+ const { since } = request;
125
+ const cursor = typeof request.page === "string" ? decode(request.page, dataset) : null;
126
+ const reset = request.dataset !== dataset ||
127
+ since <= 0 ||
128
+ since > revision ||
129
+ options.known?.(since) === false ||
130
+ cursor === undefined;
131
+ const answer = (pulled) => {
132
+ const reply = options.respond(pulled);
133
+ return { reply, bytes: Buffer.byteLength(JSON.stringify(reply)), pulled };
134
+ };
135
+ const base = { dataset, revision };
136
+ // A client from before paging: the whole reply, as it always was.
137
+ if (request.page === undefined) {
138
+ const after = reset ? 0 : since;
139
+ return answer({
140
+ ...base,
141
+ reset,
142
+ rows: (after === 0 ? sql.live.all() : sql.changes.all(after)).map(paged.read),
143
+ unpaged: options.unpaged(after),
144
+ receipts: [...receipts(after)],
145
+ });
146
+ }
147
+ // Counted once per reply: every candidate page is placed from these and from the rows it was read with.
148
+ let total;
149
+ const count = () => (total ??= tally(sql.total.get()));
150
+ const above = new Map();
151
+ /** The lowest live order group above `at` (whole, so its values are the coverage boundary), once per group. */
152
+ const higher = (at) => {
153
+ const id = JSON.stringify(at);
154
+ if (!above.has(id)) {
155
+ const found = sql.above.get(...at);
156
+ above.set(id, found === undefined ? null : values(found, group));
157
+ }
158
+ return above.get(id) ?? null;
159
+ };
160
+ /** Each row read and encoded once, by key: a row is the same everywhere in one reply (one transaction). */
161
+ const encoded = new Map();
162
+ const row = (each) => {
163
+ let found = encoded.get(each[key]);
164
+ if (found === undefined) {
165
+ const value = paged.read(each);
166
+ found = { value, bytes: Buffer.byteLength(JSON.stringify(value)) };
167
+ encoded.set(each[key], found);
168
+ }
169
+ return found;
170
+ };
171
+ /**
172
+ * A reply: `size` rows of `page` (fetched with one row more, to know if older ones are left, and live rows strictly
173
+ * below the cursor `fixed.at`, or the newest ones), the first `count` of `kept` receipts with the rows they name,
174
+ * and `extra` history rows beside them; once each, as the store holds them now. `held` counts the rows at or
175
+ * above the cursor (none on a reset). `measure` says whether one fits the caps without building it: the reply as
176
+ * `respond` makes it with no rows, plus each row's own encoded bytes and a comma between rows. `make` builds it.
177
+ */
178
+ const reply = (fixed, page, kept, extra, held) => {
179
+ const named = kept.map((each) => options.receipts.names(each).flatMap((name) => sql.named.all(name)));
180
+ /**
181
+ * Where a page of `size` rows leaves the copy: its last row (or the cursor), whether a live row of its group
182
+ * lies below it (the next row fetched), the group above it (in the page, else one query), and `held`.
183
+ */
184
+ const place = (size) => {
185
+ const last = page[size - 1] ?? fixed.at;
186
+ const next = page[size];
187
+ if (last === undefined || next === undefined) {
188
+ return { from: fixed.from, next: null, through: null, held: count(), total: count() };
189
+ }
190
+ const at = values(last, group);
191
+ let through = at;
192
+ // A page ending inside a group leaves it out: the boundary is the lowest whole group above.
193
+ if (same(next, at)) {
194
+ const inPage = page.slice(0, Math.max(0, size - 1)).findLast((each) => !same(each, at));
195
+ through = inPage === undefined ? higher(at) : values(inPage, group);
196
+ }
197
+ return {
198
+ from: fixed.from,
199
+ next: encode(dataset, last),
200
+ through: through === null ? null : paged.through(through),
201
+ held: held() + size,
202
+ total: count(),
203
+ };
204
+ };
205
+ const rows = (size, taken) => {
206
+ const out = new Map();
207
+ for (const each of [...page.slice(0, size), ...extra, ...named.slice(0, taken).flat()]) {
208
+ out.set(each[key], out.get(each[key]) ?? each);
209
+ }
210
+ return [...out.values()];
211
+ };
212
+ const pulled = (size, taken, carried) => ({
213
+ ...base,
214
+ ...fixed,
215
+ rows: carried,
216
+ receipts: kept.slice(0, taken),
217
+ page: place(size),
218
+ });
219
+ const fits = (carried, bytes) => carried + sizeOf(fixed.unpaged) <= limits.maxRows && bytes <= limits.maxBytes;
220
+ return {
221
+ /** The rows fetched for the page, one more than it may carry when older ones are left. */
222
+ fetched: page.length,
223
+ receipts: kept.length,
224
+ /** The reply's encoded bytes and whether it fits, from its rows' own encodings. */
225
+ measure: (size, taken = kept.length) => {
226
+ const carried = rows(size, taken);
227
+ let bytes = answer(pulled(size, taken, [])).bytes + Math.max(0, carried.length - 1);
228
+ for (const each of carried) {
229
+ bytes += row(each).bytes;
230
+ }
231
+ return { bytes, fits: fits(carried.length, bytes) };
232
+ },
233
+ /** The reply itself, and whether it fits, encoded whole. */
234
+ make: (size, taken = kept.length) => {
235
+ const carried = rows(size, taken);
236
+ const made = answer(pulled(size, taken, carried.map((each) => row(each).value)));
237
+ return { made, fits: fits(carried.length, made.bytes) };
238
+ },
239
+ };
240
+ };
241
+ /** A delta's reply, and the newest page's: each read once, when first asked for. */
242
+ let delta;
243
+ let fresh;
244
+ const forDelta = () => {
245
+ if (delta === undefined) {
246
+ const unpaged = options.unpaged(since);
247
+ const room = cursor === null || cursor === undefined ? 0 : limits.pageRows;
248
+ const page = room === 0 ? [] : sql.older.all(...(cursor ?? []), room + 1);
249
+ let held;
250
+ // The cursor as the row it stands for: a reply with no older row leaves the copy there.
251
+ const at = cursor === null || cursor === undefined
252
+ ? {}
253
+ : {
254
+ at: Object.fromEntries(order.map((column, index) => [column, cursor[index] ?? null])),
255
+ };
256
+ delta = reply({ reset: false, from: request.page ?? null, unpaged, ...at }, page, receipts(since), sql.changes.all(since),
257
+ // The live rows at or above the cursor: with the page's own rows, every row at or above its last.
258
+ () => (held ??= cursor ? tally(sql.held.get(...cursor)) : 0));
259
+ }
260
+ return delta;
261
+ };
262
+ const forReset = () => {
263
+ fresh ??= reply({ reset: true, from: null, unpaged: options.unpaged(0) }, sql.top.all(limits.firstRows + 1), receipts(0), [], () => 0);
264
+ return fresh;
265
+ };
266
+ /**
267
+ * The reply's shape by `fits`: a delta that fits by itself takes the older page's rows and bytes it leaves;
268
+ * otherwise everything, from the newest page: one row of it at least, then as many receipts (with their rows) as
269
+ * fit, then as many more of the first rows. A receipt left out is asked for again by the next pull.
270
+ */
271
+ const choose = (fits) => {
272
+ if (!reset) {
273
+ const built = forDelta();
274
+ // Only a delta (with its receipts' rows and the other tables) that can't fit by itself starts the copy over.
275
+ if (fits(built, 0)) {
276
+ const room = cursor === null ? 0 : limits.pageRows;
277
+ const most = Math.min(room, built.fetched);
278
+ return {
279
+ built,
280
+ size: largest(1, most, (size) => fits(built, size)) ?? 0,
281
+ receipts: built.receipts,
282
+ };
283
+ }
284
+ }
285
+ const built = forReset();
286
+ const least = Math.min(1, built.fetched);
287
+ const kept = largest(0, built.receipts, (at) => fits(built, least, at));
288
+ if (kept === undefined) {
289
+ throw new Error(`pull: the reply can't fit its caps (${limits.maxRows} rows, ${limits.maxBytes} bytes), even with one row of history`);
290
+ }
291
+ const most = Math.min(limits.firstRows, built.fetched);
292
+ return {
293
+ built,
294
+ size: largest(least, most, (at) => fits(built, at, kept)) ?? least,
295
+ receipts: kept,
296
+ };
297
+ };
298
+ // Measured from each row's own encoding, then built once. A reply whose encoding isn't its rows' (a `respond`
299
+ // that reshapes rows) is chosen again by building each candidate whole.
300
+ const measured = choose((built, size, taken) => built.measure(size, taken).fits);
301
+ const made = measured.built.make(measured.size, measured.receipts);
302
+ if (made.made.bytes === measured.built.measure(measured.size, measured.receipts).bytes) {
303
+ return made.made;
304
+ }
305
+ const exact = choose((built, size, taken) => built.make(size, taken).fits);
306
+ return exact.built.make(exact.size, exact.receipts).made;
307
+ }
308
+ /** The reply to one request, its encoded size, and what it carries before `respond` shaped it. */
309
+ function pull(request, caller) {
310
+ const own = !db.isTransaction;
311
+ if (own) {
312
+ db.exec("begin");
313
+ }
314
+ try {
315
+ return assemble(request, (since) => options.receipts.read(request.commands === undefined ? { since } : { since, commands: request.commands }, caller));
316
+ }
317
+ finally {
318
+ if (own) {
319
+ db.exec("commit");
320
+ }
321
+ }
322
+ }
323
+ return { pull };
324
+ }
@@ -1,4 +1,4 @@
1
- import type { Endpoint, Failure } from "../contract.ts";
1
+ import type { Endpoint, RefusalKind } from "../contract.ts";
2
2
  import type { WebConfig } from "./config.ts";
3
3
  import type { Logger } from "./log.ts";
4
4
  /** Who asked: their identities, whether a person (not a service token), and how they signed in. */
@@ -8,10 +8,14 @@ export type Caller = {
8
8
  login: "access" | "oidc";
9
9
  request: string;
10
10
  };
11
- /** A handler's refusal, answered as a typed failure. */
11
+ /**
12
+ * A handler's refusal, answered as a typed failure: the kit's error body, so the page can tell it from a proxy's.
13
+ * `path` names where a request broke its schema (`request.commands[1].command.value`).
14
+ */
12
15
  export declare class Refusal extends Error {
13
- readonly kind: Failure["kind"];
14
- constructor(kind: Failure["kind"], message: string);
16
+ readonly kind: RefusalKind;
17
+ readonly path: string | undefined;
18
+ constructor(kind: RefusalKind, message: string, path?: string);
15
19
  }
16
20
  /** A route: an endpoint and its handler, typed together and run untyped by the server. */
17
21
  export type Route = {
@@ -39,7 +43,7 @@ export type WebOptions = {
39
43
  socket?: Socket;
40
44
  /** The session cookie's name. */
41
45
  cookie: string;
42
- /** Request bodies past this are refused; default 64 KB. */
46
+ /** Request bodies past this many bytes are refused; default 64 KiB. Give a push endpoint `PUSH_BYTES` or more. */
43
47
  bodyLimit?: number;
44
48
  };
45
49
  export declare function serveWeb(options: WebOptions): Promise<{