@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,14 @@
1
+ import { handlers } from '@owlmeans/server-api'
2
+ import { decodeRelationshipQuery } from '@owlmeans/planning'
3
+ import type { PlanningProtocols } from '@owlmeans/planning'
4
+ import type { Context, PlanningHandlerOptions } from '../types.js'
5
+ import { concealed, handlerFacade } from '../utils/index.js'
6
+
7
+ export const listLinks = (
8
+ protocol: PlanningProtocols['link']['list'], opts?: PlanningHandlerOptions
9
+ ): ReturnType<ReturnType<typeof handlers<Context>>['request']> =>
10
+ handlers<Context>().request(protocol, async (req, ctx) => concealed(async () => {
11
+ const facade = await handlerFacade(ctx, req, opts)
12
+
13
+ return await facade.relationships.list(decodeRelationshipQuery(req.query))
14
+ }))
@@ -0,0 +1,15 @@
1
+ import { handlers } from '@owlmeans/server-api'
2
+ import type { PlanningProtocols } from '@owlmeans/planning'
3
+ import type { Context, PlanningHandlerOptions } from '../types.js'
4
+ import { planningServiceOf, scopeOf } from '../utils/index.js'
5
+
6
+ /** The schema bundle the service holds — what a client loads to answer `can()` with no round trip. */
7
+ export const listSchemas = (
8
+ protocol: PlanningProtocols['schema']['list'], opts?: PlanningHandlerOptions
9
+ ): ReturnType<ReturnType<typeof handlers<Context>>['request']> =>
10
+ handlers<Context>().request(protocol, async (req, ctx) => {
11
+ // Scoped like every other leaf: no organization, no bundle.
12
+ scopeOf(req)
13
+
14
+ return planningServiceOf(ctx, opts).schemas.bundle()
15
+ })
@@ -0,0 +1,23 @@
1
+ import { handlers } from '@owlmeans/server-api'
2
+ import type { PlanningProtocols } from '@owlmeans/planning'
3
+ import type { Context, PlanningHandlerOptions } from '../types.js'
4
+ import { concealed, handlerFacade } from '../utils/index.js'
5
+
6
+ type RequestHandler = ReturnType<ReturnType<typeof handlers<Context>>['request']>
7
+
8
+ /** @throws {WorkcardNotFound} */
9
+ export const getSpecification = (protocol: PlanningProtocols['spec']['get'], opts?: PlanningHandlerOptions): RequestHandler =>
10
+ handlers<Context>().request(protocol, async (req, ctx) => concealed(async () => {
11
+ const facade = await handlerFacade(ctx, req, opts)
12
+
13
+ return await facade.specifications.get(`${req.params.id}`)
14
+ }))
15
+
16
+ /** A document's history, newest first, replayed from its log. */
17
+ export const listSpecificationRevisions = (protocol: PlanningProtocols['spec']['revisions'], opts?: PlanningHandlerOptions): RequestHandler =>
18
+ handlers<Context>().request(protocol, async (req, ctx) => concealed(async () => {
19
+ const facade = await handlerFacade(ctx, req, opts)
20
+ const limit = req.query?.limit == null ? undefined : Number(req.query.limit)
21
+
22
+ return { items: await facade.specifications.revisions(`${req.params.id}`, Number.isFinite(limit) ? limit : undefined) }
23
+ }))
@@ -0,0 +1,14 @@
1
+ import { handlers } from '@owlmeans/server-api'
2
+ import type { PlanningProtocols } from '@owlmeans/planning'
3
+ import type { Context, PlanningHandlerOptions } from '../types.js'
4
+ import { concealed, handlerFacade } from '../utils/index.js'
5
+
6
+ /** @throws {WorkcardNotFound} for an absent transition and for another entity's alike */
7
+ export const getTransition = (
8
+ protocol: PlanningProtocols['transition']['get'], opts?: PlanningHandlerOptions
9
+ ): ReturnType<ReturnType<typeof handlers<Context>>['request']> =>
10
+ handlers<Context>().request(protocol, async (req, ctx) => concealed(async () => {
11
+ const facade = await handlerFacade(ctx, req, opts)
12
+
13
+ return await facade.transitions.get(`${req.params.transition}`)
14
+ }))
@@ -0,0 +1,52 @@
1
+ import { PLANNING_COMMIT_EVENT } from '@owlmeans/planning'
2
+ import type { CommitEvent, CommitFeedQuery, PlanningProtocols } from '@owlmeans/planning'
3
+ import { connection } from '@owlmeans/server-socket'
4
+ import type { EventMessage } from '@owlmeans/socket'
5
+ import { MessageType } from '@owlmeans/socket'
6
+ import type { Context, PlanningHandlerOptions } from '../types.js'
7
+ import { handlerFacade } from '../utils/index.js'
8
+
9
+ const text = (value: unknown): string | undefined =>
10
+ value == null || value === '' ? undefined : `${value}`
11
+
12
+ /**
13
+ * The notify tool: push the caller's entity's commits down a socket as `planning-commit` frames.
14
+ *
15
+ * The subscription is filtered by the authenticated entity (never a query value) and optionally by
16
+ * `project`/`card`. A frame without `record` — what a cross-process bus delivers — gets the card as
17
+ * it reads now (`null` once deleted), so a subscriber never re-reads per frame.
18
+ */
19
+ export const watchCommits = (
20
+ protocol: PlanningProtocols['commit']['events'], opts?: PlanningHandlerOptions
21
+ ): ReturnType<typeof connection> => connection<typeof protocol, Context>(protocol, async (conn, ctx, req) => {
22
+ const facade = await handlerFacade(ctx, req, opts)
23
+ const query = (req.query ?? {}) as CommitFeedQuery
24
+ const frame = opts?.event ?? PLANNING_COMMIT_EVENT
25
+ const filter = {
26
+ ...(text(query.project) != null ? { project: text(query.project) } : {}),
27
+ ...(text(query.card) != null ? { card: text(query.card) } : {}),
28
+ }
29
+
30
+ const unsubscribe = await facade.commits.subscribe(async (event: CommitEvent) => {
31
+ try {
32
+ const enriched = event.record !== undefined ? event : { ...event, record: await facade.cards.load(event.card) }
33
+ await conn.notify(frame, enriched)
34
+ } catch (e) {
35
+ console.error('Planning commit notify error:', e)
36
+ }
37
+ }, filter)
38
+
39
+ conn.listen(async message => {
40
+ if (typeof message !== 'object') {
41
+ return
42
+ }
43
+ const msg = message as EventMessage<void>
44
+ if (msg.type === MessageType.System && msg.event === 'close') {
45
+ try {
46
+ unsubscribe()
47
+ } catch (e) {
48
+ console.error('Planning commit unsubscribe error:', e)
49
+ }
50
+ }
51
+ })
52
+ })
package/src/consts.ts ADDED
@@ -0,0 +1,22 @@
1
+ import { PLANNING_SERVICE } from '@owlmeans/planning'
2
+
3
+ /** The alias `appendPlanningService` registers the host under — the same one a client uses. */
4
+ export const DEFAULT_ALIAS = PLANNING_SERVICE
5
+
6
+ /** The alias a memory store answers `PlanningStore.alias` with. */
7
+ export const MEMORY_STORE_ALIAS = 'planning-memory'
8
+
9
+ /**
10
+ * How many settled commits a hub remembers after their transition row may be gone.
11
+ *
12
+ * A project `delete` purges the project's own log, so without this a waiter that polls a moment
13
+ * after the fold would be told the transition never existed.
14
+ */
15
+ export const DEFAULT_COMMIT_MEMORY = 1000
16
+
17
+ /**
18
+ * The poll ladder `wait` climbs after its first immediate poll, in milliseconds. The last step
19
+ * repeats. The subscription answers first whenever the fold publishes where the hub hears it; the
20
+ * ladder is what answers when it folded somewhere the hub does not.
21
+ */
22
+ export const COMMIT_POLL_LADDER: readonly number[] = Object.freeze([250, 1000, 2000])
@@ -0,0 +1,78 @@
1
+ import { CommitState, computeChanges, isEmptyChange, TransitionAction } from '@owlmeans/planning'
2
+ import type {
3
+ ChangeSet, PlanningSchemaRegistry, PlanningScope, RelationshipDraft, Transition, TransitionActor,
4
+ TransitionExecution, WorkcardDraft,
5
+ } from '@owlmeans/planning'
6
+ import type { Resolved } from './resolve.js'
7
+
8
+ const clean = <T extends object>(record: T): T =>
9
+ Object.fromEntries(Object.entries(record).filter(([, value]) => value !== undefined)) as T
10
+
11
+ /**
12
+ * Step 9: the recorded `changes`/`unset`, and whether an update wrote nothing.
13
+ *
14
+ * @throws {IllegalTransition | PlanningError}
15
+ */
16
+ export const changeSetOf = (
17
+ exec: TransitionExecution, resolved: Resolved, schemas: PlanningSchemaRegistry, at: string
18
+ ): { set: ChangeSet, empty: boolean } => {
19
+ const set = computeChanges(resolved.card, exec, resolved.type, schemas, at, { slot: resolved.slot })
20
+ const empty = exec.action === TransitionAction.Update && isEmptyChange(set)
21
+
22
+ return { set, empty }
23
+ }
24
+
25
+ /**
26
+ * Who wrote it — the SCOPE's identity, never the execution's.
27
+ *
28
+ * `profileId`, `userId`, `service` and `channel` come only from the scope (the server builds it
29
+ * from the authenticated request); the descriptive `agent`/`runId` an in-process caller passes on
30
+ * the execution are kept unless the scope's own actor names them.
31
+ */
32
+ export const actorOf = (scope: PlanningScope, exec: TransitionExecution): TransitionActor => clean({
33
+ agent: exec.actor?.agent,
34
+ runId: exec.actor?.runId,
35
+ ...clean({ profileId: scope.profileId, userId: scope.userId, service: scope.service }),
36
+ ...clean(scope.actor ?? {}),
37
+ channel: scope.channel ?? scope.actor?.channel,
38
+ })
39
+
40
+ const withFrom = (link: RelationshipDraft, card: string): RelationshipDraft => ({ ...link, from: link.from ?? card })
41
+
42
+ /** Step 11's record: the event as it is appended, `pending`. */
43
+ export const transitionOf = (params: {
44
+ exec: TransitionExecution
45
+ resolved: Resolved
46
+ scope: PlanningScope
47
+ set: ChangeSet
48
+ cardId: string
49
+ seq: number
50
+ project?: string
51
+ at: string
52
+ }): Transition => {
53
+ const { exec, resolved, scope, set, cardId, seq, project, at } = params
54
+ const kind = resolved.create ? (exec.card as WorkcardDraft).kind : resolved.card!.kind
55
+
56
+ return clean({
57
+ entityId: scope.entityId,
58
+ card: cardId,
59
+ kind,
60
+ type: resolved.type.type,
61
+ project,
62
+ seq,
63
+ action: exec.action,
64
+ flow: set.flow,
65
+ transition: exec.action === TransitionAction.Transit ? exec.transition : undefined,
66
+ from: set.from,
67
+ to: set.to,
68
+ changes: set.changes,
69
+ unset: set.unset.length > 0 ? set.unset : undefined,
70
+ link: exec.link != null && exec.action !== TransitionAction.Create ? withFrom(exec.link, cardId) : undefined,
71
+ links: resolved.create && (exec.links?.length ?? 0) > 0 ? exec.links!.map(link => withFrom(link, cardId)) : undefined,
72
+ actor: actorOf(scope, exec),
73
+ cause: exec.cause,
74
+ key: exec.key,
75
+ at,
76
+ commit: { state: CommitState.Pending },
77
+ })
78
+ }
@@ -0,0 +1,83 @@
1
+ import {
2
+ CODE_MINT_ATTEMPTS, CodeScope, CodeStyle, CodeTaken, mintCode, TransitionAction,
3
+ } from '@owlmeans/planning'
4
+ import type {
5
+ CodePolicy, PlanningExecContext, PlanningPlugin, PlanningScope, TransitionExecution, WorkcardDraft,
6
+ } from '@owlmeans/planning'
7
+ import type { Criteria } from '@owlmeans/resource'
8
+ import type { Workcard } from '@owlmeans/planning'
9
+ import type { PluginRegistry } from '../registry.js'
10
+ import type { Resolved } from './resolve.js'
11
+
12
+ /** The criteria a code must be unique within — the policy's scope, `parent` when there is none. */
13
+ const scopeCriteria = (
14
+ scope: PlanningScope, type: string, parent: string | undefined, policy?: CodePolicy
15
+ ): Criteria<Workcard> => ({
16
+ entityId: scope.entityId,
17
+ type,
18
+ ...((policy?.uniqueWithin ?? CodeScope.Parent) === CodeScope.Parent ? { parent: parent ?? null } : {}),
19
+ }) as Criteria<Workcard>
20
+
21
+ export const takenProbe = (resolved: Resolved, scope: PlanningScope, parent: string | undefined) => {
22
+ const base = scopeCriteria(scope, resolved.type.type, parent, resolved.type.code)
23
+
24
+ return async (code: string): Promise<boolean> =>
25
+ await resolved.store.cards.count({ ...base, code } as Criteria<Workcard>) > 0
26
+ }
27
+
28
+ /**
29
+ * Step 8: the code a new card gets, or the check a changed code passes.
30
+ *
31
+ * A caller-supplied code is checked for uniqueness; otherwise the plugins' `mintCode` chain answers
32
+ * (first answer wins, and is checked too); otherwise the type's policy mints one — a slug policy
33
+ * derives it from the title. A type with no policy and no supplied code gets none.
34
+ *
35
+ * @throws {CodeTaken}
36
+ */
37
+ export const assignCode = async (
38
+ exec: TransitionExecution,
39
+ resolved: Resolved,
40
+ scope: PlanningScope,
41
+ registry: PluginRegistry,
42
+ contextOf: (plugin: PlanningPlugin) => PlanningExecContext
43
+ ): Promise<TransitionExecution> => {
44
+ const policy = resolved.type.code
45
+
46
+ if (exec.action !== TransitionAction.Create) {
47
+ const code = exec.changes?.code
48
+ if (code != null && code !== resolved.card?.code
49
+ && await takenProbe(resolved, scope, exec.changes?.parent ?? resolved.card?.parent)(code)) {
50
+ throw new CodeTaken(code)
51
+ }
52
+ return exec
53
+ }
54
+
55
+ const draft = exec.card as WorkcardDraft
56
+ const taken = takenProbe(resolved, scope, draft.parent)
57
+ if (draft.code != null && draft.code !== '') {
58
+ if (await taken(draft.code)) {
59
+ throw new CodeTaken(draft.code)
60
+ }
61
+ return exec
62
+ }
63
+ if (policy == null) {
64
+ return exec
65
+ }
66
+
67
+ let code = await registry.mintCode(draft, taken, contextOf)
68
+ if (code != null) {
69
+ if (await taken(code)) {
70
+ throw new CodeTaken(code)
71
+ }
72
+ } else {
73
+ const count = policy.style === CodeStyle.Sequential
74
+ ? await resolved.store.cards.count(scopeCriteria(scope, draft.type, draft.parent, policy))
75
+ : undefined
76
+ code = await mintCode(policy, taken, CODE_MINT_ATTEMPTS, {
77
+ ...(policy.style === CodeStyle.Slug ? { seed: draft.title } : {}),
78
+ ...(count != null ? { count } : {}),
79
+ })
80
+ }
81
+
82
+ return { ...exec, card: { ...draft, code } }
83
+ }
@@ -0,0 +1,66 @@
1
+ import { CommitState, DEFAULT_COMMIT_TIMEOUT, TransitionAction } from '@owlmeans/planning'
2
+ import type {
3
+ CommitStatus, ExecuteOptions, PlanningStore, Transition, TransitionReceipt, TransitionReceiptView, Workcard,
4
+ } from '@owlmeans/planning'
5
+
6
+ const clean = <T extends object>(record: T): T =>
7
+ Object.fromEntries(Object.entries(record).filter(([, value]) => value !== undefined)) as T
8
+
9
+ const statusOf = async (store: PlanningStore, transition: Transition): Promise<CommitStatus | null> => {
10
+ if (store.commits == null || transition.id == null) {
11
+ return null
12
+ }
13
+ try {
14
+ return await store.commits.status(transition.id)
15
+ } catch {
16
+ return null
17
+ }
18
+ }
19
+
20
+ /** The transition as its latest commit status reports it, and the card when that commit landed. */
21
+ const viewOf = async (store: PlanningStore, transition: Transition): Promise<TransitionReceiptView> => {
22
+ const status = await statusOf(store, transition)
23
+ if (status == null) {
24
+ return { transition }
25
+ }
26
+ const view: TransitionReceiptView = {
27
+ transition: { ...transition, commit: clean({ state: status.state, at: status.at, error: status.error }) },
28
+ }
29
+ if (status.state === CommitState.Committed) {
30
+ view.card = status.card !== undefined
31
+ ? status.card
32
+ : transition.action === TransitionAction.Delete
33
+ ? null
34
+ : await store.cards.get(transition.card, transition.entityId)
35
+ }
36
+ return view
37
+ }
38
+
39
+ /**
40
+ * A receipt for an appended (or idempotently found) transition.
41
+ *
42
+ * `committed()` waits on the store's commit source; a store without one (a foreign provider that
43
+ * applies writes itself) answers the card as it reads now. With `wait: true` the receipt is
44
+ * returned only once the commit landed — `CommitFailed` / `CommitTimeout` otherwise.
45
+ *
46
+ * @throws {CommitFailed | CommitTimeout} in wait mode
47
+ */
48
+ export const makeReceipt = async (
49
+ store: PlanningStore, transition: Transition, opts?: ExecuteOptions
50
+ ): Promise<TransitionReceipt> => {
51
+ const committed = async (waitOpts?: { timeout?: number }): Promise<Workcard | null> => {
52
+ if (store.commits == null || transition.id == null) {
53
+ return await store.cards.get(transition.card, transition.entityId)
54
+ }
55
+ return await store.commits.wait(transition.id, { timeout: waitOpts?.timeout ?? DEFAULT_COMMIT_TIMEOUT })
56
+ }
57
+
58
+ let view = await viewOf(store, transition)
59
+ if (opts?.wait === true && view.transition.commit.state !== CommitState.Committed) {
60
+ const card = await committed({ timeout: opts.timeout })
61
+ view = await viewOf(store, transition)
62
+ view.card = card
63
+ }
64
+
65
+ return { ...view, committed }
66
+ }
@@ -0,0 +1,174 @@
1
+ import {
2
+ CardTypeNotAllowed, isProject, normalizeParents, ParentNotFound, PlanningError, PlanningScopeMismatch,
3
+ primaryFlowOf, projectOf, slotOf, TransitionAction, UnknownStatusFlow, WorkcardKind, WorkcardNotFound,
4
+ } from '@owlmeans/planning'
5
+ import type {
6
+ AnyTypeSchema, PlanningFacade, PlanningStore, ProjectTypeSchema, Specification, SpecificationSlot,
7
+ StatusFlowSchema, TransitionExecution, Workcard, WorkcardDraft,
8
+ } from '@owlmeans/planning'
9
+ import type { PlanningRuntime } from '../service.js'
10
+
11
+ /** Everything the steps after resolution read about an execution. */
12
+ export interface Resolved {
13
+ create: boolean
14
+ /** The existing card (not a create). */
15
+ card?: Workcard
16
+ type: AnyTypeSchema
17
+ flowId: string
18
+ flow: StatusFlowSchema
19
+ /** The primary parent, when it resolves. */
20
+ parent?: Workcard
21
+ /** The slot a specification belongs to. */
22
+ slot?: SpecificationSlot
23
+ /** The store the type is written to. */
24
+ store: PlanningStore
25
+ }
26
+
27
+ const trimmed = (value: unknown): unknown => typeof value === 'string' ? value.trim() : value
28
+
29
+ const TRIMMED = ['title', 'description', 'code'] as const
30
+
31
+ const trim = <T extends object>(record: T | undefined): T | undefined => {
32
+ if (record == null) {
33
+ return record
34
+ }
35
+ const copy = { ...record } as Record<string, unknown>
36
+ for (const key of TRIMMED) {
37
+ if (typeof copy[key] === 'string') {
38
+ copy[key] = trimmed(copy[key])
39
+ }
40
+ }
41
+ return copy as T
42
+ }
43
+
44
+ /**
45
+ * Step 1: a deep copy with trimmed text, `parents ∋ parent` on a draft, and no duplicate `unset`
46
+ * paths. The caller's object is never touched.
47
+ */
48
+ export const normalizeExecution = (input: TransitionExecution): TransitionExecution => {
49
+ const exec = structuredClone(input) as TransitionExecution
50
+ if (exec.card != null && typeof exec.card === 'object') {
51
+ const draft = trim(exec.card) as WorkcardDraft
52
+ const parents = normalizeParents(draft.parent, draft.parents)
53
+ exec.card = { ...draft, ...(parents.length > 0 ? { parents, parent: draft.parent ?? parents[0] } : {}) }
54
+ }
55
+ if (exec.changes != null) {
56
+ exec.changes = trim(exec.changes)
57
+ }
58
+ if (exec.unset != null) {
59
+ exec.unset = [...new Set(exec.unset)]
60
+ }
61
+ return exec
62
+ }
63
+
64
+ /** The parent type's rule for a child of `kind`/`type`. @throws {CardTypeNotAllowed} */
65
+ export const assertChildAllowed = (
66
+ runtime: PlanningRuntime, parent: Workcard, kind: WorkcardKind, type: string
67
+ ): void => {
68
+ if (kind === WorkcardKind.Specification) {
69
+ return
70
+ }
71
+ if (!isProject(parent)) {
72
+ throw new CardTypeNotAllowed(`${parent.type}:${type}`)
73
+ }
74
+ const parentType = runtime.service().schemas.type(parent.type) as ProjectTypeSchema
75
+ const allowed = kind === WorkcardKind.Project ? parentType.projectTypes ?? [] : parentType.cardTypes ?? []
76
+ if (!allowed.includes(type)) {
77
+ throw new CardTypeNotAllowed(`${parent.type}:${type}`)
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Step 4: the card, its type and flow, its parent and — for a specification — its slot.
83
+ *
84
+ * @throws {WorkcardNotFound | PlanningScopeMismatch | UnknownWorkcardType | UnknownStatusFlow}
85
+ * @throws {ParentNotFound | CardTypeNotAllowed | PlanningError}
86
+ */
87
+ export const resolveExecution = async (
88
+ runtime: PlanningRuntime, facade: PlanningFacade, exec: TransitionExecution
89
+ ): Promise<Resolved> => {
90
+ const service = runtime.service()
91
+ const schemas = service.schemas
92
+ const entityId = facade.scope.entityId
93
+ const create = exec.action === TransitionAction.Create
94
+
95
+ let card: Workcard | undefined
96
+ let type: AnyTypeSchema
97
+ let parent: Workcard | undefined
98
+
99
+ if (create) {
100
+ if (exec.card == null || typeof exec.card !== 'object') {
101
+ throw new PlanningError('malformed:create-without-draft')
102
+ }
103
+ const draft = exec.card
104
+ type = schemas.type(draft.type)
105
+ if (type.kind !== draft.kind) {
106
+ throw new PlanningError(`malformed:kind:${draft.type}:${draft.kind}`)
107
+ }
108
+ // Normalized: the primary parent is first. It must accept the child; so must every project
109
+ // among the secondary parents.
110
+ for (const [index, id] of normalizeParents(draft.parent, draft.parents).entries()) {
111
+ const found = await facade.cards.load(id)
112
+ if (found == null) {
113
+ throw new ParentNotFound(id)
114
+ }
115
+ if (index === 0) {
116
+ parent = found
117
+ }
118
+ if (index === 0 || isProject(found)) {
119
+ assertChildAllowed(runtime, found, draft.kind, draft.type)
120
+ }
121
+ }
122
+ if (draft.kind === WorkcardKind.Specification && parent == null) {
123
+ throw new PlanningError('malformed:specification-without-parent')
124
+ }
125
+ } else {
126
+ if (typeof exec.card !== 'string' || exec.card === '') {
127
+ throw new PlanningError(`malformed:${exec.action}-without-card`)
128
+ }
129
+ const found = await runtime.reader().cards.get(exec.card, entityId)
130
+ if (found == null) {
131
+ throw new WorkcardNotFound(exec.card)
132
+ }
133
+ if (found.entityId !== entityId) {
134
+ throw new PlanningScopeMismatch(exec.card)
135
+ }
136
+ card = found
137
+ type = schemas.type(card.type)
138
+ parent = card.parent != null ? await facade.cards.load(card.parent) ?? undefined : undefined
139
+ }
140
+
141
+ const flowId = exec.flow ?? primaryFlowOf(type)
142
+ if (!type.flows.includes(flowId)) {
143
+ throw new UnknownStatusFlow(`${type.type}:${flowId}`)
144
+ }
145
+
146
+ const kind = create ? (exec.card as WorkcardDraft).kind : card!.kind
147
+ const category = create ? (exec.card as WorkcardDraft).category : (card as Specification | undefined)?.category
148
+ const slot = kind === WorkcardKind.Specification && parent != null && category != null
149
+ ? slotOf(schemas.type(parent.type), category)
150
+ : undefined
151
+
152
+ return {
153
+ create,
154
+ ...(card != null ? { card } : {}),
155
+ type,
156
+ flowId,
157
+ flow: schemas.flow(flowId),
158
+ ...(parent != null ? { parent } : {}),
159
+ ...(slot != null ? { slot } : {}),
160
+ store: service.store(type.type),
161
+ }
162
+ }
163
+
164
+ /** The project a transition is filed under: a project's own id, else its parent's project. */
165
+ export const projectFor = (resolved: Resolved, exec: TransitionExecution, cardId: string): string | undefined => {
166
+ if (resolved.card != null) {
167
+ return isProject(resolved.card) ? resolved.card.id : projectOf(resolved.card, resolved.parent)
168
+ }
169
+ const draft = exec.card as WorkcardDraft
170
+ if (draft.kind === WorkcardKind.Project) {
171
+ return cardId
172
+ }
173
+ return projectOf({ kind: draft.kind, parent: draft.parent, parents: draft.parents ?? [] } as unknown as Workcard, resolved.parent)
174
+ }