@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,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
+ }
@@ -0,0 +1,6 @@
1
+ export type * from './types.js'
2
+
3
+ export * from './commits.js'
4
+ export * from './composite.js'
5
+ export * from './fold.js'
6
+ export * from './memory.js'
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,2 @@
1
+ export * from './guard.js'
2
+ export * from './scope.js'
@@ -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
+ })