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.
- package/DEPS.md +2 -1
- package/README.md +51 -0
- package/dist/client.d.ts +31 -4
- package/dist/client.js +25 -2
- package/dist/conformance/client.test.js +7 -7
- package/dist/conformance/fixtures.d.ts +55 -3
- package/dist/conformance/fixtures.js +9 -5
- package/dist/conformance/legacy/replica-012.d.ts +52 -0
- package/dist/conformance/legacy/replica-012.js +403 -0
- package/dist/conformance/outbox-bound.test.d.ts +1 -0
- package/dist/conformance/outbox-bound.test.js +268 -0
- package/dist/conformance/paging-budget.test.d.ts +1 -0
- package/dist/conformance/paging-budget.test.js +101 -0
- package/dist/conformance/paging-fixtures.d.ts +80 -0
- package/dist/conformance/paging-fixtures.js +135 -0
- package/dist/conformance/paging-network.test.d.ts +1 -0
- package/dist/conformance/paging-network.test.js +123 -0
- package/dist/conformance/paging-state.test.d.ts +1 -0
- package/dist/conformance/paging-state.test.js +180 -0
- package/dist/conformance/paging-storage.test.d.ts +1 -0
- package/dist/conformance/paging-storage.test.js +255 -0
- package/dist/conformance/paging.test.d.ts +1 -0
- package/dist/conformance/paging.test.js +209 -0
- package/dist/conformance/pull.d.ts +74 -0
- package/dist/conformance/pull.js +400 -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/ledger.d.ts +13 -0
- package/dist/server/ledger.js +18 -0
- package/dist/server/pull.d.ts +85 -0
- package/dist/server/pull.js +324 -0
- package/dist/server/web.d.ts +9 -5
- package/dist/server/web.js +29 -12
- package/dist/sync.d.ts +221 -16
- package/dist/sync.js +565 -96
- package/dist/web/replica.js +93 -16
- package/package.json +17 -1
|
@@ -0,0 +1,644 @@
|
|
|
1
|
+
import { channel } from "node:diagnostics_channel";
|
|
2
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
3
|
+
import { createServer, request, IncomingMessage } from "node:http";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { DatabaseSync } from "node:sqlite";
|
|
7
|
+
import { afterEach, expect, test } from "vitest";
|
|
8
|
+
import { createClient } from "../client.js";
|
|
9
|
+
import { call, endpoint } from "../contract.js";
|
|
10
|
+
import { array, literal, nullable, number, object, string } from "../schema.js";
|
|
11
|
+
import { parseWebConfig } from "../server/config.js";
|
|
12
|
+
import { Ledger } from "../server/ledger.js";
|
|
13
|
+
import { Refusal, route, serveWeb } from "../server/web.js";
|
|
14
|
+
import { POLL_MS } from "../sync.js";
|
|
15
|
+
import { identityProvider } from "../test/oidc.js";
|
|
16
|
+
import { database, deferred, outcomeSchema, patch, pending, row, sync, values, } from "./fixtures.js";
|
|
17
|
+
const cleanups = [];
|
|
18
|
+
afterEach(async () => {
|
|
19
|
+
for (const close of cleanups.splice(0).toReversed()) {
|
|
20
|
+
await close();
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
const pushInput = object({
|
|
24
|
+
client: string(),
|
|
25
|
+
dataset: nullable(string()),
|
|
26
|
+
since: number(),
|
|
27
|
+
commands: array(object({
|
|
28
|
+
id: number(),
|
|
29
|
+
command: object({ name: literal("put"), value: number({ integer: true, min: 0 }) }, { strict: true }),
|
|
30
|
+
})),
|
|
31
|
+
}, { strict: true });
|
|
32
|
+
const pushOutput = object({
|
|
33
|
+
receipts: array(object({ id: number(), outcome: outcomeSchema, revision: number() })),
|
|
34
|
+
revision: number(),
|
|
35
|
+
});
|
|
36
|
+
const pushEndpoint = endpoint("POST", "/api/push", pushInput, pushOutput);
|
|
37
|
+
const echo = endpoint("POST", "/api/echo", object({ text: string() }), object({ text: string() }));
|
|
38
|
+
/** Real HTTP schema boundary, real ledger and independent business table. Pulls read actual ledger receipts. */
|
|
39
|
+
async function server(bodyLimit) {
|
|
40
|
+
const home = await mkdtemp(join(tmpdir(), "push-contract-"));
|
|
41
|
+
cleanups.push(() => rm(home, { recursive: true, force: true }));
|
|
42
|
+
const db = new DatabaseSync(":memory:");
|
|
43
|
+
cleanups.push(() => db.close());
|
|
44
|
+
db.exec(`create table commands(principal text, client text, id integer, name text, args text,
|
|
45
|
+
state text, outcome text, revision integer, created_at text, primary key(principal,client,id));
|
|
46
|
+
create table business(id text primary key, value integer, revision integer);
|
|
47
|
+
create table clock(n integer); insert into clock values(1);`);
|
|
48
|
+
const revision = () => Number(db.prepare("select n from clock").get()?.n);
|
|
49
|
+
const ledger = new Ledger({
|
|
50
|
+
db,
|
|
51
|
+
transaction: (body) => {
|
|
52
|
+
if (db.isTransaction) {
|
|
53
|
+
return body();
|
|
54
|
+
}
|
|
55
|
+
db.exec("begin immediate");
|
|
56
|
+
try {
|
|
57
|
+
const result = body();
|
|
58
|
+
db.exec("commit");
|
|
59
|
+
return result;
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
db.exec("rollback");
|
|
63
|
+
throw error;
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
stamp: () => {
|
|
67
|
+
db.exec("update clock set n=n+1");
|
|
68
|
+
return revision();
|
|
69
|
+
},
|
|
70
|
+
encode: JSON.stringify,
|
|
71
|
+
decode: (text) => outcomeSchema.parse(JSON.parse(text)),
|
|
72
|
+
});
|
|
73
|
+
const principal = "person:owner";
|
|
74
|
+
const act = async (client, id, value) => {
|
|
75
|
+
const key = { principal, client, id };
|
|
76
|
+
const result = await ledger.run(key, { name: "put", args: JSON.stringify({ value }) }, {
|
|
77
|
+
local: () => {
|
|
78
|
+
db.prepare("insert into business values(?,?,?)").run(`r${id}`, value, revision() + 1);
|
|
79
|
+
return { kind: "ok", resource: `r${id}` };
|
|
80
|
+
},
|
|
81
|
+
remote: async () => ({ kind: "failed", reason: "not an external command" }),
|
|
82
|
+
});
|
|
83
|
+
// Conflicts are per-command receipts, never a thrown refusal after earlier commands acted.
|
|
84
|
+
return {
|
|
85
|
+
id,
|
|
86
|
+
outcome: result.kind === "done" ? result.receipt : { kind: "failed", reason: result.kind },
|
|
87
|
+
revision: ledger.revisionOf(key) ?? revision(),
|
|
88
|
+
};
|
|
89
|
+
};
|
|
90
|
+
const provider = await identityProvider("owner");
|
|
91
|
+
cleanups.push(provider.close);
|
|
92
|
+
let refuse;
|
|
93
|
+
let echoCalls = 0;
|
|
94
|
+
const web = await serveWeb({
|
|
95
|
+
home,
|
|
96
|
+
assets: home,
|
|
97
|
+
env: {},
|
|
98
|
+
manifest: {},
|
|
99
|
+
cookie: "conformance",
|
|
100
|
+
log: () => { },
|
|
101
|
+
config: parseWebConfig({ port: 0, oidc: { issuer: provider.issuer, clientId: "test" }, allow: ["sub:owner"] }, []),
|
|
102
|
+
...(bodyLimit === undefined ? {} : { bodyLimit }),
|
|
103
|
+
routes: [
|
|
104
|
+
route(pushEndpoint, async (input) => {
|
|
105
|
+
if (refuse !== undefined) {
|
|
106
|
+
throw new Refusal(refuse, "not allowed now");
|
|
107
|
+
}
|
|
108
|
+
const receipts = [];
|
|
109
|
+
for (const each of input.commands) {
|
|
110
|
+
receipts.push(await act(input.client, each.id, each.command.value));
|
|
111
|
+
}
|
|
112
|
+
return { receipts, revision: revision() };
|
|
113
|
+
}),
|
|
114
|
+
route(echo, (input) => {
|
|
115
|
+
echoCalls++;
|
|
116
|
+
return input;
|
|
117
|
+
}),
|
|
118
|
+
],
|
|
119
|
+
});
|
|
120
|
+
cleanups.push(web.close);
|
|
121
|
+
let login = await fetch(new URL("auth/login", web.url), { redirect: "manual" });
|
|
122
|
+
login = await fetch(login.headers.get("location") ?? "", { redirect: "manual" });
|
|
123
|
+
login = await fetch(login.headers.get("location") ?? "", { redirect: "manual" });
|
|
124
|
+
const headers = {
|
|
125
|
+
cookie: (login.headers.get("set-cookie") ?? "").split(";")[0] ?? "",
|
|
126
|
+
origin: new URL(web.url).origin,
|
|
127
|
+
};
|
|
128
|
+
const options = { base: web.url.replace(/\/$/, ""), headers };
|
|
129
|
+
const pulls = [];
|
|
130
|
+
const network = {
|
|
131
|
+
push: (input) => call(pushEndpoint, input, options),
|
|
132
|
+
pull: async (input) => {
|
|
133
|
+
pulls.push(input.commands ?? []);
|
|
134
|
+
const rows = db
|
|
135
|
+
.prepare("select id,value,revision from business order by id")
|
|
136
|
+
.all()
|
|
137
|
+
.map((each) => row(String(each.id), Number(each.value), Number(each.revision)));
|
|
138
|
+
return {
|
|
139
|
+
ok: true,
|
|
140
|
+
value: {
|
|
141
|
+
patch: patch(revision(), rows, true),
|
|
142
|
+
receipts: ledger.receiptsOf(principal, input.commands ?? []).map((each) => ({
|
|
143
|
+
client: each.client,
|
|
144
|
+
id: each.id,
|
|
145
|
+
outcome: each.receipt,
|
|
146
|
+
revision: each.revision,
|
|
147
|
+
})),
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
return {
|
|
153
|
+
web,
|
|
154
|
+
headers,
|
|
155
|
+
options,
|
|
156
|
+
network,
|
|
157
|
+
pulls,
|
|
158
|
+
act,
|
|
159
|
+
echoCalls: () => echoCalls,
|
|
160
|
+
refuse: (kind) => {
|
|
161
|
+
refuse = kind;
|
|
162
|
+
},
|
|
163
|
+
effects: () => db
|
|
164
|
+
.prepare("select id,value,revision from business order by id")
|
|
165
|
+
.all()
|
|
166
|
+
.map((each) => [each.id, each.value, each.revision]),
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
async function launch(network, commands = [], stored = database()) {
|
|
170
|
+
await stored.port.load();
|
|
171
|
+
for (const each of commands) {
|
|
172
|
+
await stored.port.keep(each);
|
|
173
|
+
}
|
|
174
|
+
const clock = { now: 0 };
|
|
175
|
+
const answers = [];
|
|
176
|
+
const logs = [];
|
|
177
|
+
const runtime = createClient({
|
|
178
|
+
sync,
|
|
179
|
+
client: "newer",
|
|
180
|
+
ports: {
|
|
181
|
+
storage: stored.port,
|
|
182
|
+
network,
|
|
183
|
+
now: () => clock.now,
|
|
184
|
+
fingerprint: async () => "fixture-fingerprint",
|
|
185
|
+
answered: (each) => {
|
|
186
|
+
answers.push(each);
|
|
187
|
+
},
|
|
188
|
+
telemetry: (name) => {
|
|
189
|
+
logs.push(name);
|
|
190
|
+
},
|
|
191
|
+
},
|
|
192
|
+
});
|
|
193
|
+
runtime.start();
|
|
194
|
+
await runtime.settle();
|
|
195
|
+
return { runtime, stored, clock, answers, logs };
|
|
196
|
+
}
|
|
197
|
+
const queued = (id, value) => ({ ...pending("page", id, value), since: 1 });
|
|
198
|
+
async function advance(app, now) {
|
|
199
|
+
app.clock.now = now;
|
|
200
|
+
app.runtime.dispatch({ type: "tick", now });
|
|
201
|
+
await app.runtime.settle();
|
|
202
|
+
}
|
|
203
|
+
// Rejected programs are kept uncalled: the type check must reject a server-made unreadable failure.
|
|
204
|
+
function typeContract() {
|
|
205
|
+
// @ts-expect-error unreadable is a client observation, not a server Refusal
|
|
206
|
+
return new Refusal("unreadable", "broken reply");
|
|
207
|
+
}
|
|
208
|
+
void typeContract;
|
|
209
|
+
test("a schema 400 carries the exact path, before any command acts", async () => {
|
|
210
|
+
const peer = await server();
|
|
211
|
+
const input = {
|
|
212
|
+
client: "page",
|
|
213
|
+
dataset: "dataset-a",
|
|
214
|
+
since: 1,
|
|
215
|
+
commands: [
|
|
216
|
+
{ id: 1, command: { name: "put", value: 10 } },
|
|
217
|
+
{ id: 2, command: { name: "put", value: -1 } },
|
|
218
|
+
],
|
|
219
|
+
};
|
|
220
|
+
const raw = await fetch(new URL("api/push", peer.web.url), {
|
|
221
|
+
method: "POST",
|
|
222
|
+
headers: { ...peer.headers, "content-type": "application/json" },
|
|
223
|
+
body: JSON.stringify(input),
|
|
224
|
+
});
|
|
225
|
+
expect(raw.status).toBe(400);
|
|
226
|
+
expect(await raw.json()).toMatchObject({
|
|
227
|
+
kind: "invalid",
|
|
228
|
+
path: "request.commands[1].command.value",
|
|
229
|
+
});
|
|
230
|
+
const result = await call(pushEndpoint, input, peer.options);
|
|
231
|
+
expect(peer.effects()).toStrictEqual([]);
|
|
232
|
+
expect(result).toMatchObject({
|
|
233
|
+
ok: false,
|
|
234
|
+
failure: { kind: "invalid", refused: true, path: "request.commands[1].command.value" },
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
test("a bad middle command alone is refused; both good commands are resent once and answered", async () => {
|
|
238
|
+
const peer = await server();
|
|
239
|
+
const sent = [];
|
|
240
|
+
const app = await launch({
|
|
241
|
+
...peer.network,
|
|
242
|
+
push: (input) => {
|
|
243
|
+
sent.push(input.commands.map((each) => each.id));
|
|
244
|
+
return peer.network.push(input);
|
|
245
|
+
},
|
|
246
|
+
}, [queued(1, 10), queued(2, -1), queued(3, 30)]);
|
|
247
|
+
await advance(app, POLL_MS);
|
|
248
|
+
expect(sent).toStrictEqual([
|
|
249
|
+
[1, 2, 3],
|
|
250
|
+
[1, 3],
|
|
251
|
+
]);
|
|
252
|
+
expect(peer.effects()).toStrictEqual([
|
|
253
|
+
["r1", 10, 2],
|
|
254
|
+
["r3", 30, 3],
|
|
255
|
+
]);
|
|
256
|
+
expect(app.answers.map((each) => [each.id, each.state.kind])).toStrictEqual([
|
|
257
|
+
[2, "done"],
|
|
258
|
+
[1, "done"],
|
|
259
|
+
[3, "done"],
|
|
260
|
+
]);
|
|
261
|
+
expect(app.runtime.model().commands[1]?.state).toMatchObject({
|
|
262
|
+
kind: "done",
|
|
263
|
+
outcome: {
|
|
264
|
+
kind: "failed",
|
|
265
|
+
reason: "request.commands[1].command.value: expected an integer from 0 to 1.7976931348623157e+308, got -1",
|
|
266
|
+
},
|
|
267
|
+
});
|
|
268
|
+
expect((await app.stored.port.load()).commands).toHaveLength(3);
|
|
269
|
+
expect(app.logs).toContain("push.failed");
|
|
270
|
+
expect(app.runtime.faults).toStrictEqual([]);
|
|
271
|
+
});
|
|
272
|
+
test("a lost earlier outcome survives a 400 naming a different command", async () => {
|
|
273
|
+
const peer = await server();
|
|
274
|
+
let lose = true;
|
|
275
|
+
let reveal = false;
|
|
276
|
+
const sent = [];
|
|
277
|
+
const app = await launch({
|
|
278
|
+
...peer.network,
|
|
279
|
+
pull: async (input) => {
|
|
280
|
+
const result = await peer.network.pull(input);
|
|
281
|
+
return result.ok && !reveal ? { ok: true, value: { ...result.value, receipts: [] } } : result;
|
|
282
|
+
},
|
|
283
|
+
push: async (input) => {
|
|
284
|
+
sent.push(input.commands.map((each) => each.id));
|
|
285
|
+
const result = await peer.network.push(input);
|
|
286
|
+
if (lose) {
|
|
287
|
+
lose = false;
|
|
288
|
+
return { ok: false, failure: { kind: "offline", message: "reply lost after act" } };
|
|
289
|
+
}
|
|
290
|
+
reveal = true;
|
|
291
|
+
return result;
|
|
292
|
+
},
|
|
293
|
+
});
|
|
294
|
+
app.runtime.command({ name: "put", value: 10 });
|
|
295
|
+
await app.runtime.settle();
|
|
296
|
+
app.runtime.command({ name: "put", value: -1 });
|
|
297
|
+
await app.runtime.settle();
|
|
298
|
+
await advance(app, POLL_MS);
|
|
299
|
+
await advance(app, POLL_MS * 3);
|
|
300
|
+
expect(peer.effects()).toStrictEqual([["r1", 10, 2]]);
|
|
301
|
+
expect(sent.slice(0, 2)).toStrictEqual([[1], [1, 2]]);
|
|
302
|
+
expect(app.runtime.model().commands.map((each) => [each.id, each.state])).toStrictEqual([
|
|
303
|
+
[1, { kind: "done", outcome: { kind: "ok", resource: "r1" }, revision: 2 }],
|
|
304
|
+
[
|
|
305
|
+
2,
|
|
306
|
+
{
|
|
307
|
+
kind: "done",
|
|
308
|
+
outcome: {
|
|
309
|
+
kind: "failed",
|
|
310
|
+
reason: "request.commands[1].command.value: expected an integer from 0 to 1.7976931348623157e+308, got -1",
|
|
311
|
+
},
|
|
312
|
+
revision: 0,
|
|
313
|
+
},
|
|
314
|
+
],
|
|
315
|
+
]);
|
|
316
|
+
expect(app.runtime.faults).toStrictEqual([]);
|
|
317
|
+
});
|
|
318
|
+
test("a named refusal parks an uncertain command durably, never resends it, and a named pull reconciles it", async () => {
|
|
319
|
+
const peer = await server();
|
|
320
|
+
// An older contract accepted this intent. Its reply was lost before the new contract tightened value's range.
|
|
321
|
+
await peer.act("page", 1, -1);
|
|
322
|
+
const sent = [];
|
|
323
|
+
const app = await launch({
|
|
324
|
+
...peer.network,
|
|
325
|
+
pull: async (input) => {
|
|
326
|
+
const result = await peer.network.pull(input);
|
|
327
|
+
return result.ok ? { ok: true, value: { ...result.value, receipts: [] } } : result;
|
|
328
|
+
},
|
|
329
|
+
push: (input) => {
|
|
330
|
+
sent.push(input.commands.map((each) => each.id));
|
|
331
|
+
return peer.network.push(input);
|
|
332
|
+
},
|
|
333
|
+
}, [{ ...queued(1, -1), state: { kind: "unknown", failure: "reply lost" } }, queued(2, 20)]);
|
|
334
|
+
await advance(app, POLL_MS * 3);
|
|
335
|
+
expect(app.runtime.model().commands[0]?.state).toMatchObject({ kind: "unknown", parked: true });
|
|
336
|
+
expect(sent).toStrictEqual([[1, 2], [2]]);
|
|
337
|
+
expect((await app.stored.port.load()).commands[0]?.state).toMatchObject({
|
|
338
|
+
kind: "unknown",
|
|
339
|
+
parked: true,
|
|
340
|
+
});
|
|
341
|
+
let reveal = false;
|
|
342
|
+
const reloadedPushes = [];
|
|
343
|
+
const reloaded = await launch({
|
|
344
|
+
...peer.network,
|
|
345
|
+
pull: async (input) => {
|
|
346
|
+
const result = await peer.network.pull(input);
|
|
347
|
+
return result.ok && !reveal
|
|
348
|
+
? { ok: true, value: { ...result.value, receipts: [] } }
|
|
349
|
+
: result;
|
|
350
|
+
},
|
|
351
|
+
push: (input) => {
|
|
352
|
+
reloadedPushes.push(input.commands.map((each) => each.id));
|
|
353
|
+
return peer.network.push(input);
|
|
354
|
+
},
|
|
355
|
+
}, [], database({ factory: app.stored.factory, name: app.stored.name }));
|
|
356
|
+
expect(reloaded.runtime.model().commands[0]?.state).toMatchObject({
|
|
357
|
+
kind: "unknown",
|
|
358
|
+
parked: true,
|
|
359
|
+
});
|
|
360
|
+
expect(reloadedPushes).toStrictEqual([]);
|
|
361
|
+
reveal = true;
|
|
362
|
+
await advance(reloaded, POLL_MS);
|
|
363
|
+
expect(reloaded.runtime.model().commands[0]?.state).toStrictEqual({
|
|
364
|
+
kind: "done",
|
|
365
|
+
outcome: { kind: "ok", resource: "r1" },
|
|
366
|
+
revision: 2,
|
|
367
|
+
});
|
|
368
|
+
expect(reloadedPushes).toStrictEqual([]);
|
|
369
|
+
expect(peer.pulls.flat()).toContainEqual({ client: "page", id: 1 });
|
|
370
|
+
expect(peer.effects()).toStrictEqual([
|
|
371
|
+
["r1", -1, 2],
|
|
372
|
+
["r2", 20, 3],
|
|
373
|
+
]);
|
|
374
|
+
expect(reloaded.runtime.faults).toStrictEqual([]);
|
|
375
|
+
});
|
|
376
|
+
test.each(["forbidden", "conflict"])("a matching kit %s is final for a never-uncertain command", async (kind) => {
|
|
377
|
+
const peer = await server();
|
|
378
|
+
peer.refuse(kind);
|
|
379
|
+
const result = await call(pushEndpoint, {
|
|
380
|
+
client: "page",
|
|
381
|
+
dataset: "dataset-a",
|
|
382
|
+
since: 1,
|
|
383
|
+
commands: [{ id: 1, command: { name: "put", value: 10 } }],
|
|
384
|
+
}, peer.options);
|
|
385
|
+
expect(result).toMatchObject({ ok: false, failure: { kind, refused: true } });
|
|
386
|
+
const app = await launch(peer.network, [queued(1, 10)]);
|
|
387
|
+
expect(app.answers.map((each) => each.state)).toStrictEqual([
|
|
388
|
+
{ kind: "done", revision: 0, outcome: { kind: "failed", reason: "not allowed now" } },
|
|
389
|
+
]);
|
|
390
|
+
expect((await app.stored.port.load()).commands[0]?.state).toStrictEqual(app.answers[0]?.state);
|
|
391
|
+
expect(peer.effects()).toStrictEqual([]);
|
|
392
|
+
});
|
|
393
|
+
test.each(["forbidden", "conflict"])("a kit %s after a lost reply and reload cannot overwrite prior uncertainty", async (kind) => {
|
|
394
|
+
const peer = await server();
|
|
395
|
+
const first = await launch({
|
|
396
|
+
...peer.network,
|
|
397
|
+
push: async (input) => {
|
|
398
|
+
await peer.network.push(input);
|
|
399
|
+
return { ok: false, failure: { kind: "offline", message: "reply lost after act" } };
|
|
400
|
+
},
|
|
401
|
+
});
|
|
402
|
+
first.runtime.command({ name: "put", value: 10 });
|
|
403
|
+
await first.runtime.settle();
|
|
404
|
+
peer.refuse(kind);
|
|
405
|
+
const app = await launch({
|
|
406
|
+
...peer.network,
|
|
407
|
+
pull: async (input) => {
|
|
408
|
+
const result = await peer.network.pull(input);
|
|
409
|
+
return result.ok ? { ok: true, value: { ...result.value, receipts: [] } } : result;
|
|
410
|
+
},
|
|
411
|
+
}, [], database({ factory: first.stored.factory, name: first.stored.name }));
|
|
412
|
+
expect(app.runtime.model().commands[0]?.state.kind).toBe("unknown");
|
|
413
|
+
expect(app.answers).toStrictEqual([]);
|
|
414
|
+
// Receipt reconciliation is allowed even while the write is forbidden.
|
|
415
|
+
const reloaded = await launch(peer.network, [], database({ factory: app.stored.factory, name: app.stored.name }));
|
|
416
|
+
expect(reloaded.runtime.model().commands[0]?.state).toMatchObject({
|
|
417
|
+
kind: "done",
|
|
418
|
+
outcome: { kind: "ok", resource: "r1" },
|
|
419
|
+
});
|
|
420
|
+
expect(peer.effects()).toStrictEqual([["r1", 10, 2]]);
|
|
421
|
+
});
|
|
422
|
+
test("an envelope 400 restores queued intent and reload with a matching contract acts once", async () => {
|
|
423
|
+
const peer = await server();
|
|
424
|
+
const app = await launch({
|
|
425
|
+
...peer.network,
|
|
426
|
+
push: (input) => {
|
|
427
|
+
const futureRequest = { ...input, future: true };
|
|
428
|
+
return call(pushEndpoint, futureRequest, peer.options);
|
|
429
|
+
},
|
|
430
|
+
}, [queued(1, 10), queued(2, 20)]);
|
|
431
|
+
expect(app.runtime.model().connection.kind).toBe("incompatible");
|
|
432
|
+
expect(app.runtime.model().commands.map((each) => each.state)).toStrictEqual([
|
|
433
|
+
{ kind: "queued" },
|
|
434
|
+
{ kind: "queued" },
|
|
435
|
+
]);
|
|
436
|
+
expect(peer.effects()).toStrictEqual([]);
|
|
437
|
+
expect(app.logs).toContain("push.failed");
|
|
438
|
+
const reloaded = await launch(peer.network, [], database({ factory: app.stored.factory, name: app.stored.name }));
|
|
439
|
+
expect(reloaded.answers.map((each) => each.id)).toStrictEqual([1, 2]);
|
|
440
|
+
expect(peer.effects()).toStrictEqual([
|
|
441
|
+
["r1", 10, 2],
|
|
442
|
+
["r2", 20, 3],
|
|
443
|
+
]);
|
|
444
|
+
});
|
|
445
|
+
test("an unreadable 200 is unknown and incompatible; reload replays the original ledger receipt", async () => {
|
|
446
|
+
const peer = await server();
|
|
447
|
+
const incompatibleOutput = endpoint("POST", "/api/push", pushInput, object({ future: literal(true) }));
|
|
448
|
+
let failureKind = "";
|
|
449
|
+
const app = await launch({
|
|
450
|
+
...peer.network,
|
|
451
|
+
push: async (input) => {
|
|
452
|
+
const result = await call(incompatibleOutput, input, peer.options);
|
|
453
|
+
if (result.ok) {
|
|
454
|
+
throw new Error("the incompatible response schema accepted the reply");
|
|
455
|
+
}
|
|
456
|
+
failureKind = result.failure.kind;
|
|
457
|
+
return result;
|
|
458
|
+
},
|
|
459
|
+
}, [queued(1, 10)]);
|
|
460
|
+
expect(failureKind).toBe("unreadable");
|
|
461
|
+
expect(app.runtime.model().commands[0]?.state.kind).toBe("unknown");
|
|
462
|
+
expect(app.runtime.model().connection.kind).toBe("incompatible");
|
|
463
|
+
expect(app.answers).toStrictEqual([]);
|
|
464
|
+
const reloaded = await launch(peer.network, [], database({ factory: app.stored.factory, name: app.stored.name }));
|
|
465
|
+
expect(reloaded.runtime.model().commands[0]?.state).toStrictEqual({
|
|
466
|
+
kind: "done",
|
|
467
|
+
outcome: { kind: "ok", resource: "r1" },
|
|
468
|
+
revision: 2,
|
|
469
|
+
});
|
|
470
|
+
expect(values(reloaded.runtime.model().replica)).toStrictEqual([["r1", 10, 2]]);
|
|
471
|
+
expect(peer.effects()).toStrictEqual([["r1", 10, 2]]);
|
|
472
|
+
});
|
|
473
|
+
test("a handler returns a conflict receipt after an earlier command acted, and replay never repeats that effect", async () => {
|
|
474
|
+
const peer = await server();
|
|
475
|
+
await peer.act("page", 2, 20);
|
|
476
|
+
const input = {
|
|
477
|
+
client: "page",
|
|
478
|
+
dataset: "dataset-a",
|
|
479
|
+
since: 1,
|
|
480
|
+
commands: [
|
|
481
|
+
{ id: 1, command: { name: "put", value: 10 } },
|
|
482
|
+
{ id: 2, command: { name: "put", value: 99 } },
|
|
483
|
+
],
|
|
484
|
+
};
|
|
485
|
+
const expected = {
|
|
486
|
+
ok: true,
|
|
487
|
+
value: {
|
|
488
|
+
receipts: [
|
|
489
|
+
{ id: 1, outcome: { kind: "ok", resource: "r1" }, revision: 3 },
|
|
490
|
+
{ id: 2, outcome: { kind: "failed", reason: "conflict" }, revision: 2 },
|
|
491
|
+
],
|
|
492
|
+
revision: 3,
|
|
493
|
+
},
|
|
494
|
+
};
|
|
495
|
+
expect(await call(pushEndpoint, input, peer.options)).toStrictEqual(expected);
|
|
496
|
+
expect(await call(pushEndpoint, input, peer.options)).toStrictEqual(expected);
|
|
497
|
+
expect(peer.effects()).toStrictEqual([
|
|
498
|
+
["r1", 10, 3],
|
|
499
|
+
["r2", 20, 2],
|
|
500
|
+
]);
|
|
501
|
+
});
|
|
502
|
+
/** A loopback intermediary answers real fetches with status/body combinations, never a replacement fetch. */
|
|
503
|
+
async function intermediary(status, body, type = "application/json", retryAfter) {
|
|
504
|
+
const web = createServer((_request, response) => {
|
|
505
|
+
response.writeHead(status, {
|
|
506
|
+
"content-type": type,
|
|
507
|
+
...(retryAfter === undefined ? {} : { "retry-after": retryAfter }),
|
|
508
|
+
});
|
|
509
|
+
response.end(body);
|
|
510
|
+
});
|
|
511
|
+
await new Promise((resolve) => {
|
|
512
|
+
web.listen(0, "127.0.0.1", resolve);
|
|
513
|
+
});
|
|
514
|
+
cleanups.push(() => new Promise((resolve) => {
|
|
515
|
+
web.closeAllConnections();
|
|
516
|
+
web.close(() => resolve());
|
|
517
|
+
}));
|
|
518
|
+
const address = web.address();
|
|
519
|
+
// oxlint-disable-next-line anti-slop/no-runtime-typeof -- node's public address() union includes Unix sockets
|
|
520
|
+
if (address === null || typeof address === "string") {
|
|
521
|
+
throw new Error("no loopback address");
|
|
522
|
+
}
|
|
523
|
+
return { base: `http://127.0.0.1:${address.port}` };
|
|
524
|
+
}
|
|
525
|
+
test.each([
|
|
526
|
+
{ status: 403, body: "<html>proxy denied</html>", type: "text/html" },
|
|
527
|
+
{ status: 409, body: "<html>proxy denied</html>", type: "text/html" },
|
|
528
|
+
{ status: 403, body: '{"message":"proxy denied"}', type: "application/json" },
|
|
529
|
+
{ status: 409, body: '{"message":"proxy denied"}', type: "application/json" },
|
|
530
|
+
{ status: 403, body: '{"kind":"invalid","message":"mismatched kind"}', type: "application/json" },
|
|
531
|
+
{ status: 409, body: '{"kind":"invalid","message":"mismatched kind"}', type: "application/json" },
|
|
532
|
+
])("HTTP $status without a matching kit kind is never final: $body", async ({ status, body, type }) => {
|
|
533
|
+
const options = await intermediary(status, body, type);
|
|
534
|
+
const result = await call(echo, { text: "probe" }, options);
|
|
535
|
+
expect(result).toMatchObject({
|
|
536
|
+
ok: false,
|
|
537
|
+
failure: { kind: status === 403 ? "forbidden" : "conflict" },
|
|
538
|
+
});
|
|
539
|
+
const app = await launch({
|
|
540
|
+
pull: async () => ({ ok: true, value: { patch: patch(1, [], true), receipts: [] } }),
|
|
541
|
+
push: (input) => call(pushEndpoint, input, options),
|
|
542
|
+
}, [queued(1, 10)]);
|
|
543
|
+
expect(app.runtime.model().commands[0]?.state.kind).toBe("unknown");
|
|
544
|
+
expect(app.answers).toStrictEqual([]);
|
|
545
|
+
});
|
|
546
|
+
test.each([408, 429])("HTTP %i retries no earlier than Retry-After", async (status) => {
|
|
547
|
+
const options = await intermediary(status, '{"message":"busy"}', "application/json", "9");
|
|
548
|
+
expect(await call(echo, { text: "probe" }, options)).toMatchObject({
|
|
549
|
+
ok: false,
|
|
550
|
+
failure: { kind: "unavailable", retryAfterMs: 9000 },
|
|
551
|
+
});
|
|
552
|
+
let attempts = 0;
|
|
553
|
+
const app = await launch({
|
|
554
|
+
pull: async () => ({ ok: true, value: { patch: patch(1, [], true), receipts: [] } }),
|
|
555
|
+
push: (input) => {
|
|
556
|
+
attempts++;
|
|
557
|
+
return call(pushEndpoint, input, options);
|
|
558
|
+
},
|
|
559
|
+
}, [queued(1, 10)]);
|
|
560
|
+
await advance(app, 8999);
|
|
561
|
+
expect(attempts).toBe(1);
|
|
562
|
+
await advance(app, 9000);
|
|
563
|
+
expect(attempts).toBe(2);
|
|
564
|
+
expect(app.runtime.model().commands[0]?.state.kind).toBe("unknown");
|
|
565
|
+
});
|
|
566
|
+
test.each([404, 405, 413])("HTTP %i stops as incompatible, with intent unknown and logged", async (status) => {
|
|
567
|
+
const options = await intermediary(status, '{"message":"wrong endpoint"}');
|
|
568
|
+
const app = await launch({
|
|
569
|
+
pull: async () => ({ ok: true, value: { patch: patch(1, [], true), receipts: [] } }),
|
|
570
|
+
push: (input) => call(pushEndpoint, input, options),
|
|
571
|
+
}, [queued(1, 10)]);
|
|
572
|
+
expect(app.runtime.model().connection.kind).toBe("incompatible");
|
|
573
|
+
expect(app.runtime.model().commands[0]?.state.kind).toBe("unknown");
|
|
574
|
+
expect(app.logs).toContain("push.failed");
|
|
575
|
+
});
|
|
576
|
+
test.each(["not JSON", '{"text":'])("a JSON-typed successful reply that breaks JSON is unreadable: %s", async (body) => {
|
|
577
|
+
const options = await intermediary(200, body);
|
|
578
|
+
expect(await call(echo, { text: "x" }, options)).toMatchObject({
|
|
579
|
+
ok: false,
|
|
580
|
+
failure: { kind: "unreadable" },
|
|
581
|
+
});
|
|
582
|
+
});
|
|
583
|
+
test("a successful HTML login page is signed-out, not an unreadable origin reply", async () => {
|
|
584
|
+
const options = await intermediary(200, "<html>sign in</html>", "text/html");
|
|
585
|
+
expect(await call(echo, { text: "x" }, options)).toMatchObject({
|
|
586
|
+
ok: false,
|
|
587
|
+
failure: { kind: "signed-out" },
|
|
588
|
+
});
|
|
589
|
+
});
|
|
590
|
+
test("split UTF-8 reaches the handler intact", async () => {
|
|
591
|
+
const peer = await server(24);
|
|
592
|
+
const bytes = Buffer.from(JSON.stringify({ text: "é€x" }));
|
|
593
|
+
const split = bytes.indexOf(Buffer.from("€")) + 1;
|
|
594
|
+
const consumed = deferred();
|
|
595
|
+
const requests = channel("http.server.request.start");
|
|
596
|
+
// oxlint-disable-next-line anti-slop/no-unknown-parameters -- diagnostics_channel publishes an untyped Node event
|
|
597
|
+
const observe = (message) => {
|
|
598
|
+
if (!(message instanceof Object) ||
|
|
599
|
+
!("request" in message) ||
|
|
600
|
+
!(message.request instanceof IncomingMessage)) {
|
|
601
|
+
throw new Error("http.server.request.start did not publish an IncomingMessage");
|
|
602
|
+
}
|
|
603
|
+
if (message.request.url === "/api/echo") {
|
|
604
|
+
// Observe the real iterator's first read before releasing the next wire chunk. Pausing prevents this listener
|
|
605
|
+
// from draining the body while authorization is still running. No delay or assumed TCP packet boundary.
|
|
606
|
+
message.request.once("data", () => consumed.resolve());
|
|
607
|
+
message.request.pause();
|
|
608
|
+
}
|
|
609
|
+
};
|
|
610
|
+
requests.subscribe(observe);
|
|
611
|
+
try {
|
|
612
|
+
const response = new Promise((resolve, reject) => {
|
|
613
|
+
const send = request(new URL("api/echo", peer.web.url), {
|
|
614
|
+
method: "POST",
|
|
615
|
+
headers: {
|
|
616
|
+
...peer.headers,
|
|
617
|
+
"content-type": "application/json",
|
|
618
|
+
"content-length": String(bytes.length),
|
|
619
|
+
},
|
|
620
|
+
}, (reply) => {
|
|
621
|
+
let body = "";
|
|
622
|
+
reply.setEncoding("utf-8");
|
|
623
|
+
reply.on("data", (chunk) => {
|
|
624
|
+
body += chunk;
|
|
625
|
+
});
|
|
626
|
+
reply.on("end", () => resolve({ status: reply.statusCode, body }));
|
|
627
|
+
});
|
|
628
|
+
send.on("error", reject);
|
|
629
|
+
send.write(bytes.subarray(0, split));
|
|
630
|
+
void consumed.promise.then(() => send.end(bytes.subarray(split)));
|
|
631
|
+
});
|
|
632
|
+
expect(await response).toStrictEqual({ status: 200, body: '{"text":"é€x"}' });
|
|
633
|
+
}
|
|
634
|
+
finally {
|
|
635
|
+
requests.unsubscribe(observe);
|
|
636
|
+
}
|
|
637
|
+
});
|
|
638
|
+
test("the body cap measures UTF-8 bytes, not code units", async () => {
|
|
639
|
+
const peer = await server(24);
|
|
640
|
+
// 23 UTF-16 units, but 35 wire bytes: rejected before the handler runs.
|
|
641
|
+
const over = await call(echo, { text: "é".repeat(12) }, peer.options);
|
|
642
|
+
expect(peer.echoCalls()).toBe(0);
|
|
643
|
+
expect(over).toMatchObject({ ok: false, failure: { kind: "invalid", refused: true } });
|
|
644
|
+
});
|
package/dist/contract.d.ts
CHANGED
|
@@ -12,13 +12,23 @@ export declare function endpoint<Req, Res>(method: "GET" | "POST", path: string,
|
|
|
12
12
|
}): Endpoint<Req, Res>;
|
|
13
13
|
/**
|
|
14
14
|
* Why a call failed. `signed-out`: the login is gone (the only kind that reloads the page). `forbidden`: signed in but
|
|
15
|
-
* not allowed. `invalid`: the request
|
|
16
|
-
*
|
|
15
|
+
* not allowed. `invalid`: the request broke the contract. `conflict`: refused for the current state. `upstream`: a
|
|
16
|
+
* service behind the server failed. `unavailable`: the server is up but can't now. `offline`: no answer. `unreadable`:
|
|
17
|
+
* a reply that broke the contract (not JSON, or failing its schema): it may have acted, and the server never sends it.
|
|
18
|
+
*
|
|
19
|
+
* `refused`: the server's own refusal, its kit error body naming a kind that matches the status; only then can it be
|
|
20
|
+
* final, since a proxy can answer any status. `path`: where a refused request broke its schema. `retryAfterMs`: the
|
|
21
|
+
* provider's `Retry-After`, a floor for the next try.
|
|
17
22
|
*/
|
|
18
23
|
export type Failure = {
|
|
19
|
-
kind: "signed-out" | "forbidden" | "invalid" | "conflict" | "upstream" | "unavailable" | "offline";
|
|
24
|
+
kind: "signed-out" | "forbidden" | "invalid" | "conflict" | "upstream" | "unavailable" | "offline" | "unreadable";
|
|
20
25
|
message: string;
|
|
26
|
+
refused?: true;
|
|
27
|
+
path?: string;
|
|
28
|
+
retryAfterMs?: number;
|
|
21
29
|
};
|
|
30
|
+
/** The kinds a server answers with: a reply it can't read is the client's observation, never a server's. */
|
|
31
|
+
export type RefusalKind = Exclude<Failure["kind"], "unreadable">;
|
|
22
32
|
export type Result<T> = {
|
|
23
33
|
ok: true;
|
|
24
34
|
value: T;
|
|
@@ -26,12 +36,15 @@ export type Result<T> = {
|
|
|
26
36
|
ok: false;
|
|
27
37
|
failure: Failure;
|
|
28
38
|
};
|
|
29
|
-
export declare const STATUS: Record<
|
|
39
|
+
export declare const STATUS: Record<RefusalKind, number>;
|
|
40
|
+
/** The longest a `Retry-After` defers the next try: a bad header can delay sync, never stop it. */
|
|
41
|
+
export declare const RETRY_AFTER_MAX_MS = 3600000;
|
|
30
42
|
/** GET requests carry their input as one `q` query parameter of JSON; POST as the body. */
|
|
31
43
|
export declare function encodeUrl<Req>(route: Endpoint<Req, unknown>, request: Req): string;
|
|
32
44
|
/**
|
|
33
|
-
* Call an endpoint from a browser (or any fetch). Every outcome is a `Result`: thrown errors,
|
|
34
|
-
* proxy's login page means signed out), error statuses and
|
|
45
|
+
* Call an endpoint from a browser (or any fetch). Every outcome is a `Result`: thrown errors, redirects and successful
|
|
46
|
+
* HTML pages (a proxy's login page means signed out), error statuses, and successes that aren't JSON or break the
|
|
47
|
+
* schema (`unreadable`) all become a `Failure`. An error is `refused` only with the kit's own body.
|
|
35
48
|
*/
|
|
36
49
|
export declare function call<Req, Res>(route: Endpoint<Req, Res>, request: Req, options?: {
|
|
37
50
|
fetch?: typeof fetch;
|