@owlmeans/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 (139) hide show
  1. package/README.md +169 -0
  2. package/agent-meta/manifest.json +16 -0
  3. package/agent-meta/skills/planning/SKILL.md +248 -0
  4. package/build/consts.d.ts +126 -0
  5. package/build/consts.d.ts.map +1 -0
  6. package/build/consts.js +129 -0
  7. package/build/consts.js.map +1 -0
  8. package/build/entrypoints.d.ts +14 -0
  9. package/build/entrypoints.d.ts.map +1 -0
  10. package/build/entrypoints.js +69 -0
  11. package/build/entrypoints.js.map +1 -0
  12. package/build/errors.d.ts +94 -0
  13. package/build/errors.d.ts.map +1 -0
  14. package/build/errors.js +169 -0
  15. package/build/errors.js.map +1 -0
  16. package/build/helpers/apply.d.ts +27 -0
  17. package/build/helpers/apply.d.ts.map +1 -0
  18. package/build/helpers/apply.js +162 -0
  19. package/build/helpers/apply.js.map +1 -0
  20. package/build/helpers/card.d.ts +17 -0
  21. package/build/helpers/card.d.ts.map +1 -0
  22. package/build/helpers/card.js +31 -0
  23. package/build/helpers/card.js.map +1 -0
  24. package/build/helpers/changes.d.ts +51 -0
  25. package/build/helpers/changes.d.ts.map +1 -0
  26. package/build/helpers/changes.js +277 -0
  27. package/build/helpers/changes.js.map +1 -0
  28. package/build/helpers/code.d.ts +24 -0
  29. package/build/helpers/code.d.ts.map +1 -0
  30. package/build/helpers/code.js +47 -0
  31. package/build/helpers/code.js.map +1 -0
  32. package/build/helpers/index.d.ts +9 -0
  33. package/build/helpers/index.d.ts.map +1 -0
  34. package/build/helpers/index.js +9 -0
  35. package/build/helpers/index.js.map +1 -0
  36. package/build/helpers/query.d.ts +39 -0
  37. package/build/helpers/query.d.ts.map +1 -0
  38. package/build/helpers/query.js +293 -0
  39. package/build/helpers/query.js.map +1 -0
  40. package/build/helpers/specification.d.ts +21 -0
  41. package/build/helpers/specification.d.ts.map +1 -0
  42. package/build/helpers/specification.js +48 -0
  43. package/build/helpers/specification.js.map +1 -0
  44. package/build/helpers/status.d.ts +44 -0
  45. package/build/helpers/status.d.ts.map +1 -0
  46. package/build/helpers/status.js +78 -0
  47. package/build/helpers/status.js.map +1 -0
  48. package/build/helpers/validate.d.ts +30 -0
  49. package/build/helpers/validate.d.ts.map +1 -0
  50. package/build/helpers/validate.js +93 -0
  51. package/build/helpers/validate.js.map +1 -0
  52. package/build/i18n/be.json +51 -0
  53. package/build/i18n/de.json +51 -0
  54. package/build/i18n/en.json +51 -0
  55. package/build/i18n/es.json +51 -0
  56. package/build/i18n/pl.json +51 -0
  57. package/build/i18n/ru.json +51 -0
  58. package/build/i18n/uk.json +51 -0
  59. package/build/i18n.d.ts +2 -0
  60. package/build/i18n.d.ts.map +1 -0
  61. package/build/i18n.js +19 -0
  62. package/build/i18n.js.map +1 -0
  63. package/build/index.d.ts +10 -0
  64. package/build/index.d.ts.map +1 -0
  65. package/build/index.js +9 -0
  66. package/build/index.js.map +1 -0
  67. package/build/models/index.d.ts +7 -0
  68. package/build/models/index.d.ts.map +1 -0
  69. package/build/models/index.js +19 -0
  70. package/build/models/index.js.map +1 -0
  71. package/build/models/project.d.ts +4 -0
  72. package/build/models/project.d.ts.map +1 -0
  73. package/build/models/project.js +17 -0
  74. package/build/models/project.js.map +1 -0
  75. package/build/models/specification.d.ts +4 -0
  76. package/build/models/specification.d.ts.map +1 -0
  77. package/build/models/specification.js +15 -0
  78. package/build/models/specification.js.map +1 -0
  79. package/build/models/workcard.d.ts +14 -0
  80. package/build/models/workcard.d.ts.map +1 -0
  81. package/build/models/workcard.js +103 -0
  82. package/build/models/workcard.js.map +1 -0
  83. package/build/queue/index.d.ts +25 -0
  84. package/build/queue/index.d.ts.map +1 -0
  85. package/build/queue/index.js +14 -0
  86. package/build/queue/index.js.map +1 -0
  87. package/build/registry.d.ts +10 -0
  88. package/build/registry.d.ts.map +1 -0
  89. package/build/registry.js +75 -0
  90. package/build/registry.js.map +1 -0
  91. package/build/schemas.d.ts +56 -0
  92. package/build/schemas.d.ts.map +1 -0
  93. package/build/schemas.js +578 -0
  94. package/build/schemas.js.map +1 -0
  95. package/build/types.d.ts +785 -0
  96. package/build/types.d.ts.map +1 -0
  97. package/build/types.js +2 -0
  98. package/build/types.js.map +1 -0
  99. package/package.json +58 -0
  100. package/src/consts.ts +136 -0
  101. package/src/entrypoints.ts +127 -0
  102. package/src/errors.ts +207 -0
  103. package/src/helpers/apply.ts +183 -0
  104. package/src/helpers/card.ts +42 -0
  105. package/src/helpers/changes.ts +333 -0
  106. package/src/helpers/code.ts +66 -0
  107. package/src/helpers/index.ts +8 -0
  108. package/src/helpers/query.ts +345 -0
  109. package/src/helpers/specification.ts +65 -0
  110. package/src/helpers/status.ts +117 -0
  111. package/src/helpers/validate.ts +107 -0
  112. package/src/i18n/be.json +51 -0
  113. package/src/i18n/de.json +51 -0
  114. package/src/i18n/en.json +51 -0
  115. package/src/i18n/es.json +51 -0
  116. package/src/i18n/pl.json +51 -0
  117. package/src/i18n/ru.json +51 -0
  118. package/src/i18n/uk.json +51 -0
  119. package/src/i18n.ts +21 -0
  120. package/src/index.ts +9 -0
  121. package/src/models/index.ts +21 -0
  122. package/src/models/project.ts +24 -0
  123. package/src/models/specification.ts +21 -0
  124. package/src/models/workcard.ts +138 -0
  125. package/src/queue/index.ts +31 -0
  126. package/src/registry.ts +90 -0
  127. package/src/schemas.ts +654 -0
  128. package/src/types.ts +837 -0
  129. package/tests/apply.spec.ts +176 -0
  130. package/tests/code.spec.ts +40 -0
  131. package/tests/entrypoints.spec.ts +57 -0
  132. package/tests/fixtures.ts +124 -0
  133. package/tests/i18n.spec.ts +30 -0
  134. package/tests/models.spec.ts +133 -0
  135. package/tests/query.spec.ts +90 -0
  136. package/tests/schemas.spec.ts +74 -0
  137. package/tests/status.spec.ts +75 -0
  138. package/tests/tsconfig.json +17 -0
  139. package/tsconfig.json +15 -0
