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.
- package/DEPS.md +5 -3
- package/README.md +103 -1
- package/RULES.md +26 -6
- package/dist/api.d.ts +93 -0
- package/dist/api.js +111 -0
- package/dist/client.d.ts +21 -4
- package/dist/client.js +21 -2
- package/dist/conformance/agent-access.test.d.ts +1 -0
- package/dist/conformance/agent-access.test.js +74 -0
- 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/paging-state.test.js +1 -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/conformance/states.test.js +19 -8
- package/dist/contract.d.ts +19 -6
- package/dist/contract.js +76 -9
- package/dist/server/auth.d.ts +4 -3
- package/dist/server/auth.js +14 -6
- package/dist/server/cli.d.ts +117 -0
- package/dist/server/cli.js +1534 -0
- package/dist/server/config.d.ts +4 -1
- package/dist/server/config.js +2 -2
- package/dist/server/keys.d.ts +17 -0
- package/dist/server/keys.js +127 -0
- package/dist/server/pull.js +158 -67
- package/dist/server/store.d.ts +101 -0
- package/dist/server/store.js +347 -0
- package/dist/server/sync.d.ts +158 -0
- package/dist/server/sync.js +293 -0
- package/dist/server/web.d.ts +62 -9
- package/dist/server/web.js +260 -35
- package/dist/states.d.ts +52 -4
- package/dist/states.js +20 -0
- package/dist/sync.d.ts +86 -2
- package/dist/sync.js +277 -59
- package/dist/test/agents.d.ts +19 -0
- package/dist/test/agents.js +62 -0
- package/dist/test/page.d.ts +91 -0
- package/dist/test/page.js +763 -0
- package/dist/test/states.d.ts +44 -7
- package/dist/test/states.js +152 -0
- package/dist/web/replica.d.ts +7 -0
- package/dist/web/replica.js +260 -47
- package/dist/web/runtime.d.ts +102 -0
- package/dist/web/runtime.js +335 -0
- package/package.json +39 -1
|
@@ -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 {};
|