@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,253 @@
|
|
|
1
|
+
import {
|
|
2
|
+
applyUnset, assertMutable, BODY_MAX, CODE_MAX, DESCRIPTION_MAX, IllegalTransition,
|
|
3
|
+
LabelNotAllowed, MAX_LABELS, MAX_PARENTS, mergeFields, normalizeParents, ParentNotFound, PlanningError,
|
|
4
|
+
RelationshipRefused, ruleOf, SpecificationRevisionConflict, SpecificationSlotUnknown, slotOf, TITLE_MAX,
|
|
5
|
+
TransitionAction, validateFields, validateSpecificationBody, WorkcardKind,
|
|
6
|
+
} from '@owlmeans/planning'
|
|
7
|
+
import type {
|
|
8
|
+
PlanningFacade, RelationshipDraft, Specification, TransitionExecution, WorkcardDraft,
|
|
9
|
+
} from '@owlmeans/planning'
|
|
10
|
+
import type { PlanningRuntime } from '../service.js'
|
|
11
|
+
import { assertChildAllowed } from './resolve.js'
|
|
12
|
+
import type { Resolved } from './resolve.js'
|
|
13
|
+
|
|
14
|
+
const ACTIONS = new Set<string>(Object.values(TransitionAction))
|
|
15
|
+
|
|
16
|
+
const malformed = (what: string): PlanningError => new PlanningError(`malformed:${what}`)
|
|
17
|
+
|
|
18
|
+
const assertText = (name: string, value: unknown, max: number, required: boolean): void => {
|
|
19
|
+
if (value === undefined || value === null) {
|
|
20
|
+
if (required) {
|
|
21
|
+
throw malformed(name)
|
|
22
|
+
}
|
|
23
|
+
return
|
|
24
|
+
}
|
|
25
|
+
if (typeof value !== 'string' || (required && value === '') || value.length > max) {
|
|
26
|
+
throw malformed(name)
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const assertShape = (exec: TransitionExecution, resolved: Resolved): void => {
|
|
31
|
+
if (!ACTIONS.has(exec.action)) {
|
|
32
|
+
throw malformed(`action:${exec.action}`)
|
|
33
|
+
}
|
|
34
|
+
if (exec.links != null && exec.action !== TransitionAction.Create) {
|
|
35
|
+
throw malformed('links-outside-create')
|
|
36
|
+
}
|
|
37
|
+
if (exec.action === TransitionAction.Transit && (exec.transition == null || exec.transition === '')) {
|
|
38
|
+
throw malformed('transit-without-transition')
|
|
39
|
+
}
|
|
40
|
+
if ((exec.action === TransitionAction.Link || exec.action === TransitionAction.Unlink)
|
|
41
|
+
&& (exec.link?.to == null || exec.link.to === '' || exec.link.type == null || exec.link.type === '')) {
|
|
42
|
+
throw malformed(`${exec.action}-without-link`)
|
|
43
|
+
}
|
|
44
|
+
if (resolved.create) {
|
|
45
|
+
const draft = exec.card as WorkcardDraft
|
|
46
|
+
assertText('title', draft.title, TITLE_MAX, true)
|
|
47
|
+
assertText('description', draft.description, DESCRIPTION_MAX, false)
|
|
48
|
+
assertText('code', draft.code, CODE_MAX, false)
|
|
49
|
+
if ((draft.parents?.length ?? 0) > MAX_PARENTS) {
|
|
50
|
+
throw malformed('parents')
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
const changes = exec.changes ?? {}
|
|
54
|
+
if (changes.title !== undefined) {
|
|
55
|
+
assertText('title', changes.title, TITLE_MAX, true)
|
|
56
|
+
}
|
|
57
|
+
assertText('description', changes.description ?? undefined, DESCRIPTION_MAX, false)
|
|
58
|
+
assertText('code', changes.code ?? undefined, CODE_MAX, false)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const assertFlow = (exec: TransitionExecution, resolved: Resolved, runtime: PlanningRuntime): void => {
|
|
62
|
+
if (resolved.create) {
|
|
63
|
+
const status = (exec.card as WorkcardDraft).status
|
|
64
|
+
const primary = runtime.service().schemas.primaryFlow(resolved.type.type)
|
|
65
|
+
if (status != null && !primary.statuses.some(definition => definition.key === status)) {
|
|
66
|
+
throw new IllegalTransition(`${primary.id}:create:${status}`)
|
|
67
|
+
}
|
|
68
|
+
return
|
|
69
|
+
}
|
|
70
|
+
if (exec.action !== TransitionAction.Transit) {
|
|
71
|
+
return
|
|
72
|
+
}
|
|
73
|
+
const card = resolved.card!
|
|
74
|
+
const from = card.flows[resolved.flowId] ?? card.status
|
|
75
|
+
if (ruleOf(resolved.flow, exec.transition!, from) == null) {
|
|
76
|
+
throw new IllegalTransition(`${resolved.flowId}:${exec.transition}:${from}`)
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const assertFields = (exec: TransitionExecution, resolved: Resolved, runtime: PlanningRuntime): void => {
|
|
81
|
+
const schemas = runtime.service().schemas
|
|
82
|
+
if (resolved.create) {
|
|
83
|
+
const draft = exec.card as WorkcardDraft
|
|
84
|
+
validateFields(schemas, resolved.type.type, mergeFields(draft.fields, exec.changes?.fields))
|
|
85
|
+
return
|
|
86
|
+
}
|
|
87
|
+
const clears = (exec.unset ?? []).filter(path => path.startsWith('fields.'))
|
|
88
|
+
if (exec.changes?.fields == null && clears.length === 0) {
|
|
89
|
+
return
|
|
90
|
+
}
|
|
91
|
+
const merged = applyUnset({ fields: mergeFields(resolved.card!.fields, exec.changes?.fields) }, clears)
|
|
92
|
+
validateFields(schemas, resolved.type.type, merged.fields)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const assertLabels = (exec: TransitionExecution, resolved: Resolved): void => {
|
|
96
|
+
const labels = [
|
|
97
|
+
...(resolved.create ? (exec.card as WorkcardDraft).labels ?? [] : []),
|
|
98
|
+
...(exec.changes?.labels ?? []),
|
|
99
|
+
]
|
|
100
|
+
if (labels.length === 0) {
|
|
101
|
+
return
|
|
102
|
+
}
|
|
103
|
+
if (new Set(labels).size > MAX_LABELS) {
|
|
104
|
+
throw new LabelNotAllowed(`too-many:${labels.length}`)
|
|
105
|
+
}
|
|
106
|
+
const allowed = resolved.type.labels
|
|
107
|
+
const refused = allowed == null ? undefined : labels.find(label => !allowed.includes(label))
|
|
108
|
+
if (refused != null) {
|
|
109
|
+
throw new LabelNotAllowed(refused)
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const assertParentMove = async (
|
|
114
|
+
exec: TransitionExecution, resolved: Resolved, runtime: PlanningRuntime, facade: PlanningFacade
|
|
115
|
+
): Promise<void> => {
|
|
116
|
+
if (resolved.create) {
|
|
117
|
+
return
|
|
118
|
+
}
|
|
119
|
+
const changes = exec.changes ?? {}
|
|
120
|
+
const moved = [...new Set(normalizeParents(changes.parent, changes.parents))]
|
|
121
|
+
.filter(id => id !== resolved.card!.parent && !resolved.card!.parents.includes(id))
|
|
122
|
+
for (const id of moved) {
|
|
123
|
+
if (id === resolved.card!.id) {
|
|
124
|
+
throw malformed('parent-self')
|
|
125
|
+
}
|
|
126
|
+
const found = await facade.cards.load(id)
|
|
127
|
+
if (found == null) {
|
|
128
|
+
throw new ParentNotFound(id)
|
|
129
|
+
}
|
|
130
|
+
assertChildAllowed(runtime, found, resolved.card!.kind, resolved.card!.type)
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const assertSpecification = async (
|
|
135
|
+
exec: TransitionExecution, resolved: Resolved, runtime: PlanningRuntime, facade: PlanningFacade
|
|
136
|
+
): Promise<void> => {
|
|
137
|
+
const kind = resolved.create ? (exec.card as WorkcardDraft).kind : resolved.card!.kind
|
|
138
|
+
if (kind !== WorkcardKind.Specification) {
|
|
139
|
+
return
|
|
140
|
+
}
|
|
141
|
+
const schemas = runtime.service().schemas
|
|
142
|
+
|
|
143
|
+
if (resolved.create) {
|
|
144
|
+
const draft = exec.card as WorkcardDraft
|
|
145
|
+
const parent = resolved.parent!
|
|
146
|
+
if (draft.category == null || draft.category === '') {
|
|
147
|
+
throw malformed('specification-without-category')
|
|
148
|
+
}
|
|
149
|
+
const slot = slotOf(schemas.type(parent.type), draft.category)
|
|
150
|
+
if (slot == null) {
|
|
151
|
+
throw new SpecificationSlotUnknown(`${parent.type}:${draft.category}`)
|
|
152
|
+
}
|
|
153
|
+
if (draft.format != null && draft.format !== slot.format) {
|
|
154
|
+
throw malformed(`format:${draft.category}:${draft.format}`)
|
|
155
|
+
}
|
|
156
|
+
assertText('body', draft.body, BODY_MAX, false)
|
|
157
|
+
validateSpecificationBody(slot, draft.body)
|
|
158
|
+
if (slot.multiple !== true && await facade.specifications.current(parent.id!, draft.category) != null) {
|
|
159
|
+
throw new SpecificationRevisionConflict(`${parent.id}:${draft.category}`)
|
|
160
|
+
}
|
|
161
|
+
return
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const spec = resolved.card as Specification
|
|
165
|
+
const changes = exec.changes ?? {}
|
|
166
|
+
if (changes.category !== undefined && changes.category !== spec.category) {
|
|
167
|
+
throw new PlanningError('immutable:category')
|
|
168
|
+
}
|
|
169
|
+
const slot = resolved.slot
|
|
170
|
+
if (slot == null) {
|
|
171
|
+
return
|
|
172
|
+
}
|
|
173
|
+
if (changes.format != null && changes.format !== slot.format) {
|
|
174
|
+
throw malformed(`format:${spec.category}:${changes.format}`)
|
|
175
|
+
}
|
|
176
|
+
if (changes.body != null) {
|
|
177
|
+
assertText('body', changes.body, BODY_MAX, false)
|
|
178
|
+
validateSpecificationBody(slot, changes.body)
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const assertRelationships = async (
|
|
183
|
+
exec: TransitionExecution, resolved: Resolved, facade: PlanningFacade
|
|
184
|
+
): Promise<void> => {
|
|
185
|
+
const drafts: RelationshipDraft[] = exec.action === TransitionAction.Create
|
|
186
|
+
? exec.links ?? []
|
|
187
|
+
: exec.action === TransitionAction.Link || exec.action === TransitionAction.Unlink ? [exec.link!] : []
|
|
188
|
+
if (drafts.length === 0) {
|
|
189
|
+
return
|
|
190
|
+
}
|
|
191
|
+
const cardId = resolved.card?.id
|
|
192
|
+
const declared = resolved.type.relationships ?? []
|
|
193
|
+
const counted = new Map<string, number>()
|
|
194
|
+
|
|
195
|
+
for (const draft of drafts) {
|
|
196
|
+
const rule = declared.find(entry => entry.name === draft.type)
|
|
197
|
+
if (rule == null) {
|
|
198
|
+
throw new RelationshipRefused(`unknown:${draft.type}`)
|
|
199
|
+
}
|
|
200
|
+
if (draft.from != null && draft.from !== cardId) {
|
|
201
|
+
throw new RelationshipRefused(`from:${draft.from}`)
|
|
202
|
+
}
|
|
203
|
+
if (exec.action === TransitionAction.Unlink) {
|
|
204
|
+
const existing = await facade.relationships.list({ from: cardId, to: draft.to, type: draft.type })
|
|
205
|
+
if (existing.total === 0) {
|
|
206
|
+
throw new RelationshipRefused(`absent:${draft.type}:${draft.to}`)
|
|
207
|
+
}
|
|
208
|
+
continue
|
|
209
|
+
}
|
|
210
|
+
const target = await facade.cards.load(draft.to)
|
|
211
|
+
if (target == null) {
|
|
212
|
+
throw new RelationshipRefused(`to:${draft.to}`)
|
|
213
|
+
}
|
|
214
|
+
if (rule.from != null && !rule.from.includes(resolved.type.type)) {
|
|
215
|
+
throw new RelationshipRefused(`from-type:${draft.type}:${resolved.type.type}`)
|
|
216
|
+
}
|
|
217
|
+
if (rule.to != null && !rule.to.includes(target.type)) {
|
|
218
|
+
throw new RelationshipRefused(`to-type:${draft.type}:${target.type}`)
|
|
219
|
+
}
|
|
220
|
+
if (rule.single === true) {
|
|
221
|
+
const count = (counted.get(draft.type) ?? 0) + 1
|
|
222
|
+
counted.set(draft.type, count)
|
|
223
|
+
const existing = cardId == null
|
|
224
|
+
? 0
|
|
225
|
+
: (await facade.relationships.list({ from: cardId, type: draft.type })).items
|
|
226
|
+
.filter(link => link.to !== draft.to).length
|
|
227
|
+
if (count + existing > 1) {
|
|
228
|
+
throw new RelationshipRefused(`single:${draft.type}`)
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Step 6: shape, flow rule, immutables, `fields`, labels, parent moves, the specification slot and
|
|
236
|
+
* relationships — in that order. Nothing is written by a refusal here.
|
|
237
|
+
*
|
|
238
|
+
* @throws {PlanningError} `malformed:*` / `immutable:*`
|
|
239
|
+
* @throws {IllegalTransition | FieldsInvalid | LabelNotAllowed | ParentNotFound | CardTypeNotAllowed}
|
|
240
|
+
* @throws {SpecificationSlotUnknown | SpecificationRevisionConflict | RelationshipRefused}
|
|
241
|
+
*/
|
|
242
|
+
export const validateExecution = async (
|
|
243
|
+
runtime: PlanningRuntime, facade: PlanningFacade, exec: TransitionExecution, resolved: Resolved
|
|
244
|
+
): Promise<void> => {
|
|
245
|
+
assertShape(exec, resolved)
|
|
246
|
+
assertFlow(exec, resolved, runtime)
|
|
247
|
+
assertMutable(exec, resolved.type)
|
|
248
|
+
assertFields(exec, resolved, runtime)
|
|
249
|
+
assertLabels(exec, resolved)
|
|
250
|
+
await assertParentMove(exec, resolved, runtime, facade)
|
|
251
|
+
await assertSpecification(exec, resolved, runtime, facade)
|
|
252
|
+
await assertRelationships(exec, resolved, facade)
|
|
253
|
+
}
|
package/src/executor.ts
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { uuid } from '@owlmeans/basic-ids'
|
|
2
|
+
import type { BasicConfig, BasicContext } from '@owlmeans/context'
|
|
3
|
+
import { PlanningError, PlanningUnsupported, WorkcardConflict } from '@owlmeans/planning'
|
|
4
|
+
import type {
|
|
5
|
+
ExecuteOptions, PlanningExecContext, PlanningFacade, PlanningPlugin, PlanningStore, Transition,
|
|
6
|
+
TransitionExecution, TransitionReceipt,
|
|
7
|
+
} from '@owlmeans/planning'
|
|
8
|
+
import { changeSetOf, transitionOf } from './executor/changes.js'
|
|
9
|
+
import { assignCode } from './executor/code.js'
|
|
10
|
+
import { makeReceipt } from './executor/receipt.js'
|
|
11
|
+
import { normalizeExecution, projectFor, resolveExecution } from './executor/resolve.js'
|
|
12
|
+
import type { Resolved } from './executor/resolve.js'
|
|
13
|
+
import { validateExecution } from './executor/validate.js'
|
|
14
|
+
import type { PlanningRuntime } from './service.js'
|
|
15
|
+
|
|
16
|
+
const isoNow = (): string => new Date().toISOString()
|
|
17
|
+
|
|
18
|
+
/** What a type/parent change by a middleware is judged by. */
|
|
19
|
+
const identityOf = (exec: TransitionExecution): string => typeof exec.card === 'string'
|
|
20
|
+
? `${exec.action}|${exec.card}|${exec.flow ?? ''}|${exec.changes?.parent ?? ''}`
|
|
21
|
+
: `${exec.action}|${exec.card.kind}|${exec.card.type}|${exec.card.parent ?? ''}|${(exec.card.parents ?? []).join(',')}|${exec.flow ?? ''}`
|
|
22
|
+
|
|
23
|
+
const execContextOf = (
|
|
24
|
+
runtime: PlanningRuntime, facade: PlanningFacade, resolved: Resolved
|
|
25
|
+
) => (plugin: PlanningPlugin): PlanningExecContext => ({
|
|
26
|
+
context: runtime.context() as BasicContext<BasicConfig>,
|
|
27
|
+
scope: facade.scope,
|
|
28
|
+
schemas: runtime.service().schemas,
|
|
29
|
+
store: resolved.store,
|
|
30
|
+
facade,
|
|
31
|
+
plugin,
|
|
32
|
+
type: resolved.type,
|
|
33
|
+
flow: resolved.flow,
|
|
34
|
+
...(resolved.card != null ? { card: resolved.card } : {}),
|
|
35
|
+
...(resolved.parent != null ? { parent: resolved.parent } : {}),
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
const findByKey = async (
|
|
39
|
+
runtime: PlanningRuntime, entityId: string, key: string
|
|
40
|
+
): Promise<{ store: PlanningStore, transition: Transition } | null> => {
|
|
41
|
+
for (const store of runtime.stores()) {
|
|
42
|
+
const transition = await store.transitions?.byKey(entityId, key)
|
|
43
|
+
if (transition != null) {
|
|
44
|
+
return { store, transition }
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return null
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The receipt an update that changes nothing answers with: the card's latest transition. */
|
|
51
|
+
const currentReceipt = async (
|
|
52
|
+
store: PlanningStore, resolved: Resolved, entityId: string, opts?: ExecuteOptions
|
|
53
|
+
): Promise<TransitionReceipt> => {
|
|
54
|
+
const latest = await store.transitions!.list(
|
|
55
|
+
{ entityId, card: resolved.card!.id! }, { sort: [{ field: 'seq', order: 'desc' }], size: 1 }
|
|
56
|
+
)
|
|
57
|
+
const transition = latest.items[0]
|
|
58
|
+
if (transition == null) {
|
|
59
|
+
throw new PlanningUnsupported(`empty-update-without-log:${resolved.card!.id}`)
|
|
60
|
+
}
|
|
61
|
+
return await makeReceipt(store, transition, opts)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* THE write path. Nothing is appended before step 11, so every refusal leaves the log untouched.
|
|
66
|
+
*
|
|
67
|
+
* 1 normalize → 2 scope → 3 idempotency (a known `key` answers its first receipt, before any
|
|
68
|
+
* validation) → 4 resolve → 5 the plugins' `before` chain (re-resolved once when it moved the type
|
|
69
|
+
* or the parent) → 6 validate → 7 allocate the card id → 8 code → 9 changes (an update changing
|
|
70
|
+
* nothing answers the current receipt and appends nothing) → 10 seq, a CAS against the head →
|
|
71
|
+
* 11 append → 12 request the projection → 13 receipt → 14 `wait`.
|
|
72
|
+
*
|
|
73
|
+
* `after` hooks are NOT run here — the process that folds runs them, once per commit.
|
|
74
|
+
*/
|
|
75
|
+
export const executeTransition = async (
|
|
76
|
+
runtime: PlanningRuntime, facade: PlanningFacade, input: TransitionExecution, opts?: ExecuteOptions
|
|
77
|
+
): Promise<TransitionReceipt> => {
|
|
78
|
+
const scope = facade.scope
|
|
79
|
+
const service = runtime.service()
|
|
80
|
+
const at = (runtime.options.now ?? isoNow)()
|
|
81
|
+
|
|
82
|
+
let exec = normalizeExecution(input)
|
|
83
|
+
|
|
84
|
+
if (scope.entityId == null || scope.entityId === '') {
|
|
85
|
+
throw new PlanningError('malformed:scope-without-entity')
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (exec.key != null && exec.key !== '') {
|
|
89
|
+
const found = await findByKey(runtime, scope.entityId, exec.key)
|
|
90
|
+
if (found != null) {
|
|
91
|
+
return await makeReceipt(found.store, found.transition, opts)
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
let resolved = await resolveExecution(runtime, facade, exec)
|
|
96
|
+
|
|
97
|
+
const before = identityOf(exec)
|
|
98
|
+
exec = normalizeExecution(await runtime.registry.before(exec, execContextOf(runtime, facade, resolved)))
|
|
99
|
+
if (identityOf(exec) !== before) {
|
|
100
|
+
resolved = await resolveExecution(runtime, facade, exec)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
await validateExecution(runtime, facade, exec, resolved)
|
|
104
|
+
|
|
105
|
+
const store = resolved.store
|
|
106
|
+
const transitions = store.transitions
|
|
107
|
+
if (transitions == null) {
|
|
108
|
+
throw new PlanningUnsupported(`transitions:${store.alias ?? resolved.type.type}`)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const cardId = resolved.create
|
|
112
|
+
? store.newId?.() ?? runtime.options.ids?.() ?? uuid()
|
|
113
|
+
: resolved.card!.id!
|
|
114
|
+
|
|
115
|
+
exec = await assignCode(exec, resolved, scope, runtime.registry, execContextOf(runtime, facade, resolved))
|
|
116
|
+
|
|
117
|
+
const { set, empty } = changeSetOf(exec, resolved, service.schemas, at)
|
|
118
|
+
if (empty) {
|
|
119
|
+
return await currentReceipt(store, resolved, scope.entityId, opts)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const seq = await transitions.nextSeq(cardId, resolved.create ? null : exec.expectSeq ?? null)
|
|
123
|
+
if (resolved.create && seq !== 1) {
|
|
124
|
+
throw new WorkcardConflict(`${cardId}:create:${seq}`)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const transition = transitionOf({
|
|
128
|
+
exec, resolved, scope, set, cardId, seq, at,
|
|
129
|
+
project: projectFor(resolved, exec, cardId),
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
let appended: Transition
|
|
133
|
+
try {
|
|
134
|
+
appended = await transitions.append(transition)
|
|
135
|
+
} catch (error) {
|
|
136
|
+
// A lost race on the idempotency key: the winner's receipt is the answer.
|
|
137
|
+
if (exec.key != null && exec.key !== '') {
|
|
138
|
+
const winner = await transitions.byKey(scope.entityId, exec.key)
|
|
139
|
+
if (winner != null) {
|
|
140
|
+
return await makeReceipt(store, winner, opts)
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
throw error
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
await store.cards.project(cardId, appended.id != null ? { transition: appended.id } : undefined)
|
|
147
|
+
|
|
148
|
+
return await makeReceipt(store, appended, opts)
|
|
149
|
+
}
|
package/src/facade.ts
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import {
|
|
2
|
+
criteriaOf, currentSpecification, isSpecification, linkWhereOf, listOptionsOf, modelOf,
|
|
3
|
+
PlanningUnsupported, specCriteriaOf, transitionWhereOf, WorkcardKind, WorkcardNotFound,
|
|
4
|
+
} from '@owlmeans/planning'
|
|
5
|
+
import type {
|
|
6
|
+
CommitSource, PlanningFacade, PlanningScope, Specification, Workcard, WorkcardModel,
|
|
7
|
+
} from '@owlmeans/planning'
|
|
8
|
+
import { executeTransition } from './executor.js'
|
|
9
|
+
import type { PlanningRuntime } from './service.js'
|
|
10
|
+
import type { CommitHub } from './store/types.js'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The scoped facade over the service's stores.
|
|
14
|
+
*
|
|
15
|
+
* Every read carries the scope's `entityId` into the store and re-checks it on what comes back, so
|
|
16
|
+
* a card of another entity is simply absent — `load` answers `null`, `get` throws
|
|
17
|
+
* `WorkcardNotFound`, exactly as for an id that never existed. Reads go through the composite
|
|
18
|
+
* store (the default store plus the stores plugins own); writes go through the executor.
|
|
19
|
+
*/
|
|
20
|
+
export const makeStoreFacade = (runtime: PlanningRuntime, scope: PlanningScope): PlanningFacade => {
|
|
21
|
+
const entityId = scope.entityId
|
|
22
|
+
const reader = () => runtime.reader()
|
|
23
|
+
const mine = <T extends { entityId: string }>(record: T | null | undefined): T | null =>
|
|
24
|
+
record != null && record.entityId === entityId ? record : null
|
|
25
|
+
|
|
26
|
+
const commitSource = (): CommitSource => {
|
|
27
|
+
const commits = reader().commits
|
|
28
|
+
if (commits == null) {
|
|
29
|
+
throw new PlanningUnsupported('commits')
|
|
30
|
+
}
|
|
31
|
+
return commits
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The transition is this entity's — its row, or the remembered commit of a purged one. */
|
|
35
|
+
const assertTransition = async (transition: string): Promise<void> => {
|
|
36
|
+
const row = await reader().transitions?.get(transition)
|
|
37
|
+
if (row != null) {
|
|
38
|
+
if (row.entityId !== entityId) {
|
|
39
|
+
throw new WorkcardNotFound(`transition:${transition}`)
|
|
40
|
+
}
|
|
41
|
+
return
|
|
42
|
+
}
|
|
43
|
+
const recalled = (reader().commits as Partial<CommitHub> | undefined)?.recall?.(transition)
|
|
44
|
+
if (recalled == null || recalled.entityId !== entityId) {
|
|
45
|
+
throw new WorkcardNotFound(`transition:${transition}`)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const facade: PlanningFacade = {
|
|
50
|
+
scope: Object.freeze({ ...scope }),
|
|
51
|
+
|
|
52
|
+
schemas: runtime.service().schemas,
|
|
53
|
+
|
|
54
|
+
cards: {
|
|
55
|
+
load: async id => mine(await reader().cards.get(id, entityId)),
|
|
56
|
+
|
|
57
|
+
get: async id => {
|
|
58
|
+
const card = await facade.cards.load(id)
|
|
59
|
+
if (card == null) {
|
|
60
|
+
throw new WorkcardNotFound(id)
|
|
61
|
+
}
|
|
62
|
+
return card
|
|
63
|
+
},
|
|
64
|
+
|
|
65
|
+
list: async query => await reader().cards.list(criteriaOf(query, scope), listOptionsOf(query)),
|
|
66
|
+
|
|
67
|
+
count: async query => await reader().cards.count(criteriaOf(query, scope)),
|
|
68
|
+
|
|
69
|
+
summary: async (parents, query) => parents.length === 0
|
|
70
|
+
? {}
|
|
71
|
+
: await reader().cards.summary(parents, criteriaOf({ kind: query?.kind, type: query?.type }, scope)),
|
|
72
|
+
},
|
|
73
|
+
|
|
74
|
+
specifications: {
|
|
75
|
+
current: async (parent, category) => {
|
|
76
|
+
const specs = reader().specs
|
|
77
|
+
if (specs != null) {
|
|
78
|
+
return mine(await specs.current(parent, category, entityId))
|
|
79
|
+
}
|
|
80
|
+
const listed = await reader().cards.list(specCriteriaOf(parent, { category }, scope) as never, { size: 0 })
|
|
81
|
+
return currentSpecification(listed.items, category)
|
|
82
|
+
},
|
|
83
|
+
|
|
84
|
+
list: async (parent, query) => {
|
|
85
|
+
const specs = reader().specs
|
|
86
|
+
if (specs != null) {
|
|
87
|
+
return await specs.list(parent, entityId, query)
|
|
88
|
+
}
|
|
89
|
+
return await reader().cards.list(specCriteriaOf(parent, query, scope) as never, listOptionsOf(query) as never) as never
|
|
90
|
+
},
|
|
91
|
+
|
|
92
|
+
get: async id => {
|
|
93
|
+
const card = await facade.cards.load(id)
|
|
94
|
+
if (card == null || !isSpecification(card)) {
|
|
95
|
+
throw new WorkcardNotFound(id)
|
|
96
|
+
}
|
|
97
|
+
return card as Specification
|
|
98
|
+
},
|
|
99
|
+
|
|
100
|
+
revisions: async (id, limit) => {
|
|
101
|
+
await facade.specifications.get(id)
|
|
102
|
+
const specs = reader().specs
|
|
103
|
+
if (specs == null) {
|
|
104
|
+
throw new PlanningUnsupported('revisions')
|
|
105
|
+
}
|
|
106
|
+
return await specs.revisions(id, entityId, limit)
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
|
|
110
|
+
relationships: {
|
|
111
|
+
list: async query => {
|
|
112
|
+
const links = reader().links
|
|
113
|
+
return links == null
|
|
114
|
+
? { items: [], total: 0 }
|
|
115
|
+
: await links.list(linkWhereOf(query, scope), listOptionsOf(query))
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
|
|
119
|
+
transitions: {
|
|
120
|
+
get: async id => {
|
|
121
|
+
const transition = mine(await reader().transitions?.get(id))
|
|
122
|
+
if (transition == null) {
|
|
123
|
+
throw new WorkcardNotFound(`transition:${id}`)
|
|
124
|
+
}
|
|
125
|
+
return transition
|
|
126
|
+
},
|
|
127
|
+
|
|
128
|
+
list: async query => {
|
|
129
|
+
const transitions = reader().transitions
|
|
130
|
+
return transitions == null
|
|
131
|
+
? { items: [], total: 0 }
|
|
132
|
+
: await transitions.list(transitionWhereOf(query, scope), listOptionsOf(query))
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
|
|
136
|
+
commits: {
|
|
137
|
+
status: async transition => {
|
|
138
|
+
await assertTransition(transition)
|
|
139
|
+
return await commitSource().status(transition)
|
|
140
|
+
},
|
|
141
|
+
|
|
142
|
+
subscribe: (listener, filter) => commitSource().subscribe(listener, { ...filter, entityId }),
|
|
143
|
+
|
|
144
|
+
wait: async (transition, opts) => {
|
|
145
|
+
await assertTransition(transition)
|
|
146
|
+
return await commitSource().wait(transition, opts)
|
|
147
|
+
},
|
|
148
|
+
},
|
|
149
|
+
|
|
150
|
+
execute: async (exec, opts) => await executeTransition(runtime, facade, exec, opts),
|
|
151
|
+
|
|
152
|
+
model: async <T extends Workcard = Workcard>(card: T | string): Promise<WorkcardModel<T>> => {
|
|
153
|
+
const record = typeof card === 'string' ? await facade.cards.get(card) as T : card
|
|
154
|
+
if (record.entityId !== entityId) {
|
|
155
|
+
throw new WorkcardNotFound(record.id ?? WorkcardKind.Card)
|
|
156
|
+
}
|
|
157
|
+
return modelOf<T>(record, facade)
|
|
158
|
+
},
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return facade
|
|
162
|
+
}
|
package/src/helper.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { BasicConfig, BasicContext } from '@owlmeans/context'
|
|
2
|
+
import type { AbstractRequest } from '@owlmeans/entrypoint'
|
|
3
|
+
import type { PlanningFacade, PlanningProtocols, PlanningScope } from '@owlmeans/planning'
|
|
4
|
+
import { bind } from '@owlmeans/server-entrypoint'
|
|
5
|
+
import {
|
|
6
|
+
executePlanning, getCard, getCommit, getSpecification, getTransition, listCardSpecifications,
|
|
7
|
+
listCardTransitions, listCards, listLinks, listSchemas, listSpecificationRevisions, summarizeCards,
|
|
8
|
+
watchCommits,
|
|
9
|
+
} from './actions/index.js'
|
|
10
|
+
import type { PlanningHandlerOptions } from './types.js'
|
|
11
|
+
import { planningServiceOf, scopeOf } from './utils/index.js'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Bind this package's handlers to a tree declared by `makePlanningProtocols` — one binding per
|
|
15
|
+
* protocol, the base included. Pass the tree itself, never a flattened list; an application that
|
|
16
|
+
* wants its own handler for one leaf binds that protocol afterwards.
|
|
17
|
+
*/
|
|
18
|
+
export const servePlanningEntrypoints = (protocols: PlanningProtocols, opts?: PlanningHandlerOptions) => [
|
|
19
|
+
bind(protocols.base),
|
|
20
|
+
bind(protocols.schema.list, listSchemas(protocols.schema.list, opts)),
|
|
21
|
+
bind(protocols.card.list, listCards(protocols.card.list, opts)),
|
|
22
|
+
bind(protocols.card.summary, summarizeCards(protocols.card.summary, opts)),
|
|
23
|
+
bind(protocols.card.get, getCard(protocols.card.get, opts)),
|
|
24
|
+
bind(protocols.card.transitions, listCardTransitions(protocols.card.transitions, opts)),
|
|
25
|
+
bind(protocols.card.specifications, listCardSpecifications(protocols.card.specifications, opts)),
|
|
26
|
+
bind(protocols.spec.get, getSpecification(protocols.spec.get, opts)),
|
|
27
|
+
bind(protocols.spec.revisions, listSpecificationRevisions(protocols.spec.revisions, opts)),
|
|
28
|
+
bind(protocols.link.list, listLinks(protocols.link.list, opts)),
|
|
29
|
+
bind(protocols.transition.get, getTransition(protocols.transition.get, opts)),
|
|
30
|
+
bind(protocols.execute, executePlanning(protocols.execute, opts)),
|
|
31
|
+
bind(protocols.commit.get, getCommit(protocols.commit.get, opts)),
|
|
32
|
+
bind(protocols.commit.events, watchCommits(protocols.commit.events, opts)),
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The facade a hand-written handler works through: the request's entity and subject, plus `extra`
|
|
37
|
+
* (a `channel`) — never an entity from the request body.
|
|
38
|
+
*
|
|
39
|
+
* @throws {AuthorizationError} when the request carries no organization
|
|
40
|
+
*/
|
|
41
|
+
export const planningFor = (
|
|
42
|
+
ctx: BasicContext<BasicConfig>, req: AbstractRequest, extra?: Partial<PlanningScope>,
|
|
43
|
+
opts?: Pick<PlanningHandlerOptions, 'service'>
|
|
44
|
+
): PlanningFacade => planningServiceOf(ctx, opts).for(scopeOf(req, extra))
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export type * from './types.js'
|
|
2
|
+
export type * from './store/types.js'
|
|
3
|
+
|
|
4
|
+
export * from './consts.js'
|
|
5
|
+
export * from './service.js'
|
|
6
|
+
export * from './registry.js'
|
|
7
|
+
export * from './facade.js'
|
|
8
|
+
export * from './executor.js'
|
|
9
|
+
export * from './helper.js'
|
|
10
|
+
export * from './projection.js'
|
|
11
|
+
export * from './actions/index.js'
|
|
12
|
+
export * from './utils/index.js'
|
|
13
|
+
export * from './store/index.js'
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { BasicConfig, BasicContext } from '@owlmeans/context'
|
|
2
|
+
import { PLANNING_SERVICE } from '@owlmeans/planning'
|
|
3
|
+
import type { CommitEvent, PlanningService, PlanningStore } from '@owlmeans/planning'
|
|
4
|
+
import { PLANNING_PROJECT_JOB, PLANNING_PROJECTION_QUEUE } from '@owlmeans/planning/queue'
|
|
5
|
+
import type { ProjectionRequest } from '@owlmeans/planning/queue'
|
|
6
|
+
import type { JobProcessor, QueueHooks } from '@owlmeans/queue'
|
|
7
|
+
import { failPending, foldPending } from './store/fold.js'
|
|
8
|
+
import type { PlanningHostService } from './types.js'
|
|
9
|
+
import type { CommitHub, FoldResult } from './store/types.js'
|
|
10
|
+
|
|
11
|
+
export interface ProjectionOptions {
|
|
12
|
+
/** The planning service alias. */
|
|
13
|
+
service?: string
|
|
14
|
+
/** The store the queue folds — the service's default store when omitted. */
|
|
15
|
+
store?: (service: PlanningService) => PlanningStore
|
|
16
|
+
/** Where events go — the store's commit hub `publish` when it has one. */
|
|
17
|
+
publish?: (event: CommitEvent) => Promise<void>
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const serviceOf = (ctx: BasicContext<BasicConfig>, opts?: ProjectionOptions): PlanningService =>
|
|
21
|
+
ctx.service<PlanningHostService>(opts?.service ?? PLANNING_SERVICE)
|
|
22
|
+
|
|
23
|
+
const storeOf = (service: PlanningService, opts?: ProjectionOptions): PlanningStore =>
|
|
24
|
+
opts?.store?.(service) ?? service.store()
|
|
25
|
+
|
|
26
|
+
const publisherOf = (store: PlanningStore, opts?: ProjectionOptions) =>
|
|
27
|
+
opts?.publish ?? (store.commits as Partial<CommitHub> | undefined)?.publish
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The body of a projection job: fold one card with the service's `committed` as the commit
|
|
31
|
+
* listener, so the `after` chain runs in the process that took the job.
|
|
32
|
+
*
|
|
33
|
+
* Register it only where the process listens to `PLANNING_PROJECTION_QUEUE`; a durable store adds
|
|
34
|
+
* its own admission and release around it.
|
|
35
|
+
*/
|
|
36
|
+
export const makeProjectionProcessor = (
|
|
37
|
+
ctx: BasicContext<BasicConfig>, opts?: ProjectionOptions
|
|
38
|
+
): JobProcessor<ProjectionRequest, FoldResult> => async job => {
|
|
39
|
+
const service = serviceOf(ctx, opts)
|
|
40
|
+
const store = storeOf(service, opts)
|
|
41
|
+
const publish = publisherOf(store, opts)
|
|
42
|
+
|
|
43
|
+
return await foldPending(store, job.data.cardId, job.data.entityId, {
|
|
44
|
+
touch: job.touch,
|
|
45
|
+
onCommitted: async event => { await service.committed(event) },
|
|
46
|
+
...(publish != null ? { publish } : {}),
|
|
47
|
+
})
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* `onJobDead` for the projection queue: every pending transition of the card is marked failed and a
|
|
52
|
+
* failed event is published — otherwise `execute({ wait: true })` and `commits.wait` hang to their
|
|
53
|
+
* timeout. Merge it into the queue driver's hooks.
|
|
54
|
+
*/
|
|
55
|
+
export const planningQueueHooks = (ctx: BasicContext<BasicConfig>, opts?: ProjectionOptions): QueueHooks => ({
|
|
56
|
+
onJobDead: async (job, reason) => {
|
|
57
|
+
if (job.queue !== PLANNING_PROJECTION_QUEUE || job.name !== PLANNING_PROJECT_JOB) {
|
|
58
|
+
return
|
|
59
|
+
}
|
|
60
|
+
const request = job.data as ProjectionRequest
|
|
61
|
+
const store = storeOf(serviceOf(ctx, opts), opts)
|
|
62
|
+
const publish = publisherOf(store, opts)
|
|
63
|
+
await failPending(store, request.cardId, request.entityId, reason, publish != null ? { publish } : {})
|
|
64
|
+
},
|
|
65
|
+
})
|