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/DEPS.md CHANGED
@@ -29,7 +29,8 @@ seeded network's PRNG and scheduler.
29
29
  ## Budgets
30
30
 
31
31
  - **Kit client core**, gated by `pack:check` on the packed package: `schema`, `contract`, `sync`, `client`, `machine`,
32
- `money`, `digest`, `states` and `web/replica`, minified and gzipped together, ≤ 10,730 bytes (10,726 measured).
32
+ `money`, `digest`, `states` and `web/replica`, minified and gzipped together, ≤ 13,951 bytes (13,951 measured; the
33
+ paged first copy added 1,470, refused pushes and the bounded outbox 1,755).
33
34
  `cron` is browser-safe but outside the core; an app that ships it counts it in its own budget. The budget grows only
34
35
  with a new entry or guarantee, by its measured size.
35
36
  - **Hello app** (preact, signals, and `sync`, `client`, `web/replica`, `schema`, `contract`, `states`): ≤ 17 KB gzip
package/README.md CHANGED
@@ -94,6 +94,57 @@ export default defineConfig({
94
94
  });
95
95
  ```
96
96
 
97
+ push handlers return per-command outcomes as ledger receipts. once any command in a request has acted, never throw
98
+ an `invalid`, `forbidden` or `conflict` refusal for the whole request: a refusal proves only that attempt did not act,
99
+ not that an earlier delivery did not. request schemas are checked before the handler runs.
100
+
101
+ a push is at most `PUSH_BYTES` (48 KiB) of UTF-8 json, within `serveWeb`'s default `bodyLimit` (64 KiB): never set
102
+ `bodyLimit` below the page's `pushBytes`. the outbox admits at most `OUTBOX_MAX` (500) commands across tabs: place
103
+ `deriveOutboxState(model)` in its own table (`waiting` needs a connection, `finished` needs dismissing), and a
104
+ command that is `unknown` and `parked` as needing attention. a `Failure` of kind `unreadable` is a reply the page
105
+ couldn't read: it may have acted.
106
+
107
+ ### sync in pages
108
+
109
+ a device with no copy draws its newest rows after one round trip, whatever the history's size, then pulls older pages
110
+ back to back until it holds everything. the server half is `slopkit/server/pull`: one history table paged newest
111
+ first by a keyset order, the other tables whole, receipts with the rows they name, all in one read transaction and
112
+ within your caps.
113
+
114
+ ```ts
115
+ import { createPull } from "slopkit/server/pull";
116
+
117
+ const pulls = createPull({
118
+ db, dataset: () => store.dataset(), revision: () => store.revision(),
119
+ // newest first by (date, id), descending; id is unique. bump version when the order changes
120
+ paged: { table: "transactions", order: ["date", "id"], version: 1, live: "deleted = 0", read: toTransaction,
121
+ through: ([date]) => String(date) },
122
+ unpaged: (since) => ({ accounts: store.accounts(since) }), // whole on every reset
123
+ // the commands the page names (any of its pages'), or an old page's own receipts since `since`
124
+ receipts: { read: ({ since, commands }, viewer: Viewer) => commands === undefined
125
+ ? ledger.receipts(viewer.principal, viewer.client, since).map((each) => ({ ...each, client: viewer.client }))
126
+ : ledger.receiptsOf(viewer.principal, commands),
127
+ names: (each) => [each.receipt.transaction] },
128
+ limits: { firstRows: 200, pageRows: 2000, maxRows: 5000, maxBytes: 2_000_000 },
129
+ // your endpoint's reply: the caps measure its json
130
+ respond: ({ rows, unpaged, receipts, ...rest }) => ({ ...rest, transactions: rows, ...unpaged, commands: receipts }),
131
+ });
132
+
133
+ route(pull, (request, caller) => pulls.pull(request, viewerOf(request, caller)).reply);
134
+ ```
135
+
136
+ the page's pull request carries `page` (the engine fills it in; a request without it gets the old whole reply), and
137
+ the copy's history is its own subject: place `deriveHistoryState(model)` (loading, none, partial, complete) and
138
+ `deriveDurabilityState(model)` (the storage states) in two tables and compose both. partial is a claim beside the
139
+ numbers, "history from <through>, loading older · held of total", and totals cover only `through` and above. run the
140
+ pull contract against your own pull in your tests:
141
+
142
+ ```ts
143
+ import { pullConformance } from "slopkit/conformance/pull";
144
+
145
+ pullConformance({ name: "my app", create: async (limits) => fixtureOverMyRealStore(limits) });
146
+ ```
147
+
97
148
  the shared lint rules go on top of ultracite's presets (`oxlint.config.ts`):
98
149
 
99
150
  ```ts
