slopkit 0.2.0 → 0.4.0-rc.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 (52) hide show
  1. package/DEPS.md +5 -3
  2. package/README.md +103 -1
  3. package/RULES.md +26 -6
  4. package/dist/api.d.ts +93 -0
  5. package/dist/api.js +111 -0
  6. package/dist/client.d.ts +21 -4
  7. package/dist/client.js +21 -2
  8. package/dist/conformance/agent-access.test.d.ts +1 -0
  9. package/dist/conformance/agent-access.test.js +74 -0
  10. package/dist/conformance/client.test.js +7 -7
  11. package/dist/conformance/fixtures.d.ts +1 -0
  12. package/dist/conformance/fixtures.js +5 -1
  13. package/dist/conformance/outbox-bound.test.d.ts +1 -0
  14. package/dist/conformance/outbox-bound.test.js +268 -0
  15. package/dist/conformance/paging-state.test.js +1 -0
  16. package/dist/conformance/push-bytes.test.d.ts +1 -0
  17. package/dist/conformance/push-bytes.test.js +174 -0
  18. package/dist/conformance/push-refusal.test.d.ts +1 -0
  19. package/dist/conformance/push-refusal.test.js +644 -0
  20. package/dist/conformance/states.test.js +19 -8
  21. package/dist/contract.d.ts +19 -6
  22. package/dist/contract.js +76 -9
  23. package/dist/server/auth.d.ts +4 -3
  24. package/dist/server/auth.js +14 -6
  25. package/dist/server/cli.d.ts +117 -0
  26. package/dist/server/cli.js +1534 -0
  27. package/dist/server/config.d.ts +4 -1
  28. package/dist/server/config.js +2 -2
  29. package/dist/server/keys.d.ts +17 -0
  30. package/dist/server/keys.js +127 -0
  31. package/dist/server/pull.js +158 -67
  32. package/dist/server/store.d.ts +101 -0
  33. package/dist/server/store.js +347 -0
  34. package/dist/server/sync.d.ts +158 -0
  35. package/dist/server/sync.js +293 -0
  36. package/dist/server/web.d.ts +62 -9
  37. package/dist/server/web.js +260 -35
  38. package/dist/states.d.ts +52 -4
  39. package/dist/states.js +20 -0
  40. package/dist/sync.d.ts +86 -2
  41. package/dist/sync.js +277 -59
  42. package/dist/test/agents.d.ts +19 -0
  43. package/dist/test/agents.js +62 -0
  44. package/dist/test/page.d.ts +91 -0
  45. package/dist/test/page.js +763 -0
  46. package/dist/test/states.d.ts +44 -7
  47. package/dist/test/states.js +152 -0
  48. package/dist/web/replica.d.ts +7 -0
  49. package/dist/web/replica.js +260 -47
  50. package/dist/web/runtime.d.ts +102 -0
  51. package/dist/web/runtime.js +335 -0
  52. package/package.json +39 -1
@@ -22,7 +22,10 @@ export type WebConfig = {
22
22
  scopes: string[];
23
23
  subjectClaim: string;
24
24
  } | undefined;
25
- /** `sub:<subject>`, `email:<address>`, or `service:<client id>` for a proxy's service token. */
25
+ /**
26
+ * `sub:<subject>`, `email:<address>`, `service:<client id>` for a proxy's service token, or `key:<name>` for an
27
+ * agent's key (`slopkit/server/keys`).
28
+ */
26
29
  allow: string[];
27
30
  };
28
31
  export type Fields = Record<string, unknown>;
@@ -107,10 +107,10 @@ export function parseWebConfig(value, warnings) {
107
107
  }
108
108
  const listed = web.allow ?? [];
109
109
  const allow = Array.isArray(listed)
110
- ? listed.filter((entry) => typeof entry === "string" && /^(sub|email|service):.+$/.test(entry))
110
+ ? listed.filter((entry) => typeof entry === "string" && /^(sub|email|service|key):.+$/.test(entry))
111
111
  : [];
112
112
  if (!Array.isArray(listed) || allow.length !== listed.length) {
113
- throw new Error("web.allow must list sub:<subject>, email:<address> or service:<client id>");
113
+ throw new Error("web.allow must list sub:<subject>, email:<address>, service:<client id> or key:<name>");
114
114
  }
