@boxxy/server 0.0.1 → 0.1.4
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/README.md +57 -5
- package/dist/auth/authorize-call.d.ts +11 -0
- package/dist/auth/is-browser-request.d.ts +1 -0
- package/dist/auth/pick-request-scope.d.ts +9 -0
- package/dist/config/pick-server-secrets.d.ts +16 -0
- package/dist/config/read-caller-proof.d.ts +10 -0
- package/dist/config/read-server-config.d.ts +9 -0
- package/dist/config/read-trusted-issuers.d.ts +8 -0
- package/dist/config/server-config-schema.d.ts +42 -0
- package/dist/create-capacity-backoff.d.ts +2 -0
- package/dist/handlers/api.d.ts +1683 -0
- package/dist/handlers/attempt-handlers.d.ts +130 -0
- package/dist/handlers/build-owner-fact.d.ts +3 -0
- package/dist/handlers/item-handlers.d.ts +168 -0
- package/dist/handlers/outbox-handlers.d.ts +7 -0
- package/dist/handlers/plan-handlers.d.ts +62 -0
- package/dist/handlers/read-item-ids.d.ts +10 -0
- package/dist/handlers/relation-handlers.d.ts +41 -0
- package/dist/handlers/report-handlers.d.ts +102 -0
- package/dist/handlers/rules-handlers.d.ts +20 -0
- package/dist/handlers/runner-handlers.d.ts +65 -0
- package/dist/handlers/to-wire-json.d.ts +5 -0
- package/dist/handlers/write-levers.d.ts +11 -0
- package/dist/index.d.ts +12 -0
- package/dist/main.d.ts +1 -0
- package/dist/no-caller-proof.d.ts +2 -0
- package/dist/outbox/create-off-outbox.d.ts +3 -0
- package/dist/outbox/start-outbox-sender.d.ts +10 -0
- package/dist/read-limited-request.d.ts +1 -0
- package/dist/rules/create-rules-holder.d.ts +20 -0
- package/dist/rules/load-server-rules.d.ts +7 -0
- package/dist/rules/read-rules-checkout.d.ts +9 -0
- package/dist/serve-api.d.ts +50 -0
- package/dist/start-server.d.ts +18 -0
- package/dist/test-utils/build-mcp-request.d.ts +8 -0
- package/dist/test-utils/build-rpc-request.d.ts +7 -0
- package/dist/test-utils/build-session-input.d.ts +1 -0
- package/dist/test-utils/create-fake-caller-proof.d.ts +5 -0
- package/dist/test-utils/create-test-server.d.ts +35 -0
- package/dist/test-utils/list-owner-procedures.d.ts +5 -0
- package/dist/test-utils/read-mcp-answer.d.ts +11 -0
- package/dist/test-utils/wait-for.d.ts +1 -0
- package/dist/tick/run-tick.d.ts +24 -0
- package/dist/tick/start-tick-loop.d.ts +12 -0
- package/dist/types.d.ts +41 -0
- package/package.json +48 -4
- package/src/auth/authorize-call.ts +50 -0
- package/src/auth/is-browser-request.ts +8 -0
- package/src/auth/pick-request-scope.ts +37 -0
- package/src/config/pick-server-secrets.ts +48 -0
- package/src/config/read-caller-proof.ts +36 -0
- package/src/config/read-server-config.ts +41 -0
- package/src/config/read-trusted-issuers.ts +60 -0
- package/src/config/server-config-schema.ts +71 -0
- package/src/create-capacity-backoff.ts +18 -0
- package/src/handlers/api.ts +23 -0
- package/src/handlers/attempt-handlers.ts +244 -0
- package/src/handlers/build-owner-fact.ts +20 -0
- package/src/handlers/item-handlers.ts +462 -0
- package/src/handlers/outbox-handlers.ts +15 -0
- package/src/handlers/plan-handlers.ts +75 -0
- package/src/handlers/read-item-ids.ts +31 -0
- package/src/handlers/relation-handlers.ts +143 -0
- package/src/handlers/report-handlers.ts +112 -0
- package/src/handlers/rules-handlers.ts +46 -0
- package/src/handlers/runner-handlers.ts +117 -0
- package/src/handlers/to-wire-json.ts +20 -0
- package/src/handlers/write-levers.ts +53 -0
- package/src/index.ts +12 -0
- package/src/main.ts +31 -0
- package/src/no-caller-proof.ts +7 -0
- package/src/outbox/create-off-outbox.ts +21 -0
- package/src/outbox/start-outbox-sender.ts +89 -0
- package/src/read-limited-request.ts +43 -0
- package/src/rules/create-rules-holder.ts +107 -0
- package/src/rules/load-server-rules.ts +24 -0
- package/src/rules/read-rules-checkout.ts +65 -0
- package/src/serve-api.ts +178 -0
- package/src/start-server.ts +308 -0
- package/src/test-utils/build-mcp-request.ts +34 -0
- package/src/test-utils/build-rpc-request.ts +23 -0
- package/src/test-utils/build-session-input.ts +16 -0
- package/src/test-utils/create-fake-caller-proof.ts +18 -0
- package/src/test-utils/create-test-server.ts +166 -0
- package/src/test-utils/list-owner-procedures.ts +38 -0
- package/src/test-utils/read-mcp-answer.ts +26 -0
- package/src/test-utils/wait-for.ts +14 -0
- package/src/tick/run-tick.ts +212 -0
- package/src/tick/start-tick-loop.ts +46 -0
- package/src/types.ts +86 -0
|
@@ -0,0 +1,462 @@
|
|
|
1
|
+
import { endItemAttempts, requestWake } from '@boxxy/dispatch';
|
|
2
|
+
import type {
|
|
3
|
+
Criterion,
|
|
4
|
+
ItemId,
|
|
5
|
+
ItemKindName,
|
|
6
|
+
ItemRow,
|
|
7
|
+
ItemView,
|
|
8
|
+
ItemWithLevers,
|
|
9
|
+
NoteRow,
|
|
10
|
+
RelationRow,
|
|
11
|
+
StoreReader,
|
|
12
|
+
} from '@boxxy/model';
|
|
13
|
+
import { brandSchemas } from '@boxxy/model';
|
|
14
|
+
import { ORPCError } from '@orpc/server';
|
|
15
|
+
import type { ServerDeps } from '../types.ts';
|
|
16
|
+
import { api } from './api.ts';
|
|
17
|
+
import { buildOwnerFact } from './build-owner-fact.ts';
|
|
18
|
+
import { readItemIds } from './read-item-ids.ts';
|
|
19
|
+
import { writeLevers } from './write-levers.ts';
|
|
20
|
+
|
|
21
|
+
const owner = 'boxxy:owner:local';
|
|
22
|
+
|
|
23
|
+
// the kind of an item filed with none; triage sets the real one
|
|
24
|
+
const untriagedKind = 'core.idea';
|
|
25
|
+
|
|
26
|
+
// The owner's commands and reads on one item. A command writes one store change with its facts,
|
|
27
|
+
// or nothing; a read writes nothing.
|
|
28
|
+
export const itemHandlers = {
|
|
29
|
+
file: api.item.file.handler((options) => {
|
|
30
|
+
const input = options.input;
|
|
31
|
+
const notFound = options.errors.NOT_FOUND;
|
|
32
|
+
const deps = options.context.deps;
|
|
33
|
+
const kind = parseKnownKind(deps, input.kind ?? untriagedKind);
|
|
34
|
+
const repo = brandSchemas.repoName.safeParse(input.repo);
|
|
35
|
+
|
|
36
|
+
if (kind === null || !repo.success) {
|
|
37
|
+
throw new ORPCError('BAD_REQUEST', { message: 'unknown item kind or repository name' });
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return deps.store.exclusive(async (tx) => {
|
|
41
|
+
const now = deps.clock.now();
|
|
42
|
+
|
|
43
|
+
const blockers = await readItemIds(tx, deps, input.blockedBy);
|
|
44
|
+
|
|
45
|
+
if (blockers.kind === 'not-found') {
|
|
46
|
+
throw notFound({ data: { item: blockers.item } });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const row: ItemRow = {
|
|
50
|
+
id: brandSchemas.itemId.parse(deps.newId()),
|
|
51
|
+
kind,
|
|
52
|
+
repo: repo.data,
|
|
53
|
+
title: input.title,
|
|
54
|
+
outcome: input.outcome,
|
|
55
|
+
acceptance: input.acceptance.map((text, index) => buildCriterion(index + 1, text)),
|
|
56
|
+
fields: input.designMode === undefined ? {} : { designMode: input.designMode },
|
|
57
|
+
labels: input.labels,
|
|
58
|
+
foundInAttempt: null,
|
|
59
|
+
filedBy: owner,
|
|
60
|
+
filedAt: now,
|
|
61
|
+
closedAt: null,
|
|
62
|
+
closeReason: null,
|
|
63
|
+
keptSession: null,
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const { keptSession: _keptSession, ...filed } = row;
|
|
67
|
+
const relations = blockers.items.map((source) => buildBlocksRow(source, row.id, now));
|
|
68
|
+
|
|
69
|
+
await tx.applyChange({
|
|
70
|
+
at: now,
|
|
71
|
+
writes: [
|
|
72
|
+
{ op: 'insert', table: 'items', row },
|
|
73
|
+
...relations.map(
|
|
74
|
+
(relation) => ({ op: 'insert', table: 'relations', row: relation }) as const,
|
|
75
|
+
),
|
|
76
|
+
],
|
|
77
|
+
facts: [
|
|
78
|
+
buildOwnerFact(deps, row.id, 'boxxy.item.filed', filed),
|
|
79
|
+
...relations.map((relation) =>
|
|
80
|
+
buildOwnerFact(deps, relation.source, 'boxxy.relation.added', {
|
|
81
|
+
source: relation.source,
|
|
82
|
+
target: relation.target,
|
|
83
|
+
type: relation.type,
|
|
84
|
+
}),
|
|
85
|
+
),
|
|
86
|
+
],
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
return { item: row.id };
|
|
90
|
+
});
|
|
91
|
+
}),
|
|
92
|
+
edit: api.item.edit.handler((options) => {
|
|
93
|
+
const input = options.input;
|
|
94
|
+
const deps = options.context.deps;
|
|
95
|
+
|
|
96
|
+
return deps.store.exclusive(async (tx) => {
|
|
97
|
+
const item = await readItemOrThrow(tx, deps, input.item, options.errors.NOT_FOUND);
|
|
98
|
+
|
|
99
|
+
const acceptance =
|
|
100
|
+
input.acceptance === undefined ? undefined : buildAcceptance(item, input.acceptance);
|
|
101
|
+
|
|
102
|
+
if (acceptance === null) {
|
|
103
|
+
throw new ORPCError('BAD_REQUEST', { message: 'an acceptance entry names no criterion' });
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const set = {
|
|
107
|
+
...(input.title === undefined ? {} : { title: input.title }),
|
|
108
|
+
...(input.outcome === undefined ? {} : { outcome: input.outcome }),
|
|
109
|
+
...(acceptance === undefined ? {} : { acceptance }),
|
|
110
|
+
...(input.designMode === undefined
|
|
111
|
+
? {}
|
|
112
|
+
: { fields: { ...item.fields, designMode: input.designMode } }),
|
|
113
|
+
...(input.labels === undefined ? {} : { labels: input.labels }),
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const now = deps.clock.now();
|
|
117
|
+
|
|
118
|
+
await tx.applyChange({
|
|
119
|
+
at: now,
|
|
120
|
+
writes: [{ op: 'update', table: 'items', key: { id: item.id }, expect: {}, set }],
|
|
121
|
+
facts: [buildOwnerFact(deps, item.id, 'boxxy.item.edited', { ...set, byAttempt: null })],
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
return { item: item.id };
|
|
125
|
+
});
|
|
126
|
+
}),
|
|
127
|
+
close: api.item.close.handler((options) => {
|
|
128
|
+
const input = options.input;
|
|
129
|
+
const notFound = options.errors.NOT_FOUND;
|
|
130
|
+
const deps = options.context.deps;
|
|
131
|
+
|
|
132
|
+
return deps.store.exclusive(async (tx) => {
|
|
133
|
+
const item = await readItemOrThrow(tx, deps, input.item, notFound);
|
|
134
|
+
|
|
135
|
+
const of =
|
|
136
|
+
input.of === undefined ? null : await readItemOrThrow(tx, deps, input.of, notFound);
|
|
137
|
+
|
|
138
|
+
if (item.closedAt !== null) {
|
|
139
|
+
return { item: item.id, ended: [] };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const now = deps.clock.now();
|
|
143
|
+
const reason = of === null ? input.reason : 'duplicate';
|
|
144
|
+
|
|
145
|
+
const ended = endItemAttempts({
|
|
146
|
+
attempts: await tx.listItemAttempts(item.id),
|
|
147
|
+
actor: owner,
|
|
148
|
+
now,
|
|
149
|
+
newId: deps.newId,
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
const relation = of === null ? null : buildRelationRow(item.id, of.id, 'duplicate-of', now);
|
|
153
|
+
|
|
154
|
+
await tx.applyChange({
|
|
155
|
+
at: now,
|
|
156
|
+
under: ended.under,
|
|
157
|
+
writes: [
|
|
158
|
+
...ended.writes,
|
|
159
|
+
{
|
|
160
|
+
op: 'update',
|
|
161
|
+
table: 'items',
|
|
162
|
+
key: { id: item.id },
|
|
163
|
+
expect: { closedAt: null },
|
|
164
|
+
set: { closedAt: now, closeReason: reason },
|
|
165
|
+
},
|
|
166
|
+
...(relation === null
|
|
167
|
+
? []
|
|
168
|
+
: [{ op: 'insert', table: 'relations', row: relation } as const]),
|
|
169
|
+
],
|
|
170
|
+
facts: [
|
|
171
|
+
...ended.facts,
|
|
172
|
+
buildOwnerFact(deps, item.id, 'boxxy.item.closed', {
|
|
173
|
+
reason,
|
|
174
|
+
byAttempt: null,
|
|
175
|
+
ended: ended.ended,
|
|
176
|
+
}),
|
|
177
|
+
...(relation === null
|
|
178
|
+
? []
|
|
179
|
+
: [
|
|
180
|
+
buildOwnerFact(deps, item.id, 'boxxy.relation.added', {
|
|
181
|
+
source: relation.source,
|
|
182
|
+
target: relation.target,
|
|
183
|
+
type: relation.type,
|
|
184
|
+
}),
|
|
185
|
+
]),
|
|
186
|
+
],
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
return { item: item.id, ended: [...ended.ended] };
|
|
190
|
+
});
|
|
191
|
+
}),
|
|
192
|
+
retry: api.item.retry.handler(async (options) => {
|
|
193
|
+
const input = options.input;
|
|
194
|
+
const notFound = options.errors.NOT_FOUND;
|
|
195
|
+
const unknownPhase = options.errors.UNKNOWN_PHASE;
|
|
196
|
+
const deps = options.context.deps;
|
|
197
|
+
const rules = deps.rulesState().rules;
|
|
198
|
+
|
|
199
|
+
const item = await deps.store.read((tx) => readItemOrThrow(tx, deps, input.item, notFound));
|
|
200
|
+
|
|
201
|
+
const phase =
|
|
202
|
+
input.phase === undefined
|
|
203
|
+
? null
|
|
204
|
+
: rules.kinds.get(item.kind)?.pipeline.steps.find((step) => step.phase === input.phase)
|
|
205
|
+
?.phase;
|
|
206
|
+
|
|
207
|
+
if (phase === undefined) {
|
|
208
|
+
throw unknownPhase();
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const answer = await writeLevers(deps, [item.id], {
|
|
212
|
+
kind: 'item.retry',
|
|
213
|
+
until: null,
|
|
214
|
+
expiresAt: null,
|
|
215
|
+
reason: null,
|
|
216
|
+
phase,
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
if (answer.kind === 'not-found') {
|
|
220
|
+
throw notFound({ data: { item: answer.item } });
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return { lever: answer.levers[0] ?? 0 };
|
|
224
|
+
}),
|
|
225
|
+
wake: api.item.wake.handler(async (options) => {
|
|
226
|
+
const deps = options.context.deps;
|
|
227
|
+
|
|
228
|
+
const attempts = await deps.store.read(async (tx) => {
|
|
229
|
+
const item = await readItemOrThrow(tx, deps, options.input.item, options.errors.NOT_FOUND);
|
|
230
|
+
|
|
231
|
+
return tx.listItemAttempts(item.id);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
const asleep = attempts.find((attempt) => attempt.state === 'sleeping');
|
|
235
|
+
|
|
236
|
+
if (asleep === undefined) {
|
|
237
|
+
return { kind: 'not-sleeping' } as const;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const result = await requestWake({
|
|
241
|
+
store: deps.store,
|
|
242
|
+
attempt: asleep.id,
|
|
243
|
+
by: 'command',
|
|
244
|
+
actor: owner,
|
|
245
|
+
now: deps.clock.now(),
|
|
246
|
+
newId: deps.newId,
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
return result.kind === 'applied'
|
|
250
|
+
? ({ kind: 'requested', attempt: asleep.id, atcSession: asleep.atcSession } as const)
|
|
251
|
+
: ({ kind: 'not-sleeping' } as const);
|
|
252
|
+
}),
|
|
253
|
+
note: api.item.note.handler((options) => {
|
|
254
|
+
const input = options.input;
|
|
255
|
+
const deps = options.context.deps;
|
|
256
|
+
|
|
257
|
+
return deps.store.exclusive(async (tx) => {
|
|
258
|
+
const item = await readItemOrThrow(tx, deps, input.item, options.errors.NOT_FOUND);
|
|
259
|
+
const notes = await tx.listItemNotes(item.id);
|
|
260
|
+
|
|
261
|
+
const now = deps.clock.now();
|
|
262
|
+
|
|
263
|
+
const note: NoteRow = {
|
|
264
|
+
item: item.id,
|
|
265
|
+
seq: Math.max(0, ...notes.map((entry) => entry.seq)) + 1,
|
|
266
|
+
text: input.text,
|
|
267
|
+
by: owner,
|
|
268
|
+
at: now,
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
await tx.applyChange({
|
|
272
|
+
at: now,
|
|
273
|
+
writes: [{ op: 'insert', table: 'notes', row: note }],
|
|
274
|
+
facts: [
|
|
275
|
+
buildOwnerFact(deps, item.id, 'boxxy.item.noted', { seq: note.seq, text: note.text }),
|
|
276
|
+
],
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
return { item: item.id, seq: note.seq };
|
|
280
|
+
});
|
|
281
|
+
}),
|
|
282
|
+
get: api.item.get.handler(async (options) => {
|
|
283
|
+
const input = options.input;
|
|
284
|
+
const notFound = options.errors.NOT_FOUND;
|
|
285
|
+
const deps = options.context.deps;
|
|
286
|
+
const item = brandSchemas.itemId.safeParse(input.item);
|
|
287
|
+
|
|
288
|
+
const detail = item.success
|
|
289
|
+
? await deps.store.read((tx) =>
|
|
290
|
+
tx.readItemDetail(item.data, deps.clock.now(), deps.rulesState().rules),
|
|
291
|
+
)
|
|
292
|
+
: null;
|
|
293
|
+
|
|
294
|
+
if (detail === null) {
|
|
295
|
+
throw notFound({ data: { item: input.item } });
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
return {
|
|
299
|
+
item: toItemView(detail.item),
|
|
300
|
+
notes: [...detail.notes],
|
|
301
|
+
attempts: [...detail.attempts],
|
|
302
|
+
relations: [...detail.relations],
|
|
303
|
+
evidence: [...detail.evidence],
|
|
304
|
+
pullRequests: [...detail.pullRequests],
|
|
305
|
+
};
|
|
306
|
+
}),
|
|
307
|
+
list: api.item.list.handler(async (options) => {
|
|
308
|
+
const input = options.input;
|
|
309
|
+
const deps = options.context.deps;
|
|
310
|
+
const now = deps.clock.now();
|
|
311
|
+
const repo = input.repo === undefined ? undefined : brandSchemas.repoName.safeParse(input.repo);
|
|
312
|
+
|
|
313
|
+
if (repo?.success === false) {
|
|
314
|
+
return { items: [], next: null };
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const listed = await deps.store.read(async (tx) => {
|
|
318
|
+
const items = await tx.listItems(
|
|
319
|
+
{
|
|
320
|
+
...(input.state === undefined ? {} : { state: input.state }),
|
|
321
|
+
...(repo === undefined ? {} : { repo: repo.data }),
|
|
322
|
+
},
|
|
323
|
+
now,
|
|
324
|
+
deps.rulesState().rules,
|
|
325
|
+
);
|
|
326
|
+
|
|
327
|
+
return Promise.all(
|
|
328
|
+
items.map(async (item) => ({ item, notes: await tx.listItemNotes(item.id) })),
|
|
329
|
+
);
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
const matched = listed
|
|
333
|
+
.filter((entry) => isListMatch(entry.item, entry.notes, input))
|
|
334
|
+
.map((entry) => entry.item)
|
|
335
|
+
.toSorted(
|
|
336
|
+
(first, second) => second.filedAt - first.filedAt || second.id.localeCompare(first.id),
|
|
337
|
+
);
|
|
338
|
+
|
|
339
|
+
const start =
|
|
340
|
+
input.after === undefined ? 0 : matched.findIndex((item) => item.id === input.after) + 1;
|
|
341
|
+
|
|
342
|
+
const page = matched.slice(start, start + input.limit);
|
|
343
|
+
const last = page.at(-1);
|
|
344
|
+
|
|
345
|
+
return {
|
|
346
|
+
items: page.map((item) => toItemView(item)),
|
|
347
|
+
next: last !== undefined && start + input.limit < matched.length ? last.id : null,
|
|
348
|
+
};
|
|
349
|
+
}),
|
|
350
|
+
};
|
|
351
|
+
|
|
352
|
+
function parseKnownKind(deps: ServerDeps, raw: string): ItemKindName | null {
|
|
353
|
+
const kind = brandSchemas.itemKindName.safeParse(raw);
|
|
354
|
+
|
|
355
|
+
return kind.success && deps.rulesState().rules.kinds.has(kind.data) ? kind.data : null;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function buildCriterion(index: number, text: string): Criterion {
|
|
359
|
+
return { id: brandSchemas.criterionId.parse(`a${index}`), text, evidence: [] };
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function buildBlocksRow(source: ItemId, target: ItemId, at: ItemRow['filedAt']): RelationRow {
|
|
363
|
+
return buildRelationRow(source, target, 'blocks', at);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function buildRelationRow(
|
|
367
|
+
source: ItemId,
|
|
368
|
+
target: ItemId,
|
|
369
|
+
type: string,
|
|
370
|
+
at: ItemRow['filedAt'],
|
|
371
|
+
): RelationRow {
|
|
372
|
+
return {
|
|
373
|
+
source,
|
|
374
|
+
target,
|
|
375
|
+
type: brandSchemas.relationTypeName.parse(type),
|
|
376
|
+
addedBy: owner,
|
|
377
|
+
addedAt: at,
|
|
378
|
+
removedAt: null,
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
interface NotFoundOptions {
|
|
383
|
+
readonly data: { readonly item: string };
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
type NotFound = (options: NotFoundOptions) => Error;
|
|
387
|
+
|
|
388
|
+
async function readItemOrThrow(
|
|
389
|
+
tx: StoreReader,
|
|
390
|
+
deps: ServerDeps,
|
|
391
|
+
raw: string,
|
|
392
|
+
notFound: NotFound,
|
|
393
|
+
): Promise<ItemView> {
|
|
394
|
+
const id = brandSchemas.itemId.safeParse(raw);
|
|
395
|
+
|
|
396
|
+
const item = id.success
|
|
397
|
+
? await tx.readItem(id.data, deps.clock.now(), deps.rulesState().rules)
|
|
398
|
+
: null;
|
|
399
|
+
|
|
400
|
+
if (item === null) {
|
|
401
|
+
throw notFound({ data: { item: raw } });
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
return item;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// An entry with the ID of a criterion keeps that criterion's evidence; an entry with no ID is a
|
|
408
|
+
// new criterion. null: an entry names a criterion the item does not have.
|
|
409
|
+
function buildAcceptance(
|
|
410
|
+
item: ItemView,
|
|
411
|
+
entries: readonly { readonly id?: string | undefined; readonly text: string }[],
|
|
412
|
+
): Criterion[] | null {
|
|
413
|
+
const known = new Map(item.acceptance.map((criterion) => [String(criterion.id), criterion]));
|
|
414
|
+
|
|
415
|
+
let next = Math.max(0, ...item.acceptance.map((criterion) => Number(criterion.id.slice(1))));
|
|
416
|
+
const built: Criterion[] = [];
|
|
417
|
+
|
|
418
|
+
for (const entry of entries) {
|
|
419
|
+
if (entry.id === undefined) {
|
|
420
|
+
next += 1;
|
|
421
|
+
|
|
422
|
+
built.push(buildCriterion(next, entry.text));
|
|
423
|
+
continue;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
const kept = known.get(entry.id);
|
|
427
|
+
|
|
428
|
+
if (kept === undefined) {
|
|
429
|
+
return null;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
built.push({ ...kept, text: entry.text });
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
return built;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function toItemView(item: ItemWithLevers): ItemView {
|
|
439
|
+
const { levers: _levers, ...view } = item;
|
|
440
|
+
|
|
441
|
+
return view;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
interface ListFilter {
|
|
445
|
+
readonly phase?: string | undefined;
|
|
446
|
+
readonly text?: string | undefined;
|
|
447
|
+
readonly label?: string | undefined;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function isListMatch(item: ItemView, notes: readonly NoteRow[], filter: ListFilter): boolean {
|
|
451
|
+
const text = filter.text?.toLowerCase();
|
|
452
|
+
|
|
453
|
+
return (
|
|
454
|
+
(filter.phase === undefined ||
|
|
455
|
+
(item.position.kind === 'phase' && item.position.phase === filter.phase)) &&
|
|
456
|
+
(filter.label === undefined || item.labels.includes(filter.label)) &&
|
|
457
|
+
(text === undefined ||
|
|
458
|
+
[item.title, item.outcome, ...notes.map((note) => note.text)].some((value) =>
|
|
459
|
+
value.toLowerCase().includes(text),
|
|
460
|
+
))
|
|
461
|
+
);
|
|
462
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { api } from './api.ts';
|
|
2
|
+
|
|
3
|
+
// Clears the park mark of every parked row, then asks the sender for a turn, so the rows go out
|
|
4
|
+
// before its next timer.
|
|
5
|
+
export const outboxHandlers = {
|
|
6
|
+
retry: api.outbox.retry.handler(async (options) => {
|
|
7
|
+
const deps = options.context.deps;
|
|
8
|
+
|
|
9
|
+
const unparked = await deps.store.exclusive((tx) => tx.unparkFacts());
|
|
10
|
+
|
|
11
|
+
deps.outbox.nudge();
|
|
12
|
+
|
|
13
|
+
return { unparked };
|
|
14
|
+
}),
|
|
15
|
+
};
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { Instant } from '@boxxy/model';
|
|
2
|
+
import { brandSchemas } from '@boxxy/model';
|
|
3
|
+
import { api } from './api.ts';
|
|
4
|
+
import type { LeverAnswer } from './write-levers.ts';
|
|
5
|
+
import { writeLevers } from './write-levers.ts';
|
|
6
|
+
|
|
7
|
+
const noLever = { until: null, expiresAt: null, reason: null, phase: null } as const;
|
|
8
|
+
|
|
9
|
+
// The planning levers. Each records one lever per item, which `decide` reads on the next claim.
|
|
10
|
+
export const planHandlers = {
|
|
11
|
+
defer: api.plan.defer.handler(async (options) => {
|
|
12
|
+
const input = options.input;
|
|
13
|
+
|
|
14
|
+
const answer = await writeLevers(options.context.deps, [input.item], {
|
|
15
|
+
...noLever,
|
|
16
|
+
kind: 'plan.defer',
|
|
17
|
+
until: input.until === undefined ? null : toInstant(input.until),
|
|
18
|
+
reason: input.reason,
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
return { lever: getFirstLever(answer, options.errors.NOT_FOUND) };
|
|
22
|
+
}),
|
|
23
|
+
undefer: api.plan.undefer.handler(async (options) => {
|
|
24
|
+
const answer = await writeLevers(options.context.deps, [options.input.item], {
|
|
25
|
+
...noLever,
|
|
26
|
+
kind: 'plan.undefer',
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
return { lever: getFirstLever(answer, options.errors.NOT_FOUND) };
|
|
30
|
+
}),
|
|
31
|
+
expedite: api.plan.expedite.handler(async (options) => {
|
|
32
|
+
const input = options.input;
|
|
33
|
+
const notFound = options.errors.NOT_FOUND;
|
|
34
|
+
|
|
35
|
+
const answer = await writeLevers(options.context.deps, input.items, {
|
|
36
|
+
...noLever,
|
|
37
|
+
kind: 'plan.expedite',
|
|
38
|
+
expiresAt: input.expires === undefined ? null : toInstant(input.expires),
|
|
39
|
+
reason: input.reason,
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
if (answer.kind === 'not-found') {
|
|
43
|
+
throw notFound({ data: { item: answer.item } });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return { levers: [...answer.levers] };
|
|
47
|
+
}),
|
|
48
|
+
unexpedite: api.plan.unexpedite.handler(async (options) => {
|
|
49
|
+
const answer = await writeLevers(options.context.deps, [options.input.item], {
|
|
50
|
+
...noLever,
|
|
51
|
+
kind: 'plan.unexpedite',
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
return { lever: getFirstLever(answer, options.errors.NOT_FOUND) };
|
|
55
|
+
}),
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
// The contract takes ISO 8601 with an offset; the store keeps epoch milliseconds.
|
|
59
|
+
function toInstant(iso: string): Instant {
|
|
60
|
+
return brandSchemas.instant.parse(Math.max(0, Date.parse(iso)));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
interface NotFoundOptions {
|
|
64
|
+
readonly data: { readonly item: string };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
type NotFound = (options: NotFoundOptions) => Error;
|
|
68
|
+
|
|
69
|
+
function getFirstLever(answer: LeverAnswer, notFound: NotFound): number {
|
|
70
|
+
if (answer.kind === 'not-found') {
|
|
71
|
+
throw notFound({ data: { item: answer.item } });
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return answer.levers[0] ?? 0;
|
|
75
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { ItemId, StoreReader } from '@boxxy/model';
|
|
2
|
+
import { brandSchemas } from '@boxxy/model';
|
|
3
|
+
import type { ServerDeps } from '../types.ts';
|
|
4
|
+
|
|
5
|
+
export type ItemIdsAnswer =
|
|
6
|
+
| { readonly kind: 'found'; readonly items: ItemId[] }
|
|
7
|
+
|
|
8
|
+
// the first entry that names no stored item
|
|
9
|
+
| { readonly kind: 'not-found'; readonly item: string };
|
|
10
|
+
|
|
11
|
+
// The IDs as the store holds them, in the order given. The wire carries plain strings.
|
|
12
|
+
export async function readItemIds(
|
|
13
|
+
tx: StoreReader,
|
|
14
|
+
deps: ServerDeps,
|
|
15
|
+
raw: readonly string[],
|
|
16
|
+
): Promise<ItemIdsAnswer> {
|
|
17
|
+
const items: ItemId[] = [];
|
|
18
|
+
const rules = deps.rulesState().rules;
|
|
19
|
+
|
|
20
|
+
for (const entry of raw) {
|
|
21
|
+
const parsed = brandSchemas.itemId.safeParse(entry);
|
|
22
|
+
|
|
23
|
+
if (!parsed.success || (await tx.readItem(parsed.data, deps.clock.now(), rules)) === null) {
|
|
24
|
+
return { kind: 'not-found', item: entry };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
items.push(parsed.data);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return { kind: 'found', items };
|
|
31
|
+
}
|