package/dist/client.d.ts CHANGED
@@ -7,7 +7,7 @@
7
7
  * the app's own hooks (`answered`, for example opening or closing a provider's window, and `telemetry`).
8
8
  */
9
9
  import type { Result } from "./contract.ts";
10
- import type { Commit, createSync, Model, Msg, Patch, Pending, Receipt, Replica } from "./sync.ts";
10
+ import type { Commit, createSync, Model, Msg, OutboxFull, Patch, Pending, Receipt, Replica } from "./sync.ts";
11
11
  /** The disk was forgotten or moved to another dataset under this page (another tab): its writes stop, and it reloads. */
12
12
  export declare class Replaced extends Error {
13
13
  constructor();
@@ -19,17 +19,34 @@ export type StoragePort<T, S, C, O> = {
19
19
  commands: Pending<C, O>[];
20
20
  unreadable: number;
21
21
  }>;
22
- /** Commit a patch, decided against the disk inside its transaction; returns what the disk now holds. */
22
+ /**
23
+ * Commit a patch, decided against the disk inside its transaction; returns what the disk now holds, and `whole:
24
+ * false` when it took the patch as a delta under its own history (so it lacks rows of this page's copy).
25
+ */
23
26
  commit: (commit: Commit<T, S>) => Promise<{
24
27
  dataset: string;
25
28
  revision: number;
29
+ whole?: boolean;
26
30
  }>;
27
- /** Write one command (an intent, or its outcome) under its key. */
28
- keep: (pending: Pending<C, O>) => Promise<void>;
31
+ /**
32
+ * Write one command (an intent, or its outcome) under its key. A key the outbox doesn't hold yet is admitted only
33
+ * while it holds fewer than `max` commands (default `OUTBOX_MAX`), decided inside the write's own transaction so
34
+ * every tab shares the bound; otherwise nothing is written and the store's counts come back. A kept key always
35
+ * updates, and nothing kept is ever dropped for room.
36
+ */
37
+ keep: (pending: Pending<C, O>, max?: number) => Promise<void | OutboxFull>;
29
38
  drop: (keys: {
30
39
  client: string;
31
40
  id: number;
32
41
  }[]) => Promise<void>;
42
+ /**
43
+ * Journal a push before it is sent: these kept commands move to `sending`, all in one write (a key not kept is left
44
+ * alone). A command loaded so is uncertain, since that attempt may have acted.
45
+ */
46
+ mark: (keys: {
47
+ client: string;
48
+ id: number;
49
+ }[]) => Promise<void>;
33
50
  };
