@acquiredintelligence/attend 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +580 -2
- package/dist/attend.d.ts +30 -0
- package/dist/attend.d.ts.map +1 -0
- package/dist/attend.js +428 -0
- package/dist/attend.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +4 -0
- package/dist/cli.js.map +1 -0
- package/dist/core/ids.d.ts +12 -0
- package/dist/core/ids.d.ts.map +1 -0
- package/dist/core/ids.js +97 -0
- package/dist/core/ids.js.map +1 -0
- package/dist/core/reducer.d.ts +26 -0
- package/dist/core/reducer.d.ts.map +1 -0
- package/dist/core/reducer.js +437 -0
- package/dist/core/reducer.js.map +1 -0
- package/dist/core/seen.d.ts +22 -0
- package/dist/core/seen.d.ts.map +1 -0
- package/dist/core/seen.js +61 -0
- package/dist/core/seen.js.map +1 -0
- package/dist/core/state.d.ts +12 -0
- package/dist/core/state.d.ts.map +1 -0
- package/dist/core/state.js +90 -0
- package/dist/core/state.js.map +1 -0
- package/dist/core/step.d.ts +14 -0
- package/dist/core/step.d.ts.map +1 -0
- package/dist/core/step.js +17 -0
- package/dist/core/step.js.map +1 -0
- package/dist/core/threshold.d.ts +40 -0
- package/dist/core/threshold.d.ts.map +1 -0
- package/dist/core/threshold.js +72 -0
- package/dist/core/threshold.js.map +1 -0
- package/dist/core/types.d.ts +704 -0
- package/dist/core/types.d.ts.map +1 -0
- package/dist/core/types.js +24 -0
- package/dist/core/types.js.map +1 -0
- package/dist/core/window.d.ts +38 -0
- package/dist/core/window.d.ts.map +1 -0
- package/dist/core/window.js +322 -0
- package/dist/core/window.js.map +1 -0
- package/dist/decisions.d.ts +53 -0
- package/dist/decisions.d.ts.map +1 -0
- package/dist/decisions.js +115 -0
- package/dist/decisions.js.map +1 -0
- package/dist/errors.d.ts +36 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +53 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -0
- package/dist/internal/adapter.d.ts +21 -0
- package/dist/internal/adapter.d.ts.map +1 -0
- package/dist/internal/adapter.js +62 -0
- package/dist/internal/adapter.js.map +1 -0
- package/dist/internal/assert.d.ts +5 -0
- package/dist/internal/assert.d.ts.map +1 -0
- package/dist/internal/assert.js +14 -0
- package/dist/internal/assert.js.map +1 -0
- package/dist/internal/clock.d.ts +8 -0
- package/dist/internal/clock.d.ts.map +1 -0
- package/dist/internal/clock.js +38 -0
- package/dist/internal/clock.js.map +1 -0
- package/dist/internal/http.d.ts +42 -0
- package/dist/internal/http.d.ts.map +1 -0
- package/dist/internal/http.js +122 -0
- package/dist/internal/http.js.map +1 -0
- package/dist/internal/images.d.ts +18 -0
- package/dist/internal/images.d.ts.map +1 -0
- package/dist/internal/images.js +75 -0
- package/dist/internal/images.js.map +1 -0
- package/dist/internal/json.d.ts +3 -0
- package/dist/internal/json.d.ts.map +1 -0
- package/dist/internal/json.js +5 -0
- package/dist/internal/json.js.map +1 -0
- package/dist/internal/prompt.d.ts +31 -0
- package/dist/internal/prompt.d.ts.map +1 -0
- package/dist/internal/prompt.js +149 -0
- package/dist/internal/prompt.js.map +1 -0
- package/dist/internal/shared.d.ts +12 -0
- package/dist/internal/shared.d.ts.map +1 -0
- package/dist/internal/shared.js +134 -0
- package/dist/internal/shared.js.map +1 -0
- package/dist/jev.d.ts +74 -0
- package/dist/jev.d.ts.map +1 -0
- package/dist/jev.js +156 -0
- package/dist/jev.js.map +1 -0
- package/dist/llm.d.ts +164 -0
- package/dist/llm.d.ts.map +1 -0
- package/dist/llm.js +79 -0
- package/dist/llm.js.map +1 -0
- package/dist/mcp.d.ts +130 -0
- package/dist/mcp.d.ts.map +1 -0
- package/dist/mcp.js +550 -0
- package/dist/mcp.js.map +1 -0
- package/dist/memory.d.ts +10 -0
- package/dist/memory.d.ts.map +1 -0
- package/dist/memory.js +50 -0
- package/dist/memory.js.map +1 -0
- package/dist/postgres.d.ts +25 -0
- package/dist/postgres.d.ts.map +1 -0
- package/dist/postgres.js +56 -0
- package/dist/postgres.js.map +1 -0
- package/dist/redis.d.ts +20 -0
- package/dist/redis.d.ts.map +1 -0
- package/dist/redis.js +83 -0
- package/dist/redis.js.map +1 -0
- package/dist/server/compat.d.ts +13 -0
- package/dist/server/compat.d.ts.map +1 -0
- package/dist/server/compat.js +5 -0
- package/dist/server/compat.js.map +1 -0
- package/dist/server/cursor.d.ts +15 -0
- package/dist/server/cursor.d.ts.map +1 -0
- package/dist/server/cursor.js +50 -0
- package/dist/server/cursor.js.map +1 -0
- package/dist/server/events.d.ts +42 -0
- package/dist/server/events.d.ts.map +1 -0
- package/dist/server/events.js +189 -0
- package/dist/server/events.js.map +1 -0
- package/dist/server/fanout.d.ts +28 -0
- package/dist/server/fanout.d.ts.map +1 -0
- package/dist/server/fanout.js +184 -0
- package/dist/server/fanout.js.map +1 -0
- package/dist/server/guard.d.ts +19 -0
- package/dist/server/guard.d.ts.map +1 -0
- package/dist/server/guard.js +27 -0
- package/dist/server/guard.js.map +1 -0
- package/dist/server/http.d.ts +4 -0
- package/dist/server/http.d.ts.map +1 -0
- package/dist/server/http.js +323 -0
- package/dist/server/http.js.map +1 -0
- package/dist/server/ingest.d.ts +4 -0
- package/dist/server/ingest.d.ts.map +1 -0
- package/dist/server/ingest.js +400 -0
- package/dist/server/ingest.js.map +1 -0
- package/dist/server/install.d.ts +5 -0
- package/dist/server/install.d.ts.map +1 -0
- package/dist/server/install.js +91 -0
- package/dist/server/install.js.map +1 -0
- package/dist/server/poll.d.ts +4 -0
- package/dist/server/poll.d.ts.map +1 -0
- package/dist/server/poll.js +45 -0
- package/dist/server/poll.js.map +1 -0
- package/dist/server/serve.d.ts +19 -0
- package/dist/server/serve.d.ts.map +1 -0
- package/dist/server/serve.js +238 -0
- package/dist/server/serve.js.map +1 -0
- package/dist/server/sessions.d.ts +14 -0
- package/dist/server/sessions.d.ts.map +1 -0
- package/dist/server/sessions.js +54 -0
- package/dist/server/sessions.js.map +1 -0
- package/dist/server/stream.d.ts +11 -0
- package/dist/server/stream.d.ts.map +1 -0
- package/dist/server/stream.js +216 -0
- package/dist/server/stream.js.map +1 -0
- package/dist/server/tools.d.ts +22 -0
- package/dist/server/tools.d.ts.map +1 -0
- package/dist/server/tools.js +55 -0
- package/dist/server/tools.js.map +1 -0
- package/dist/server/types.d.ts +351 -0
- package/dist/server/types.d.ts.map +1 -0
- package/dist/server/types.js +5 -0
- package/dist/server/types.js.map +1 -0
- package/dist/server.d.ts +11 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +10 -0
- package/dist/server.js.map +1 -0
- package/dist/shell/budget.d.ts +4 -0
- package/dist/shell/budget.d.ts.map +1 -0
- package/dist/shell/budget.js +5 -0
- package/dist/shell/budget.js.map +1 -0
- package/dist/shell/copy.d.ts +17 -0
- package/dist/shell/copy.d.ts.map +1 -0
- package/dist/shell/copy.js +67 -0
- package/dist/shell/copy.js.map +1 -0
- package/dist/shell/decisions.d.ts +6 -0
- package/dist/shell/decisions.d.ts.map +1 -0
- package/dist/shell/decisions.js +42 -0
- package/dist/shell/decisions.js.map +1 -0
- package/dist/shell/read.d.ts +7 -0
- package/dist/shell/read.d.ts.map +1 -0
- package/dist/shell/read.js +21 -0
- package/dist/shell/read.js.map +1 -0
- package/dist/shell/scheduler.d.ts +4 -0
- package/dist/shell/scheduler.d.ts.map +1 -0
- package/dist/shell/scheduler.js +90 -0
- package/dist/shell/scheduler.js.map +1 -0
- package/dist/shell/scope.d.ts +27 -0
- package/dist/shell/scope.d.ts.map +1 -0
- package/dist/shell/scope.js +343 -0
- package/dist/shell/scope.js.map +1 -0
- package/dist/shell/types.d.ts +156 -0
- package/dist/shell/types.d.ts.map +1 -0
- package/dist/shell/types.js +2 -0
- package/dist/shell/types.js.map +1 -0
- package/dist/sqlite.d.ts +14 -0
- package/dist/sqlite.d.ts.map +1 -0
- package/dist/sqlite.js +39 -0
- package/dist/sqlite.js.map +1 -0
- package/dist/step.d.ts +18 -0
- package/dist/step.d.ts.map +1 -0
- package/dist/step.js +14 -0
- package/dist/step.js.map +1 -0
- package/dist/store/memory.d.ts +12 -0
- package/dist/store/memory.d.ts.map +1 -0
- package/dist/store/memory.js +264 -0
- package/dist/store/memory.js.map +1 -0
- package/dist/store/postgres.d.ts +24 -0
- package/dist/store/postgres.d.ts.map +1 -0
- package/dist/store/postgres.js +364 -0
- package/dist/store/postgres.js.map +1 -0
- package/dist/store/redis.d.ts +12 -0
- package/dist/store/redis.d.ts.map +1 -0
- package/dist/store/redis.js +540 -0
- package/dist/store/redis.js.map +1 -0
- package/dist/store/sqlite-worker.d.ts +91 -0
- package/dist/store/sqlite-worker.d.ts.map +1 -0
- package/dist/store/sqlite-worker.js +322 -0
- package/dist/store/sqlite-worker.js.map +1 -0
- package/dist/store/sqlite.d.ts +34 -0
- package/dist/store/sqlite.d.ts.map +1 -0
- package/dist/store/sqlite.js +238 -0
- package/dist/store/sqlite.js.map +1 -0
- package/dist/store/types.d.ts +137 -0
- package/dist/store/types.d.ts.map +1 -0
- package/dist/store/types.js +2 -0
- package/dist/store/types.js.map +1 -0
- package/dist/testing/conformance.d.ts +13 -0
- package/dist/testing/conformance.d.ts.map +1 -0
- package/dist/testing/conformance.js +542 -0
- package/dist/testing/conformance.js.map +1 -0
- package/dist/testing/wire.d.ts +16 -0
- package/dist/testing/wire.d.ts.map +1 -0
- package/dist/testing/wire.js +304 -0
- package/dist/testing/wire.js.map +1 -0
- package/dist/testing.d.ts +113 -0
- package/dist/testing.d.ts.map +1 -0
- package/dist/testing.js +655 -0
- package/dist/testing.js.map +1 -0
- package/dist/types.d.ts +5 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +5 -0
- package/dist/types.js.map +1 -0
- package/package.json +130 -4
- package/skills/attend/SKILL.md +231 -0
- package/skills/attend/references/judges.md +283 -0
- package/skills/attend/references/kinds.md +62 -0
- package/skills/attend/references/mcp.md +132 -0
- package/skills/attend/references/sinks.md +73 -0
- package/skills/attend/references/stores.md +90 -0
- package/skills/attend/references/testing.md +120 -0
package/dist/testing.js
ADDED
|
@@ -0,0 +1,655 @@
|
|
|
1
|
+
import { PermanentError } from './errors.js';
|
|
2
|
+
import { createAttend } from './attend.js';
|
|
3
|
+
import { memoryStore } from './memory.js';
|
|
4
|
+
import { assertEqual, assertOk } from './internal/assert.js';
|
|
5
|
+
import { drain } from './internal/clock.js';
|
|
6
|
+
import { drainCopies } from './shell/copy.js';
|
|
7
|
+
import { readRecord } from './shell/read.js';
|
|
8
|
+
export { drain } from './internal/clock.js';
|
|
9
|
+
export function fakeClock(start = 0) {
|
|
10
|
+
let now = start;
|
|
11
|
+
let seq = 0;
|
|
12
|
+
const timers = [];
|
|
13
|
+
const earliest = () => timers.reduce((best, t) => (best === null || t.at < best.at || (t.at === best.at && t.seq < best.seq) ? t : best), null);
|
|
14
|
+
return {
|
|
15
|
+
now: () => now,
|
|
16
|
+
at(at, fn) {
|
|
17
|
+
const timer = { at, seq: seq++, fn };
|
|
18
|
+
timers.push(timer);
|
|
19
|
+
return () => {
|
|
20
|
+
const i = timers.indexOf(timer);
|
|
21
|
+
if (i !== -1)
|
|
22
|
+
timers.splice(i, 1);
|
|
23
|
+
};
|
|
24
|
+
},
|
|
25
|
+
nextAt: () => earliest()?.at ?? null,
|
|
26
|
+
async set(at) {
|
|
27
|
+
for (let next = earliest(); next !== null && next.at <= at; next = earliest()) {
|
|
28
|
+
now = Math.max(now, next.at);
|
|
29
|
+
timers.splice(timers.indexOf(next), 1);
|
|
30
|
+
next.fn();
|
|
31
|
+
await drainCopies();
|
|
32
|
+
await drain();
|
|
33
|
+
await drainCopies();
|
|
34
|
+
await drain();
|
|
35
|
+
}
|
|
36
|
+
now = Math.max(now, at);
|
|
37
|
+
await drainCopies();
|
|
38
|
+
await drain();
|
|
39
|
+
await drainCopies();
|
|
40
|
+
await drain();
|
|
41
|
+
},
|
|
42
|
+
advance(ms) {
|
|
43
|
+
return this.set(now + ms);
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
/** Answers in order from a script; the last entry repeats. Records every input. */
|
|
48
|
+
export function scriptedJudge(script) {
|
|
49
|
+
const calls = [];
|
|
50
|
+
const judge = async (input, signal) => {
|
|
51
|
+
const answer = script[Math.min(calls.length, script.length - 1)];
|
|
52
|
+
calls.push(input);
|
|
53
|
+
if (answer === undefined)
|
|
54
|
+
throw new Error('scriptedJudge: empty script');
|
|
55
|
+
if ('hang' in answer) {
|
|
56
|
+
return new Promise((_, reject) => signal.addEventListener('abort', () => reject(new Error('aborted')), { once: true }));
|
|
57
|
+
}
|
|
58
|
+
if ('fail' in answer)
|
|
59
|
+
throw answer.permanent ? new PermanentError(answer.fail) : new Error(answer.fail);
|
|
60
|
+
return answer;
|
|
61
|
+
};
|
|
62
|
+
return Object.assign(judge, { calls });
|
|
63
|
+
}
|
|
64
|
+
export function recordingSink() {
|
|
65
|
+
const delivered = [];
|
|
66
|
+
const attempts = new Map();
|
|
67
|
+
let failing = 0;
|
|
68
|
+
let permanent = false;
|
|
69
|
+
return {
|
|
70
|
+
delivered,
|
|
71
|
+
attempts,
|
|
72
|
+
failNext(n, p = false) {
|
|
73
|
+
failing = n;
|
|
74
|
+
permanent = p;
|
|
75
|
+
},
|
|
76
|
+
async deliver(decision) {
|
|
77
|
+
attempts.set(decision.id, (attempts.get(decision.id) ?? 0) + 1);
|
|
78
|
+
if (failing > 0) {
|
|
79
|
+
failing -= 1;
|
|
80
|
+
throw permanent ? new PermanentError('sink refused') : new Error('sink failed');
|
|
81
|
+
}
|
|
82
|
+
delivered.push(decision);
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
const event = (id) => ({ id, at: 0, name: 'n', data: { id } });
|
|
87
|
+
/** Every guarantee in `EventLog`'s doc comments. `canBlock` says whether `blockMs` waits. */
|
|
88
|
+
export function logConformance(make, options) {
|
|
89
|
+
const canBlock = options?.canBlock ?? true;
|
|
90
|
+
const cases = [
|
|
91
|
+
[
|
|
92
|
+
'read(null) returns from the oldest retained entry in order',
|
|
93
|
+
async (log) => {
|
|
94
|
+
await log.append('s', [event('a'), event('b')]);
|
|
95
|
+
await log.append('s', [event('c')]);
|
|
96
|
+
assertEqual((await log.read('s', null, { limit: 10 })).map((e) => e.event.id), ['a', 'b', 'c'], 'order');
|
|
97
|
+
},
|
|
98
|
+
],
|
|
99
|
+
[
|
|
100
|
+
'positions strictly increase and read(after) returns strictly after',
|
|
101
|
+
async (log) => {
|
|
102
|
+
await log.append('s', [event('a'), event('b'), event('c')]);
|
|
103
|
+
const all = await log.read('s', null, { limit: 10 });
|
|
104
|
+
for (let i = 1; i < all.length; i += 1)
|
|
105
|
+
assertOk((all[i]?.position ?? '') > (all[i - 1]?.position ?? ''), 'increasing');
|
|
106
|
+
const after = await log.read('s', all[0]?.position ?? null, { limit: 10 });
|
|
107
|
+
assertEqual(after.map((e) => e.event.id), ['b', 'c'], 'strictly after');
|
|
108
|
+
assertEqual(await log.read('s', all[2]?.position ?? null, { limit: 10 }), [], 'nothing after the last');
|
|
109
|
+
},
|
|
110
|
+
],
|
|
111
|
+
[
|
|
112
|
+
'limit is honoured and the next read continues',
|
|
113
|
+
async (log) => {
|
|
114
|
+
await log.append('s', [event('a'), event('b'), event('c')]);
|
|
115
|
+
const first = await log.read('s', null, { limit: 2 });
|
|
116
|
+
assertEqual(first.map((e) => e.event.id), ['a', 'b'], 'first page');
|
|
117
|
+
const second = await log.read('s', first[1]?.position ?? null, { limit: 2 });
|
|
118
|
+
assertEqual(second.map((e) => e.event.id), ['c'], 'second page');
|
|
119
|
+
},
|
|
120
|
+
],
|
|
121
|
+
[
|
|
122
|
+
'scopes are separate',
|
|
123
|
+
async (log) => {
|
|
124
|
+
await log.append('s', [event('a')]);
|
|
125
|
+
await log.append('t', [event('a')]);
|
|
126
|
+
assertEqual((await log.read('s', null, { limit: 10 })).length, 1, 'one per scope');
|
|
127
|
+
},
|
|
128
|
+
],
|
|
129
|
+
[
|
|
130
|
+
'two producers appending concurrently lose nothing',
|
|
131
|
+
async (log) => {
|
|
132
|
+
await Promise.all([
|
|
133
|
+
log.append('s', Array.from({ length: 50 }, (_, i) => event(`p1-${i}`))),
|
|
134
|
+
log.append('s', Array.from({ length: 50 }, (_, i) => event(`p2-${i}`))),
|
|
135
|
+
]);
|
|
136
|
+
const ids = (await log.read('s', null, { limit: 1000 })).map((e) => e.event.id);
|
|
137
|
+
assertEqual(new Set(ids).size, 100, 'all present');
|
|
138
|
+
},
|
|
139
|
+
],
|
|
140
|
+
[
|
|
141
|
+
'events round-trip deep-equal',
|
|
142
|
+
async (log) => {
|
|
143
|
+
const e = { id: 'a', at: 7, name: 'x.y', data: { n: 1, s: 'ünï 🌍', l: [null, { k: false }] }, text: 'one line' };
|
|
144
|
+
await log.append('s', [e]);
|
|
145
|
+
assertEqual((await log.read('s', null, { limit: 1 }))[0]?.event, e, 'round trip');
|
|
146
|
+
},
|
|
147
|
+
],
|
|
148
|
+
canBlock
|
|
149
|
+
? [
|
|
150
|
+
'read with blockMs returns early on append and empty at the timeout',
|
|
151
|
+
async (log) => {
|
|
152
|
+
const started = Date.now();
|
|
153
|
+
const pending = log.read('s', null, { limit: 10, blockMs: 5000 });
|
|
154
|
+
await log.append('s', [event('a')]);
|
|
155
|
+
assertEqual((await pending).map((e) => e.event.id), ['a'], 'woken by append');
|
|
156
|
+
assertOk(Date.now() - started < 4000, 'returned before the timeout');
|
|
157
|
+
const last = (await log.read('s', null, { limit: 10 }))[0]?.position ?? null;
|
|
158
|
+
assertEqual(await log.read('s', last, { limit: 10, blockMs: 20 }), [], 'empty at the timeout');
|
|
159
|
+
},
|
|
160
|
+
]
|
|
161
|
+
: [
|
|
162
|
+
'read with blockMs returns at once',
|
|
163
|
+
async (log) => {
|
|
164
|
+
const started = Date.now();
|
|
165
|
+
assertEqual(await log.read('s', null, { limit: 10, blockMs: 5000 }), [], 'empty');
|
|
166
|
+
assertOk(Date.now() - started < 1000, 'did not wait');
|
|
167
|
+
},
|
|
168
|
+
],
|
|
169
|
+
];
|
|
170
|
+
return cases.map(([name, run]) => ({ name, run: async () => run(await make()) }));
|
|
171
|
+
}
|
|
172
|
+
export { decisionLogConformance, scopeStoreConformance } from './testing/conformance.js';
|
|
173
|
+
export { eventsWireConformance } from './testing/wire.js';
|
|
174
|
+
export { suggestThreshold } from './core/threshold.js';
|
|
175
|
+
// ─── crash harness ───────────────────────────────────────────────────────────
|
|
176
|
+
/** Thrown by a crashed instance's store, and by the harness once a scheduled crash struck. */
|
|
177
|
+
export class CrashError extends Error {
|
|
178
|
+
constructor() {
|
|
179
|
+
super('attend: crashed');
|
|
180
|
+
this.name = 'CrashError';
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Runs `createAttend` on one scope with a fake clock over a store it can crash at any
|
|
185
|
+
* commit. Judges, sinks and context must resolve without real time, or `idle()` cannot
|
|
186
|
+
* see them.
|
|
187
|
+
*/
|
|
188
|
+
export function harness(config, options) {
|
|
189
|
+
const scope = options?.scope ?? 'scope';
|
|
190
|
+
const inner = options?.store ?? memoryStore();
|
|
191
|
+
const clock = fakeClock(options?.start ?? 0);
|
|
192
|
+
const sweepMs = options?.sweepMs ?? 1000;
|
|
193
|
+
const deadLetters = [];
|
|
194
|
+
const traces = [];
|
|
195
|
+
let commits = 0;
|
|
196
|
+
let pending = 0;
|
|
197
|
+
let schedule = null;
|
|
198
|
+
let crashed = false;
|
|
199
|
+
function die(instance) {
|
|
200
|
+
crashed = true;
|
|
201
|
+
instance.state.dead = true;
|
|
202
|
+
instance.controller.abort();
|
|
203
|
+
}
|
|
204
|
+
function start() {
|
|
205
|
+
const controller = new AbortController();
|
|
206
|
+
const state = { dead: false };
|
|
207
|
+
const guard = async (work) => {
|
|
208
|
+
if (state.dead)
|
|
209
|
+
throw new CrashError();
|
|
210
|
+
pending += 1;
|
|
211
|
+
try {
|
|
212
|
+
return await work();
|
|
213
|
+
}
|
|
214
|
+
finally {
|
|
215
|
+
pending -= 1;
|
|
216
|
+
}
|
|
217
|
+
};
|
|
218
|
+
const strike = (n, point) => {
|
|
219
|
+
if (schedule === null || schedule.commit !== n || schedule.point !== point)
|
|
220
|
+
return;
|
|
221
|
+
schedule = null;
|
|
222
|
+
crashed = true;
|
|
223
|
+
state.dead = true;
|
|
224
|
+
controller.abort();
|
|
225
|
+
throw new CrashError();
|
|
226
|
+
};
|
|
227
|
+
const store = {
|
|
228
|
+
durable: inner.durable,
|
|
229
|
+
load: (scope) => guard(() => inner.load(scope)),
|
|
230
|
+
due: (now, limit, after) => guard(() => inner.due(now, limit, after)),
|
|
231
|
+
remove: (scope) => guard(() => inner.remove(scope)),
|
|
232
|
+
log: {
|
|
233
|
+
epoch: () => guard(() => inner.log.epoch()),
|
|
234
|
+
bounds: () => guard(() => inner.log.bounds()),
|
|
235
|
+
read: (query) => guard(() => inner.log.read(query)),
|
|
236
|
+
find: (kind, id) => guard(() => inner.log.find(kind, id)),
|
|
237
|
+
wait: (after, ms, signal) => guard(() => inner.log.wait(after, ms, signal)),
|
|
238
|
+
trim: (retention) => guard(() => inner.log.trim(retention)),
|
|
239
|
+
compare: (a, b) => inner.log.compare(a, b),
|
|
240
|
+
},
|
|
241
|
+
commit: (writes) => guard(async () => {
|
|
242
|
+
commits += 1;
|
|
243
|
+
const n = commits;
|
|
244
|
+
strike(n, 'before');
|
|
245
|
+
const result = await inner.commit(writes);
|
|
246
|
+
strike(n, 'after');
|
|
247
|
+
return result;
|
|
248
|
+
}),
|
|
249
|
+
};
|
|
250
|
+
const attend = createAttend({
|
|
251
|
+
...config,
|
|
252
|
+
store,
|
|
253
|
+
clock,
|
|
254
|
+
deadLetter: async (letter, signal) => {
|
|
255
|
+
if (typeof config.deadLetter === 'function')
|
|
256
|
+
await config.deadLetter(letter, signal);
|
|
257
|
+
deadLetters.push(letter);
|
|
258
|
+
},
|
|
259
|
+
observe: (t) => {
|
|
260
|
+
traces.push(t);
|
|
261
|
+
config.observe?.(t);
|
|
262
|
+
},
|
|
263
|
+
});
|
|
264
|
+
return { attend, controller, done: attend.run({ signal: controller.signal, sweepMs }), state };
|
|
265
|
+
}
|
|
266
|
+
let current = start();
|
|
267
|
+
async function idle() {
|
|
268
|
+
for (;;) {
|
|
269
|
+
await drainCopies();
|
|
270
|
+
await drain();
|
|
271
|
+
if (pending === 0)
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
function check() {
|
|
276
|
+
if (crashed)
|
|
277
|
+
throw new CrashError();
|
|
278
|
+
}
|
|
279
|
+
async function advance(ms) {
|
|
280
|
+
const target = clock.now() + ms;
|
|
281
|
+
await idle();
|
|
282
|
+
check();
|
|
283
|
+
for (let next = clock.nextAt(); next !== null && next <= target; next = clock.nextAt()) {
|
|
284
|
+
await clock.set(next);
|
|
285
|
+
await idle();
|
|
286
|
+
check();
|
|
287
|
+
}
|
|
288
|
+
await clock.set(target);
|
|
289
|
+
await idle();
|
|
290
|
+
check();
|
|
291
|
+
}
|
|
292
|
+
return {
|
|
293
|
+
clock,
|
|
294
|
+
store: inner,
|
|
295
|
+
scope,
|
|
296
|
+
deadLetters,
|
|
297
|
+
traces,
|
|
298
|
+
get attend() {
|
|
299
|
+
return current.attend;
|
|
300
|
+
},
|
|
301
|
+
get commits() {
|
|
302
|
+
return commits;
|
|
303
|
+
},
|
|
304
|
+
get crashed() {
|
|
305
|
+
return crashed;
|
|
306
|
+
},
|
|
307
|
+
async push(events) {
|
|
308
|
+
await current.attend.push(scope, events);
|
|
309
|
+
await idle();
|
|
310
|
+
check();
|
|
311
|
+
},
|
|
312
|
+
async flush() {
|
|
313
|
+
await current.attend.flush(scope);
|
|
314
|
+
await advance(0);
|
|
315
|
+
},
|
|
316
|
+
advance,
|
|
317
|
+
idle,
|
|
318
|
+
crash() {
|
|
319
|
+
die(current);
|
|
320
|
+
},
|
|
321
|
+
crashAt(commit, point) {
|
|
322
|
+
schedule = { commit: commits + commit, point };
|
|
323
|
+
},
|
|
324
|
+
async restart() {
|
|
325
|
+
die(current);
|
|
326
|
+
await current.done.catch(() => undefined);
|
|
327
|
+
crashed = false;
|
|
328
|
+
current = start();
|
|
329
|
+
await idle();
|
|
330
|
+
},
|
|
331
|
+
async stop() {
|
|
332
|
+
current.controller.abort();
|
|
333
|
+
await current.done;
|
|
334
|
+
},
|
|
335
|
+
inspect: () => readRecord(inner, scope, { decisions: config.sink === 'log' ? 'log' : 'lane', deadLetters: config.deadLetter === 'log' ? 'log' : 'lane' }),
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
// ─── crash suite ─────────────────────────────────────────────────────────────
|
|
339
|
+
const sessionKind = (e) => {
|
|
340
|
+
switch (e.name) {
|
|
341
|
+
case 'close':
|
|
342
|
+
return 'closing';
|
|
343
|
+
case 'say':
|
|
344
|
+
return 'flush';
|
|
345
|
+
case 'look':
|
|
346
|
+
return 'passive';
|
|
347
|
+
default:
|
|
348
|
+
return 'consequence';
|
|
349
|
+
}
|
|
350
|
+
};
|
|
351
|
+
/** Short leases and backoffs, so a stranded call recovers well inside the gaps the session leaves between acts. */
|
|
352
|
+
const SESSION_POLICY = { judgeTimeoutMs: 1000, deliveryTimeoutMs: 2000, retryBackoffMs: [1000, 1000, 1000] };
|
|
353
|
+
/**
|
|
354
|
+
* A scripted session over a store: a closing act delivered, a passive act the judge waits
|
|
355
|
+
* on and then merges with a closing act whose delivery exhausts the sink, a flush whose
|
|
356
|
+
* first judge call fails, an act the judge refuses for good, and an act the judge skips.
|
|
357
|
+
* Judge and sink decide from event ids, so a re-judged window gets the same answer. Each
|
|
358
|
+
* act is left to resolve under the worst stranded lease before the next one settles, so
|
|
359
|
+
* the windows, and so the ids, are the same in every run.
|
|
360
|
+
*/
|
|
361
|
+
async function session(store, crash) {
|
|
362
|
+
const delivered = new Set();
|
|
363
|
+
const flaked = new Set();
|
|
364
|
+
const judge = async (input) => {
|
|
365
|
+
const ids = input.window.events.map((e) => e.id);
|
|
366
|
+
const has = (id) => ids.includes(id);
|
|
367
|
+
if (has('f'))
|
|
368
|
+
throw new PermanentError('refused');
|
|
369
|
+
if (has('e') && !flaked.has(input.window.id)) {
|
|
370
|
+
flaked.add(input.window.id);
|
|
371
|
+
throw new Error('flaky');
|
|
372
|
+
}
|
|
373
|
+
if (has('b') && !has('d') && input.routes.some((r) => r.route === 'wait'))
|
|
374
|
+
return { route: 'wait' };
|
|
375
|
+
if (has('d'))
|
|
376
|
+
return { route: 'interrupt' };
|
|
377
|
+
if (has('e'))
|
|
378
|
+
return { route: 'note' };
|
|
379
|
+
if (has('g'))
|
|
380
|
+
return { route: 'skip' };
|
|
381
|
+
return { route: 'queue' };
|
|
382
|
+
};
|
|
383
|
+
const sink = {
|
|
384
|
+
async deliver(decision) {
|
|
385
|
+
if (decision.route === 'interrupt')
|
|
386
|
+
throw new Error('sink down');
|
|
387
|
+
delivered.add(decision.id);
|
|
388
|
+
},
|
|
389
|
+
};
|
|
390
|
+
const h = harness({ judge, sink, kind: sessionKind, settle: SESSION_POLICY }, { store, sweepMs: 10_000 });
|
|
391
|
+
if (crash)
|
|
392
|
+
h.crashAt(crash.commit, crash.point);
|
|
393
|
+
const survive = async (op) => {
|
|
394
|
+
try {
|
|
395
|
+
await op();
|
|
396
|
+
}
|
|
397
|
+
catch (error) {
|
|
398
|
+
if (!(error instanceof CrashError))
|
|
399
|
+
throw error;
|
|
400
|
+
await h.restart();
|
|
401
|
+
await op();
|
|
402
|
+
}
|
|
403
|
+
};
|
|
404
|
+
const push = (id, name) => survive(() => h.push([{ id, at: 0, name, data: {}, text: `${name} ${id}` }]));
|
|
405
|
+
const advance = (ms) => {
|
|
406
|
+
const target = h.clock.now() + ms;
|
|
407
|
+
return survive(() => h.advance(target - h.clock.now()));
|
|
408
|
+
};
|
|
409
|
+
await push('a', 'close');
|
|
410
|
+
await advance(600);
|
|
411
|
+
await advance(8000);
|
|
412
|
+
await push('b', 'look');
|
|
413
|
+
await advance(4100);
|
|
414
|
+
await push('d', 'close');
|
|
415
|
+
await advance(600);
|
|
416
|
+
await advance(25_000);
|
|
417
|
+
await push('e', 'say');
|
|
418
|
+
await advance(1500);
|
|
419
|
+
await advance(8000);
|
|
420
|
+
await push('f', 'close');
|
|
421
|
+
await advance(600);
|
|
422
|
+
await advance(8000);
|
|
423
|
+
await push('g', 'close');
|
|
424
|
+
await advance(600);
|
|
425
|
+
await advance(60_000);
|
|
426
|
+
await h.stop();
|
|
427
|
+
await store.close?.();
|
|
428
|
+
return { decisions: [...delivered].sort(), letters: [...new Set(h.deadLetters.map((l) => l.id))].sort(), commits: h.commits };
|
|
429
|
+
}
|
|
430
|
+
/** The window ids the session produces in scope `scope`: `a` and `e` are delivered, `b,d` and `f` are dead letters. */
|
|
431
|
+
const SESSION_DECISIONS = ['cf8923dfae84f930', 'cf8927dfae84fffc'];
|
|
432
|
+
const SESSION_LETTERS = ['cf892adfae850515', 'eaf0348d05594776'];
|
|
433
|
+
/**
|
|
434
|
+
* Whole-runtime guarantee over a real adapter: the scripted session runs once cleanly, then
|
|
435
|
+
* once per commit boundary with a crash injected there, restarting over the same store.
|
|
436
|
+
* Every run must produce the same set of delivered decision ids and dead-letter ids.
|
|
437
|
+
*/
|
|
438
|
+
export function crashSuite(make) {
|
|
439
|
+
const survives = (point) => async () => {
|
|
440
|
+
const clean = await session(await make(), null);
|
|
441
|
+
for (let commit = 1; commit <= clean.commits; commit += 1) {
|
|
442
|
+
const crashed = await session(await make(), { commit, point });
|
|
443
|
+
assertEqual(crashed.decisions, clean.decisions, `decisions after a crash ${point} commit ${commit}`);
|
|
444
|
+
assertEqual(crashed.letters, clean.letters, `dead letters after a crash ${point} commit ${commit}`);
|
|
445
|
+
}
|
|
446
|
+
};
|
|
447
|
+
return [
|
|
448
|
+
{
|
|
449
|
+
name: 'the scripted session delivers two decisions and two dead letters',
|
|
450
|
+
async run() {
|
|
451
|
+
const clean = await session(await make(), null);
|
|
452
|
+
assertEqual(clean.decisions, SESSION_DECISIONS, 'decisions');
|
|
453
|
+
assertEqual(clean.letters, SESSION_LETTERS, 'dead letters');
|
|
454
|
+
},
|
|
455
|
+
},
|
|
456
|
+
{ name: 'the same ids survive a crash before every commit', run: survives('before') },
|
|
457
|
+
{ name: 'the same ids survive a crash after every commit', run: survives('after') },
|
|
458
|
+
];
|
|
459
|
+
}
|
|
460
|
+
async function multiSession(store, crash) {
|
|
461
|
+
const clock = fakeClock();
|
|
462
|
+
const groups = [];
|
|
463
|
+
const delivered = new Set();
|
|
464
|
+
const letters = new Set();
|
|
465
|
+
const flaked = new Set();
|
|
466
|
+
const scopes = ['m/x', 'm/y', 'm/z'];
|
|
467
|
+
let commits = 0;
|
|
468
|
+
let pending = 0;
|
|
469
|
+
let victim = null;
|
|
470
|
+
const strikeState = { struck: null };
|
|
471
|
+
const instances = [];
|
|
472
|
+
const judge = async (input) => {
|
|
473
|
+
const has = (id) => input.window.events.some((event) => event.id === `${input.scope}/${id}`);
|
|
474
|
+
if (has('f'))
|
|
475
|
+
throw new PermanentError('refused');
|
|
476
|
+
if (has('e') && !flaked.has(input.window.id)) {
|
|
477
|
+
flaked.add(input.window.id);
|
|
478
|
+
throw new Error('flaky');
|
|
479
|
+
}
|
|
480
|
+
if (has('b') && !has('d') && input.routes.some((route) => route.route === 'wait'))
|
|
481
|
+
return { route: 'wait' };
|
|
482
|
+
return { route: has('d') ? 'interrupt' : has('e') ? 'note' : has('g') ? 'skip' : 'queue' };
|
|
483
|
+
};
|
|
484
|
+
function start(index) {
|
|
485
|
+
const controller = new AbortController();
|
|
486
|
+
const state = { dead: false };
|
|
487
|
+
// After a conflict the shell backs off in real time, then reloads the scope: no store call is in flight meanwhile.
|
|
488
|
+
const reloading = new Set();
|
|
489
|
+
const guard = async (work) => {
|
|
490
|
+
if (state.dead)
|
|
491
|
+
throw new CrashError();
|
|
492
|
+
pending += 1;
|
|
493
|
+
try {
|
|
494
|
+
return await work();
|
|
495
|
+
}
|
|
496
|
+
finally {
|
|
497
|
+
pending -= 1;
|
|
498
|
+
}
|
|
499
|
+
};
|
|
500
|
+
const view = {
|
|
501
|
+
...store,
|
|
502
|
+
load: (scope) => guard(() => { reloading.delete(scope); return store.load(scope); }),
|
|
503
|
+
due: (now, limit, after) => guard(() => store.due(now, limit, after)),
|
|
504
|
+
remove: (scope) => guard(() => store.remove(scope)),
|
|
505
|
+
log: {
|
|
506
|
+
epoch: () => guard(() => store.log.epoch()),
|
|
507
|
+
bounds: () => guard(() => store.log.bounds()),
|
|
508
|
+
read: (query) => guard(() => store.log.read(query)),
|
|
509
|
+
find: (kind, id) => guard(() => store.log.find(kind, id)),
|
|
510
|
+
wait: (after, ms, signal) => guard(() => store.log.wait(after, ms, signal)),
|
|
511
|
+
trim: (retention) => guard(() => store.log.trim(retention)),
|
|
512
|
+
compare: (a, b) => store.log.compare(a, b),
|
|
513
|
+
},
|
|
514
|
+
commit: (writes) => guard(async () => {
|
|
515
|
+
const n = ++commits;
|
|
516
|
+
const snapshot = writes.some((write) => write.snapshot !== null);
|
|
517
|
+
const effect = writes.some((write) => write.inputs.some(({ input }) => input.kind === 'tick' || input.kind === 'judged' || input.kind === 'judgeFailed'));
|
|
518
|
+
const strike = (point) => {
|
|
519
|
+
if (strikeState.struck !== null || crash?.commit !== n || crash.point !== point)
|
|
520
|
+
return;
|
|
521
|
+
strikeState.struck = { snapshot, effect };
|
|
522
|
+
victim = index;
|
|
523
|
+
state.dead = true;
|
|
524
|
+
reloading.clear();
|
|
525
|
+
controller.abort();
|
|
526
|
+
throw new CrashError();
|
|
527
|
+
};
|
|
528
|
+
strike('before');
|
|
529
|
+
const outcomes = await store.commit(writes);
|
|
530
|
+
writes.forEach((write, i) => { if (outcomes[i] === 'conflict')
|
|
531
|
+
reloading.add(write.scope); });
|
|
532
|
+
groups.push({ scopes: writes.map((write) => write.scope), outcomes, snapshot, effect });
|
|
533
|
+
strike('after');
|
|
534
|
+
return outcomes;
|
|
535
|
+
}),
|
|
536
|
+
};
|
|
537
|
+
const attend = createAttend({ store: view, clock, kind: sessionKind, judge, settle: SESSION_POLICY,
|
|
538
|
+
observe: (trace) => { if (trace.kind === 'abandoned')
|
|
539
|
+
reloading.delete(trace.scope); },
|
|
540
|
+
sink: { async deliver(decision) { if (decision.route === 'interrupt')
|
|
541
|
+
throw new Error('sink down'); delivered.add(decision.id); } },
|
|
542
|
+
deadLetter: (letter) => { letters.add(letter.id); } });
|
|
543
|
+
return { attend, controller, reloading, get dead() { return state.dead; }, done: attend.run({ signal: controller.signal, sweepMs: 10000 }) };
|
|
544
|
+
}
|
|
545
|
+
instances.push(start(0), start(1));
|
|
546
|
+
const idle = async () => {
|
|
547
|
+
do {
|
|
548
|
+
await drainCopies();
|
|
549
|
+
await drain();
|
|
550
|
+
} while (pending > 0 || instances.some((instance) => instance.reloading.size > 0));
|
|
551
|
+
};
|
|
552
|
+
const check = () => { if (victim !== null)
|
|
553
|
+
throw new CrashError(); };
|
|
554
|
+
const survive = async (work) => {
|
|
555
|
+
try {
|
|
556
|
+
await work();
|
|
557
|
+
await idle();
|
|
558
|
+
check();
|
|
559
|
+
}
|
|
560
|
+
catch (error) {
|
|
561
|
+
if (!(error instanceof CrashError) || victim === null)
|
|
562
|
+
throw error;
|
|
563
|
+
const index = victim;
|
|
564
|
+
await instances[index].done;
|
|
565
|
+
victim = null;
|
|
566
|
+
instances[index] = start(index);
|
|
567
|
+
await idle();
|
|
568
|
+
await work();
|
|
569
|
+
await idle();
|
|
570
|
+
check();
|
|
571
|
+
}
|
|
572
|
+
};
|
|
573
|
+
const push = (index, id, name, targets = scopes) => survive(async () => {
|
|
574
|
+
await Promise.all(targets.map((scope) => instances[index].attend.push(scope, [{ id: `${scope}/${id}`, at: 0, name, data: {}, text: `${name} ${id}` }])));
|
|
575
|
+
});
|
|
576
|
+
const advance = (ms) => {
|
|
577
|
+
const target = clock.now() + ms;
|
|
578
|
+
return survive(async () => {
|
|
579
|
+
for (let next = clock.nextAt(); next !== null && next <= target; next = clock.nextAt()) {
|
|
580
|
+
await clock.set(next);
|
|
581
|
+
await idle();
|
|
582
|
+
check();
|
|
583
|
+
}
|
|
584
|
+
await clock.set(target);
|
|
585
|
+
});
|
|
586
|
+
};
|
|
587
|
+
try {
|
|
588
|
+
await idle();
|
|
589
|
+
await push(0, 'a', 'close');
|
|
590
|
+
await advance(600);
|
|
591
|
+
await advance(8000);
|
|
592
|
+
await push(1, 'c', 'out', ['m/x']);
|
|
593
|
+
await push(0, 'h', 'close');
|
|
594
|
+
await advance(600);
|
|
595
|
+
await advance(8000);
|
|
596
|
+
await push(1, 'b', 'look');
|
|
597
|
+
await advance(4100);
|
|
598
|
+
await push(0, 'd', 'close');
|
|
599
|
+
await advance(600);
|
|
600
|
+
await advance(25000);
|
|
601
|
+
await push(1, 'e', 'say');
|
|
602
|
+
await advance(1500);
|
|
603
|
+
await advance(8000);
|
|
604
|
+
await push(0, 'f', 'close');
|
|
605
|
+
await advance(600);
|
|
606
|
+
await advance(8000);
|
|
607
|
+
await push(1, 'g', 'close');
|
|
608
|
+
await advance(600);
|
|
609
|
+
await advance(60000);
|
|
610
|
+
return { decisions: [...delivered].sort(), letters: [...letters].sort(), groups, commits, struck: strikeState.struck };
|
|
611
|
+
}
|
|
612
|
+
finally {
|
|
613
|
+
for (const instance of instances)
|
|
614
|
+
instance.controller.abort();
|
|
615
|
+
await Promise.all(instances.map((instance) => instance.done));
|
|
616
|
+
await store.close?.();
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
/** Cross-scope recovery, including mixed conflicts, snapshots and effect-start boundaries. */
|
|
620
|
+
export function multiScopeCrashSuite(make) {
|
|
621
|
+
const verify = (result) => {
|
|
622
|
+
assertEqual(result.decisions, MULTI_DECISIONS, 'multi-scope decisions');
|
|
623
|
+
assertEqual(result.letters, MULTI_LETTERS, 'multi-scope dead letters');
|
|
624
|
+
};
|
|
625
|
+
const survives = (point) => async () => {
|
|
626
|
+
const clean = await multiSession(await make(), null);
|
|
627
|
+
verify(clean);
|
|
628
|
+
let snapshots = 0;
|
|
629
|
+
let effects = 0;
|
|
630
|
+
for (let commit = 1; commit <= clean.commits; commit += 1) {
|
|
631
|
+
const result = await multiSession(await make(), { commit, point });
|
|
632
|
+
verify(result);
|
|
633
|
+
if (result.struck?.snapshot)
|
|
634
|
+
snapshots += 1;
|
|
635
|
+
if (result.struck?.effect)
|
|
636
|
+
effects += 1;
|
|
637
|
+
}
|
|
638
|
+
assertOk(snapshots > 0, 'crashed during a snapshot');
|
|
639
|
+
assertOk(effects > 0, 'crashed between commit and effect start');
|
|
640
|
+
};
|
|
641
|
+
return [
|
|
642
|
+
{ name: 'three scopes group commits and isolate a conflict beside successful writes', async run() {
|
|
643
|
+
const result = await multiSession(await make(), null);
|
|
644
|
+
verify(result);
|
|
645
|
+
assertOk(result.groups.some((group) => group.scopes.length === 3), 'a group of three');
|
|
646
|
+
assertOk(result.groups.some((group) => group.outcomes.includes('conflict') && group.outcomes.includes('ok')), 'mixed outcomes');
|
|
647
|
+
assertOk(result.groups.some((group) => group.snapshot), 'a snapshot commit');
|
|
648
|
+
} },
|
|
649
|
+
{ name: 'three scopes keep their ids after a crash before every group', run: survives('before') },
|
|
650
|
+
{ name: 'three scopes keep their ids after a crash after every group', run: survives('after') },
|
|
651
|
+
];
|
|
652
|
+
}
|
|
653
|
+
const MULTI_DECISIONS = ['4b0d164deed6032f', '57926a9b506cd3a1', '57926e9b506cda6d', '6be88691d9775406', '6be88f91d9776351', '6be89391d9776a1d', 'd29b92ea485105d3', 'd29b96ea48510c9f', 'd29b9dea48511884'];
|
|
654
|
+
const MULTI_LETTERS = ['51a3be624f9cf887', '57926b9b506cd554', '6be89091d9776504', 'ba2ddaa47740a48a', 'c43155c9c7ae9dd8', 'd29b93ea48510786'];
|
|
655
|
+
//# sourceMappingURL=testing.js.map
|