@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,345 @@
1
+ import type { Criteria, ListOptions, ResourceRecord, Sort } from '@owlmeans/resource'
2
+ import { IntrinsicStatus, WorkcardKind } from '../consts.js'
3
+ import { PlanningError } from '../errors.js'
4
+ import type {
5
+ IntrinsicCounts, ListWire, PlanningScope, RelationshipQuery, RelationshipQueryWire, RelationshipWhere,
6
+ Specification, SpecificationQuery, SpecificationQueryWire, SummaryQuery, SummaryQueryWire, SummaryView,
7
+ TransitionQuery, TransitionQueryWire, TransitionWhere, Workcard, WorkcardQuery, WorkcardQueryWire,
8
+ } from '../types.js'
9
+
10
+ type Scope = Pick<PlanningScope, 'entityId'>
11
+
12
+ const clean = <T extends object>(record: T): T =>
13
+ Object.fromEntries(Object.entries(record).filter(([, value]) => value !== undefined)) as T
14
+
15
+ /** `%`, `_` and `\` are wildcards/escapes to `$ilike`; a search text means them literally. */
16
+ const escapeLike = (text: string): string => text.replace(/[\\%_]/g, char => `\\${char}`)
17
+
18
+ /**
19
+ * `WorkcardQuery` → `Criteria<Workcard>` — the ONE translation every store answers, so a query
20
+ * means the same thing in memory, in Mongo and in a client mirror.
21
+ *
22
+ * `entityId` always comes from the scope (a query that forgot it would be a cross-tenant read);
23
+ * `undefined` values are omitted. Paging and sorting are {@link listOptionsOf}'s.
24
+ */
25
+ export const criteriaOf = (query?: WorkcardQuery | null, scope?: Scope): Criteria<Workcard> => {
26
+ const where: Record<string, unknown> = {
27
+ entityId: scope?.entityId,
28
+ kind: query?.kind,
29
+ type: query?.type,
30
+ status: query?.status,
31
+ intrinsic: query?.intrinsic,
32
+ code: query?.code,
33
+ parent: query?.parent,
34
+ parents: query?.within != null ? { $contains: query.within } : undefined,
35
+ labels: query?.labels != null ? { $overlaps: query.labels } : undefined,
36
+ id: query?.ids != null ? { $in: query.ids } : undefined,
37
+ category: query?.category,
38
+ updatedAt: query?.updatedSince != null ? { $gte: query.updatedSince } : undefined,
39
+ }
40
+ if (query?.flow != null) {
41
+ where[`flows.${query.flow.id}`] = query.flow.status
42
+ }
43
+ for (const [key, value] of Object.entries(query?.fields ?? {})) {
44
+ if (value !== undefined) {
45
+ where[`fields.${key}`] = value
46
+ }
47
+ }
48
+ if (query?.q != null && query.q !== '') {
49
+ const like = `%${escapeLike(query.q)}%`
50
+ where.$or = [
51
+ { title: { $ilike: like } },
52
+ { description: { $ilike: like } },
53
+ { code: { $startsWith: query.q } },
54
+ ]
55
+ }
56
+
57
+ return clean(where) as Criteria<Workcard>
58
+ }
59
+
60
+ /** Paging and sorting out of a query. `size: 0` (no limit) survives. */
61
+ export const listOptionsOf = <T extends ResourceRecord>(query?: ListOptions<T> | null): ListOptions<T> => clean({
62
+ page: query?.page,
63
+ size: query?.size,
64
+ sort: query?.sort,
65
+ })
66
+
67
+ /** The criteria over a parent's specifications. */
68
+ export const specCriteriaOf = (
69
+ parent: string, query?: SpecificationQuery | null, scope?: Scope
70
+ ): Criteria<Specification> => clean({
71
+ entityId: scope?.entityId,
72
+ kind: WorkcardKind.Specification,
73
+ parent,
74
+ category: query?.category,
75
+ }) as Criteria<Specification>
76
+
77
+ export const linkWhereOf = (query: RelationshipQuery | null | undefined, scope: Scope): RelationshipWhere => clean({
78
+ entityId: scope.entityId,
79
+ from: query?.from,
80
+ to: query?.to,
81
+ type: query?.type,
82
+ })
83
+
84
+ export const transitionWhereOf = (query: TransitionQuery | null | undefined, scope: Scope): TransitionWhere => clean({
85
+ entityId: scope.entityId,
86
+ card: query?.card,
87
+ project: query?.project,
88
+ sinceSeq: query?.sinceSeq,
89
+ action: query?.action,
90
+ state: query?.state,
91
+ })
92
+
93
+ const emptyCounts = (): IntrinsicCounts => ({
94
+ total: 0, [IntrinsicStatus.Planned]: 0, [IntrinsicStatus.InProgress]: 0, [IntrinsicStatus.Closed]: 0,
95
+ })
96
+
97
+ /** Counts of DIRECT children (`card.parent`) per parent, by intrinsic state. No key for a parent with none. */
98
+ export const summaryOf = (cards: Workcard[], parents: string[]): SummaryView => cards.reduce<SummaryView>(
99
+ (view, card) => {
100
+ if (card.parent == null || !parents.includes(card.parent)) {
101
+ return view
102
+ }
103
+ const counts = view[card.parent] ?? emptyCounts()
104
+ counts.total += 1
105
+ counts[card.intrinsic] = (counts[card.intrinsic] ?? 0) + 1
106
+ view[card.parent] = counts
107
+ return view
108
+ }, {}
109
+ )
110
+
111
+ // ─── Wire encoding ───────────────────────────────────────────────────────────────────────────────
112
+
113
+ /** A list as one query value: comma-joined, or JSON when an element itself holds a comma. */
114
+ export const encodeList = (value?: string | readonly string[] | null): string | undefined => {
115
+ if (value == null) {
116
+ return undefined
117
+ }
118
+ if (typeof value === 'string') {
119
+ return encodeList([value])
120
+ }
121
+ if (value.length === 0) {
122
+ return undefined
123
+ }
124
+
125
+ return value.some(entry => entry.includes(',') || entry.startsWith('['))
126
+ ? JSON.stringify(value)
127
+ : value.join(',')
128
+ }
129
+
130
+ /** Every entry of a list query value, whatever shape the transport left it in. */
131
+ export const decodeList = (value: unknown, key: string = 'list'): string[] | undefined => {
132
+ if (value == null || value === '') {
133
+ return undefined
134
+ }
135
+ if (Array.isArray(value)) {
136
+ const entries = value.filter(entry => entry != null && entry !== '').map(entry => `${entry}`)
137
+ return entries.length > 0 ? entries : undefined
138
+ }
139
+ if (typeof value !== 'string') {
140
+ return [`${value}`]
141
+ }
142
+ if (value.startsWith('[')) {
143
+ const parsed: unknown = (() => {
144
+ try {
145
+ return JSON.parse(value)
146
+ } catch {
147
+ throw new PlanningError(`malformed:query:${key}`)
148
+ }
149
+ })()
150
+ return Array.isArray(parsed) ? parsed.map(entry => `${entry}`) : undefined
151
+ }
152
+ const entries = value.split(',').map(entry => entry.trim()).filter(entry => entry !== '')
153
+
154
+ return entries.length > 0 ? entries : undefined
155
+ }
156
+
157
+ /** A list decoded to a bare value when it holds one — the same criteria either way. */
158
+ const decodeOneOrMany = <T extends string>(value: unknown, key: string): T | T[] | undefined => {
159
+ const entries = decodeList(value, key) as T[] | undefined
160
+ return entries == null ? undefined : entries.length === 1 ? entries[0] : entries
161
+ }
162
+
163
+ const encodeJson = (value: unknown): string | undefined => value == null ? undefined : JSON.stringify(value)
164
+
165
+ const decodeJson = <T>(value: unknown, key: string): T | undefined => {
166
+ if (value == null || value === '') {
167
+ return undefined
168
+ }
169
+ if (typeof value !== 'string') {
170
+ return value as T
171
+ }
172
+ try {
173
+ return JSON.parse(value) as T
174
+ } catch {
175
+ throw new PlanningError(`malformed:query:${key}`)
176
+ }
177
+ }
178
+
179
+ const decodeNumber = (value: unknown, key: string): number | undefined => {
180
+ if (value == null || value === '') {
181
+ return undefined
182
+ }
183
+ const number = typeof value === 'number' ? value : Number(value)
184
+ if (Number.isNaN(number)) {
185
+ throw new PlanningError(`malformed:query:${key}`)
186
+ }
187
+
188
+ return number
189
+ }
190
+
191
+ const decodeBoolean = (value: unknown): boolean | undefined =>
192
+ value == null || value === '' ? undefined : value === true || value === 'true' || value === '1'
193
+
194
+ const decodeText = (value: unknown): string | undefined =>
195
+ value == null || value === '' ? undefined : `${Array.isArray(value) ? value[0] : value}`
196
+
197
+ /** `[{ field: 'order' }, { field: 'updatedAt', order: 'desc' }]` ⇄ `order,-updatedAt`. */
198
+ export const encodeSort = <T>(sort?: Sort<T>[] | null): string | undefined =>
199
+ sort == null || sort.length === 0 ? undefined : sort.map(entry => typeof entry === 'string'
200
+ ? entry
201
+ : `${entry.order === 'desc' ? '-' : ''}${entry.field}`).join(',')
202
+
203
+ export const decodeSort = <T>(value: unknown): Sort<T>[] | undefined => {
204
+ if (value == null || value === '') {
205
+ return undefined
206
+ }
207
+ const entries: unknown[] = Array.isArray(value) ? value : typeof value === 'string' ? value.split(',') : [value]
208
+ const sort = entries.flatMap((entry): Sort<T>[] => {
209
+ if (typeof entry !== 'string') {
210
+ return entry != null && typeof entry === 'object' && 'field' in entry ? [entry as Sort<T>] : []
211
+ }
212
+ const field = entry.trim()
213
+ if (field === '' || field === '-') {
214
+ return []
215
+ }
216
+ return field.startsWith('-')
217
+ ? [{ field: field.slice(1) as Sort<T> & string, order: 'desc' } as Sort<T>]
218
+ : [field as Sort<T>]
219
+ })
220
+
221
+ return sort.length > 0 ? sort : undefined
222
+ }
223
+
224
+ const encodeListOptions = (query?: ListOptions<any> | null): ListWire => clean({
225
+ page: query?.page,
226
+ size: query?.size,
227
+ sort: encodeSort(query?.sort),
228
+ })
229
+
230
+ const decodeListOptions = <T>(wire: Record<string, unknown>): ListOptions<T> => clean({
231
+ page: decodeNumber(wire.page, 'page'),
232
+ size: decodeNumber(wire.size, 'size'),
233
+ sort: decodeSort<T>(wire.sort),
234
+ })
235
+
236
+ const asRecord = (wire: unknown): Record<string, unknown> =>
237
+ wire != null && typeof wire === 'object' ? wire as Record<string, unknown> : {}
238
+
239
+ export const encodeWorkcardQuery = (query?: WorkcardQuery | null): WorkcardQueryWire => clean({
240
+ ...encodeListOptions(query),
241
+ kind: encodeList(query?.kind),
242
+ type: encodeList(query?.type),
243
+ parent: query?.parent,
244
+ within: query?.within,
245
+ status: encodeList(query?.status),
246
+ intrinsic: encodeList(query?.intrinsic),
247
+ flow: encodeJson(query?.flow),
248
+ labels: encodeList(query?.labels),
249
+ code: encodeList(query?.code),
250
+ ids: encodeList(query?.ids),
251
+ fields: query?.fields != null && Object.keys(query.fields).length > 0 ? encodeJson(query.fields) : undefined,
252
+ q: query?.q,
253
+ category: encodeList(query?.category),
254
+ updatedSince: query?.updatedSince,
255
+ })
256
+
257
+ /** @throws {PlanningError} `malformed:query:<key>` for a value that does not decode */
258
+ export const decodeWorkcardQuery = (wire?: WorkcardQueryWire | WorkcardQuery | null): WorkcardQuery => {
259
+ const value = asRecord(wire)
260
+ return clean({
261
+ ...decodeListOptions<Workcard>(value),
262
+ kind: decodeOneOrMany<WorkcardKind>(value.kind, 'kind'),
263
+ type: decodeOneOrMany(value.type, 'type'),
264
+ parent: decodeText(value.parent),
265
+ within: decodeText(value.within),
266
+ status: decodeOneOrMany(value.status, 'status'),
267
+ intrinsic: decodeOneOrMany<IntrinsicStatus>(value.intrinsic, 'intrinsic'),
268
+ flow: decodeJson<WorkcardQuery['flow']>(value.flow, 'flow'),
269
+ labels: decodeList(value.labels, 'labels'),
270
+ code: decodeOneOrMany(value.code, 'code'),
271
+ ids: decodeList(value.ids, 'ids'),
272
+ fields: decodeJson<Record<string, unknown>>(value.fields, 'fields'),
273
+ q: decodeText(value.q),
274
+ category: decodeOneOrMany(value.category, 'category'),
275
+ updatedSince: decodeText(value.updatedSince),
276
+ })
277
+ }
278
+
279
+ export const encodeSummaryQuery = (query: SummaryQuery): SummaryQueryWire => clean({
280
+ parents: encodeList(query.parents) ?? '',
281
+ kind: query.kind,
282
+ type: encodeList(query.type),
283
+ })
284
+
285
+ export const decodeSummaryQuery = (wire?: SummaryQueryWire | SummaryQuery | null): SummaryQuery => {
286
+ const value = asRecord(wire)
287
+ return clean({
288
+ parents: decodeList(value.parents, 'parents') ?? [],
289
+ kind: decodeText(value.kind) as WorkcardKind | undefined,
290
+ type: decodeOneOrMany(value.type, 'type'),
291
+ })
292
+ }
293
+
294
+ export const encodeTransitionQuery = (query?: TransitionQuery | null): TransitionQueryWire => clean({
295
+ ...encodeListOptions(query),
296
+ card: query?.card,
297
+ project: query?.project,
298
+ sinceSeq: query?.sinceSeq,
299
+ action: encodeList(query?.action),
300
+ state: query?.state,
301
+ })
302
+
303
+ export const decodeTransitionQuery = (wire?: TransitionQueryWire | TransitionQuery | null): TransitionQuery => {
304
+ const value = asRecord(wire)
305
+ return clean({
306
+ ...decodeListOptions(value),
307
+ card: decodeText(value.card),
308
+ project: decodeText(value.project),
309
+ sinceSeq: decodeNumber(value.sinceSeq, 'sinceSeq'),
310
+ action: decodeOneOrMany(value.action, 'action'),
311
+ state: decodeText(value.state),
312
+ }) as TransitionQuery
313
+ }
314
+
315
+ export const encodeSpecificationQuery = (query?: SpecificationQuery | null): SpecificationQueryWire => clean({
316
+ ...encodeListOptions(query),
317
+ category: encodeList(query?.category),
318
+ all: query?.all,
319
+ })
320
+
321
+ export const decodeSpecificationQuery = (wire?: SpecificationQueryWire | SpecificationQuery | null): SpecificationQuery => {
322
+ const value = asRecord(wire)
323
+ return clean({
324
+ ...decodeListOptions<Specification>(value),
325
+ category: decodeOneOrMany(value.category, 'category'),
326
+ all: decodeBoolean(value.all),
327
+ })
328
+ }
329
+
330
+ export const encodeRelationshipQuery = (query?: RelationshipQuery | null): RelationshipQueryWire => clean({
331
+ ...encodeListOptions(query),
332
+ from: encodeList(query?.from),
333
+ to: encodeList(query?.to),
334
+ type: encodeList(query?.type),
335
+ })
336
+
337
+ export const decodeRelationshipQuery = (wire?: RelationshipQueryWire | RelationshipQuery | null): RelationshipQuery => {
338
+ const value = asRecord(wire)
339
+ return clean({
340
+ ...decodeListOptions(value),
341
+ from: decodeOneOrMany(value.from, 'from'),
342
+ to: decodeOneOrMany(value.to, 'to'),
343
+ type: decodeOneOrMany(value.type, 'type'),
344
+ }) as RelationshipQuery
345
+ }
@@ -0,0 +1,65 @@
1
+ import { WorkcardKind } from '../consts.js'
2
+ import { UnknownWorkcardType } from '../errors.js'
3
+ import type {
4
+ AnyTypeSchema, PlanningSchemaRegistry, Specification, SpecificationSlot, Workcard,
5
+ } from '../types.js'
6
+
7
+ /** The slot a parent type declares for a category. */
8
+ export const slotOf = (type: AnyTypeSchema, category: string): SpecificationSlot | undefined =>
9
+ type.specifications.find(slot => slot.category === category)
10
+
11
+ export const isRevisioned = (slot?: SpecificationSlot | null): boolean => slot?.revisioned === true
12
+
13
+ export const bodyCharsOf = (body?: string | null): number | undefined => body == null ? undefined : body.length
14
+
15
+ /** The revision a write lands as: 1 for a new document, current + 1 after, none when unrevisioned. */
16
+ export const nextRevision = (
17
+ current: Pick<Specification, 'revision'> | null | undefined, slot?: SpecificationSlot | null
18
+ ): number | undefined => isRevisioned(slot) ? (current?.revision ?? 0) + 1 : undefined
19
+
20
+ /**
21
+ * The current document of a category among a parent's specifications: the highest revision, then
22
+ * the most recently updated. `null` when the category has none.
23
+ */
24
+ export const currentSpecification = (
25
+ specs: Workcard[], category: string
26
+ ): Specification | null => specs
27
+ .filter((spec): spec is Specification => spec.kind === WorkcardKind.Specification
28
+ && (spec as Specification).category === category)
29
+ .reduce<Specification | null>((best, spec) => {
30
+ if (best == null) {
31
+ return spec
32
+ }
33
+ const byRevision = (spec.revision ?? 0) - (best.revision ?? 0)
34
+ if (byRevision !== 0) {
35
+ return byRevision > 0 ? spec : best
36
+ }
37
+ return (spec.updatedAt ?? spec.createdAt) > (best.updatedAt ?? best.createdAt) ? spec : best
38
+ }, null)
39
+
40
+ const prefixOf = (type: string): string => type.includes(':') ? type.slice(0, type.lastIndexOf(':')) : ''
41
+
42
+ /**
43
+ * The specification type a new document of a slot is created with: the slot's own `type`, else
44
+ * the only registered specification type, else the one sharing the parent type's prefix
45
+ * (`viable:project` → `viable:spec`).
46
+ *
47
+ * @throws {UnknownWorkcardType} when none or several candidates remain
48
+ */
49
+ export const specificationTypeOf = (
50
+ registry: Pick<PlanningSchemaRegistry, 'types'>, parentType: string, slot?: SpecificationSlot | null
51
+ ): string => {
52
+ if (slot?.type != null) {
53
+ return slot.type
54
+ }
55
+ const candidates = registry.types().filter(type => type.kind === WorkcardKind.Specification)
56
+ if (candidates.length === 1) {
57
+ return candidates[0].type
58
+ }
59
+ const prefixed = candidates.filter(type => prefixOf(type.type) === prefixOf(parentType))
60
+ if (prefixed.length === 1) {
61
+ return prefixed[0].type
62
+ }
63
+
64
+ throw new UnknownWorkcardType(`specification:${parentType}:${slot?.category ?? ''}`)
65
+ }
@@ -0,0 +1,117 @@
1
+ import { ANY_STATUS, INTRINSIC_ORDER, IntrinsicPolicy, IntrinsicStatus } from '../consts.js'
2
+ import { PlanningError, UnknownStatusFlow } from '../errors.js'
3
+ import type {
4
+ AnyTypeSchema, PlanningSchemaRegistry, StatusDefinition, StatusFlowSchema, StatusTransitionRule,
5
+ } from '../types.js'
6
+
7
+ type FlowLookup = Pick<PlanningSchemaRegistry, 'flow'>
8
+
9
+ /** The primary flow id of a type — `flows[0]`. @throws {UnknownStatusFlow} when it declares none */
10
+ export const primaryFlowOf = (type: AnyTypeSchema): string => {
11
+ const primary = type.flows[0]
12
+ if (primary == null) {
13
+ throw new UnknownStatusFlow(`${type.type}:primary`)
14
+ }
15
+
16
+ return primary
17
+ }
18
+
19
+ export const flowIdsOf = (type: AnyTypeSchema): string[] => [...type.flows]
20
+
21
+ export const statusDefinitionOf = (flow: StatusFlowSchema, status: string): StatusDefinition | undefined =>
22
+ flow.statuses.find(definition => definition.key === status)
23
+
24
+ /** The intrinsic state a status maps onto, or `undefined` for a status the flow does not declare. */
25
+ export const intrinsicOf = (flow: StatusFlowSchema, status: string): IntrinsicStatus | undefined =>
26
+ statusDefinitionOf(flow, status)?.intrinsic
27
+
28
+ /** The status marked `initial`, else the first one. @throws {PlanningError} for a flow with none */
29
+ export const initialStatusOf = (flow: StatusFlowSchema): string => {
30
+ const status = flow.statuses.find(definition => definition.initial === true) ?? flow.statuses[0]
31
+ if (status == null) {
32
+ throw new PlanningError(`flow-empty:${flow.id}`)
33
+ }
34
+
35
+ return status.key
36
+ }
37
+
38
+ export const isTerminal = (flow: StatusFlowSchema, status: string): boolean =>
39
+ statusDefinitionOf(flow, status)?.terminal === true
40
+
41
+ const fromMatches = (rule: StatusTransitionRule, status: string): boolean =>
42
+ rule.from === ANY_STATUS || (Array.isArray(rule.from) && rule.from.includes(status))
43
+
44
+ /**
45
+ * The rule a named transition follows from a status.
46
+ *
47
+ * A name may be declared several times with different `from` sets; the rule naming the status
48
+ * explicitly wins, and a `'*'` rule of that name answers only when none does.
49
+ */
50
+ export const ruleOf = (
51
+ flow: StatusFlowSchema, transition: string, from: string
52
+ ): StatusTransitionRule | undefined => {
53
+ const named = flow.transitions.filter(rule => rule.name === transition)
54
+
55
+ return named.find(rule => rule.from !== ANY_STATUS && fromMatches(rule, from))
56
+ ?? named.find(rule => rule.from === ANY_STATUS)
57
+ }
58
+
59
+ export const canTransit = (flow: StatusFlowSchema, transition: string, from: string): boolean =>
60
+ ruleOf(flow, transition, from) != null
61
+
62
+ /**
63
+ * Every transition available from a status, one rule per name (the one `ruleOf` would pick), in
64
+ * declaration order. `explicit: true` keeps only the rules offered to a person.
65
+ */
66
+ export const transitionsFrom = (
67
+ flow: StatusFlowSchema, status: string, opts?: { explicit?: boolean }
68
+ ): StatusTransitionRule[] => {
69
+ const names = [...new Set(flow.transitions.map(rule => rule.name))]
70
+
71
+ return names.map(name => ruleOf(flow, name, status))
72
+ .filter((rule): rule is StatusTransitionRule => rule != null)
73
+ .filter(rule => opts?.explicit !== true || rule.explicit === true)
74
+ }
75
+
76
+ /**
77
+ * A new card's status per flow: every flow's initial status, with the primary one overridden by
78
+ * `status` when the draft names one.
79
+ *
80
+ * @throws {UnknownStatusFlow}
81
+ */
82
+ export const initialFlowsOf = (
83
+ type: AnyTypeSchema, registry: FlowLookup, status?: string
84
+ ): Record<string, string> => {
85
+ const primary = primaryFlowOf(type)
86
+
87
+ return Object.fromEntries(type.flows.map(id => [
88
+ id, id === primary && status != null ? status : initialStatusOf(registry.flow(id)),
89
+ ]))
90
+ }
91
+
92
+ /**
93
+ * The card's intrinsic state over its flows. {@link IntrinsicPolicy.Primary} reads the primary
94
+ * flow; {@link IntrinsicPolicy.All} takes the least advanced flow. A status the flow does not
95
+ * declare reads as planned.
96
+ *
97
+ * @throws {UnknownStatusFlow}
98
+ */
99
+ export const resolveIntrinsic = (
100
+ type: AnyTypeSchema, flows: Record<string, string>, registry: FlowLookup
101
+ ): IntrinsicStatus => {
102
+ const read = (id: string): IntrinsicStatus => {
103
+ const status = flows[id]
104
+ return status == null
105
+ ? IntrinsicStatus.Planned
106
+ : intrinsicOf(registry.flow(id), status) ?? IntrinsicStatus.Planned
107
+ }
108
+
109
+ if (type.intrinsic !== IntrinsicPolicy.All) {
110
+ return read(primaryFlowOf(type))
111
+ }
112
+
113
+ return type.flows.map(read).reduce<IntrinsicStatus>(
114
+ (least, intrinsic) => INTRINSIC_ORDER[intrinsic] < INTRINSIC_ORDER[least] ? intrinsic : least,
115
+ IntrinsicStatus.Closed
116
+ )
117
+ }
@@ -0,0 +1,107 @@
1
+ import { Ajv } from 'ajv'
2
+ import type { ErrorObject, ValidateFunction } from 'ajv'
3
+ import formatsPlugin from 'ajv-formats'
4
+ import { SpecificationFormat, WorkcardKind } from '../consts.js'
5
+ import { FieldsInvalid, PlanningError } from '../errors.js'
6
+ import { AnyWorkcardSchema, SpecificationSchema, WorkcardSchema } from '../schemas.js'
7
+ import type { PlanningSchemaRegistry, SpecificationSlot, Workcard } from '../types.js'
8
+
9
+ /** The ajv every planning validation compiles with: lenient about unknown keywords, all errors, formats. */
10
+ export const makeAjv = (): Ajv => {
11
+ const ajv = new Ajv({ strict: false, allErrors: true })
12
+ formatsPlugin(ajv as any)
13
+
14
+ return ajv
15
+ }
16
+
17
+ let shared: Ajv | undefined
18
+ const ajvOf = (): Ajv => shared ??= makeAjv()
19
+
20
+ const compiled = new Map<object, ValidateFunction>()
21
+ const compile = (schema: object): ValidateFunction => {
22
+ let validate = compiled.get(schema)
23
+ if (validate == null) {
24
+ validate = ajvOf().compile(schema)
25
+ compiled.set(schema, validate)
26
+ }
27
+
28
+ return validate
29
+ }
30
+
31
+ /** One line per ajv error: `/path message`. */
32
+ export const ajvErrorText = (errors?: ErrorObject[] | null): string =>
33
+ (errors ?? []).map(error => `${error.instancePath === '' ? '/' : error.instancePath} ${error.message ?? 'invalid'}`)
34
+ .join('; ')
35
+
36
+ /** Keys holding `.` or `$` at any depth — a document store cannot hold them. */
37
+ export const invalidFieldKeys = (fields: unknown, path: string = ''): string[] => {
38
+ if (fields == null || typeof fields !== 'object') {
39
+ return []
40
+ }
41
+ if (Array.isArray(fields)) {
42
+ return fields.flatMap((entry, index) => invalidFieldKeys(entry, `${path}${index}.`))
43
+ }
44
+
45
+ return Object.entries(fields).flatMap(([key, value]) => [
46
+ ...(key.includes('.') || key.includes('$') ? [`${path}${key}`] : []),
47
+ ...invalidFieldKeys(value, `${path}${key}.`),
48
+ ])
49
+ }
50
+
51
+ /**
52
+ * Check `fields` against the type's schema, and its keys against the store rule.
53
+ *
54
+ * @throws {FieldsInvalid}
55
+ */
56
+ export const validateFields = (
57
+ registry: Pick<PlanningSchemaRegistry, 'validator'>, type: string, fields: Record<string, unknown>
58
+ ): void => {
59
+ const keys = invalidFieldKeys(fields)
60
+ if (keys.length > 0) {
61
+ throw new FieldsInvalid(`keys:${keys.join(',')}`)
62
+ }
63
+ const validate = registry.validator(type)
64
+ if (!validate(fields)) {
65
+ throw new FieldsInvalid(`${type}:${ajvErrorText(validate.errors)}`)
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Check a whole record: its shape by kind, then its `fields` by type.
71
+ *
72
+ * @throws {PlanningError} `malformed:card:…` for a shape fault
73
+ * @throws {FieldsInvalid}
74
+ */
75
+ export const validateCard = (card: Workcard, registry: Pick<PlanningSchemaRegistry, 'validator'>): void => {
76
+ const validate = compile(card.kind === WorkcardKind.Specification
77
+ ? SpecificationSchema
78
+ : card.kind === WorkcardKind.Card || card.kind === WorkcardKind.Project ? WorkcardSchema : AnyWorkcardSchema)
79
+ if (!validate(card)) {
80
+ throw new PlanningError(`malformed:card:${ajvErrorText(validate.errors)}`)
81
+ }
82
+ validateFields(registry, card.type, card.fields)
83
+ }
84
+
85
+ /**
86
+ * Check a document body against its slot: a JSON slot must parse, and match the slot's schema when
87
+ * it declares one.
88
+ *
89
+ * @throws {FieldsInvalid}
90
+ */
91
+ export const validateSpecificationBody = (slot: SpecificationSlot, body?: string | null): void => {
92
+ if (body == null || slot.format !== SpecificationFormat.Json) {
93
+ return
94
+ }
95
+ let parsed: unknown
96
+ try {
97
+ parsed = JSON.parse(body)
98
+ } catch {
99
+ throw new FieldsInvalid(`body:${slot.category}:not-json`)
100
+ }
101
+ if (slot.schema != null && typeof slot.schema === 'object') {
102
+ const validate = compile(slot.schema)
103
+ if (!validate(parsed)) {
104
+ throw new FieldsInvalid(`body:${slot.category}:${ajvErrorText(validate.errors)}`)
105
+ }
106
+ }
107
+ }
@@ -0,0 +1,51 @@
1
+ {
2
+ "kind": {
3
+ "card": "Картка",
4
+ "project": "Праект",
5
+ "specification": "Спецыфікацыя"
6
+ },
7
+ "intrinsic": {
8
+ "planned": "Запланавана",
9
+ "in-progress": "У працы",
10
+ "closed": "Закрыта"
11
+ },
12
+ "action": {
13
+ "create": "Створана",
14
+ "update": "Абноўлена",
15
+ "transit": "Статус зменены",
16
+ "link": "Звязана",
17
+ "unlink": "Сувязь выдалена",
18
+ "delete": "Выдалена"
19
+ },
20
+ "commit": {
21
+ "pending": "Захаванне…",
22
+ "committed": "Захавана",
23
+ "failed": "Не захавана"
24
+ },
25
+ "format": {
26
+ "markdown": "Markdown",
27
+ "json": "JSON",
28
+ "text": "Звычайны тэкст"
29
+ },
30
+ "errors": {
31
+ "PlanningError": "Не ўдалося выканаць запыт планавання",
32
+ "PlanningErrorWorkcardNotFound": "Гэты элемент не існуе або недаступны вам",
33
+ "PlanningErrorUnknownWorkcardType": "Гэты тып элемента невядомы",
34
+ "PlanningErrorUnknownStatusFlow": "Гэты паток статусаў невядомы",
35
+ "PlanningErrorParentNotFound": "Бацькоўскі элемент не існуе",
36
+ "PlanningErrorCardTypeNotAllowed": "Элемент гэтага тыпу нельга дадаць сюды",
37
+ "PlanningErrorRefused": "Змяненне адхілена",
38
+ "PlanningErrorIllegalTransition": "Такая змена статусу недапушчальная з бягучага статусу",
39
+ "PlanningErrorFieldsInvalid": "Некаторыя палі маюць недапушчальныя значэнні",
40
+ "PlanningErrorLabelNotAllowed": "Гэтая метка тут недапушчальная",
41
+ "PlanningErrorSpecificationSlotUnknown": "Гэты від дакумента тут недапушчальны",
42
+ "PlanningErrorSpecificationRevisionConflict": "Гэты дакумент ужо існуе — адрэдагуйце яго",
43
+ "PlanningErrorRelationshipRefused": "Гэтая сувязь недапушчальная",
44
+ "PlanningErrorCodeTaken": "Гэты код ужо заняты",
45
+ "PlanningErrorWorkcardConflict": "Хтосьці змяніў гэты элемент за гэты час — абнавіце і паспрабуйце зноў",
46
+ "PlanningErrorCommitTimeout": "Змяненне яшчэ захоўваецца — праверце крыху пазней",
47
+ "PlanningErrorCommitFailed": "Не ўдалося захаваць змяненне",
48
+ "PlanningErrorScopeMismatch": "Гэты элемент не існуе або недаступны вам",
49
+ "PlanningErrorUnsupported": "Гэтая аперацыя не падтрымліваецца для гэтага элемента"
50
+ }
51
+ }