slopkit 0.2.0 → 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.
- package/DEPS.md +2 -2
- package/README.md +10 -0
- package/dist/client.d.ts +16 -3
- package/dist/client.js +21 -2
- package/dist/conformance/client.test.js +7 -7
- package/dist/conformance/fixtures.d.ts +1 -0
- package/dist/conformance/fixtures.js +5 -1
- package/dist/conformance/outbox-bound.test.d.ts +1 -0
- package/dist/conformance/outbox-bound.test.js +268 -0
- package/dist/conformance/push-bytes.test.d.ts +1 -0
- package/dist/conformance/push-bytes.test.js +174 -0
- package/dist/conformance/push-refusal.test.d.ts +1 -0
- package/dist/conformance/push-refusal.test.js +644 -0
- package/dist/contract.d.ts +19 -6
- package/dist/contract.js +76 -9
- package/dist/server/pull.js +158 -67
- package/dist/server/web.d.ts +9 -5
- package/dist/server/web.js +29 -12
- package/dist/sync.d.ts +84 -2
- package/dist/sync.js +276 -59
- package/dist/web/replica.js +46 -4
- package/package.json +1 -1
package/DEPS.md
CHANGED
|
@@ -29,8 +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, ≤
|
|
33
|
-
paged first copy added 1,470).
|
|
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).
|
|
34
34
|
`cron` is browser-safe but outside the core; an app that ships it counts it in its own budget. The budget grows only
|
|
35
35
|
with a new entry or guarantee, by its measured size.
|
|
36
36
|
- **Hello app** (preact, signals, and `sync`, `client`, `web/replica`, `schema`, `contract`, `states`): ≤ 17 KB gzip
|
package/README.md
CHANGED
|
@@ -94,6 +94,16 @@ 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
|
+
|
|
97
107
|
### sync in pages
|
|
98
108
|
|
|
99
109
|
a device with no copy draws its newest rows after one round trip, whatever the history's size, then pulls older pages
|
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();
|
|
@@ -28,12 +28,25 @@ export type StoragePort<T, S, C, O> = {
|
|
|
28
28
|
revision: number;
|
|
29
29
|
whole?: boolean;
|
|
30
30
|
}>;
|
|
31
|
-
/**
|
|
32
|
-
|
|
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>;
|
|
33
38
|
drop: (keys: {
|
|
34
39
|
client: string;
|
|
35
40
|
id: number;
|
|
36
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>;
|
|
37
50
|
};
|
|
38
51
|
export type NetworkPort<P, S, C, O> = {
|
|
39
52
|
pull: (request: {
|
package/dist/client.js
CHANGED
|
@@ -53,7 +53,7 @@ export function createClient(options) {
|
|
|
53
53
|
case "push": {
|
|
54
54
|
const ids = effect.commands.map((each) => each.id);
|
|
55
55
|
const client = effect.client;
|
|
56
|
-
|
|
56
|
+
const send = () => ports.network
|
|
57
57
|
.push({
|
|
58
58
|
client,
|
|
59
59
|
dataset: effect.dataset,
|
|
@@ -68,6 +68,15 @@ export function createClient(options) {
|
|
|
68
68
|
ids,
|
|
69
69
|
failure: { kind: "offline", message: reason(error) },
|
|
70
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(),
|
|
71
80
|
})), dispatch);
|
|
72
81
|
}
|
|
73
82
|
case "commit": {
|
|
@@ -80,7 +89,17 @@ export function createClient(options) {
|
|
|
80
89
|
}
|
|
81
90
|
case "keep": {
|
|
82
91
|
const { client, id } = effect.pending;
|
|
83
|
-
return track(ports.storage.keep(effect.pending).then(() =>
|
|
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) => ({
|
|
84
103
|
type: "keepFailed",
|
|
85
104
|
client,
|
|
86
105
|
id,
|
|
@@ -13,7 +13,7 @@ function peer() {
|
|
|
13
13
|
const sent = [];
|
|
14
14
|
const pulls = [];
|
|
15
15
|
let loseNextPush = false;
|
|
16
|
-
let
|
|
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 (
|
|
61
|
-
|
|
60
|
+
if (unreadableNextPush) {
|
|
61
|
+
unreadableNextPush = false;
|
|
62
62
|
return {
|
|
63
63
|
ok: false,
|
|
64
|
-
failure: { kind: "
|
|
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
|
-
|
|
79
|
-
|
|
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.
|
|
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);
|
|
@@ -27,7 +27,11 @@ export 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({
|
|
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,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import { expect, test } from "vitest";
|
|
2
|
+
import { createClient } from "../client.js";
|
|
3
|
+
import * as syncApi from "../sync.js";
|
|
4
|
+
import { createSync } from "../sync.js";
|
|
5
|
+
import { database, deferred, pending, rowsLens } from "./fixtures.js";
|
|
6
|
+
function bounded(max = 3, joins = false) {
|
|
7
|
+
return createSync({
|
|
8
|
+
tables: [rowsLens],
|
|
9
|
+
empty: { rows: new Map() },
|
|
10
|
+
onlineOnly: () => false,
|
|
11
|
+
refused: (_command, reason) => ({ kind: "failed", reason }),
|
|
12
|
+
outboxMax: max,
|
|
13
|
+
joins: (waiting, next) => joins && waiting.value === next.value,
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
const offline = {
|
|
17
|
+
pull: async () => ({ ok: false, failure: { kind: "offline", message: "offline" } }),
|
|
18
|
+
push: async () => {
|
|
19
|
+
throw new Error("no offline intent may be sent");
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
function client(stored, id, max = 3, joins = false) {
|
|
23
|
+
const answers = [];
|
|
24
|
+
const runtime = createClient({
|
|
25
|
+
sync: bounded(max, joins),
|
|
26
|
+
client: id,
|
|
27
|
+
ports: {
|
|
28
|
+
storage: stored.port,
|
|
29
|
+
network: offline,
|
|
30
|
+
now: () => 0,
|
|
31
|
+
fingerprint: async () => "",
|
|
32
|
+
answered: (answer) => {
|
|
33
|
+
answers.push(answer);
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
return { runtime, answers };
|
|
38
|
+
}
|
|
39
|
+
test("outbox has a shared default of 500 at the storage boundary", async () => {
|
|
40
|
+
const stored = database();
|
|
41
|
+
await stored.port.load();
|
|
42
|
+
const results = await Promise.all(Array.from({ length: 501 }, (_, at) => stored.port.keep(pending("page", at + 1))));
|
|
43
|
+
expect(results.slice(0, 500)).toStrictEqual(Array.from({ length: 500 }, () => undefined));
|
|
44
|
+
expect(results[500]).toStrictEqual({ kind: "full", waiting: 500, finished: 0, max: 500 });
|
|
45
|
+
expect((await stored.port.load()).commands).toHaveLength(500);
|
|
46
|
+
expect(syncApi.OUTBOX_MAX).toBe(500);
|
|
47
|
+
});
|
|
48
|
+
test("outboxMax must be a positive safe integer", () => {
|
|
49
|
+
for (const max of [0, -1, 1.5, Number.NaN]) {
|
|
50
|
+
expect(() => bounded(max)).toThrow(/outboxMax must be a positive integer/);
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
test("keep admits and counts in one transaction, but always permits an existing key's outcome", async () => {
|
|
54
|
+
const stored = database();
|
|
55
|
+
await stored.port.load();
|
|
56
|
+
const first = pending("page", 1);
|
|
57
|
+
expect(await stored.port.keep(first, 1)).toBeUndefined();
|
|
58
|
+
expect(await stored.port.keep(pending("page", 2), 1)).toStrictEqual({
|
|
59
|
+
kind: "full",
|
|
60
|
+
waiting: 1,
|
|
61
|
+
finished: 0,
|
|
62
|
+
max: 1,
|
|
63
|
+
});
|
|
64
|
+
expect(await stored.port.keep({ ...first, state: { kind: "done", outcome: { kind: "ok", resource: "r1" }, revision: 0 } }, 1)).toBeUndefined();
|
|
65
|
+
expect(await stored.port.keep(pending("page", 3), 1)).toStrictEqual({
|
|
66
|
+
kind: "full",
|
|
67
|
+
waiting: 0,
|
|
68
|
+
finished: 1,
|
|
69
|
+
max: 1,
|
|
70
|
+
});
|
|
71
|
+
expect((await stored.port.load()).commands).toHaveLength(1);
|
|
72
|
+
});
|
|
73
|
+
test("two offline clients share one transactional admission bound; every unadmitted tap is answered without a record", async () => {
|
|
74
|
+
const a = database();
|
|
75
|
+
const b = database({ factory: a.factory, name: a.name });
|
|
76
|
+
const ca = client(a, "tab-a");
|
|
77
|
+
const cb = client(b, "tab-b");
|
|
78
|
+
ca.runtime.start();
|
|
79
|
+
cb.runtime.start();
|
|
80
|
+
await Promise.all([ca.runtime.settle(), cb.runtime.settle()]);
|
|
81
|
+
// Neither page has heard of the other's taps. All six keeps overlap before either page gets an acknowledgement.
|
|
82
|
+
for (const value of [1, 2, 3]) {
|
|
83
|
+
ca.runtime.command({ name: "put", value });
|
|
84
|
+
cb.runtime.command({ name: "put", value: value + 10 });
|
|
85
|
+
}
|
|
86
|
+
await Promise.all([ca.runtime.settle(), cb.runtime.settle()]);
|
|
87
|
+
const disk = await a.port.load();
|
|
88
|
+
const models = [...ca.runtime.model().commands, ...cb.runtime.model().commands];
|
|
89
|
+
const refused = [...ca.answers, ...cb.answers];
|
|
90
|
+
expect(disk.commands).toHaveLength(3);
|
|
91
|
+
expect(models).toHaveLength(3);
|
|
92
|
+
expect(new Set(disk.commands.map((each) => `${each.client}:${each.id}`)).size).toBe(3);
|
|
93
|
+
expect(models
|
|
94
|
+
.map((each) => `${each.client}:${each.id}`)
|
|
95
|
+
.toSorted((left, right) => left.localeCompare(right))).toStrictEqual(disk.commands
|
|
96
|
+
.map((each) => `${each.client}:${each.id}`)
|
|
97
|
+
.toSorted((left, right) => left.localeCompare(right)));
|
|
98
|
+
expect(refused).toHaveLength(3);
|
|
99
|
+
for (const answer of refused) {
|
|
100
|
+
expect(answer.state).toMatchObject({
|
|
101
|
+
kind: "done",
|
|
102
|
+
revision: 0,
|
|
103
|
+
outcome: { kind: "failed" },
|
|
104
|
+
});
|
|
105
|
+
if (answer.state.kind !== "done" || answer.state.outcome.kind !== "failed") {
|
|
106
|
+
throw new Error("not a refused tap");
|
|
107
|
+
}
|
|
108
|
+
expect(answer.state.outcome.reason).toMatch(/outbox.*full/i);
|
|
109
|
+
expect(models).not.toContainEqual(answer);
|
|
110
|
+
expect(disk.commands.some((each) => each.client === answer.client && each.id === answer.id)).toBe(false);
|
|
111
|
+
}
|
|
112
|
+
expect(ca.runtime.model().storage.kind).toBe("ok");
|
|
113
|
+
expect(cb.runtime.model().storage.kind).toBe("ok");
|
|
114
|
+
expect([...ca.runtime.faults, ...cb.runtime.faults]).toStrictEqual([]);
|
|
115
|
+
const denied = ca.answers.length > 0 ? ca : cb;
|
|
116
|
+
expect(syncApi.deriveOutboxState(denied.runtime.model())).toStrictEqual({
|
|
117
|
+
kind: "full",
|
|
118
|
+
waiting: 3,
|
|
119
|
+
finished: 0,
|
|
120
|
+
max: 3,
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
test("a full outbox leaves model and disk unchanged; joins coalesces before admission", async () => {
|
|
124
|
+
const stored = database();
|
|
125
|
+
const app = client(stored, "page", 1, true);
|
|
126
|
+
app.runtime.start();
|
|
127
|
+
await app.runtime.settle();
|
|
128
|
+
app.runtime.command({ name: "put", value: 1 });
|
|
129
|
+
await app.runtime.settle();
|
|
130
|
+
const before = (await stored.port.load()).commands;
|
|
131
|
+
app.runtime.command({ name: "put", value: 1 });
|
|
132
|
+
await app.runtime.settle();
|
|
133
|
+
expect(app.answers).toStrictEqual([]);
|
|
134
|
+
app.runtime.command({ name: "put", value: 2 });
|
|
135
|
+
await app.runtime.settle();
|
|
136
|
+
expect(app.runtime.model().commands).toHaveLength(1);
|
|
137
|
+
expect((await stored.port.load()).commands).toStrictEqual(before);
|
|
138
|
+
expect(app.answers).toHaveLength(1);
|
|
139
|
+
expect(app.runtime.model().storage.kind).toBe("ok");
|
|
140
|
+
});
|
|
141
|
+
test("a late load plus concurrent keeps obeys the disk count, not the page's pre-load count", async () => {
|
|
142
|
+
const stored = database();
|
|
143
|
+
const old = client(stored, "old");
|
|
144
|
+
old.runtime.start();
|
|
145
|
+
await old.runtime.settle();
|
|
146
|
+
for (const value of [1, 2, 3]) {
|
|
147
|
+
old.runtime.command({ name: "put", value });
|
|
148
|
+
}
|
|
149
|
+
await old.runtime.settle();
|
|
150
|
+
const loaded = deferred();
|
|
151
|
+
const answers = [];
|
|
152
|
+
const late = createClient({
|
|
153
|
+
sync: bounded(),
|
|
154
|
+
client: "late",
|
|
155
|
+
ports: {
|
|
156
|
+
storage: { ...stored.port, load: () => loaded.promise },
|
|
157
|
+
network: offline,
|
|
158
|
+
now: () => 3001,
|
|
159
|
+
fingerprint: async () => "",
|
|
160
|
+
answered: (answer) => {
|
|
161
|
+
answers.push(answer);
|
|
162
|
+
},
|
|
163
|
+
},
|
|
164
|
+
});
|
|
165
|
+
late.start();
|
|
166
|
+
late.dispatch({ type: "tick", now: 6002 });
|
|
167
|
+
late.command({ name: "put", value: 4 });
|
|
168
|
+
loaded.resolve(await stored.port.load());
|
|
169
|
+
await late.settle();
|
|
170
|
+
expect((await stored.port.load()).commands.map((each) => each.command.value)).toStrictEqual([
|
|
171
|
+
1, 2, 3,
|
|
172
|
+
]);
|
|
173
|
+
expect(late.model().commands.map((each) => each.command.value)).toStrictEqual([1, 2, 3]);
|
|
174
|
+
expect(answers).toHaveLength(1);
|
|
175
|
+
});
|
|
176
|
+
test("grandfathered records are never dropped; outcomes update at capacity and dismissal permits a new admission", async () => {
|
|
177
|
+
const stored = database();
|
|
178
|
+
const old = client(stored, "old", 4);
|
|
179
|
+
old.runtime.start();
|
|
180
|
+
await old.runtime.settle();
|
|
181
|
+
for (const value of [1, 2, 3, 4]) {
|
|
182
|
+
old.runtime.command({ name: "put", value });
|
|
183
|
+
}
|
|
184
|
+
await old.runtime.settle();
|
|
185
|
+
const reopened = database({ factory: stored.factory, name: stored.name });
|
|
186
|
+
const app = client(reopened, "new", 3);
|
|
187
|
+
app.runtime.start();
|
|
188
|
+
await app.runtime.settle();
|
|
189
|
+
// A late receipt is an update of an existing key, even when the current bound is below the existing count.
|
|
190
|
+
app.runtime.dispatch({
|
|
191
|
+
type: "pushed",
|
|
192
|
+
client: "old",
|
|
193
|
+
ids: [1],
|
|
194
|
+
revision: 0,
|
|
195
|
+
receipts: [{ id: 1, outcome: { kind: "ok", resource: "r1" }, revision: 0 }],
|
|
196
|
+
now: 0,
|
|
197
|
+
});
|
|
198
|
+
await app.runtime.settle();
|
|
199
|
+
expect((await stored.port.load()).commands).toHaveLength(4);
|
|
200
|
+
expect((await stored.port.load()).commands[0]?.state).toStrictEqual({
|
|
201
|
+
kind: "done",
|
|
202
|
+
outcome: { kind: "ok", resource: "r1" },
|
|
203
|
+
revision: 0,
|
|
204
|
+
});
|
|
205
|
+
app.runtime.command({ name: "put", value: 5 });
|
|
206
|
+
await app.runtime.settle();
|
|
207
|
+
expect((await stored.port.load()).commands).toHaveLength(4);
|
|
208
|
+
expect(syncApi.deriveOutboxState(app.runtime.model())).toStrictEqual({
|
|
209
|
+
kind: "full",
|
|
210
|
+
waiting: 3,
|
|
211
|
+
finished: 1,
|
|
212
|
+
max: 3,
|
|
213
|
+
});
|
|
214
|
+
// The count must be below max, not merely equal to max.
|
|
215
|
+
app.runtime.dispatch({ type: "dismiss", client: "old", id: 1, now: 0 });
|
|
216
|
+
await app.runtime.settle();
|
|
217
|
+
app.runtime.command({ name: "put", value: 6 });
|
|
218
|
+
await app.runtime.settle();
|
|
219
|
+
expect((await stored.port.load()).commands).toHaveLength(3);
|
|
220
|
+
app.runtime.dispatch({
|
|
221
|
+
type: "pushed",
|
|
222
|
+
client: "old",
|
|
223
|
+
ids: [2],
|
|
224
|
+
revision: 0,
|
|
225
|
+
receipts: [{ id: 2, outcome: { kind: "ok", resource: "r2" }, revision: 0 }],
|
|
226
|
+
now: 0,
|
|
227
|
+
});
|
|
228
|
+
await app.runtime.settle();
|
|
229
|
+
app.runtime.dispatch({ type: "dismiss", client: "old", id: 2, now: 0 });
|
|
230
|
+
await app.runtime.settle();
|
|
231
|
+
app.runtime.command({ name: "put", value: 7 });
|
|
232
|
+
await app.runtime.settle();
|
|
233
|
+
expect((await stored.port.load()).commands.map((each) => each.command.value).toSorted((a, b) => a - b)).toStrictEqual([3, 4, 7]);
|
|
234
|
+
expect(app.runtime.faults).toStrictEqual([]);
|
|
235
|
+
});
|
|
236
|
+
test("the outbox subject distinguishes waiting work from finished work needing dismissal", async () => {
|
|
237
|
+
const stored = database();
|
|
238
|
+
const app = client(stored, "page", 1);
|
|
239
|
+
app.runtime.start();
|
|
240
|
+
await app.runtime.settle();
|
|
241
|
+
expect(syncApi.deriveOutboxState(app.runtime.model())).toStrictEqual({ kind: "ok" });
|
|
242
|
+
app.runtime.command({ name: "put", value: 1 });
|
|
243
|
+
await app.runtime.settle();
|
|
244
|
+
expect(syncApi.deriveOutboxState(app.runtime.model())).toStrictEqual({
|
|
245
|
+
kind: "full",
|
|
246
|
+
waiting: 1,
|
|
247
|
+
finished: 0,
|
|
248
|
+
max: 1,
|
|
249
|
+
});
|
|
250
|
+
app.runtime.dispatch({
|
|
251
|
+
type: "pushed",
|
|
252
|
+
client: "page",
|
|
253
|
+
ids: [1],
|
|
254
|
+
revision: 0,
|
|
255
|
+
receipts: [{ id: 1, outcome: { kind: "ok", resource: "r1" }, revision: 0 }],
|
|
256
|
+
now: 0,
|
|
257
|
+
});
|
|
258
|
+
await app.runtime.settle();
|
|
259
|
+
expect(syncApi.deriveOutboxState(app.runtime.model())).toStrictEqual({
|
|
260
|
+
kind: "full",
|
|
261
|
+
waiting: 0,
|
|
262
|
+
finished: 1,
|
|
263
|
+
max: 1,
|
|
264
|
+
});
|
|
265
|
+
app.runtime.dispatch({ type: "dismiss", client: "page", id: 1, now: 0 });
|
|
266
|
+
await app.runtime.settle();
|
|
267
|
+
expect(syncApi.deriveOutboxState(app.runtime.model())).toStrictEqual({ kind: "ok" });
|
|
268
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { IDBFactory } from "fake-indexeddb";
|
|
2
|
+
import { expect, test } from "vitest";
|
|
3
|
+
import { createClient } from "../client.js";
|
|
4
|
+
import { literal, nullable, number, object, optional, string, variant } from "../schema.js";
|
|
5
|
+
import * as syncApi from "../sync.js";
|
|
6
|
+
import { createSync, POLL_MS } from "../sync.js";
|
|
7
|
+
import { indexedReplica } from "../web/replica.js";
|
|
8
|
+
import { outcomeSchema } from "./fixtures.js";
|
|
9
|
+
const pendingSchema = object({
|
|
10
|
+
client: string(),
|
|
11
|
+
id: number(),
|
|
12
|
+
dataset: nullable(string()),
|
|
13
|
+
since: optional(number()),
|
|
14
|
+
command: object({ text: string() }),
|
|
15
|
+
at: number(),
|
|
16
|
+
state: variant("kind", {
|
|
17
|
+
saving: object({ kind: literal("saving") }),
|
|
18
|
+
queued: object({ kind: literal("queued") }),
|
|
19
|
+
sending: object({ kind: literal("sending") }),
|
|
20
|
+
unknown: object({
|
|
21
|
+
kind: literal("unknown"),
|
|
22
|
+
failure: string(),
|
|
23
|
+
parked: optional(literal(true)),
|
|
24
|
+
}),
|
|
25
|
+
done: object({ kind: literal("done"), outcome: outcomeSchema, revision: number() }),
|
|
26
|
+
}),
|
|
27
|
+
});
|
|
28
|
+
function engine(pushBytes) {
|
|
29
|
+
return createSync({
|
|
30
|
+
tables: [],
|
|
31
|
+
empty: {},
|
|
32
|
+
onlineOnly: () => false,
|
|
33
|
+
refused: (_command, reason) => ({ kind: "failed", reason }),
|
|
34
|
+
...(pushBytes === undefined ? {} : { pushBytes }),
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
function storage() {
|
|
38
|
+
return indexedReplica({
|
|
39
|
+
factory: new IDBFactory(),
|
|
40
|
+
name: "bytes",
|
|
41
|
+
version: 1,
|
|
42
|
+
tables: [],
|
|
43
|
+
empty: {},
|
|
44
|
+
server: object({}),
|
|
45
|
+
pending: pendingSchema,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
const queued = (id, text) => ({
|
|
49
|
+
client: "earlier",
|
|
50
|
+
id,
|
|
51
|
+
dataset: "d",
|
|
52
|
+
since: 1,
|
|
53
|
+
command: { text },
|
|
54
|
+
at: 0,
|
|
55
|
+
state: { kind: "queued" },
|
|
56
|
+
});
|
|
57
|
+
async function run(commands, pushBytes) {
|
|
58
|
+
const disk = storage();
|
|
59
|
+
await disk.load();
|
|
60
|
+
for (const each of commands) {
|
|
61
|
+
await disk.keep(each);
|
|
62
|
+
}
|
|
63
|
+
const requests = [];
|
|
64
|
+
const answered = [];
|
|
65
|
+
const client = createClient({
|
|
66
|
+
sync: engine(pushBytes),
|
|
67
|
+
client: "new",
|
|
68
|
+
ports: {
|
|
69
|
+
storage: disk,
|
|
70
|
+
now: () => 0,
|
|
71
|
+
fingerprint: async () => "fixture",
|
|
72
|
+
answered: (each) => {
|
|
73
|
+
answered.push(each);
|
|
74
|
+
},
|
|
75
|
+
network: {
|
|
76
|
+
pull: async () => ({
|
|
77
|
+
ok: true,
|
|
78
|
+
value: {
|
|
79
|
+
patch: {
|
|
80
|
+
dataset: "d",
|
|
81
|
+
revision: 1,
|
|
82
|
+
reset: true,
|
|
83
|
+
rows: {},
|
|
84
|
+
server: {},
|
|
85
|
+
checksum: "fixture",
|
|
86
|
+
},
|
|
87
|
+
receipts: [],
|
|
88
|
+
},
|
|
89
|
+
}),
|
|
90
|
+
push: async (input) => {
|
|
91
|
+
requests.push({
|
|
92
|
+
bytes: new TextEncoder().encode(JSON.stringify(input)).byteLength,
|
|
93
|
+
ids: input.commands.map((each) => each.id),
|
|
94
|
+
});
|
|
95
|
+
return {
|
|
96
|
+
ok: true,
|
|
97
|
+
value: {
|
|
98
|
+
revision: 1,
|
|
99
|
+
receipts: input.commands.map((each) => ({
|
|
100
|
+
id: each.id,
|
|
101
|
+
outcome: { kind: "ok", resource: `r${each.id}` },
|
|
102
|
+
revision: 1,
|
|
103
|
+
})),
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
client.start();
|
|
111
|
+
await client.settle();
|
|
112
|
+
return { client, disk, requests, answered };
|
|
113
|
+
}
|
|
114
|
+
test("the default push budget is 48 KiB of the whole encoded request, including multibyte commands", async () => {
|
|
115
|
+
const app = await run([
|
|
116
|
+
queued(1, "é".repeat(13_000)),
|
|
117
|
+
queued(2, "€".repeat(9000)),
|
|
118
|
+
queued(3, "x"),
|
|
119
|
+
]);
|
|
120
|
+
expect(app.requests.map((each) => each.ids)).toStrictEqual([[1], [2, 3]]);
|
|
121
|
+
expect(app.requests.every((each) => each.bytes <= 49_152)).toBe(true);
|
|
122
|
+
expect(app.answered.map((each) => each.id)).toStrictEqual([1, 2, 3]);
|
|
123
|
+
expect(app.client.faults).toStrictEqual([]);
|
|
124
|
+
expect(syncApi.PUSH_BYTES).toBe(49_152);
|
|
125
|
+
});
|
|
126
|
+
test("packing stops at the first non-fitting command, rather than skipping it for a smaller later command", async () => {
|
|
127
|
+
// Literal wire sizes: envelope 58, each encoded command 30 + its text bytes, separators 1.
|
|
128
|
+
// First+second is over 300, but first+third fits. Oldest-first must not choose first+third.
|
|
129
|
+
const app = await run([queued(1, "é".repeat(50)), queued(2, "€".repeat(40)), queued(3, "x")], 300);
|
|
130
|
+
expect(app.requests.map((each) => each.ids)).toStrictEqual([[1], [2, 3]]);
|
|
131
|
+
expect(app.requests.every((each) => each.bytes <= 300)).toBe(true);
|
|
132
|
+
expect(app.answered.map((each) => each.id)).toStrictEqual([1, 2, 3]);
|
|
133
|
+
});
|
|
134
|
+
test.each([false, true])("a kept oversize oldest command does not strand later work (uncertain: %s)", async (uncertain) => {
|
|
135
|
+
const old = queued(1, "é".repeat(200));
|
|
136
|
+
if (uncertain) {
|
|
137
|
+
old.state = { kind: "unknown", failure: "an earlier reply was lost" };
|
|
138
|
+
}
|
|
139
|
+
const app = await run([old, queued(2, "small")], 300);
|
|
140
|
+
expect(app.requests.map((each) => each.ids)).toStrictEqual([[2]]);
|
|
141
|
+
const first = app.client.model().commands[0];
|
|
142
|
+
expect(first?.state).toMatchObject(uncertain
|
|
143
|
+
? { kind: "unknown", parked: true }
|
|
144
|
+
: { kind: "done", revision: 0, outcome: { kind: "failed" } });
|
|
145
|
+
expect(app.client.model().commands[1]?.state).toStrictEqual({
|
|
146
|
+
kind: "done",
|
|
147
|
+
outcome: { kind: "ok", resource: "r2" },
|
|
148
|
+
revision: 1,
|
|
149
|
+
});
|
|
150
|
+
expect((await app.disk.load()).commands[0]?.state).toStrictEqual(first?.state);
|
|
151
|
+
expect(app.client.faults).toStrictEqual([]);
|
|
152
|
+
app.client.dispatch({ type: "tick", now: POLL_MS * 3 });
|
|
153
|
+
await app.client.settle();
|
|
154
|
+
expect(app.requests.map((each) => each.ids)).toStrictEqual([[2]]);
|
|
155
|
+
});
|
|
156
|
+
test("a new oversize tap is answered without being retained or sent", async () => {
|
|
157
|
+
const app = await run([], 300);
|
|
158
|
+
app.client.command({ text: "é".repeat(200) });
|
|
159
|
+
await app.client.settle();
|
|
160
|
+
expect(app.requests).toStrictEqual([]);
|
|
161
|
+
expect(app.client.model().commands).toStrictEqual([]);
|
|
162
|
+
expect((await app.disk.load()).commands).toStrictEqual([]);
|
|
163
|
+
expect(app.answered).toHaveLength(1);
|
|
164
|
+
expect(app.answered[0]?.state).toMatchObject({
|
|
165
|
+
kind: "done",
|
|
166
|
+
revision: 0,
|
|
167
|
+
outcome: { kind: "failed" },
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
test("pushBytes must be a positive safe integer", () => {
|
|
171
|
+
for (const bytes of [0, -1, 1.5, Number.NaN]) {
|
|
172
|
+
expect(() => engine(bytes)).toThrow(/pushBytes must be a positive integer/);
|
|
173
|
+
}
|
|
174
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|