@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.
- package/README.md +169 -0
- package/agent-meta/manifest.json +16 -0
- package/agent-meta/skills/planning/SKILL.md +248 -0
- package/build/consts.d.ts +126 -0
- package/build/consts.d.ts.map +1 -0
- package/build/consts.js +129 -0
- package/build/consts.js.map +1 -0
- package/build/entrypoints.d.ts +14 -0
- package/build/entrypoints.d.ts.map +1 -0
- package/build/entrypoints.js +69 -0
- package/build/entrypoints.js.map +1 -0
- package/build/errors.d.ts +94 -0
- package/build/errors.d.ts.map +1 -0
- package/build/errors.js +169 -0
- package/build/errors.js.map +1 -0
- package/build/helpers/apply.d.ts +27 -0
- package/build/helpers/apply.d.ts.map +1 -0
- package/build/helpers/apply.js +162 -0
- package/build/helpers/apply.js.map +1 -0
- package/build/helpers/card.d.ts +17 -0
- package/build/helpers/card.d.ts.map +1 -0
- package/build/helpers/card.js +31 -0
- package/build/helpers/card.js.map +1 -0
- package/build/helpers/changes.d.ts +51 -0
- package/build/helpers/changes.d.ts.map +1 -0
- package/build/helpers/changes.js +277 -0
- package/build/helpers/changes.js.map +1 -0
- package/build/helpers/code.d.ts +24 -0
- package/build/helpers/code.d.ts.map +1 -0
- package/build/helpers/code.js +47 -0
- package/build/helpers/code.js.map +1 -0
- package/build/helpers/index.d.ts +9 -0
- package/build/helpers/index.d.ts.map +1 -0
- package/build/helpers/index.js +9 -0
- package/build/helpers/index.js.map +1 -0
- package/build/helpers/query.d.ts +39 -0
- package/build/helpers/query.d.ts.map +1 -0
- package/build/helpers/query.js +293 -0
- package/build/helpers/query.js.map +1 -0
- package/build/helpers/specification.d.ts +21 -0
- package/build/helpers/specification.d.ts.map +1 -0
- package/build/helpers/specification.js +48 -0
- package/build/helpers/specification.js.map +1 -0
- package/build/helpers/status.d.ts +44 -0
- package/build/helpers/status.d.ts.map +1 -0
- package/build/helpers/status.js +78 -0
- package/build/helpers/status.js.map +1 -0
- package/build/helpers/validate.d.ts +30 -0
- package/build/helpers/validate.d.ts.map +1 -0
- package/build/helpers/validate.js +93 -0
- package/build/helpers/validate.js.map +1 -0
- package/build/i18n/be.json +51 -0
- package/build/i18n/de.json +51 -0
- package/build/i18n/en.json +51 -0
- package/build/i18n/es.json +51 -0
- package/build/i18n/pl.json +51 -0
- package/build/i18n/ru.json +51 -0
- package/build/i18n/uk.json +51 -0
- package/build/i18n.d.ts +2 -0
- package/build/i18n.d.ts.map +1 -0
- package/build/i18n.js +19 -0
- package/build/i18n.js.map +1 -0
- package/build/index.d.ts +10 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +9 -0
- package/build/index.js.map +1 -0
- package/build/models/index.d.ts +7 -0
- package/build/models/index.d.ts.map +1 -0
- package/build/models/index.js +19 -0
- package/build/models/index.js.map +1 -0
- package/build/models/project.d.ts +4 -0
- package/build/models/project.d.ts.map +1 -0
- package/build/models/project.js +17 -0
- package/build/models/project.js.map +1 -0
- package/build/models/specification.d.ts +4 -0
- package/build/models/specification.d.ts.map +1 -0
- package/build/models/specification.js +15 -0
- package/build/models/specification.js.map +1 -0
- package/build/models/workcard.d.ts +14 -0
- package/build/models/workcard.d.ts.map +1 -0
- package/build/models/workcard.js +103 -0
- package/build/models/workcard.js.map +1 -0
- package/build/queue/index.d.ts +25 -0
- package/build/queue/index.d.ts.map +1 -0
- package/build/queue/index.js +14 -0
- package/build/queue/index.js.map +1 -0
- package/build/registry.d.ts +10 -0
- package/build/registry.d.ts.map +1 -0
- package/build/registry.js +75 -0
- package/build/registry.js.map +1 -0
- package/build/schemas.d.ts +56 -0
- package/build/schemas.d.ts.map +1 -0
- package/build/schemas.js +578 -0
- package/build/schemas.js.map +1 -0
- package/build/types.d.ts +785 -0
- package/build/types.d.ts.map +1 -0
- package/build/types.js +2 -0
- package/build/types.js.map +1 -0
- package/package.json +58 -0
- package/src/consts.ts +136 -0
- package/src/entrypoints.ts +127 -0
- package/src/errors.ts +207 -0
- package/src/helpers/apply.ts +183 -0
- package/src/helpers/card.ts +42 -0
- package/src/helpers/changes.ts +333 -0
- package/src/helpers/code.ts +66 -0
- package/src/helpers/index.ts +8 -0
- package/src/helpers/query.ts +345 -0
- package/src/helpers/specification.ts +65 -0
- package/src/helpers/status.ts +117 -0
- package/src/helpers/validate.ts +107 -0
- package/src/i18n/be.json +51 -0
- package/src/i18n/de.json +51 -0
- package/src/i18n/en.json +51 -0
- package/src/i18n/es.json +51 -0
- package/src/i18n/pl.json +51 -0
- package/src/i18n/ru.json +51 -0
- package/src/i18n/uk.json +51 -0
- package/src/i18n.ts +21 -0
- package/src/index.ts +9 -0
- package/src/models/index.ts +21 -0
- package/src/models/project.ts +24 -0
- package/src/models/specification.ts +21 -0
- package/src/models/workcard.ts +138 -0
- package/src/queue/index.ts +31 -0
- package/src/registry.ts +90 -0
- package/src/schemas.ts +654 -0
- package/src/types.ts +837 -0
- package/tests/apply.spec.ts +176 -0
- package/tests/code.spec.ts +40 -0
- package/tests/entrypoints.spec.ts +57 -0
- package/tests/fixtures.ts +124 -0
- package/tests/i18n.spec.ts +30 -0
- package/tests/models.spec.ts +133 -0
- package/tests/query.spec.ts +90 -0
- package/tests/schemas.spec.ts +74 -0
- package/tests/status.spec.ts +75 -0
- package/tests/tsconfig.json +17 -0
- 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 } : {}
|