@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.
Files changed (174) hide show
  1. package/README.md +150 -0
  2. package/agent-meta/manifest.json +16 -0
  3. package/agent-meta/skills/server-planning/SKILL.md +203 -0
  4. package/build/actions/cards.d.ts +16 -0
  5. package/build/actions/cards.d.ts.map +1 -0
  6. package/build/actions/cards.js +32 -0
  7. package/build/actions/cards.js.map +1 -0
  8. package/build/actions/commit.d.ts +14 -0
  9. package/build/actions/commit.d.ts.map +1 -0
  10. package/build/actions/commit.js +48 -0
  11. package/build/actions/commit.js.map +1 -0
  12. package/build/actions/execute.d.ts +13 -0
  13. package/build/actions/execute.d.ts.map +1 -0
  14. package/build/actions/execute.js +35 -0
  15. package/build/actions/execute.js.map +1 -0
  16. package/build/actions/index.d.ts +9 -0
  17. package/build/actions/index.d.ts.map +1 -0
  18. package/build/actions/index.js +9 -0
  19. package/build/actions/index.js.map +1 -0
  20. package/build/actions/links.d.ts +5 -0
  21. package/build/actions/links.d.ts.map +1 -0
  22. package/build/actions/links.js +8 -0
  23. package/build/actions/links.js.map +1 -0
  24. package/build/actions/schemas.d.ts +6 -0
  25. package/build/actions/schemas.d.ts.map +1 -0
  26. package/build/actions/schemas.js +9 -0
  27. package/build/actions/schemas.js.map +1 -0
  28. package/build/actions/specs.d.ts +10 -0
  29. package/build/actions/specs.d.ts.map +1 -0
  30. package/build/actions/specs.js +14 -0
  31. package/build/actions/specs.js.map +1 -0
  32. package/build/actions/transitions.d.ts +6 -0
  33. package/build/actions/transitions.d.ts.map +1 -0
  34. package/build/actions/transitions.js +8 -0
  35. package/build/actions/transitions.js.map +1 -0
  36. package/build/actions/watch.d.ts +12 -0
  37. package/build/actions/watch.d.ts.map +1 -0
  38. package/build/actions/watch.js +45 -0
  39. package/build/actions/watch.js.map +1 -0
  40. package/build/consts.d.ts +18 -0
  41. package/build/consts.d.ts.map +1 -0
  42. package/build/consts.js +19 -0
  43. package/build/consts.js.map +1 -0
  44. package/build/executor/changes.d.ts +31 -0
  45. package/build/executor/changes.d.ts.map +1 -0
  46. package/build/executor/changes.js +55 -0
  47. package/build/executor/changes.js.map +1 -0
  48. package/build/executor/code.d.ts +15 -0
  49. package/build/executor/code.d.ts.map +1 -0
  50. package/build/executor/code.js +59 -0
  51. package/build/executor/code.js.map +1 -0
  52. package/build/executor/receipt.d.ts +12 -0
  53. package/build/executor/receipt.d.ts.map +1 -0
  54. package/build/executor/receipt.js +56 -0
  55. package/build/executor/receipt.js.map +1 -0
  56. package/build/executor/resolve.d.ts +35 -0
  57. package/build/executor/resolve.d.ts.map +1 -0
  58. package/build/executor/resolve.js +136 -0
  59. package/build/executor/resolve.js.map +1 -0
  60. package/build/executor/validate.d.ts +13 -0
  61. package/build/executor/validate.d.ts.map +1 -0
  62. package/build/executor/validate.js +222 -0
  63. package/build/executor/validate.js.map +1 -0
  64. package/build/executor.d.ts +15 -0
  65. package/build/executor.d.ts.map +1 -0
  66. package/build/executor.js +113 -0
  67. package/build/executor.js.map +1 -0
  68. package/build/facade.d.ts +12 -0
  69. package/build/facade.d.ts.map +1 -0
  70. package/build/facade.js +131 -0
  71. package/build/facade.js.map +1 -0
  72. package/build/helper.d.ts +18 -0
  73. package/build/helper.d.ts.map +1 -0
  74. package/build/helper.js +32 -0
  75. package/build/helper.js.map +1 -0
  76. package/build/index.d.ts +13 -0
  77. package/build/index.d.ts.map +1 -0
  78. package/build/index.js +11 -0
  79. package/build/index.js.map +1 -0
  80. package/build/projection.d.ts +28 -0
  81. package/build/projection.d.ts.map +1 -0
  82. package/build/projection.js +40 -0
  83. package/build/projection.js.map +1 -0
  84. package/build/registry.d.ts +30 -0
  85. package/build/registry.d.ts.map +1 -0
  86. package/build/registry.js +103 -0
  87. package/build/registry.js.map +1 -0
  88. package/build/service.d.ts +40 -0
  89. package/build/service.d.ts.map +1 -0
  90. package/build/service.js +109 -0
  91. package/build/service.js.map +1 -0
  92. package/build/store/commits.d.ts +15 -0
  93. package/build/store/commits.d.ts.map +1 -0
  94. package/build/store/commits.js +147 -0
  95. package/build/store/commits.js.map +1 -0
  96. package/build/store/composite.d.ts +12 -0
  97. package/build/store/composite.d.ts.map +1 -0
  98. package/build/store/composite.js +150 -0
  99. package/build/store/composite.js.map +1 -0
  100. package/build/store/fold.d.ts +39 -0
  101. package/build/store/fold.d.ts.map +1 -0
  102. package/build/store/fold.js +220 -0
  103. package/build/store/fold.js.map +1 -0
  104. package/build/store/index.d.ts +6 -0
  105. package/build/store/index.d.ts.map +1 -0
  106. package/build/store/index.js +5 -0
  107. package/build/store/index.js.map +1 -0
  108. package/build/store/memory.d.ts +19 -0
  109. package/build/store/memory.d.ts.map +1 -0
  110. package/build/store/memory.js +0 -0
  111. package/build/store/memory.js.map +1 -0
  112. package/build/store/types.d.ts +82 -0
  113. package/build/store/types.d.ts.map +1 -0
  114. package/build/store/types.js +2 -0
  115. package/build/store/types.js.map +1 -0
  116. package/build/types.d.ts +46 -0
  117. package/build/types.d.ts.map +1 -0
  118. package/build/types.js +2 -0
  119. package/build/types.js.map +1 -0
  120. package/build/utils/guard.d.ts +19 -0
  121. package/build/utils/guard.d.ts.map +1 -0
  122. package/build/utils/guard.js +35 -0
  123. package/build/utils/guard.js.map +1 -0
  124. package/build/utils/index.d.ts +3 -0
  125. package/build/utils/index.d.ts.map +1 -0
  126. package/build/utils/index.js +3 -0
  127. package/build/utils/index.js.map +1 -0
  128. package/build/utils/scope.d.ts +20 -0
  129. package/build/utils/scope.d.ts.map +1 -0
  130. package/build/utils/scope.js +36 -0
  131. package/build/utils/scope.js.map +1 -0
  132. package/package.json +66 -0
  133. package/src/actions/cards.ts +52 -0
  134. package/src/actions/commit.ts +55 -0
  135. package/src/actions/execute.ts +47 -0
  136. package/src/actions/index.ts +8 -0
  137. package/src/actions/links.ts +14 -0
  138. package/src/actions/schemas.ts +15 -0
  139. package/src/actions/specs.ts +23 -0
  140. package/src/actions/transitions.ts +14 -0
  141. package/src/actions/watch.ts +52 -0
  142. package/src/consts.ts +22 -0
  143. package/src/executor/changes.ts +78 -0
  144. package/src/executor/code.ts +83 -0
  145. package/src/executor/receipt.ts +66 -0
  146. package/src/executor/resolve.ts +174 -0
  147. package/src/executor/validate.ts +253 -0
  148. package/src/executor.ts +149 -0
  149. package/src/facade.ts +162 -0
  150. package/src/helper.ts +44 -0
  151. package/src/index.ts +13 -0
  152. package/src/projection.ts +65 -0
  153. package/src/registry.ts +143 -0
  154. package/src/service.ts +157 -0
  155. package/src/store/commits.ts +174 -0
  156. package/src/store/composite.ts +171 -0
  157. package/src/store/fold.ts +257 -0
  158. package/src/store/index.ts +6 -0
  159. package/src/store/memory.ts +0 -0
  160. package/src/store/types.ts +94 -0
  161. package/src/types.ts +48 -0
  162. package/src/utils/guard.ts +40 -0
  163. package/src/utils/index.ts +2 -0
  164. package/src/utils/scope.ts +51 -0
  165. package/tests/commits.spec.ts +78 -0
  166. package/tests/context.ts +198 -0
  167. package/tests/entrypoints.spec.ts +72 -0
  168. package/tests/executor.spec.ts +69 -0
  169. package/tests/memory-store.spec.ts +84 -0
  170. package/tests/plugins.spec.ts +86 -0
  171. package/tests/specifications.spec.ts +61 -0
  172. package/tests/tsconfig.json +17 -0
  173. package/tests/validation.spec.ts +62 -0
  174. 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
+ }
@@ -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
+ })