115
115
  if (access === undefined && oidc === undefined) {
116
116
  throw new Error("web.json needs a login: web.access (an identity-aware proxy) or web.oidc");
@@ -0,0 +1,17 @@
1
+ /** A new key named `name`: returns its token, the only time it exists outside the agent that keeps it. */
2
+ export declare function addKey(home: string, name: string): Promise<string>;
3
+ /** Removes the key named `name`; whether there was one. Its token stops working at the next request. */
4
+ export declare function revokeKey(home: string, name: string): Promise<boolean>;
5
+ /**
6
+ * The keys a server checks, reread whenever the file changes (its size, time or inode), so a revocation holds from the
7
+ * next request. A file it can't read refuses every key until it can.
8
+ */
9
+ export declare class Keys {
10
+ private readonly path;
11
+ private seen;
12
+ private entries;
13
+ constructor(home: string);
14
+ /** The key's name for a token, or undefined when it isn't one of ours. */
15
+ verify(token: string): Promise<string | undefined>;
16
+ private load;
17
+ }
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Agent keys: bearer tokens an agent or a check sends as `Authorization: Bearer <token>`, for an app's API on its own
3
+ * binding without a person's login or a proxy's service token. The home keeps only each key's SHA-256
4
+ * (`<home>/web-keys.json`, mode 0600); the token is shown once, by `addKey`. A key's identity is `key:<name>`, allowed
5
+ * only when `web.allow` lists it, and it is never a person.
6
+ */
7
+ import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
8
+ import { readFile, rename, stat, writeFile } from "node:fs/promises";
9
+ import { join } from "node:path";
10
+ const FILE = "web-keys.json";
11
+ const NAME = /^[a-z0-9-]{1,32}$/;
12
+ const TOKEN = /^([a-z0-9-]{1,32})\.([A-Za-z0-9_-]{43})$/;
13
+ const digest = (token) => createHash("sha256").update(token).digest();
14
+ function parse(raw) {
15
+ const value = JSON.parse(raw);
16
+ if (!Array.isArray(value)) {
17
+ throw new TypeError(`${FILE} must be a list`);
18
+ }
19
+ return value.map((entry) => {
20
+ if (typeof entry !== "object" || entry === null) {
21
+ throw new TypeError(`${FILE} holds an entry that isn't an object`);
22
+ }
23
+ const { name, sha256 } = Object.fromEntries(Object.entries(entry));
24
+ if (typeof name !== "string" || !NAME.test(name) || typeof sha256 !== "string") {
25
+ throw new TypeError(`${FILE} holds an entry without a name and a sha256`);
26
+ }
27
+ return { name, sha256 };
28
+ });
29
+ }
30
+ async function read(home) {
31
+ const raw = await readFile(join(home, FILE), "utf-8").catch((error) => {
32
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") {
33
+ return "[]";
34
+ }
35
+ throw error;
36
+ });
37
+ return parse(raw);
38
+ }
39
+ async function write(home, entries) {
40
+ const path = join(home, FILE);
41
+ const temporary = `${path}.${randomBytes(6).toString("hex")}.tmp`;
42
+ await writeFile(temporary, `${JSON.stringify(entries, null, 2)}\n`, { mode: 0o600 });
43
+ await rename(temporary, path);
44
+ }
45
+ /** Changes to one home's keys, one at a time in this process: each reads the file its predecessor wrote. */
46
+ const writing = new Map();
47
+ function serialized(home, change) {
48
+ const next = (writing.get(home) ?? Promise.resolve()).then(change, change);
49
+ writing.set(home, next.catch(() => undefined));
50
+ return next;
51
+ }
52
+ /** A new key named `name`: returns its token, the only time it exists outside the agent that keeps it. */
53
+ export function addKey(home, name) {
54
+ if (!NAME.test(name)) {
55
+ return Promise.reject(new Error("a key's name is 1–32 of a-z, 0-9 and -"));
56
+ }
57
+ return serialized(home, async () => {
58
+ const entries = await read(home);
59
+ if (entries.some((entry) => entry.name === name)) {
60
+ throw new Error(`a key named ${name} exists; revoke it first`);
61
+ }
62
+ const token = `${name}.${randomBytes(32).toString("base64url")}`;
63
+ await write(home, [...entries, { name, sha256: digest(token).toString("base64url") }]);
64
+ return token;
65
+ });
66
+ }
67
+ /** Removes the key named `name`; whether there was one. Its token stops working at the next request. */
68
+ export function revokeKey(home, name) {
69
+ return serialized(home, async () => {
70
+ const entries = await read(home);
71
+ const kept = entries.filter((entry) => entry.name !== name);
72
+ if (kept.length === entries.length) {
73
+ return false;
74
+ }
75
+ await write(home, kept);
76
+ return true;
77
+ });
78
+ }
79
+ /**
80
+ * The keys a server checks, reread whenever the file changes (its size, time or inode), so a revocation holds from the
81
+ * next request. A file it can't read refuses every key until it can.
82
+ */
83
+ export class Keys {
84
+ path;
85
+ seen = "";
86
+ entries;
87
+ constructor(home) {
88
+ this.path = join(home, FILE);
89
+ }
90
+ /** The key's name for a token, or undefined when it isn't one of ours. */
91
+ async verify(token) {
92
+ const shape = TOKEN.exec(token);
93
+ if (shape?.[1] === undefined) {
94
+ return undefined;
95
+ }
96
+ const name = shape[1];
97
+ const entries = await this.load();
98
+ const entry = entries?.find((each) => each.name === name);
99
+ if (entry === undefined) {
100
+ return undefined;
101
+ }
102
+ const expected = Buffer.from(entry.sha256, "base64url");
103
+ const actual = digest(token);
104
+ return expected.length === actual.length && timingSafeEqual(expected, actual)
105
+ ? name
106
+ : undefined;
107
+ }
108
+ async load() {
109
+ const found = await stat(this.path).catch(() => undefined);
110
+ if (found === undefined) {
111
+ this.seen = "";
112
+ this.entries = [];
113
+ return this.entries;
114
+ }
115
+ const version = `${found.ino}:${found.size}:${found.mtimeMs}`;
116
+ if (version !== this.seen) {
117
+ try {
118
+ this.entries = parse(await readFile(this.path, "utf-8"));
119
+ }
120
+ catch {
121
+ this.entries = undefined;
122
+ }
123
+ this.seen = version;
124
+ }
125
+ return this.entries;
126
+ }
127
+ }
@@ -19,6 +19,10 @@
19
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
20
  * that can't fit even so (the other tables alone are over the caps) is never sent: `pull` throws, a server fault