34
51
  export type NetworkPort<P, S, C, O> = {
35
52
  pull: (request: {
@@ -37,6 +54,16 @@ export type NetworkPort<P, S, C, O> = {
37
54
  dataset: string | null;
38
55
  since: number;
39
56
  verify: boolean;
57
+ /**
58
+ * The older page wanted: null for none (a paged reset is understood), the copy's `next` while partial. Absent asks
59
+ * for an old server's whole reply.
60
+ */
61
+ page?: string | null;
62
+ /** Commands waiting on an outcome or on their effects, any page's: their receipts come with the rows they name. */
63
+ commands?: {
64
+ client: string;
65
+ id: number;
66
+ }[];
40
67
  }) => Promise<Result<{
41
68
  patch: Patch<P, S>;
42
69
  receipts: Receipt<O>[];
package/dist/client.js CHANGED
@@ -37,6 +37,10 @@ export function createClient(options) {
37
37
  dataset: effect.dataset,
38
38
  since,
39
39
  verify: effect.verify,
40
+ // An old server's whole copy is asked as an old client asks it: with neither.
41
+ ...(effect.page === undefined
42
+ ? {}
43
+ : { page: effect.page, commands: effect.commands }),
40
44
  })
41
45
  .then((result) => result.ok
42
46
  ? { type: "pulled", since, ...result.value, now: now() }
@@ -49,7 +53,7 @@ export function createClient(options) {
49
53
  case "push": {
50
54
  const ids = effect.commands.map((each) => each.id);
51
55
  const client = effect.client;
52
- return track(ports.network
56
+ const send = () => ports.network
53
57
  .push({
54
58
  client,
55
59
  dataset: effect.dataset,
@@ -64,6 +68,15 @@ export function createClient(options) {
64
68
  ids,
65
69
  failure: { kind: "offline", message: reason(error) },
66
70
  now: now(),
71
+ }));
72
+ // Journalled before it is sent: a page that dies mid-push leaves these commands uncertain on disk.
73
+ return track(ports.storage.mark(ids.map((id) => ({ client, id }))).then(send, (error) => ({
74
+ type: "unsent",
75
+ client,
76
+ ids,
77
+ reason: reason(error),
78
+ replaced: error instanceof Replaced,
79
+ now: now(),
67
80
  })), dispatch);
68
81
  }
69
82
  case "commit": {
@@ -76,7 +89,17 @@ export function createClient(options) {
76
89
  }
77
90
  case "keep": {
78
91
  const { client, id } = effect.pending;
79
- return track(ports.storage.keep(effect.pending).then(() => ({ type: "kept", client, id, now: now() }), (error) => ({
92
+ return track(ports.storage.keep(effect.pending, effect.max).then((kept) => kept === undefined
93
+ ? { type: "kept", client, id, now: now() }
94
+ : {
95
+ type: "full",
96
+ client,
97
+ id,
98
+ waiting: kept.waiting,
99
+ finished: kept.finished,
100
+ max: kept.max,
101
+ now: now(),
102
+ }, (error) => ({
80
103
  type: "keepFailed",
81
104
  client,
82
105
  id,
@@ -13,7 +13,7 @@ function peer() {
13
13
  const sent = [];
14
14
  const pulls = [];
15
15
  let loseNextPush = false;
16
- let invalidNextPush = false;
16
+ let unreadableNextPush = false;
17
17
  let rejectPull = false;
18
18
  let unavailable = false;
19
19
  const network = {
@@ -57,11 +57,11 @@ function peer() {
57
57
  loseNextPush = false;
58
58
  return { ok: false, failure: { kind: "offline", message: "reply lost after execution" } };
59
59
  }
60
- if (invalidNextPush) {
61
- invalidNextPush = false;
60
+ if (unreadableNextPush) {
61
+ unreadableNextPush = false;
62
62
  return {
63
63
  ok: false,
64
- failure: { kind: "invalid", message: "unreadable reply after execution" },
64
+ failure: { kind: "unreadable", message: "unreadable reply after execution" },
65
65
  };
66
66
  }
67
67
  return { ok: true, value: { receipts: answers, revision } };
@@ -75,8 +75,8 @@ function peer() {
75
75
  loseReply: () => {
76
76
  loseNextPush = true;
77
77
  },
78
- invalidReply: () => {
79
- invalidNextPush = true;
78
+ unreadableReply: () => {
79
+ unreadableNextPush = true;
80
80
  },
81
81
  failPull: () => {
82
82
  rejectPull = true;
@@ -315,7 +315,7 @@ describe("independent client executor conformance", () => {
315
315
  const remote = peer();
316
316
  const app = client(stored, remote.network, "page-a", { now: 0 });
317
317
  await start(app, stored);
318
- remote.invalidReply();
318
+ remote.unreadableReply();
319
319
  app.runtime.command({ name: "put", value: 125 });
320
320
  await app.runtime.settle();
321
321
  expect(remote.effects()).toBe(1);
@@ -1,7 +1,7 @@
1
1
  import type { NetworkPort } from "../client.ts";
2
2
  import type { Infer, Schema } from "../schema.ts";
3
3
  import type { Key, Pending, Replica } from "../sync.ts";
4
- declare const rowSchema: Schema<{
4
+ export declare const rowSchema: Schema<{
5
5
  deleted: boolean;
6
6
  id: string;
7
7
  revision: number;
@@ -14,7 +14,7 @@ export type Tables = {
14
14
  export type PatchRows = {
15
15
  rows: Row[];
16
16
  };
17
- declare const serverSchema: Schema<{
17
+ export declare const serverSchema: Schema<{
18
18
  build: string;
19
19
  }>;
20
20
  export type Server = Infer<typeof serverSchema>;
@@ -40,6 +40,58 @@ export declare const outcomeSchema: Schema<({
40
40
  kind: "unknown";
41
41
  })>;
42
42
  export type Outcome = Infer<typeof outcomeSchema>;
43
+ export declare const pendingSchema: Schema<{
44
+ at: number;
45
+ client: string;
46
+ command: {
47
+ name: "put";
48
+ value: number;
49
+ };
50
+ dataset: string | null;
51
+ id: number;
52
+ since?: number;
53
+ state: ({
54
+ kind: "saving";
55
+ } & {
56
+ kind: "saving";
57
+ }) | ({
58
+ kind: "queued";
59
+ } & {
60
+ kind: "queued";
61
+ }) | ({
62
+ kind: "sending";
63
+ } & {
64
+ kind: "sending";
65
+ }) | ({
66
+ failure: string;
67
+ kind: "unknown";
68
+ parked?: true;
69
+ } & {
70
+ kind: "unknown";
71
+ }) | ({
72
+ kind: "done";
73
+ outcome: ({
74
+ kind: "ok";
75
+ resource: string;
76
+ } & {
77
+ kind: "ok";
78
+ }) | ({
79
+ kind: "failed";
80
+ reason: string;
81
+ } & {
82
+ kind: "failed";
83
+ }) | ({
84
+ kind: "unknown";
85
+ reason: string;
86
+ } & {
87
+ kind: "unknown";
88
+ });
89
+ revision: number;
90
+ } & {
91
+ kind: "done";
92
+ });
93
+ }>;
94
+ export declare const rowsLens: import("../sync.ts").Lens<Tables, PatchRows>;
43
95
  export declare const sync: {
44
96
  initial: (client: string, now: number) => import("../sync.ts").Model<Tables, {
45
97
  build: string;
@@ -147,7 +199,7 @@ export declare const sync: {
147
199
  build: string;
148
200
  }>, patch: import("../sync.ts").Patch<PatchRows, {
149
201
  build: string;
150
- }>) => {
202
+ }>, since: number) => "gap" | {
151
203
  replica: Replica<Tables, {
152
204
  build: string;
153
205
  }>;
@@ -3,20 +3,20 @@ import { IDBFactory } from "fake-indexeddb";
3
3
  import { boolean, literal, nullable, number, object, optional, string, variant, } from "../schema.js";
4
4
  import { createSync, table } from "../sync.js";
5
5
  import { indexedReplica } from "../web/replica.js";
6
- const rowSchema = object({
6
+ export const rowSchema = object({
7
7
  id: string(),
8
8
  value: number({ integer: true }),
9
9
  deleted: boolean(),
10
10
  revision: number({ integer: true, min: 0 }),
11
11
  }, { strict: true });
12
- const serverSchema = object({ build: string() }, { strict: true });
12
+ export const serverSchema = object({ build: string() }, { strict: true });
13
13
  const commandSchema = object({ name: literal("put"), value: number({ integer: true }) }, { strict: true });
14
14
  export const outcomeSchema = variant("kind", {
15
15
  ok: object({ kind: literal("ok"), resource: string() }, { strict: true }),
16
16
  failed: object({ kind: literal("failed"), reason: string() }, { strict: true }),
17
17
  unknown: object({ kind: literal("unknown"), reason: string() }, { strict: true }),
18
18
  });
19
- const pendingSchema = object({
19
+ export const pendingSchema = object({
20
20
  client: string(),
21
21
  id: number({ integer: true, min: 1 }),
22
22
  dataset: nullable(string()),
@@ -27,7 +27,11 @@ const pendingSchema = object({
27
27
  saving: object({ kind: literal("saving") }),
28
28
  queued: object({ kind: literal("queued") }),
29
29
  sending: object({ kind: literal("sending") }),
30
- unknown: object({ kind: literal("unknown"), failure: string() }),
30
+ unknown: object({
31
+ kind: literal("unknown"),
32
+ failure: string(),
33
+ parked: optional(literal(true)),
34
+ }),
31
35
  done: object({
32
36
  kind: literal("done"),
33
37
  outcome: outcomeSchema,
@@ -36,7 +40,7 @@ const pendingSchema = object({
36
40
  }),
37
41
  });
38
42
  const empty = { rows: new Map() };
39
- const rowsLens = table({
43
+ export const rowsLens = table({
40
44
  name: "rows",
41
45
  schema: rowSchema,
42
46
  rows: (patch) => patch.rows,
@@ -0,0 +1,52 @@
1
+ import type { Schema } from "../../schema.ts";
2
+ import type { Lens, Op, Pending } from "../../sync.ts";
3
+ type Replica<T, S> = {
4
+ dataset: string | null;
5
+ revision: number;
6
+ tables: T;
7
+ server: S | null;
8
+ };
9
+ type StoragePort<T, S, C, O> = {
10
+ load: () => Promise<{
11
+ replica: Replica<T, S>;
12
+ commands: Pending<C, O>[];
13
+ unreadable: number;
14
+ }>;
15
+ commit: (commit: {
16
+ replica: Replica<T, S>;
17
+ ops: Op[];
18
+ reset: boolean;
19
+ base: number;
20
+ replaces: string | null;
21
+ }) => Promise<{
22
+ dataset: string;
23
+ revision: number;
24
+ }>;
25
+ keep: (pending: Pending<C, O>) => Promise<void>;
26
+ drop: (keys: {
27
+ client: string;
28
+ id: number;
29
+ }[]) => Promise<void>;
30
+ };
31
+ export declare function indexedReplica<T, P, S, C, O>(options: {
32
+ factory: IDBFactory;
33
+ name: string;
34
+ /** The object stores' layout: bump when a table is added or renamed. Row shapes are checked on their own. */
35
+ version: number;
36
+ tables: readonly Lens<T, P>[];
37
+ empty: T;
38
+ server: Schema<S>;
39
+ pending: Schema<Pending<C, O>>;
40
+ interfere?: (what: "commit" | "keep" | "drop", transaction: IDBTransaction) => void;
41
+ /**
42
+ * An outbox an earlier release kept in another store: on upgrade each record is moved into the outbox, converted to
43
+ * a command (or kept as it was, under its own key, to be counted unreadable). Never dropped.
44
+ */
45
+ legacy?: {
46
+ store: string;
47
+ convert: (value: unknown) => Pending<C, O> | undefined;
48
+ };
49
+ }): StoragePort<T, S, C, O> & {
50
+ forget: () => Promise<void>;
51
+ };
52
+ export {};