@owlmeans/server-planning 0.1.18-rc.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +150 -0
- package/agent-meta/manifest.json +16 -0
- package/agent-meta/skills/server-planning/SKILL.md +203 -0
- package/build/actions/cards.d.ts +16 -0
- package/build/actions/cards.d.ts.map +1 -0
- package/build/actions/cards.js +32 -0
- package/build/actions/cards.js.map +1 -0
- package/build/actions/commit.d.ts +14 -0
- package/build/actions/commit.d.ts.map +1 -0
- package/build/actions/commit.js +48 -0
- package/build/actions/commit.js.map +1 -0
- package/build/actions/execute.d.ts +13 -0
- package/build/actions/execute.d.ts.map +1 -0
- package/build/actions/execute.js +35 -0
- package/build/actions/execute.js.map +1 -0
- package/build/actions/index.d.ts +9 -0
- package/build/actions/index.d.ts.map +1 -0
- package/build/actions/index.js +9 -0
- package/build/actions/index.js.map +1 -0
- package/build/actions/links.d.ts +5 -0
- package/build/actions/links.d.ts.map +1 -0
- package/build/actions/links.js +8 -0
- package/build/actions/links.js.map +1 -0
- package/build/actions/schemas.d.ts +6 -0
- package/build/actions/schemas.d.ts.map +1 -0
- package/build/actions/schemas.js +9 -0
- package/build/actions/schemas.js.map +1 -0
- package/build/actions/specs.d.ts +10 -0
- package/build/actions/specs.d.ts.map +1 -0
- package/build/actions/specs.js +14 -0
- package/build/actions/specs.js.map +1 -0
- package/build/actions/transitions.d.ts +6 -0
- package/build/actions/transitions.d.ts.map +1 -0
- package/build/actions/transitions.js +8 -0
- package/build/actions/transitions.js.map +1 -0
- package/build/actions/watch.d.ts +12 -0
- package/build/actions/watch.d.ts.map +1 -0
- package/build/actions/watch.js +45 -0
- package/build/actions/watch.js.map +1 -0
- package/build/consts.d.ts +18 -0
- package/build/consts.d.ts.map +1 -0
- package/build/consts.js +19 -0
- package/build/consts.js.map +1 -0
- package/build/executor/changes.d.ts +31 -0
- package/build/executor/changes.d.ts.map +1 -0
- package/build/executor/changes.js +55 -0
- package/build/executor/changes.js.map +1 -0
- package/build/executor/code.d.ts +15 -0
- package/build/executor/code.d.ts.map +1 -0
- package/build/executor/code.js +59 -0
- package/build/executor/code.js.map +1 -0
- package/build/executor/receipt.d.ts +12 -0
- package/build/executor/receipt.d.ts.map +1 -0
- package/build/executor/receipt.js +56 -0
- package/build/executor/receipt.js.map +1 -0
- package/build/executor/resolve.d.ts +35 -0
- package/build/executor/resolve.d.ts.map +1 -0
- package/build/executor/resolve.js +136 -0
- package/build/executor/resolve.js.map +1 -0
- package/build/executor/validate.d.ts +13 -0
- package/build/executor/validate.d.ts.map +1 -0
- package/build/executor/validate.js +222 -0
- package/build/executor/validate.js.map +1 -0
- package/build/executor.d.ts +15 -0
- package/build/executor.d.ts.map +1 -0
- package/build/executor.js +113 -0
- package/build/executor.js.map +1 -0
- package/build/facade.d.ts +12 -0
- package/build/facade.d.ts.map +1 -0
- package/build/facade.js +131 -0
- package/build/facade.js.map +1 -0
- package/build/helper.d.ts +18 -0
- package/build/helper.d.ts.map +1 -0
- package/build/helper.js +32 -0
- package/build/helper.js.map +1 -0
- package/build/index.d.ts +13 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +11 -0
- package/build/index.js.map +1 -0
- package/build/projection.d.ts +28 -0
- package/build/projection.d.ts.map +1 -0
- package/build/projection.js +40 -0
- package/build/projection.js.map +1 -0
- package/build/registry.d.ts +30 -0
- package/build/registry.d.ts.map +1 -0
- package/build/registry.js +103 -0
- package/build/registry.js.map +1 -0
- package/build/service.d.ts +40 -0
- package/build/service.d.ts.map +1 -0
- package/build/service.js +109 -0
- package/build/service.js.map +1 -0
- package/build/store/commits.d.ts +15 -0
- package/build/store/commits.d.ts.map +1 -0
- package/build/store/commits.js +147 -0
- package/build/store/commits.js.map +1 -0
- package/build/store/composite.d.ts +12 -0
- package/build/store/composite.d.ts.map +1 -0
- package/build/store/composite.js +150 -0
- package/build/store/composite.js.map +1 -0
- package/build/store/fold.d.ts +39 -0
- package/build/store/fold.d.ts.map +1 -0
- package/build/store/fold.js +220 -0
- package/build/store/fold.js.map +1 -0
- package/build/store/index.d.ts +6 -0
- package/build/store/index.d.ts.map +1 -0
- package/build/store/index.js +5 -0
- package/build/store/index.js.map +1 -0
- package/build/store/memory.d.ts +19 -0
- package/build/store/memory.d.ts.map +1 -0
- package/build/store/memory.js +0 -0
- package/build/store/memory.js.map +1 -0
- package/build/store/types.d.ts +82 -0
- package/build/store/types.d.ts.map +1 -0
- package/build/store/types.js +2 -0
- package/build/store/types.js.map +1 -0
- package/build/types.d.ts +46 -0
- package/build/types.d.ts.map +1 -0
- package/build/types.js +2 -0
- package/build/types.js.map +1 -0
- package/build/utils/guard.d.ts +19 -0
- package/build/utils/guard.d.ts.map +1 -0
- package/build/utils/guard.js +35 -0
- package/build/utils/guard.js.map +1 -0
- package/build/utils/index.d.ts +3 -0
- package/build/utils/index.d.ts.map +1 -0
- package/build/utils/index.js +3 -0
- package/build/utils/index.js.map +1 -0
- package/build/utils/scope.d.ts +20 -0
- package/build/utils/scope.d.ts.map +1 -0
- package/build/utils/scope.js +36 -0
- package/build/utils/scope.js.map +1 -0
- package/package.json +66 -0
- package/src/actions/cards.ts +52 -0
- package/src/actions/commit.ts +55 -0
- package/src/actions/execute.ts +47 -0
- package/src/actions/index.ts +8 -0
- package/src/actions/links.ts +14 -0
- package/src/actions/schemas.ts +15 -0
- package/src/actions/specs.ts +23 -0
- package/src/actions/transitions.ts +14 -0
- package/src/actions/watch.ts +52 -0
- package/src/consts.ts +22 -0
- package/src/executor/changes.ts +78 -0
- package/src/executor/code.ts +83 -0
- package/src/executor/receipt.ts +66 -0
- package/src/executor/resolve.ts +174 -0
- package/src/executor/validate.ts +253 -0
- package/src/executor.ts +149 -0
- package/src/facade.ts +162 -0
- package/src/helper.ts +44 -0
- package/src/index.ts +13 -0
- package/src/projection.ts +65 -0
- package/src/registry.ts +143 -0
- package/src/service.ts +157 -0
- package/src/store/commits.ts +174 -0
- package/src/store/composite.ts +171 -0
- package/src/store/fold.ts +257 -0
- package/src/store/index.ts +6 -0
- package/src/store/memory.ts +0 -0
- package/src/store/types.ts +94 -0
- package/src/types.ts +48 -0
- package/src/utils/guard.ts +40 -0
- package/src/utils/index.ts +2 -0
- package/src/utils/scope.ts +51 -0
- package/tests/commits.spec.ts +78 -0
- package/tests/context.ts +198 -0
- package/tests/entrypoints.spec.ts +72 -0
- package/tests/executor.spec.ts +69 -0
- package/tests/memory-store.spec.ts +84 -0
- package/tests/plugins.spec.ts +86 -0
- package/tests/specifications.spec.ts +61 -0
- package/tests/tsconfig.json +17 -0
- package/tests/validation.spec.ts +62 -0
- package/tsconfig.json +16 -0
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import {
|
|
2
|
+
applyRelationship, applyTransition, CommitState, isProject, PlanningUnsupported, TransitionAction,
|
|
3
|
+
} from '@owlmeans/planning'
|
|
4
|
+
import type {
|
|
5
|
+
CommitEvent, PlanningStore, Relationship, SpecificationRevision, Transition, Workcard,
|
|
6
|
+
} from '@owlmeans/planning'
|
|
7
|
+
import type { FoldOptions, FoldResult } from './types.js'
|
|
8
|
+
|
|
9
|
+
const isoNow = (): string => new Date().toISOString()
|
|
10
|
+
|
|
11
|
+
const clean = <T extends object>(record: T): T =>
|
|
12
|
+
Object.fromEntries(Object.entries(record).filter(([, value]) => value !== undefined)) as T
|
|
13
|
+
|
|
14
|
+
const errorText = (error: unknown): string =>
|
|
15
|
+
error instanceof Error ? error.message : `${error}`
|
|
16
|
+
|
|
17
|
+
const safely = async (label: string, run: () => Promise<void> | void | undefined): Promise<void> => {
|
|
18
|
+
try {
|
|
19
|
+
await run()
|
|
20
|
+
} catch (error) {
|
|
21
|
+
console.error(`planning: ${label} failed:`, error)
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The event a settled transition publishes. `record` is in-process only — strip it before a bus. */
|
|
26
|
+
export const commitEventOf = (
|
|
27
|
+
transition: Transition, state: CommitState, at: string, extra?: { error?: string, record?: Workcard | null }
|
|
28
|
+
): CommitEvent => clean({
|
|
29
|
+
transition: transition.id!,
|
|
30
|
+
card: transition.card,
|
|
31
|
+
entityId: transition.entityId,
|
|
32
|
+
project: transition.project,
|
|
33
|
+
kind: transition.kind,
|
|
34
|
+
type: transition.type,
|
|
35
|
+
seq: transition.seq,
|
|
36
|
+
action: transition.action,
|
|
37
|
+
state,
|
|
38
|
+
at,
|
|
39
|
+
error: extra?.error,
|
|
40
|
+
record: extra?.record,
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
const bearsRelationships = (transition: Transition): boolean =>
|
|
44
|
+
transition.action === TransitionAction.Link
|
|
45
|
+
|| transition.action === TransitionAction.Unlink
|
|
46
|
+
|| transition.action === TransitionAction.Delete
|
|
47
|
+
|| (transition.action === TransitionAction.Create && (transition.links?.length ?? 0) > 0)
|
|
48
|
+
|
|
49
|
+
const foldRelationships = async (store: PlanningStore, transition: Transition): Promise<void> => {
|
|
50
|
+
const links = store.links
|
|
51
|
+
if (links == null || !bearsRelationships(transition)) {
|
|
52
|
+
return
|
|
53
|
+
}
|
|
54
|
+
const where = { entityId: transition.entityId }
|
|
55
|
+
const [outgoing, incoming] = await Promise.all([
|
|
56
|
+
links.list({ ...where, from: transition.card }, { size: 0 }),
|
|
57
|
+
links.list({ ...where, to: transition.card }, { size: 0 }),
|
|
58
|
+
])
|
|
59
|
+
const before = [...new Map([...outgoing.items, ...incoming.items].map(link => [link.id, link])).values()]
|
|
60
|
+
const after = applyRelationship(before, transition)
|
|
61
|
+
const kept = new Set(after.filter(link => link.id != null).map(link => link.id))
|
|
62
|
+
|
|
63
|
+
for (const link of before) {
|
|
64
|
+
if (link.id != null && !kept.has(link.id)) {
|
|
65
|
+
await links.drop({ entityId: transition.entityId, id: link.id })
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
for (const link of after) {
|
|
69
|
+
if (link.id == null) {
|
|
70
|
+
await links.put(link as Relationship)
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const writeFold = async (
|
|
76
|
+
store: PlanningStore, before: Workcard | null, after: Workcard | null, transition: Transition
|
|
77
|
+
): Promise<void> => {
|
|
78
|
+
if (transition.action === TransitionAction.Delete) {
|
|
79
|
+
if (before != null && isProject(before)) {
|
|
80
|
+
// A project takes everything under it, its own log included — the hub remembers the commit.
|
|
81
|
+
await store.cards.purge(transition.card, transition.entityId)
|
|
82
|
+
return
|
|
83
|
+
}
|
|
84
|
+
await foldRelationships(store, transition)
|
|
85
|
+
await store.cards.drop(transition.card, transition.entityId)
|
|
86
|
+
return
|
|
87
|
+
}
|
|
88
|
+
if (after != null) {
|
|
89
|
+
await store.cards.put(after)
|
|
90
|
+
}
|
|
91
|
+
await foldRelationships(store, transition)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Fold every pending transition of one card — the projection body every store reuses.
|
|
96
|
+
*
|
|
97
|
+
* Transitions are read in `seq` order past the card's own `seq`, and only `pending` ones are
|
|
98
|
+
* applied, each with `applyTransition` and nothing else. Per transition: write the record (a
|
|
99
|
+
* `delete` drops it, a project `delete` purges everything under it), fold its relationships, mark
|
|
100
|
+
* it `committed`, `publish` the event, then run `onCommitted` — so the `after` chain runs in THIS
|
|
101
|
+
* process, exactly once per committed transition. A transition that cannot be folded is marked
|
|
102
|
+
* `failed` with the reason and a failed event is published, and the fold goes PAST it: the card's
|
|
103
|
+
* `seq` advances to the failed transition with every other value as it was, so the next
|
|
104
|
+
* transition applies normally. Nothing is removed from the log. A create that fails leaves no card
|
|
105
|
+
* to advance, so what follows it fails too; a store that refuses the advancing write itself stops
|
|
106
|
+
* the fold there and reports `followUp`, leaving the rest pending for a retry.
|
|
107
|
+
*
|
|
108
|
+
* The caller owns single flight: two folds of one card running at once would both publish.
|
|
109
|
+
*
|
|
110
|
+
* @throws {PlanningUnsupported} for a store without a transition log
|
|
111
|
+
*/
|
|
112
|
+
export const foldPending = async (
|
|
113
|
+
store: PlanningStore, cardId: string, entityId: string, opts: FoldOptions = {}
|
|
114
|
+
): Promise<FoldResult> => {
|
|
115
|
+
const transitions = store.transitions
|
|
116
|
+
if (transitions == null) {
|
|
117
|
+
throw new PlanningUnsupported(`fold:${store.alias ?? 'store'}`)
|
|
118
|
+
}
|
|
119
|
+
const now = opts.now ?? isoNow
|
|
120
|
+
|
|
121
|
+
let card = await store.cards.get(cardId, entityId)
|
|
122
|
+
const listed = await transitions.list(
|
|
123
|
+
{ entityId, card: cardId, sinceSeq: card?.seq ?? 0, state: CommitState.Pending },
|
|
124
|
+
{ sort: ['seq'], size: opts.limit ?? 0 }
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
let folded = 0
|
|
128
|
+
let failed = 0
|
|
129
|
+
for (const transition of listed.items) {
|
|
130
|
+
if (transition.commit.state !== CommitState.Pending || transition.id == null) {
|
|
131
|
+
continue
|
|
132
|
+
}
|
|
133
|
+
await opts.touch?.()
|
|
134
|
+
const at = now()
|
|
135
|
+
|
|
136
|
+
let next: Workcard | null
|
|
137
|
+
try {
|
|
138
|
+
next = applyTransition(card, transition)
|
|
139
|
+
if (card != null && next === card) {
|
|
140
|
+
// Already applied by another folder, which owns its commit and its event.
|
|
141
|
+
continue
|
|
142
|
+
}
|
|
143
|
+
await writeFold(store, card, next, transition)
|
|
144
|
+
} catch (error) {
|
|
145
|
+
failed++
|
|
146
|
+
const message = errorText(error)
|
|
147
|
+
if (card != null && transition.seq > card.seq) {
|
|
148
|
+
// Fold PAST it: the record keeps its previous values and only its cursor moves, so the
|
|
149
|
+
// next transition still applies. The card is re-written whole, which also undoes a write
|
|
150
|
+
// that got half way before it threw.
|
|
151
|
+
const skipped: Workcard = { ...card, seq: transition.seq, head: Math.max(card.head ?? card.seq, transition.seq) }
|
|
152
|
+
try {
|
|
153
|
+
await store.cards.put(skipped)
|
|
154
|
+
card = skipped
|
|
155
|
+
} catch (advance) {
|
|
156
|
+
// The store itself refuses writes: leave the rest pending for a retry rather than
|
|
157
|
+
// failing every later transition as out of order.
|
|
158
|
+
console.error(`planning: cannot advance ${cardId} past failed seq ${transition.seq}:`, advance)
|
|
159
|
+
await safely('commit failure', () => transitions.commit(transition.id!, { state: CommitState.Failed, at, error: message }))
|
|
160
|
+
await safely('publish', () => opts.publish?.(commitEventOf(transition, CommitState.Failed, at, { error: message })))
|
|
161
|
+
return { card, folded, failed, followUp: true }
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
await safely('commit failure', () => transitions.commit(transition.id!, { state: CommitState.Failed, at, error: message }))
|
|
165
|
+
const event = commitEventOf(transition, CommitState.Failed, at, { error: message })
|
|
166
|
+
await safely('publish', () => opts.publish?.(event))
|
|
167
|
+
continue
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
card = next
|
|
171
|
+
folded++
|
|
172
|
+
await safely('commit', () => transitions.commit(transition.id!, { state: CommitState.Committed, at }))
|
|
173
|
+
const event = commitEventOf(transition, CommitState.Committed, at, { record: next })
|
|
174
|
+
await safely('publish', () => opts.publish?.(event))
|
|
175
|
+
await safely('onCommitted', () => opts.onCommitted?.(event))
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return { card, folded, failed, followUp: listed.total > listed.items.length }
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Mark every pending transition of a card `failed` and publish a failed event for each — what a
|
|
183
|
+
* queued store does when its projection job is dead, or waiters hang until their timeout.
|
|
184
|
+
*/
|
|
185
|
+
export const failPending = async (
|
|
186
|
+
store: PlanningStore, cardId: string, entityId: string, reason: string,
|
|
187
|
+
opts: Pick<FoldOptions, 'publish' | 'now'> = {}
|
|
188
|
+
): Promise<number> => {
|
|
189
|
+
const transitions = store.transitions
|
|
190
|
+
if (transitions == null) {
|
|
191
|
+
return 0
|
|
192
|
+
}
|
|
193
|
+
const at = (opts.now ?? isoNow)()
|
|
194
|
+
const pending = await transitions.list({ entityId, card: cardId, state: CommitState.Pending }, { sort: ['seq'], size: 0 })
|
|
195
|
+
for (const transition of pending.items) {
|
|
196
|
+
if (transition.id == null) {
|
|
197
|
+
continue
|
|
198
|
+
}
|
|
199
|
+
await transitions.commit(transition.id, { state: CommitState.Failed, at, error: reason })
|
|
200
|
+
const event = commitEventOf(transition, CommitState.Failed, at, { error: reason })
|
|
201
|
+
await safely('publish', () => opts.publish?.(event))
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return pending.items.length
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const CONTENT_KEYS = ['body', 'ref', 'format', 'version'] as const
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* A specification's history, replayed from its log: one entry per create and per committed
|
|
211
|
+
* content change, newest first. The fold is `applyTransition` itself, so an entry's `body` is the
|
|
212
|
+
* body AT that revision even when the transition changed only `ref`.
|
|
213
|
+
*/
|
|
214
|
+
export const revisionsFromLog = (log: Transition[], limit?: number): SpecificationRevision[] => {
|
|
215
|
+
const ordered = log.filter(transition => transition.commit.state !== CommitState.Pending)
|
|
216
|
+
.sort((left, right) => left.seq - right.seq)
|
|
217
|
+
|
|
218
|
+
const entries: SpecificationRevision[] = []
|
|
219
|
+
let state: Workcard | null = null
|
|
220
|
+
for (const transition of ordered) {
|
|
221
|
+
if (transition.commit.state === CommitState.Failed) {
|
|
222
|
+
// The fold went past it; so does the replay.
|
|
223
|
+
if (state != null && transition.seq > state.seq) {
|
|
224
|
+
state = { ...(state as Workcard), seq: transition.seq }
|
|
225
|
+
}
|
|
226
|
+
continue
|
|
227
|
+
}
|
|
228
|
+
try {
|
|
229
|
+
state = applyTransition(state, transition)
|
|
230
|
+
} catch {
|
|
231
|
+
break
|
|
232
|
+
}
|
|
233
|
+
if (state == null) {
|
|
234
|
+
break
|
|
235
|
+
}
|
|
236
|
+
const changed = transition.action === TransitionAction.Create
|
|
237
|
+
|| CONTENT_KEYS.some(key => (transition.changes as Record<string, unknown>)[key] !== undefined
|
|
238
|
+
|| transition.unset?.includes(key) === true)
|
|
239
|
+
if (!changed) {
|
|
240
|
+
continue
|
|
241
|
+
}
|
|
242
|
+
const spec = state as Workcard & { body?: string, ref?: string, bodyChars?: number, version?: number, revision?: number }
|
|
243
|
+
entries.push(clean({
|
|
244
|
+
revision: spec.revision ?? entries.length + 1,
|
|
245
|
+
body: spec.body,
|
|
246
|
+
ref: spec.ref,
|
|
247
|
+
bodyChars: spec.bodyChars,
|
|
248
|
+
version: spec.version,
|
|
249
|
+
at: transition.at,
|
|
250
|
+
by: transition.actor,
|
|
251
|
+
transition: transition.id!,
|
|
252
|
+
}))
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const newest = entries.reverse()
|
|
256
|
+
return limit != null && limit > 0 ? newest.slice(0, limit) : newest
|
|
257
|
+
}
|
|
Binary file
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
CommitEvent, CommitSource, CommitStatus, PlanningStore, ProjectionStore, Relationship,
|
|
3
|
+
RelationshipStore, SpecificationStore, Transition, TransitionStore, Workcard,
|
|
4
|
+
} from '@owlmeans/planning'
|
|
5
|
+
|
|
6
|
+
/** What a folding store calls for every committed transition — the service's `committed`. */
|
|
7
|
+
export interface CommitListener {
|
|
8
|
+
(event: CommitEvent): Promise<void>
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* A store the planning service can hand its `committed` to.
|
|
13
|
+
*
|
|
14
|
+
* `appendPlanningService` calls `bind` on the default store and on every plugin store it resolves,
|
|
15
|
+
* so the process that FOLDS is the process that runs the `after` chain. A store without `bind`
|
|
16
|
+
* reaches the service some other way (a durable store's projection processor looks it up on the
|
|
17
|
+
* context).
|
|
18
|
+
*/
|
|
19
|
+
export interface BindablePlanningStore extends PlanningStore {
|
|
20
|
+
bind?: (committed: CommitListener) => void
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface CommitHubOptions {
|
|
24
|
+
/** The authoritative answer for one transition, or `null` when the store has no such row. */
|
|
25
|
+
status: (transition: string) => Promise<CommitStatus | null>
|
|
26
|
+
/** Settled events remembered for `status` after their row is gone. */
|
|
27
|
+
remember?: number
|
|
28
|
+
/** The poll ladder `wait` climbs, in milliseconds; the last step repeats. */
|
|
29
|
+
ladder?: readonly number[]
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** An in-process commit fan-out: the `CommitSource` a store answers with, plus the publishing half. */
|
|
33
|
+
export interface CommitHub extends CommitSource {
|
|
34
|
+
/** Deliver an event to every matching subscriber. A listener that throws is logged, never rethrown. */
|
|
35
|
+
publish: (event: CommitEvent) => Promise<void>
|
|
36
|
+
/** A settled event remembered by transition id, whether or not its row still exists. */
|
|
37
|
+
recall: (transition: string) => CommitEvent | undefined
|
|
38
|
+
/** How many subscribers are attached. */
|
|
39
|
+
listeners: () => number
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface FoldOptions {
|
|
43
|
+
/** The service's `committed` — run once per committed transition, after `publish`. */
|
|
44
|
+
onCommitted?: CommitListener
|
|
45
|
+
/** Where commit events go. Strip `record` before a cross-process bus. */
|
|
46
|
+
publish?: (event: CommitEvent) => Promise<void>
|
|
47
|
+
now?: () => string
|
|
48
|
+
/** The most transitions one call folds; the rest is reported as `followUp`. Unlimited when omitted. */
|
|
49
|
+
limit?: number
|
|
50
|
+
/** Called between transitions — a queue processor renews its lock here. */
|
|
51
|
+
touch?: () => Promise<void>
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface FoldResult {
|
|
55
|
+
card: Workcard | null
|
|
56
|
+
folded: number
|
|
57
|
+
failed: number
|
|
58
|
+
/** More pending transitions than `limit` allowed in one call. */
|
|
59
|
+
followUp: boolean
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface MemoryPlanningStoreSeed {
|
|
63
|
+
cards?: Workcard[]
|
|
64
|
+
links?: Relationship[]
|
|
65
|
+
transitions?: Transition[]
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface MemoryPlanningStoreOptions {
|
|
69
|
+
/** Fold and publish inside `project()`. Default `true`; `false` folds only on `flush()`. */
|
|
70
|
+
sync?: boolean
|
|
71
|
+
ids?: () => string
|
|
72
|
+
now?: () => string
|
|
73
|
+
seed?: MemoryPlanningStoreSeed
|
|
74
|
+
/** The commit listener used until a service binds its own. */
|
|
75
|
+
onCommitted?: CommitListener
|
|
76
|
+
alias?: string
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface MemoryPlanningStore extends BindablePlanningStore {
|
|
80
|
+
transitions: TransitionStore
|
|
81
|
+
cards: ProjectionStore
|
|
82
|
+
specs: SpecificationStore
|
|
83
|
+
links: RelationshipStore
|
|
84
|
+
commits: CommitHub
|
|
85
|
+
bind: (committed: CommitListener) => void
|
|
86
|
+
/** Fold what is pending — for one card, or for every card with a pending transition. */
|
|
87
|
+
flush: (card?: string) => Promise<void>
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** One owning route of a composite store. */
|
|
91
|
+
export interface StoreRoute {
|
|
92
|
+
owns: (type: string) => boolean
|
|
93
|
+
store: PlanningStore
|
|
94
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { BasicConfig, BasicContext, LazyService } from '@owlmeans/context'
|
|
2
|
+
import type { AbstractRequest } from '@owlmeans/entrypoint'
|
|
3
|
+
import type {
|
|
4
|
+
AnyTypeSchema, PlanningPlugin, PlanningScope, PlanningService, PlanningStore, StatusFlowSchema,
|
|
5
|
+
WithPlanningService,
|
|
6
|
+
} from '@owlmeans/planning'
|
|
7
|
+
import type { ApiServerAppend } from '@owlmeans/server-api'
|
|
8
|
+
import type { ServerConfig, ServerContext } from '@owlmeans/server-context'
|
|
9
|
+
|
|
10
|
+
export interface Config extends ServerConfig { }
|
|
11
|
+
|
|
12
|
+
export interface Context<C extends Config = Config> extends ServerContext<C>, ApiServerAppend, WithPlanningService { }
|
|
13
|
+
|
|
14
|
+
export interface PlanningServiceOptions {
|
|
15
|
+
/** The default store — every type no plugin owns. An in-memory store when omitted. */
|
|
16
|
+
store?: PlanningStore
|
|
17
|
+
plugins?: PlanningPlugin[]
|
|
18
|
+
/** Schemas registered before any plugin's. */
|
|
19
|
+
schemas?: { types?: AnyTypeSchema[], flows?: StatusFlowSchema[] }
|
|
20
|
+
/** Run the `after` chain in this process. Default `true`. */
|
|
21
|
+
hooks?: boolean
|
|
22
|
+
/** Card ids when the store mints none. */
|
|
23
|
+
ids?: () => string
|
|
24
|
+
/** ISO-8601 timestamps. */
|
|
25
|
+
now?: () => string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The registered host. Lazy on purpose: a plugin package reaches it with `ensurePlanningService`
|
|
30
|
+
* from an application's `makeContext`, before the context initializes, and `context.service()`
|
|
31
|
+
* refuses an uninitialized non-lazy service.
|
|
32
|
+
*/
|
|
33
|
+
export interface PlanningHostService extends PlanningService, LazyService { }
|
|
34
|
+
|
|
35
|
+
/** Extra scope a deployment derives from the request — typically the write `channel`. */
|
|
36
|
+
export interface PlanningScopeExtractor {
|
|
37
|
+
(req: AbstractRequest, ctx: BasicContext<BasicConfig>): Partial<PlanningScope> | Promise<Partial<PlanningScope>>
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface PlanningHandlerOptions {
|
|
41
|
+
/** The planning service alias. `PLANNING_SERVICE` when omitted. */
|
|
42
|
+
service?: string
|
|
43
|
+
/** The socket frame name commit events are pushed under. `PLANNING_COMMIT_EVENT` when omitted. */
|
|
44
|
+
event?: string
|
|
45
|
+
/** The longest single long poll, in seconds — clamps `commit.get`'s `wait` and `execute`'s `timeout`. */
|
|
46
|
+
maxPoll?: number
|
|
47
|
+
scope?: PlanningScopeExtractor
|
|
48
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { PlanningScopeMismatch, WorkcardNotFound } from '@owlmeans/planning'
|
|
2
|
+
import type { Workcard } from '@owlmeans/planning'
|
|
3
|
+
|
|
4
|
+
export const notFoundOf = (id: string): WorkcardNotFound => new WorkcardNotFound(id)
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A record of another entity is absent, not forbidden.
|
|
8
|
+
*
|
|
9
|
+
* @throws {WorkcardNotFound}
|
|
10
|
+
*/
|
|
11
|
+
export const assertScope = <T extends Pick<Workcard, 'entityId'>>(
|
|
12
|
+
record: T | null | undefined, scope: { entityId: string }, id: string
|
|
13
|
+
): T => {
|
|
14
|
+
if (record == null || record.entityId !== scope.entityId) {
|
|
15
|
+
throw notFoundOf(id)
|
|
16
|
+
}
|
|
17
|
+
return record
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Run a handler body so that a scope mismatch leaves as `WorkcardNotFound` — telling "not yours"
|
|
22
|
+
* apart from "does not exist" is what turns an id space into an enumeration oracle.
|
|
23
|
+
*/
|
|
24
|
+
export const concealed = async <T>(run: () => Promise<T>): Promise<T> => {
|
|
25
|
+
try {
|
|
26
|
+
return await run()
|
|
27
|
+
} catch (error) {
|
|
28
|
+
if (error instanceof PlanningScopeMismatch
|
|
29
|
+
|| (error as { type?: string } | null)?.type === PlanningScopeMismatch.typeName) {
|
|
30
|
+
throw new WorkcardNotFound()
|
|
31
|
+
}
|
|
32
|
+
throw error
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Seconds as the wire sends them, clamped to `[0, max]`. */
|
|
37
|
+
export const clampSeconds = (value: unknown, max: number): number => {
|
|
38
|
+
const number = typeof value === 'number' ? value : Number(value ?? 0)
|
|
39
|
+
return Number.isFinite(number) ? Math.min(Math.max(number, 0), max) : 0
|
|
40
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { requireEntityKey } from '@owlmeans/auth-common'
|
|
2
|
+
import type { BasicConfig, BasicContext } from '@owlmeans/context'
|
|
3
|
+
import type { AbstractRequest } from '@owlmeans/entrypoint'
|
|
4
|
+
import { PLANNING_SERVICE } from '@owlmeans/planning'
|
|
5
|
+
import type { PlanningFacade, PlanningScope, PlanningService, TransitionActor } from '@owlmeans/planning'
|
|
6
|
+
import type { PlanningHandlerOptions, PlanningHostService } from '../types.js'
|
|
7
|
+
|
|
8
|
+
const clean = <T extends object>(record: T): T =>
|
|
9
|
+
Object.fromEntries(Object.entries(record).filter(([, value]) => value != null && value !== '')) as T
|
|
10
|
+
|
|
11
|
+
/** The authenticated subject of a request, as a transition names it. */
|
|
12
|
+
export const actorOf = (req: AbstractRequest): TransitionActor => clean({
|
|
13
|
+
profileId: req.auth?.profileId,
|
|
14
|
+
userId: req.auth?.userId,
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The scope a request reads and writes as.
|
|
19
|
+
*
|
|
20
|
+
* `entityId` is `requireEntityKey(req)` — the resolved organization id, never a value from the
|
|
21
|
+
* body or the query — and `extra` cannot replace it; `extra` adds what the deployment derives
|
|
22
|
+
* (a `channel`, a `service`). The actor is the authenticated subject plus that channel.
|
|
23
|
+
*
|
|
24
|
+
* @throws {AuthorizationError} when the request carries no organization
|
|
25
|
+
*/
|
|
26
|
+
export const scopeOf = (req: AbstractRequest, extra?: Partial<PlanningScope>): PlanningScope => {
|
|
27
|
+
const entityId = requireEntityKey(req)
|
|
28
|
+
const subject = actorOf(req)
|
|
29
|
+
const channel = extra?.channel ?? extra?.actor?.channel
|
|
30
|
+
|
|
31
|
+
return clean({
|
|
32
|
+
...extra,
|
|
33
|
+
...subject,
|
|
34
|
+
entityId,
|
|
35
|
+
channel,
|
|
36
|
+
actor: clean({ ...extra?.actor, ...subject, channel }),
|
|
37
|
+
}) as PlanningScope
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const planningServiceOf = (
|
|
41
|
+
ctx: BasicContext<BasicConfig>, opts?: Pick<PlanningHandlerOptions, 'service'>
|
|
42
|
+
): PlanningService => ctx.service<PlanningHostService>(opts?.service ?? PLANNING_SERVICE)
|
|
43
|
+
|
|
44
|
+
/** The facade a handler works through: the request's scope plus what `opts.scope` derives. */
|
|
45
|
+
export const handlerFacade = async (
|
|
46
|
+
ctx: BasicContext<BasicConfig>, req: AbstractRequest, opts?: PlanningHandlerOptions
|
|
47
|
+
): Promise<PlanningFacade> => {
|
|
48
|
+
const extra = await opts?.scope?.(req, ctx)
|
|
49
|
+
|
|
50
|
+
return planningServiceOf(ctx, opts).for(scopeOf(req, extra ?? undefined))
|
|
51
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { CommitFailed, CommitState, CommitTimeout, TransitionAction, WorkcardKind } from '@owlmeans/planning'
|
|
3
|
+
import type { CommitEvent, Transition } from '@owlmeans/planning'
|
|
4
|
+
import { createProject, ENTITY, makeTestPlanning, PROJECT } from './context.js'
|
|
5
|
+
|
|
6
|
+
const createPending = (planning: ReturnType<Awaited<ReturnType<typeof makeTestPlanning>>['facade']>, title: string) =>
|
|
7
|
+
planning.execute({ card: { kind: WorkcardKind.Project, type: PROJECT, title }, action: TransitionAction.Create })
|
|
8
|
+
|
|
9
|
+
describe('@owlmeans/server-planning — commits', () => {
|
|
10
|
+
test('waiting on a commit that already landed resolves with the card', async () => {
|
|
11
|
+
const { facade } = await makeTestPlanning()
|
|
12
|
+
const planning = facade()
|
|
13
|
+
const receipt = await createPending(planning, 'Landed')
|
|
14
|
+
|
|
15
|
+
expect(receipt.card!.title).toBe('Landed')
|
|
16
|
+
expect((await receipt.committed())!.id).toBe(receipt.transition.card)
|
|
17
|
+
expect((await planning.commits.wait(receipt.transition.id!, { timeout: 50 }))!.title).toBe('Landed')
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
test('a wait that times out is CommitTimeout and leaves the transition pending', async () => {
|
|
21
|
+
const { facade, store } = await makeTestPlanning({}, { sync: false })
|
|
22
|
+
const planning = facade()
|
|
23
|
+
const receipt = await createPending(planning, 'Queued')
|
|
24
|
+
|
|
25
|
+
expect(receipt.transition.commit.state).toBe(CommitState.Pending)
|
|
26
|
+
await expect(receipt.committed({ timeout: 30 })).rejects.toBeInstanceOf(CommitTimeout)
|
|
27
|
+
expect((await planning.commits.status(receipt.transition.id!)).state).toBe(CommitState.Pending)
|
|
28
|
+
expect(await planning.cards.load(receipt.transition.card)).toBeNull()
|
|
29
|
+
|
|
30
|
+
const waiting = planning.commits.wait(receipt.transition.id!, { timeout: 1000 })
|
|
31
|
+
await store.flush()
|
|
32
|
+
expect((await waiting)!.title).toBe('Queued')
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
test('a fold that fails marks the transition failed, and a waiter gets CommitFailed with the reason', async () => {
|
|
36
|
+
const { facade, store } = await makeTestPlanning()
|
|
37
|
+
const planning = facade()
|
|
38
|
+
const project = await createProject(planning, 'Stable')
|
|
39
|
+
const rogue = await store.transitions.append({
|
|
40
|
+
entityId: ENTITY, card: project.id!, kind: WorkcardKind.Project, type: PROJECT, seq: 3,
|
|
41
|
+
action: TransitionAction.Update, changes: { title: 'Skipped a seq' }, actor: {},
|
|
42
|
+
at: new Date().toISOString(), commit: { state: CommitState.Pending },
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
await store.cards.project(project.id!)
|
|
46
|
+
|
|
47
|
+
const refusal = await planning.commits.wait(rogue.id!, { timeout: 100 }).catch(error => error)
|
|
48
|
+
expect(refusal).toBeInstanceOf(CommitFailed)
|
|
49
|
+
expect(refusal.message).toContain('out-of-order')
|
|
50
|
+
expect((await planning.commits.status(rogue.id!)).state).toBe(CommitState.Failed)
|
|
51
|
+
expect((await planning.cards.get(project.id!)).title).toBe('Stable')
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
test('a failing middle transition does not block the next one — the fold goes past it', async () => {
|
|
55
|
+
const { facade, store } = await makeTestPlanning()
|
|
56
|
+
const planning = facade()
|
|
57
|
+
const project = await createProject(planning, 'Before')
|
|
58
|
+
const events: CommitEvent[] = []
|
|
59
|
+
store.commits.subscribe(event => { events.push(event) })
|
|
60
|
+
const logRow = (seq: number, patch: Partial<Transition>): Transition => ({
|
|
61
|
+
entityId: ENTITY, card: project.id!, kind: WorkcardKind.Project, type: PROJECT, seq,
|
|
62
|
+
action: TransitionAction.Update, changes: {}, actor: {}, at: new Date().toISOString(),
|
|
63
|
+
commit: { state: CommitState.Pending }, ...patch,
|
|
64
|
+
})
|
|
65
|
+
// A corrupted row: `unset` is not a list, so the fold of seq 2 throws.
|
|
66
|
+
const broken = await store.transitions.append(logRow(2, { changes: { title: 'Broken' }, unset: 5 as never }))
|
|
67
|
+
const next = await store.transitions.append(logRow(3, { changes: { title: 'After' } }))
|
|
68
|
+
|
|
69
|
+
await store.cards.project(project.id!)
|
|
70
|
+
|
|
71
|
+
expect(events.map(event => [event.seq, event.state])).toEqual([[2, CommitState.Failed], [3, CommitState.Committed]])
|
|
72
|
+
expect((await planning.commits.status(broken.id!)).state).toBe(CommitState.Failed)
|
|
73
|
+
expect((await planning.commits.wait(next.id!, { timeout: 100 }))!.title).toBe('After')
|
|
74
|
+
const card = await planning.cards.get(project.id!)
|
|
75
|
+
expect(card.seq).toBe(3)
|
|
76
|
+
expect(card.title).toBe('After')
|
|
77
|
+
})
|
|
78
|
+
})
|