21
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.
22
26
  * - **Receipts carry their rows.** A row a receipt names is in the reply that carries the receipt, even below the page,
23
27
  * so a command settles only on a copy that holds its effects. A paging client names the commands it waits on (any of
24
28
  * its pages', at most a push's worth); a receipt left out for room is asked for again by its next pull.
@@ -92,6 +96,8 @@ export function createPull(options) {
92
96
  named: db.prepare(`select * from ${paged.table} where ${key} = ?`),
93
97
  };
94
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]);
95
101
  /** A cursor's order values, or undefined for one this history can't honour. */
96
102
  function decode(text, dataset) {
97
103
  let parsed;
@@ -112,27 +118,6 @@ export function createPull(options) {
112
118
  ? at.filter((value) => typeof value === "string" || typeof value === "number")
113
119
  : undefined;
114
120
  }
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
121
  function assemble(request, receipts) {
137
122
  const dataset = options.dataset();
138
123
  const revision = options.revision();
@@ -159,60 +144,166 @@ export function createPull(options) {
159
144
  receipts: [...receipts(after)],
160
145
  });
161
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
+ };
162
171
  /**
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.
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.
166
177
  */
167
- const reply = (fixed, page, kept, extra) => {
178
+ const reply = (fixed, page, kept, extra, held) => {
168
179
  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);
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() };
173
189
  }
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 };
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
+ },
183
239
  };
184
240
  };
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;
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));
202
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;
203
304
  }
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;
305
+ const exact = choose((built, size, taken) => built.make(size, taken).fits);
306
+ return exact.built.make(exact.size, exact.receipts).made;
216
307
  }
217
308
  /** The reply to one request, its encoded size, and what it carries before `respond` shaped it. */
