@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,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
|
+
}
|
package/src/i18n/be.json
ADDED
|
@@ -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
|
+
}
|