@@ -0,0 +1,183 @@
1
+ import { IntrinsicStatus, TransitionAction } from '../consts.js'
2
+ import { PlanningError } from '../errors.js'
3
+ import type { Relationship, RelationshipDraft, Transition, Workcard } from '../types.js'
4
+
5
+ /** Never written by a transition's `changes`: the fold takes them from the transition itself. */
6
+ const IDENTITY_KEYS = new Set(['id', 'kind', 'type', 'entityId', 'seq', 'head', 'createdAt'])
7
+
8
+ /** Cannot be cleared: a record without them is not a record. */
9
+ const REQUIRED_KEYS = new Set([
10
+ ...IDENTITY_KEYS, 'title', 'parents', 'status', 'intrinsic', 'flows', 'labels', 'fields',
11
+ ])
12
+
13
+ /** Merged shallowly into the card's own map rather than replacing it. */
14
+ const MERGED_KEYS = new Set(['fields', 'flows'])
15
+
16
+ const clean = <T extends object>(record: T): T =>
17
+ Object.fromEntries(Object.entries(record).filter(([, value]) => value !== undefined)) as T
18
+
19
+ const outOfOrder = (card: Workcard | null | undefined, transition: Transition): PlanningError =>
20
+ new PlanningError(`fold:out-of-order:${transition.card}:${card?.seq ?? 0}:${transition.seq}`)
21
+
22
+ const unsetOn = (record: Record<string, unknown>, paths: string[] | undefined): void => {
23
+ for (const path of paths ?? []) {
24
+ const dot = path.indexOf('.')
25
+ if (dot > 0) {
26
+ const head = path.slice(0, dot)
27
+ const map = record[head]
28
+ if (MERGED_KEYS.has(head) && map != null && typeof map === 'object') {
29
+ delete (map as Record<string, unknown>)[path.slice(dot + 1)]
30
+ }
31
+ continue
32
+ }
33
+ if (!REQUIRED_KEYS.has(path)) {
34
+ delete record[path]
35
+ }
36
+ }
37
+ }
38
+
39
+ const writeChanges = (record: Record<string, unknown>, changes: Record<string, unknown>): void => {
40
+ for (const [key, value] of Object.entries(changes)) {
41
+ if (value === undefined || IDENTITY_KEYS.has(key)) {
42
+ continue
43
+ }
44
+ if (MERGED_KEYS.has(key)) {
45
+ const merged = { ...(record[key] as Record<string, unknown> | undefined ?? {}) }
46
+ for (const [entry, entryValue] of Object.entries(value as Record<string, unknown> ?? {})) {
47
+ if (entryValue === null) {
48
+ delete merged[entry]
49
+ } else if (entryValue !== undefined) {
50
+ merged[entry] = entryValue
51
+ }
52
+ }
53
+ record[key] = merged
54
+ continue
55
+ }
56
+ if (value === null) {
57
+ if (!REQUIRED_KEYS.has(key)) {
58
+ delete record[key]
59
+ }
60
+ continue
61
+ }
62
+ record[key] = value
63
+ }
64
+ }
65
+
66
+ /**
67
+ * THE fold: the card after one transition. Pure, total, no I/O — every store (memory, Mongo, a
68
+ * client mirror) folds with this and nothing else, so one log always means one card.
69
+ *
70
+ * - An already-applied transition (`seq <= card.seq`) returns the card unchanged: re-folding is
71
+ * safe.
72
+ * - Anything but the next seq (`card.seq + 1`, or 1 for a create with no card) is refused with
73
+ * `PlanningError('fold:out-of-order')`.
74
+ * - `create` builds the whole record from `changes` plus the transition's identity; `delete`
75
+ * answers `null`; `link`/`unlink` only move `seq`/`head`/`updatedAt` (the edges are
76
+ * {@link applyRelationship}'s); `update`/`transit` write `changes` — `fields` and `flows` merge,
77
+ * everything else replaces — and clear `unset`.
78
+ * - Every result has `seq = transition.seq`, `head = max(head, seq)`, `updatedAt = transition.at`.
79
+ *
80
+ * @throws {PlanningError} `planning:fold:out-of-order:…`
81
+ */
82
+ export const applyTransition = <T extends Workcard = Workcard>(
83
+ card: T | null | undefined, transition: Transition
84
+ ): T | null => {
85
+ if (card != null && transition.seq <= card.seq) {
86
+ return card
87
+ }
88
+ if (card == null) {
89
+ if (transition.action === TransitionAction.Delete) {
90
+ return null
91
+ }
92
+ if (transition.action !== TransitionAction.Create || transition.seq !== 1) {
93
+ throw outOfOrder(card, transition)
94
+ }
95
+ } else if (transition.seq !== card.seq + 1) {
96
+ throw outOfOrder(card, transition)
97
+ }
98
+
99
+ if (transition.action === TransitionAction.Delete) {
100
+ return null
101
+ }
102
+
103
+ const head = Math.max(card?.head ?? card?.seq ?? 0, transition.seq)
104
+ const changes = structuredClone(transition.changes ?? {}) as Record<string, unknown>
105
+
106
+ if (transition.action === TransitionAction.Create) {
107
+ const record: Record<string, unknown> = {
108
+ parents: [], labels: [], fields: {}, flows: {}, status: '', intrinsic: IntrinsicStatus.Planned,
109
+ }
110
+ writeChanges(record, changes)
111
+ unsetOn(record, transition.unset)
112
+
113
+ return clean({
114
+ ...record,
115
+ id: transition.card,
116
+ kind: transition.kind,
117
+ type: transition.type,
118
+ entityId: transition.entityId,
119
+ seq: transition.seq,
120
+ head,
121
+ createdAt: transition.at,
122
+ updatedAt: transition.at,
123
+ }) as unknown as T
124
+ }
125
+
126
+ const record = structuredClone(card) as unknown as Record<string, unknown>
127
+ if (transition.action === TransitionAction.Update || transition.action === TransitionAction.Transit) {
128
+ writeChanges(record, changes)
129
+ unsetOn(record, transition.unset)
130
+ }
131
+ record.seq = transition.seq
132
+ record.head = head
133
+ record.updatedAt = transition.at
134
+
135
+ return clean(record) as unknown as T
136
+ }
137
+
138
+ const edgeOf = (transition: Transition, draft: RelationshipDraft): Relationship => clean({
139
+ entityId: transition.entityId,
140
+ type: draft.type,
141
+ from: draft.from ?? transition.card,
142
+ to: draft.to,
143
+ project: transition.project,
144
+ fields: draft.fields == null ? undefined : structuredClone(draft.fields),
145
+ createdAt: transition.at,
146
+ transition: transition.id,
147
+ })
148
+
149
+ const sameEdge = (left: Pick<Relationship, 'entityId' | 'from' | 'to' | 'type'>, right: Pick<Relationship, 'entityId' | 'from' | 'to' | 'type'>): boolean =>
150
+ left.entityId === right.entityId && left.from === right.from && left.to === right.to && left.type === right.type
151
+
152
+ /**
153
+ * The relationships after one transition. Pure and total, like {@link applyTransition}.
154
+ *
155
+ * `create` adds its `links`, `link` adds its `link`, `unlink` removes the matching edge, and a
156
+ * `delete` removes every edge touching the card. An edge that already exists is kept as it is, so
157
+ * re-folding adds nothing. Every other action answers the list unchanged. Always a new array.
158
+ */
159
+ export const applyRelationship = (links: Relationship[], transition: Transition): Relationship[] => {
160
+ const add = (drafts: RelationshipDraft[]): Relationship[] => drafts.reduce((result, draft) => {
161
+ const edge = edgeOf(transition, draft)
162
+ return result.some(existing => sameEdge(existing, edge)) ? result : [...result, edge]
163
+ }, [...links])
164
+
165
+ switch (transition.action) {
166
+ case TransitionAction.Create:
167
+ return add(transition.links ?? [])
168
+ case TransitionAction.Link:
169
+ return add(transition.link == null ? [] : [transition.link])
170
+ case TransitionAction.Unlink: {
171
+ if (transition.link == null) {
172
+ return [...links]
173
+ }
174
+ const edge = edgeOf(transition, transition.link)
175
+ return links.filter(existing => !sameEdge(existing, edge))
176
+ }
177
+ case TransitionAction.Delete:
178
+ return links.filter(existing => existing.entityId !== transition.entityId
179
+ || (existing.from !== transition.card && existing.to !== transition.card))
180
+ default:
181
+ return [...links]
182
+ }
183
+ }
@@ -0,0 +1,42 @@
1
+ import { WorkcardKind } from '../consts.js'
2
+ import type { Card, Project, Specification, Workcard, WorkcardDraft } from '../types.js'
3
+
4
+ export const isProject = (card?: Workcard | null): card is Project => card?.kind === WorkcardKind.Project
5
+
6
+ export const isCard = (card?: Workcard | null): card is Card => card?.kind === WorkcardKind.Card
7
+
8
+ export const isSpecification = (card?: Workcard | null): card is Specification =>
9
+ card?.kind === WorkcardKind.Specification
10
+
11
+ /** `parents` with `parent` first and no repeats — the invariant every record keeps. */
12
+ export const normalizeParents = (parent?: string | null, parents?: string[] | null): string[] =>
13
+ [...new Set([...(parent != null && parent !== '' ? [parent] : []), ...(parents ?? [])])]
14
+ .filter(id => id !== '')
15
+
16
+ /** A draft with the collections every record carries filled in. */
17
+ export const cardDefaults = (draft: WorkcardDraft): WorkcardDraft & Required<Pick<WorkcardDraft, 'parents' | 'labels' | 'fields'>> => ({
18
+ ...draft,
19
+ parents: normalizeParents(draft.parent, draft.parents),
20
+ labels: [...new Set(draft.labels ?? [])],
21
+ fields: draft.fields ?? {},
22
+ })
23
+
24
+ /** A transition was allocated for the card and has not been folded yet. */
25
+ export const isPending = (card: Pick<Workcard, 'seq' | 'head'>): boolean => (card.head ?? card.seq) > card.seq
26
+
27
+ export const parentOf = (card: Workcard): string | undefined => card.parent ?? card.parents[0]
28
+
29
+ /**
30
+ * The project a card belongs to: a project is its own; a card under a project is that project's;
31
+ * anything deeper is its parent's project, which needs the parent to answer.
32
+ */
33
+ export const projectOf = (card: Workcard, parent?: Workcard | null): string | undefined => {
34
+ if (isProject(card)) {
35
+ return card.id
36
+ }
37
+ if (parent != null) {
38
+ return isProject(parent) ? parent.id : parent.parent
39
+ }
40
+
41
+ return card.parent
42
+ }
@@ -0,0 +1,333 @@
1
+ import { IntrinsicStatus, SpecificationFormat, TransitionAction, WorkcardKind } from '../consts.js'
2
+ import { IllegalTransition, PlanningError } from '../errors.js'
3
+ import type {
4
+ AnyTypeSchema, PlanningSchemaRegistry, Specification, SpecificationSlot, TransitionExecution,
5
+ Workcard, WorkcardChanges, WorkcardDraft,
6
+ } from '../types.js'
7
+ import { normalizeParents } from './card.js'
8
+ import { bodyCharsOf, nextRevision } from './specification.js'
9
+ import { initialFlowsOf, initialStatusOf, primaryFlowOf, resolveIntrinsic, ruleOf } from './status.js'
10
+
11
+ type FlowLookup = Pick<PlanningSchemaRegistry, 'flow'>
12
+
13
+ export interface ChangeSet {
14
+ changes: WorkcardChanges
15
+ unset: string[]
16
+ /** Transit only: the flow that moved, and the move. */
17
+ flow?: string
18
+ from?: string
19
+ to?: string
20
+ }
21
+
22
+ /** Never in a caller's changes: the transition itself carries them. */
23
+ const IDENTITY_KEYS = ['id', 'kind', 'type', 'entityId', 'seq', 'head', 'createdAt']
24
+ /** Move only through `transit` (or a create's draft). */
25
+ const FLOW_KEYS = ['status', 'intrinsic', 'flows', 'closedAt']
26
+ /** Computed by the executor, never supplied. */
27
+ const DERIVED_KEYS = ['revision', 'bodyChars']
28
+ /** Cannot be cleared. */
29
+ const REQUIRED_KEYS = ['title', 'parents', 'labels', 'fields', 'category', 'format']
30
+ /** Changing one of these produces a new revision of a revisioned document. */
31
+ const CONTENT_KEYS = ['body', 'ref', 'format', 'version']
32
+
33
+ const isPlainObject = (value: unknown): value is Record<string, unknown> =>
34
+ value != null && typeof value === 'object' && !Array.isArray(value)
35
+
36
+ /** Structural equality over JSON values. */
37
+ export const sameValue = (left: unknown, right: unknown): boolean => {
38
+ if (left === right) {
39
+ return true
40
+ }
41
+ if (Array.isArray(left) && Array.isArray(right)) {
42
+ return left.length === right.length && left.every((entry, index) => sameValue(entry, right[index]))
43
+ }
44
+ if (isPlainObject(left) && isPlainObject(right)) {
45
+ const keys = Object.keys(left).filter(key => left[key] !== undefined)
46
+ const other = Object.keys(right).filter(key => right[key] !== undefined)
47
+ return keys.length === other.length && keys.every(key => sameValue(left[key], right[key]))
48
+ }
49
+
50
+ return false
51
+ }
52
+
53
+ const clean = <T extends object>(record: T): T =>
54
+ Object.fromEntries(Object.entries(record).filter(([, value]) => value !== undefined)) as T
55
+
56
+ /** A shallow merge that drops keys patched to `null`. */
57
+ export const mergeFields = (
58
+ base?: Record<string, unknown> | null, patch?: Record<string, unknown> | null
59
+ ): Record<string, unknown> => Object.fromEntries(Object.entries({ ...(base ?? {}), ...(patch ?? {}) })
60
+ .filter(([, value]) => value !== null && value !== undefined))
61
+
62
+ /** A copy of the record with the paths (`key`, `fields.key`, `flows.id`) removed. */
63
+ export const applyUnset = <T extends object>(record: T, unset?: string[]): T => {
64
+ const copy = structuredClone(record) as Record<string, unknown>
65
+ for (const path of unset ?? []) {
66
+ const [head, ...rest] = path.split('.')
67
+ if (rest.length > 0) {
68
+ const map = copy[head]
69
+ if (isPlainObject(map)) {
70
+ delete map[rest.join('.')]
71
+ }
72
+ } else {
73
+ delete copy[head]
74
+ }
75
+ }
76
+
77
+ return copy as T
78
+ }
79
+
80
+ /**
81
+ * Refuse an execution that writes what only the transition, the flow or the executor may write.
82
+ *
83
+ * `status`/`intrinsic`/`flows`/`closedAt` move only through `transit` (a create's draft may name
84
+ * the initial status); `code` is fixed once minted unless the type's policy says `mutable`;
85
+ * `category` is fixed; `revision`/`bodyChars` are computed.
86
+ *
87
+ * @throws {PlanningError} `planning:immutable:<field>`
88
+ */
89
+ export const assertMutable = (exec: TransitionExecution, type: AnyTypeSchema): void => {
90
+ const create = exec.action === TransitionAction.Create
91
+ const codeFixed = type.code != null && type.code.mutable !== true
92
+ const refuse = (key: string): never => {
93
+ throw new PlanningError(`immutable:${FLOW_KEYS.includes(key) ? 'status' : key}`)
94
+ }
95
+
96
+ for (const [key, value] of Object.entries(exec.changes ?? {})) {
97
+ if (value === undefined) {
98
+ continue
99
+ }
100
+ if (IDENTITY_KEYS.includes(key) || DERIVED_KEYS.includes(key)) {
101
+ refuse(key)
102
+ }
103
+ if (!create && (FLOW_KEYS.includes(key) || key === 'category' || (key === 'code' && codeFixed))) {
104
+ refuse(key)
105
+ }
106
+ }
107
+
108
+ for (const path of exec.unset ?? []) {
109
+ const head = path.split('.')[0]
110
+ if (IDENTITY_KEYS.includes(path) || DERIVED_KEYS.includes(path) || REQUIRED_KEYS.includes(path)
111
+ || FLOW_KEYS.includes(head) || (path === 'code' && codeFixed)) {
112
+ refuse(path)
113
+ }
114
+ }
115
+ }
116
+
117
+ const reach = (record: Record<string, unknown>, path: string): unknown => path.split('.')
118
+ .reduce<unknown>((value, step) => isPlainObject(value) ? value[step] : undefined, record)
119
+
120
+ /** Only what differs from the card; `null` becomes an unset of a field that has a value. */
121
+ const diffInto = (card: Workcard, changes: WorkcardChanges | undefined, skip: string[], set: ChangeSet): void => {
122
+ const out = set.changes as Record<string, unknown>
123
+ const current = card as unknown as Record<string, unknown>
124
+
125
+ for (const [key, value] of Object.entries(changes ?? {})) {
126
+ if (value === undefined || IDENTITY_KEYS.includes(key) || skip.includes(key)) {
127
+ continue
128
+ }
129
+ if (key === 'fields' || key === 'flows') {
130
+ const own = (current[key] ?? {}) as Record<string, unknown>
131
+ const patch: Record<string, unknown> = {}
132
+ for (const [entry, entryValue] of Object.entries(value as Record<string, unknown> ?? {})) {
133
+ if (entryValue === undefined) {
134
+ continue
135
+ }
136
+ if (entryValue === null) {
137
+ if (own[entry] !== undefined) {
138
+ set.unset.push(`${key}.${entry}`)
139
+ }
140
+ } else if (!sameValue(own[entry], entryValue)) {
141
+ patch[entry] = entryValue
142
+ }
143
+ }
144
+ if (Object.keys(patch).length > 0) {
145
+ out[key] = patch
146
+ }
147
+ continue
148
+ }
149
+ if (value === null) {
150
+ if (current[key] != null) {
151
+ set.unset.push(key)
152
+ }
153
+ continue
154
+ }
155
+ if (!sameValue(current[key], value)) {
156
+ out[key] = value
157
+ }
158
+ }
159
+ }
160
+
161
+ const unsetInto = (card: Workcard, unset: string[] | undefined, set: ChangeSet): void => {
162
+ for (const path of unset ?? []) {
163
+ if (reach(card as unknown as Record<string, unknown>, path) !== undefined && !set.unset.includes(path)) {
164
+ set.unset.push(path)
165
+ }
166
+ }
167
+ }
168
+
169
+ /** A moved primary parent replaces the old one in `parents`. */
170
+ const reparent = (card: Workcard, set: ChangeSet): void => {
171
+ const out = set.changes
172
+ const moved = out.parent !== undefined || set.unset.includes('parent')
173
+ if (!moved || out.parents !== undefined) {
174
+ return
175
+ }
176
+ const parents = normalizeParents(out.parent, card.parents.filter(parent => parent !== card.parent))
177
+ if (!sameValue(parents, card.parents)) {
178
+ out.parents = parents
179
+ }
180
+ }
181
+
182
+ const reviseInto = (card: Workcard, slot: SpecificationSlot | null | undefined, set: ChangeSet): void => {
183
+ if (card.kind !== WorkcardKind.Specification) {
184
+ return
185
+ }
186
+ const out = set.changes
187
+ if (out.body !== undefined) {
188
+ out.bodyChars = bodyCharsOf(out.body)
189
+ } else if (set.unset.includes('body') && !set.unset.includes('bodyChars')) {
190
+ set.unset.push('bodyChars')
191
+ }
192
+ const revised = CONTENT_KEYS.some(key => (out as Record<string, unknown>)[key] !== undefined || set.unset.includes(key))
193
+ if (revised) {
194
+ const revision = nextRevision(card as Specification, slot)
195
+ if (revision != null) {
196
+ out.revision = revision
197
+ }
198
+ }
199
+ }
200
+
201
+ const createOf = (
202
+ exec: TransitionExecution, type: AnyTypeSchema, registry: FlowLookup, at: string, slot?: SpecificationSlot | null
203
+ ): ChangeSet => {
204
+ if (typeof exec.card === 'string') {
205
+ throw new PlanningError('malformed:create-without-draft')
206
+ }
207
+ const draft: WorkcardDraft = exec.card
208
+ const overlay = Object.fromEntries(Object.entries(exec.changes ?? {})
209
+ .filter(([key]) => !FLOW_KEYS.includes(key) && !IDENTITY_KEYS.includes(key) && !DERIVED_KEYS.includes(key))
210
+ ) as WorkcardChanges
211
+
212
+ const parent = overlay.parent ?? draft.parent
213
+ const flows = initialFlowsOf(type, registry, draft.status ?? exec.changes?.status)
214
+ const intrinsic = resolveIntrinsic(type, flows, registry)
215
+
216
+ const record: Record<string, unknown> = {
217
+ title: draft.title,
218
+ description: draft.description,
219
+ code: draft.code,
220
+ order: draft.order,
221
+ createdBy: draft.createdBy,
222
+ ...overlay,
223
+ labels: [...new Set(overlay.labels ?? draft.labels ?? [])],
224
+ parent,
225
+ parents: normalizeParents(parent, overlay.parents ?? draft.parents),
226
+ fields: mergeFields(draft.fields, overlay.fields),
227
+ flows,
228
+ status: flows[primaryFlowOf(type)],
229
+ intrinsic,
230
+ closedAt: intrinsic === IntrinsicStatus.Closed ? at : undefined,
231
+ }
232
+
233
+ if (draft.kind === WorkcardKind.Specification) {
234
+ const body = overlay.body ?? draft.body
235
+ Object.assign(record, {
236
+ category: overlay.category ?? draft.category ?? slot?.category,
237
+ format: overlay.format ?? draft.format ?? slot?.format ?? SpecificationFormat.Markdown,
238
+ body,
239
+ ref: overlay.ref ?? draft.ref,
240
+ version: overlay.version ?? draft.version ?? slot?.version,
241
+ bodyChars: bodyCharsOf(body),
242
+ revision: nextRevision(null, slot),
243
+ })
244
+ }
245
+
246
+ return { changes: clean(record) as WorkcardChanges, unset: [] }
247
+ }
248
+
249
+ /**
250
+ * The `changes`/`unset` a transition records (plan step 9).
251
+ *
252
+ * - `create`: the whole initial record from the draft (overlaid by `exec.changes`) — `parents`
253
+ * normalized, every flow at its initial status (the draft's `status` for the primary one),
254
+ * `status`/`intrinsic` mirrored, `closedAt` when it starts closed; a specification gets its
255
+ * format, `bodyChars` and revision 1 when the slot is revisioned.
256
+ * - `update`: only what differs; `null` or `exec.unset` clears a field that has a value; a moved
257
+ * `parent` replaces the old one in `parents`; a revisioned document whose content changed gets
258
+ * `revision + 1`. An update that changes nothing is empty ({@link isEmptyChange}).
259
+ * - `transit`: `flows[flow] = rule.to`, `status` when the flow is primary, `intrinsic` when it
260
+ * moves, `closedAt` set on entering closed and cleared on leaving it — plus the caller's own
261
+ * changes, diffed as in an update.
262
+ * - `link`, `unlink`, `delete`: nothing.
263
+ *
264
+ * @throws {IllegalTransition} a transit the flow does not offer from the current status
265
+ * @throws {PlanningError} `malformed:*` for an execution missing what its action needs
266
+ */
267
+ export const computeChanges = (
268
+ card: Workcard | null | undefined,
269
+ exec: TransitionExecution,
270
+ type: AnyTypeSchema,
271
+ registry: FlowLookup,
272
+ at: string,
273
+ opts?: { slot?: SpecificationSlot | null }
274
+ ): ChangeSet => {
275
+ if (exec.action === TransitionAction.Create) {
276
+ return createOf(exec, type, registry, at, opts?.slot)
277
+ }
278
+ const set: ChangeSet = { changes: {}, unset: [] }
279
+ if (exec.action !== TransitionAction.Update && exec.action !== TransitionAction.Transit) {
280
+ return set
281
+ }
282
+ if (card == null) {
283
+ throw new PlanningError(`malformed:${exec.action}-without-card`)
284
+ }
285
+
286
+ if (exec.action === TransitionAction.Update) {
287
+ diffInto(card, exec.changes, [], set)
288
+ unsetInto(card, exec.unset, set)
289
+ reparent(card, set)
290
+ reviseInto(card, opts?.slot, set)
291
+
292
+ return set
293
+ }
294
+
295
+ if (exec.transition == null) {
296
+ throw new PlanningError('malformed:transit-without-transition')
297
+ }
298
+ const primary = primaryFlowOf(type)
299
+ const flowId = exec.flow ?? primary
300
+ const flow = registry.flow(flowId)
301
+ const from = card.flows[flowId] ?? (flowId === primary ? card.status : initialStatusOf(flow))
302
+ const rule = ruleOf(flow, exec.transition, from)
303
+ if (rule == null) {
304
+ throw new IllegalTransition(`${flowId}:${exec.transition}:${from}`)
305
+ }
306
+
307
+ diffInto(card, exec.changes, FLOW_KEYS, set)
308
+ unsetInto(card, exec.unset?.filter(path => !FLOW_KEYS.includes(path.split('.')[0])), set)
309
+ reparent(card, set)
310
+ reviseInto(card, opts?.slot, set)
311
+
312
+ const flows = { ...card.flows, [flowId]: rule.to }
313
+ set.changes.flows = { [flowId]: rule.to }
314
+ if (flowId === primary) {
315
+ set.changes.status = rule.to
316
+ }
317
+ const intrinsic = resolveIntrinsic(type, flows, registry)
318
+ if (intrinsic !== card.intrinsic) {
319
+ set.changes.intrinsic = intrinsic
320
+ }
321
+ if (intrinsic === IntrinsicStatus.Closed && card.intrinsic !== IntrinsicStatus.Closed) {
322
+ set.changes.closedAt = at
323
+ } else if (intrinsic !== IntrinsicStatus.Closed && card.closedAt != null) {
324
+ set.unset.push('closedAt')
325
+ }
326
+
327
+ return { ...set, flow: flowId, from, to: rule.to }
328
+ }
329
+
330
+ /** Nothing to write: no changed value and nothing to clear. */
331
+ export const isEmptyChange = (set: Pick<ChangeSet, 'changes' | 'unset'>): boolean =>
332
+ set.unset.length === 0 && Object.entries(set.changes).every(([, value]) =>
333
+ value === undefined || (isPlainObject(value) && Object.keys(value).length === 0))
@@ -0,0 +1,66 @@
1
+ import { createIdOfLength, generateWordSlug, IdStyle, nextSlugCandidate } from '@owlmeans/basic-ids'
2
+ import { CODE_MAX, CODE_MINT_ATTEMPTS, CodeScope, CodeStyle, DEFAULT_CODE_LENGTH } from '../consts.js'
3
+ import { CodeTaken } from '../errors.js'
4
+ import type { CodePolicy, WorkcardDraft } from '../types.js'
5
+
6
+ export interface MintCodeOptions {
7
+ /** Slug style: the text the slug is derived from (a title). A word slug when omitted. */
8
+ seed?: string
9
+ /** Sequential style: how many codes the scope already holds. */
10
+ count?: number
11
+ }
12
+
13
+ /** `Some Title!` → `some-title`. */
14
+ export const slugOf = (text: string, max: number = CODE_MAX): string => text
15
+ .normalize('NFKD').replace(/[̀-ͯ]/g, '')
16
+ .toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '')
17
+ .slice(0, max).replace(/-+$/g, '')
18
+
19
+ const candidateOf = (policy: CodePolicy, attempt: number, base: string, opts?: MintCodeOptions): string => {
20
+ const prefix = policy.prefix ?? ''
21
+ switch (policy.style) {
22
+ case CodeStyle.Slug:
23
+ return `${prefix}${nextSlugCandidate(base, attempt + 1)}`
24
+ case CodeStyle.Sequential: {
25
+ const number = `${(opts?.count ?? 0) + attempt + 1}`
26
+ return `${prefix}${policy.length != null ? number.padStart(policy.length, '0') : number}`
27
+ }
28
+ case CodeStyle.Random:
29
+ default: {
30
+ const random = createIdOfLength(policy.length ?? DEFAULT_CODE_LENGTH, IdStyle.Base58)
31
+ return `${prefix}${policy.uppercase === true ? random.toUpperCase() : random}`
32
+ }
33
+ }
34
+ }
35
+
36
+ /**
37
+ * Mint a code under a policy, asking `taken` for each candidate.
38
+ *
39
+ * `random` draws `prefix + Base58(length)` (upper-cased when asked) afresh per attempt; `slug`
40
+ * walks `base`, `base-2`, `base-3` from the seed (a word slug without one); `sequential` counts on
41
+ * from `opts.count`, zero-padded to `length`. After `attempts` taken candidates it refuses.
42
+ *
43
+ * @throws {CodeTaken}
44
+ */
45
+ export const mintCode = async (
46
+ policy: CodePolicy,
47
+ taken: (code: string) => boolean | Promise<boolean>,
48
+ attempts: number = CODE_MINT_ATTEMPTS,
49
+ opts?: MintCodeOptions
50
+ ): Promise<string> => {
51
+ const seeded = opts?.seed != null ? slugOf(opts.seed, CODE_MAX - (policy.prefix?.length ?? 0) - 4) : ''
52
+ const base = policy.style === CodeStyle.Slug ? (seeded !== '' ? seeded : generateWordSlug()) : ''
53
+
54
+ for (let attempt = 0; attempt < attempts; ++attempt) {
55
+ const candidate = candidateOf(policy, attempt, base, opts)
56
+ if (!await taken(candidate)) {
57
+ return candidate
58
+ }
59
+ }
60
+
61
+ throw new CodeTaken(`${policy.style}:${policy.prefix ?? ''}:${attempts}`)
62
+ }
63
+
64
+ /** Where a draft's code must be unique: its parent, or the whole entity. */
65
+ export const codeScopeOf = (policy: CodePolicy, draft: Pick<WorkcardDraft, 'parent'>): { parent?: string } =>
66
+ policy.uniqueWithin === CodeScope.Parent ? { parent: draft.parent } : {}
@@ -0,0 +1,8 @@
1
+ export * from './apply.js'
2
+ export * from './card.js'
3
+ export * from './changes.js'
4
+ export * from './code.js'
5
+ export * from './query.js'
6
+ export * from './specification.js'
7
+ export * from './status.js'
8
+ export * from './validate.js'