218
309
  function pull(request, caller) {
@@ -0,0 +1,101 @@
1
+ import { DatabaseSync } from "node:sqlite";
2
+ import { Jobs } from "./jobs.ts";
3
+ import { Ledger } from "./ledger.ts";
4
+ /**
5
+ * The core tables every replicated store has, before the app's own: `meta`, the command ledger's `commands`
6
+ * (`slopkit/server/ledger`), `jobs` (`slopkit/server/jobs`) and the `revisions` log. Never edited once shipped.
7
+ */
8
+ export declare const CORE_TABLES = "create table meta(key text primary key, value text not null);\n create table commands(\n principal text not null check (length(principal) > 0),\n client text not null, id integer not null check (id > 0),\n name text not null, args text not null,\n state text not null check (state in ('accepted', 'done', 'retired')),\n outcome text check ((state = 'done') = (outcome is not null)),\n revision integer check ((state = 'accepted') = (revision is null)),\n created_at text not null,\n primary key (principal, client, id)) strict;\n create index commands_revision on commands(principal, client, revision);\n create trigger commands_never_deleted before delete on commands\n begin select raise(abort, 'a command key is kept forever: retire it'); end;\n create table jobs(\n id integer primary key,\n kind text not null check (length(kind) > 0),\n key text not null,\n state text not null check (state in ('due', 'running', 'done', 'failed')),\n due_at text not null, deadline_at text not null,\n attempts integer not null default 0 check (attempts >= 0),\n note text,\n result text check ((state = 'done') = (result is not null)),\n who text check ((state = 'failed') = (who is not null) and (who is null or who in ('you', 'us', 'gone'))),\n reason text check ((state = 'failed') = (reason is not null)),\n asked_by text not null default '[]',\n created_at text not null, updated_at text not null);\n create unique index jobs_open on jobs(kind, key) where state in ('due', 'running');\n create table revisions(revision integer primary key, at text not null) strict;\n insert into meta(key, value) values ('revision', '1'), ('history_floor', '1'),\n ('dataset', lower(hex(randomblob(12))));";
9
+ /** An app's store schema: its own tables at `baseVersion`, and the migrations since, one version each. */
10
+ export type StoreSchema = {
11
+ /** The app's tables as a new store starts them (the core tables come first, made by the kit). */
12
+ base: string;
13
+ /** Tables a replica holds: stamped and never deleted, by trigger. */
14
+ replicated: readonly string[];
15
+ baseVersion: number;
16
+ /** Each moves the schema one version up from `baseVersion`; never edit a shipped one, append. */
17
+ migrations: readonly string[];
18
+ /** Indexes that only speed reads, made at every open: no row's meaning depends on one. */
19
+ indexes?: string;
20
+ };
21
+ /** The store schema a release writes: what `version --json` reports and a rollback compares. */
22
+ export declare function schemaVersion(schema: StoreSchema): number;
23
+ export type StoreOptions = {
24
+ /** The app's name, as a refusal says it ("update <name>"). */
25
+ name: string;
26
+ schema: StoreSchema;
27
+ now?: () => number;
28
+ };
29
+ export declare class SyncStore {
30
+ readonly db: DatabaseSync;
31
+ readonly path: string;
32
+ readonly now: () => number;
33
+ /**
34
+ * Set when the store opened older than what it had already served (restored from a backup), or a caller proved it
35
+ * went back: the dataset and revision it had served, and its revision then. It took a new dataset, so every replica
36
+ * starts over and no intent made against the lost history runs here.
37
+ */
38
+ restored: {
39
+ dataset: string;
40
+ served: number;
41
+ revision: number;
42
+ } | undefined;
43
+ private readonly head;
44
+ private servedHead;
45
+ private depth;
46
+ constructor(path: string, options: StoreOptions);
47
+ private userVersion;
48
+ private migrate;
49
+ /** The head beside the store: a store that opens behind what it served went back, and takes a new dataset. */
50
+ private checkHead;
51
+ /** A caller proved this store went back: it holds `seen` of this very dataset, more than the store has. */
52
+ wentBack(seen: number): boolean;
53
+ /** Record that `revision` of this dataset left the server (a pull or push reply), before the reply does. */
54
+ served(revision: number): void;
55
+ close(): void;
56
+ /** All of `body` or none of it. Nested calls join the outer transaction: a failure anywhere undoes all of it. */
57
+ transaction<T>(body: () => T): T;
58
+ meta(key: string): string | undefined;
59
+ setMeta(key: string, value: string): void;
60
+ /** This store's identity: a replica of another store starts over. */
61
+ dataset(): string;
62
+ revision(): number;
63
+ /** The store's clock as stored: now, or `at` (milliseconds). */
64
+ iso(at?: number): string;
65
+ /**
66
+ * Whether `revision` is one of this history: none (0), the head, at or before the floor (the revisions before they
67
+ * were logged), or one this store stamped and still remembers. A page holding another came from a history this store
68
+ * doesn't have (a restore's lost future), or is older than the log keeps: it starts over, and its taps don't run.
69
+ */
70
+ known(revision: number): boolean;
71
+ /**
72
+ * The next revision, for rows written in the current transaction: past the last, and at least the clock in
73
+ * microseconds (plus a random part below a millisecond), so two histories forked from one copy never stamp the same
74
+ * number while the clock moves forward. Each is logged (`known`).
75
+ */
76
+ stamp(): number;
77
+ /** The command ledger (`slopkit/server/ledger`) on this store: its transaction and its stamp. */
78
+ createLedger<K>(codec: {
79
+ encode: (receipt: K) => string;
80
+ decode: (text: string) => K;
81
+ recover?: (command: {
82
+ name: string;
83
+ args: string;
84
+ }) => K;
85
+ }): Ledger<K>;
86
+ /** Jobs (`slopkit/server/jobs`) on this store. */
87
+ createJobs<R>(codec: {
88
+ encode: (result: R) => string;
89
+ decode: (text: string) => R;
90
+ }): Jobs<R>;
91
+ /**
92
+ * Retention, which never deletes a key: receipts accepted more than `receiptsMs` ago retire (their outcome dropped, never acting
93
+ * again), logged revisions older than `revisionsMs` are forgotten except the head (a page holding one starts over),
94
+ * and finished jobs beyond the newest `jobsKept` of each kind are pruned.
95
+ */
96
+ retain(options?: {
97
+ receiptsMs?: number;
98
+ revisionsMs?: number;
99
+ jobsKept?: number;
100
+ }): void;
101
+ }