@adecore/plan 0.23.1 → 0.24.1
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/dist/apply.js +33 -7
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/permissions.js +16 -9
- package/package.json +2 -1
- package/src/apply.ts +37 -10
- package/src/index.ts +1 -6
- package/src/permissions.ts +22 -10
package/dist/apply.js
CHANGED
|
@@ -3,9 +3,19 @@ import { z } from 'zod';
|
|
|
3
3
|
import { canApply } from './permissions.js';
|
|
4
4
|
import { allItems, allSteps, effectiveChecks, locateItem, missingItem, refuse, structureProblem } from './tree.js';
|
|
5
5
|
const ID_ALPHABET = 'abcdefghijklmnopqrstuvwxyz0123456789';
|
|
6
|
+
const ID_LENGTH = 6;
|
|
7
|
+
/* 256 is no multiple of the alphabet, so a byte at or above this would favor its first characters. */
|
|
8
|
+
const ID_BYTE_LIMIT = 256 - (256 % ID_ALPHABET.length);
|
|
6
9
|
export function randomItemId() {
|
|
7
|
-
|
|
8
|
-
|
|
10
|
+
let id = '';
|
|
11
|
+
while (id.length < ID_LENGTH) {
|
|
12
|
+
for (const byte of crypto.getRandomValues(new Uint8Array(ID_LENGTH))) {
|
|
13
|
+
if (byte < ID_BYTE_LIMIT && id.length < ID_LENGTH) {
|
|
14
|
+
id += ID_ALPHABET[byte % ID_ALPHABET.length];
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return id;
|
|
9
19
|
}
|
|
10
20
|
function issueMessage(error) {
|
|
11
21
|
const issue = error.issues[0];
|
|
@@ -15,9 +25,10 @@ function issueMessage(error) {
|
|
|
15
25
|
const path = issue.path.map((part) => (typeof part === 'number' ? `[${part}]` : `.${String(part)}`)).join('');
|
|
16
26
|
return `${path ? `${path.replace(/^\./, '')}: ` : ''}${issue.message}`;
|
|
17
27
|
}
|
|
28
|
+
const MINT_ATTEMPTS = 1000;
|
|
18
29
|
function idMinter(taken, mintId, minted) {
|
|
19
30
|
return () => {
|
|
20
|
-
for (let attempt = 0; attempt <
|
|
31
|
+
for (let attempt = 0; attempt < MINT_ATTEMPTS; attempt++) {
|
|
21
32
|
const id = mintId();
|
|
22
33
|
if (!taken.has(id)) {
|
|
23
34
|
taken.add(id);
|
|
@@ -25,7 +36,7 @@ function idMinter(taken, mintId, minted) {
|
|
|
25
36
|
return id;
|
|
26
37
|
}
|
|
27
38
|
}
|
|
28
|
-
|
|
39
|
+
return refuse('duplicate-id', `mintId gave no id the plan does not already have in ${MINT_ATTEMPTS} tries`);
|
|
29
40
|
};
|
|
30
41
|
}
|
|
31
42
|
function setState(step, state, actor, now) {
|
|
@@ -160,6 +171,9 @@ function applyOne(plan, op, options, mint, dropped) {
|
|
|
160
171
|
}
|
|
161
172
|
case 'add': {
|
|
162
173
|
const id = op.id ?? mint();
|
|
174
|
+
if (typeof id !== 'string') {
|
|
175
|
+
return id;
|
|
176
|
+
}
|
|
163
177
|
if (op.id !== undefined && locateItem(plan, op.id)) {
|
|
164
178
|
return refuse('duplicate-id', `The plan already has an item "${op.id}"`);
|
|
165
179
|
}
|
|
@@ -339,6 +353,18 @@ export function createPlan(draft, options) {
|
|
|
339
353
|
}
|
|
340
354
|
}
|
|
341
355
|
const mint = idMinter(taken, options.mintId ?? randomItemId, minted);
|
|
356
|
+
// Minted up front so a refusal stops here; the tree below takes them in the same order, parents first.
|
|
357
|
+
const fresh = [];
|
|
358
|
+
for (const item of allDraftItems(source.items)) {
|
|
359
|
+
if (item.id === undefined) {
|
|
360
|
+
const id = mint();
|
|
361
|
+
if (typeof id !== 'string') {
|
|
362
|
+
return id;
|
|
363
|
+
}
|
|
364
|
+
fresh.push(id);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
const nextId = () => fresh.shift();
|
|
342
368
|
const described = (value) => {
|
|
343
369
|
if (value.description === '') {
|
|
344
370
|
delete value.description;
|
|
@@ -347,7 +373,7 @@ export function createPlan(draft, options) {
|
|
|
347
373
|
};
|
|
348
374
|
const toStep = (step) => {
|
|
349
375
|
const { steps, state, note, ...rest } = step;
|
|
350
|
-
const result = described({ ...rest, id: step.id ??
|
|
376
|
+
const result = described({ ...rest, id: step.id ?? nextId() });
|
|
351
377
|
if (steps && steps.length > 0) {
|
|
352
378
|
result.steps = steps.map(toStep);
|
|
353
379
|
}
|
|
@@ -359,10 +385,10 @@ export function createPlan(draft, options) {
|
|
|
359
385
|
}
|
|
360
386
|
return result;
|
|
361
387
|
};
|
|
362
|
-
const toText = (text) => described({ ...text, id: text.id ??
|
|
388
|
+
const toText = (text) => described({ ...text, id: text.id ?? nextId() });
|
|
363
389
|
const items = source.items.map((item) => {
|
|
364
390
|
if (item.type === 'section') {
|
|
365
|
-
return described({ ...item, id: item.id ??
|
|
391
|
+
return described({ ...item, id: item.id ?? nextId(), items: item.items.map((child) => (child.type === 'text' ? toText(child) : toStep(child))) });
|
|
366
392
|
}
|
|
367
393
|
return item.type === 'text' ? toText(item) : toStep(item);
|
|
368
394
|
});
|
package/dist/index.d.ts
CHANGED
|
@@ -2,4 +2,4 @@ export { type PlanApplyOptions, type PlanApplied, randomItemId, applyPlanOps, va
|
|
|
2
2
|
export { planToMarkdown, parsePlanMarkdown } from './markdown.ts';
|
|
3
3
|
export { type PlanVerdict, canApply, canDeletePlan } from './permissions.ts';
|
|
4
4
|
export { PLAN_STATE_MARKERS, PLAN_LEGEND, type PlanTextOptions, progressText, renderPlanText } from './text.ts';
|
|
5
|
-
export { type PlanRefusalCode, type PlanRefusal, refuse,
|
|
5
|
+
export { type PlanRefusalCode, type PlanRefusal, refuse, findItem, allItems, allSteps, isParentStep, effectiveChecks, isFinishedOutcome, stepState, type PlanProgress, planProgress, leafSteps, activeStepIds } from './tree.ts';
|
package/dist/index.js
CHANGED
|
@@ -2,4 +2,4 @@ export { randomItemId, applyPlanOps, validatePlan, PlanDraftStepSchema, PlanDraf
|
|
|
2
2
|
export { planToMarkdown, parsePlanMarkdown } from './markdown.js';
|
|
3
3
|
export { canApply, canDeletePlan } from './permissions.js';
|
|
4
4
|
export { PLAN_STATE_MARKERS, PLAN_LEGEND, progressText, renderPlanText } from './text.js';
|
|
5
|
-
export { refuse,
|
|
5
|
+
export { refuse, findItem, allItems, allSteps, isParentStep, effectiveChecks, isFinishedOutcome, stepState, planProgress, leafSteps, activeStepIds } from './tree.js';
|
package/dist/permissions.js
CHANGED
|
@@ -28,14 +28,20 @@ function mayState(plan, step, state, actor) {
|
|
|
28
28
|
}
|
|
29
29
|
return ALLOWED;
|
|
30
30
|
}
|
|
31
|
-
/*
|
|
32
|
-
|
|
33
|
-
|
|
31
|
+
/*
|
|
32
|
+
* Adding under a leaf takes its state away, which may not happen to a state a person set. Its state
|
|
33
|
+
* then follows from the sub-steps, so a step only a person checks takes only sub-steps a person checks.
|
|
34
|
+
*/
|
|
35
|
+
function mayHoldChildren(plan, under, after, added) {
|
|
36
|
+
const parent = under !== undefined ? locateItem(plan, under)?.item : after !== undefined ? locateItem(plan, after)?.parent : null;
|
|
37
|
+
if (parent?.type !== 'step') {
|
|
34
38
|
return ALLOWED;
|
|
35
39
|
}
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
40
|
+
if (!isParentStep(parent) && holdsPersonState(parent)) {
|
|
41
|
+
return refuse('set-by-person', `A person set "${parent.id}" to ${parent.state}; a sub-step would take that state away`);
|
|
42
|
+
}
|
|
43
|
+
if (effectiveChecks(plan, parent) === 'person' && added.some((checks) => checks !== 'person')) {
|
|
44
|
+
return refuse('person-only', `Only a person checks "${parent.id}", so a sub-step under it is one a person checks too`);
|
|
39
45
|
}
|
|
40
46
|
return ALLOWED;
|
|
41
47
|
}
|
|
@@ -86,7 +92,7 @@ export function canApply(op, actor, plan) {
|
|
|
86
92
|
return ALLOWED;
|
|
87
93
|
}
|
|
88
94
|
case 'add':
|
|
89
|
-
return mayHoldChildren(plan, op.under);
|
|
95
|
+
return mayHoldChildren(plan, op.under, op.after, op.type === 'step' ? [op.checks ?? plan.meta.checks] : []);
|
|
90
96
|
case 'edit': {
|
|
91
97
|
const location = locateItem(plan, op.id);
|
|
92
98
|
if (!location) {
|
|
@@ -117,10 +123,11 @@ export function canApply(op, actor, plan) {
|
|
|
117
123
|
return ALLOWED;
|
|
118
124
|
}
|
|
119
125
|
case 'move': {
|
|
120
|
-
|
|
126
|
+
const location = locateItem(plan, op.id);
|
|
127
|
+
if (!location) {
|
|
121
128
|
return missingItem(op.id);
|
|
122
129
|
}
|
|
123
|
-
return mayHoldChildren(plan, op.under);
|
|
130
|
+
return mayHoldChildren(plan, op.under, op.after, allSteps([location.item]).map((step) => effectiveChecks(plan, step)));
|
|
124
131
|
}
|
|
125
132
|
case 'remove': {
|
|
126
133
|
const location = locateItem(plan, op.id);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adecore/plan",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.24.1",
|
|
4
4
|
"description": "Plan schemas, atomic tree operations, actor permissions and Markdown.",
|
|
5
5
|
"license": "FSL-1.1-MIT",
|
|
6
6
|
"author": "Bas Milius",
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
"dist",
|
|
20
20
|
"src",
|
|
21
21
|
"!src/**/*.test.ts",
|
|
22
|
+
"!src/**/__snapshots__",
|
|
22
23
|
"!src/fixtures"
|
|
23
24
|
],
|
|
24
25
|
"exports": {
|
package/src/apply.ts
CHANGED
|
@@ -27,7 +27,7 @@ export interface PlanApplyOptions {
|
|
|
27
27
|
actor: PlanActor;
|
|
28
28
|
/* ISO time written into `at`; injected so a test never reads the clock. */
|
|
29
29
|
now: string;
|
|
30
|
-
/* Mints an id for an item that comes without one; retried until it is not taken. */
|
|
30
|
+
/* Mints an id for an item that comes without one; retried until it is not taken, and refused when it never is. */
|
|
31
31
|
mintId?: () => string;
|
|
32
32
|
}
|
|
33
33
|
|
|
@@ -41,10 +41,20 @@ export interface PlanApplied {
|
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
const ID_ALPHABET = 'abcdefghijklmnopqrstuvwxyz0123456789';
|
|
44
|
+
const ID_LENGTH = 6;
|
|
45
|
+
/* 256 is no multiple of the alphabet, so a byte at or above this would favor its first characters. */
|
|
46
|
+
const ID_BYTE_LIMIT = 256 - (256 % ID_ALPHABET.length);
|
|
44
47
|
|
|
45
48
|
export function randomItemId(): string {
|
|
46
|
-
|
|
47
|
-
|
|
49
|
+
let id = '';
|
|
50
|
+
while (id.length < ID_LENGTH) {
|
|
51
|
+
for (const byte of crypto.getRandomValues(new Uint8Array(ID_LENGTH))) {
|
|
52
|
+
if (byte < ID_BYTE_LIMIT && id.length < ID_LENGTH) {
|
|
53
|
+
id += ID_ALPHABET[byte % ID_ALPHABET.length];
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return id;
|
|
48
58
|
}
|
|
49
59
|
|
|
50
60
|
function issueMessage(error: z.ZodError): string {
|
|
@@ -56,9 +66,11 @@ function issueMessage(error: z.ZodError): string {
|
|
|
56
66
|
return `${path ? `${path.replace(/^\./, '')}: ` : ''}${issue.message}`;
|
|
57
67
|
}
|
|
58
68
|
|
|
69
|
+
const MINT_ATTEMPTS = 1000;
|
|
70
|
+
|
|
59
71
|
function idMinter(taken: Set<string>, mintId: () => string, minted: string[]) {
|
|
60
|
-
return (): string => {
|
|
61
|
-
for (let attempt = 0; attempt <
|
|
72
|
+
return (): string | PlanRefusal => {
|
|
73
|
+
for (let attempt = 0; attempt < MINT_ATTEMPTS; attempt++) {
|
|
62
74
|
const id = mintId();
|
|
63
75
|
if (!taken.has(id)) {
|
|
64
76
|
taken.add(id);
|
|
@@ -66,7 +78,7 @@ function idMinter(taken: Set<string>, mintId: () => string, minted: string[]) {
|
|
|
66
78
|
return id;
|
|
67
79
|
}
|
|
68
80
|
}
|
|
69
|
-
|
|
81
|
+
return refuse('duplicate-id', `mintId gave no id the plan does not already have in ${MINT_ATTEMPTS} tries`);
|
|
70
82
|
};
|
|
71
83
|
}
|
|
72
84
|
|
|
@@ -176,7 +188,7 @@ function newItem(op: Extract<PlanOp, { op: 'add' }>, id: string): PlanItem {
|
|
|
176
188
|
return { type: 'step', ...base, ...(op.checks ? { checks: op.checks } : {}) };
|
|
177
189
|
}
|
|
178
190
|
|
|
179
|
-
function applyOne(plan: Plan, op: PlanOp, options: PlanApplyOptions, mint: () => string, dropped: string[]): PlanRefusal | null {
|
|
191
|
+
function applyOne(plan: Plan, op: PlanOp, options: PlanApplyOptions, mint: () => string | PlanRefusal, dropped: string[]): PlanRefusal | null {
|
|
180
192
|
const verdict = canApply(op, options.actor, plan);
|
|
181
193
|
if (!verdict.ok) {
|
|
182
194
|
return verdict;
|
|
@@ -210,6 +222,9 @@ function applyOne(plan: Plan, op: PlanOp, options: PlanApplyOptions, mint: () =>
|
|
|
210
222
|
}
|
|
211
223
|
case 'add': {
|
|
212
224
|
const id = op.id ?? mint();
|
|
225
|
+
if (typeof id !== 'string') {
|
|
226
|
+
return id;
|
|
227
|
+
}
|
|
213
228
|
if (op.id !== undefined && locateItem(plan, op.id)) {
|
|
214
229
|
return refuse('duplicate-id', `The plan already has an item "${op.id}"`);
|
|
215
230
|
}
|
|
@@ -411,6 +426,18 @@ export function createPlan(draft: PlanDraft, options: PlanCreateOptions): PlanAp
|
|
|
411
426
|
}
|
|
412
427
|
}
|
|
413
428
|
const mint = idMinter(taken, options.mintId ?? randomItemId, minted);
|
|
429
|
+
// Minted up front so a refusal stops here; the tree below takes them in the same order, parents first.
|
|
430
|
+
const fresh: string[] = [];
|
|
431
|
+
for (const item of allDraftItems(source.items)) {
|
|
432
|
+
if (item.id === undefined) {
|
|
433
|
+
const id = mint();
|
|
434
|
+
if (typeof id !== 'string') {
|
|
435
|
+
return id;
|
|
436
|
+
}
|
|
437
|
+
fresh.push(id);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
const nextId = (): string => fresh.shift()!;
|
|
414
441
|
const described = <T extends { description?: string }>(value: T): T => {
|
|
415
442
|
if (value.description === '') {
|
|
416
443
|
delete value.description;
|
|
@@ -419,7 +446,7 @@ export function createPlan(draft: PlanDraft, options: PlanCreateOptions): PlanAp
|
|
|
419
446
|
};
|
|
420
447
|
const toStep = (step: PlanDraftStep): PlanStep => {
|
|
421
448
|
const { steps, state, note, ...rest } = step;
|
|
422
|
-
const result: PlanStep = described({ ...rest, id: step.id ??
|
|
449
|
+
const result: PlanStep = described({ ...rest, id: step.id ?? nextId() });
|
|
423
450
|
if (steps && steps.length > 0) {
|
|
424
451
|
result.steps = steps.map(toStep);
|
|
425
452
|
}
|
|
@@ -431,10 +458,10 @@ export function createPlan(draft: PlanDraft, options: PlanCreateOptions): PlanAp
|
|
|
431
458
|
}
|
|
432
459
|
return result;
|
|
433
460
|
};
|
|
434
|
-
const toText = (text: PlanDraftText): PlanText => described({ ...text, id: text.id ??
|
|
461
|
+
const toText = (text: PlanDraftText): PlanText => described({ ...text, id: text.id ?? nextId() });
|
|
435
462
|
const items = source.items.map((item): PlanItem => {
|
|
436
463
|
if (item.type === 'section') {
|
|
437
|
-
return described({ ...item, id: item.id ??
|
|
464
|
+
return described({ ...item, id: item.id ?? nextId(), items: item.items.map((child) => (child.type === 'text' ? toText(child) : toStep(child))) });
|
|
438
465
|
}
|
|
439
466
|
return item.type === 'text' ? toText(item) : toStep(item);
|
|
440
467
|
});
|
package/src/index.ts
CHANGED
|
@@ -27,20 +27,15 @@ export {
|
|
|
27
27
|
type PlanRefusalCode,
|
|
28
28
|
type PlanRefusal,
|
|
29
29
|
refuse,
|
|
30
|
-
type PlanLocation,
|
|
31
|
-
locateItem,
|
|
32
30
|
findItem,
|
|
33
31
|
allItems,
|
|
34
32
|
allSteps,
|
|
35
33
|
isParentStep,
|
|
36
34
|
effectiveChecks,
|
|
37
35
|
isFinishedOutcome,
|
|
38
|
-
deriveState,
|
|
39
36
|
stepState,
|
|
40
37
|
type PlanProgress,
|
|
41
38
|
planProgress,
|
|
42
39
|
leafSteps,
|
|
43
|
-
activeStepIds
|
|
44
|
-
holdsPersonState,
|
|
45
|
-
structureProblem
|
|
40
|
+
activeStepIds
|
|
46
41
|
} from './tree.ts';
|
package/src/permissions.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Plan, PlanActor, PlanOp, PlanStep, PlanStepState } from './protocol.ts';
|
|
1
|
+
import type { Plan, PlanActor, PlanChecks, PlanOp, PlanStep, PlanStepState } from './protocol.ts';
|
|
2
2
|
import { allSteps, effectiveChecks, holdsPersonState, isParentStep, locateItem, missingItem, refuse, type PlanRefusal } from './tree.ts';
|
|
3
3
|
|
|
4
4
|
export type PlanVerdict = { ok: true } | PlanRefusal;
|
|
@@ -36,14 +36,20 @@ function mayState(plan: Plan, step: PlanStep, state: PlanStepState, actor: PlanA
|
|
|
36
36
|
return ALLOWED;
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
/*
|
|
40
|
-
|
|
41
|
-
|
|
39
|
+
/*
|
|
40
|
+
* Adding under a leaf takes its state away, which may not happen to a state a person set. Its state
|
|
41
|
+
* then follows from the sub-steps, so a step only a person checks takes only sub-steps a person checks.
|
|
42
|
+
*/
|
|
43
|
+
function mayHoldChildren(plan: Plan, under: string | undefined, after: string | undefined, added: readonly PlanChecks[]): PlanVerdict {
|
|
44
|
+
const parent = under !== undefined ? locateItem(plan, under)?.item : after !== undefined ? locateItem(plan, after)?.parent : null;
|
|
45
|
+
if (parent?.type !== 'step') {
|
|
42
46
|
return ALLOWED;
|
|
43
47
|
}
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
48
|
+
if (!isParentStep(parent) && holdsPersonState(parent)) {
|
|
49
|
+
return refuse('set-by-person', `A person set "${parent.id}" to ${parent.state}; a sub-step would take that state away`);
|
|
50
|
+
}
|
|
51
|
+
if (effectiveChecks(plan, parent) === 'person' && added.some((checks) => checks !== 'person')) {
|
|
52
|
+
return refuse('person-only', `Only a person checks "${parent.id}", so a sub-step under it is one a person checks too`);
|
|
47
53
|
}
|
|
48
54
|
return ALLOWED;
|
|
49
55
|
}
|
|
@@ -95,7 +101,7 @@ export function canApply(op: PlanOp, actor: PlanActor, plan: Plan): PlanVerdict
|
|
|
95
101
|
return ALLOWED;
|
|
96
102
|
}
|
|
97
103
|
case 'add':
|
|
98
|
-
return mayHoldChildren(plan, op.under);
|
|
104
|
+
return mayHoldChildren(plan, op.under, op.after, op.type === 'step' ? [op.checks ?? plan.meta.checks] : []);
|
|
99
105
|
case 'edit': {
|
|
100
106
|
const location = locateItem(plan, op.id);
|
|
101
107
|
if (!location) {
|
|
@@ -126,10 +132,16 @@ export function canApply(op: PlanOp, actor: PlanActor, plan: Plan): PlanVerdict
|
|
|
126
132
|
return ALLOWED;
|
|
127
133
|
}
|
|
128
134
|
case 'move': {
|
|
129
|
-
|
|
135
|
+
const location = locateItem(plan, op.id);
|
|
136
|
+
if (!location) {
|
|
130
137
|
return missingItem(op.id);
|
|
131
138
|
}
|
|
132
|
-
return mayHoldChildren(
|
|
139
|
+
return mayHoldChildren(
|
|
140
|
+
plan,
|
|
141
|
+
op.under,
|
|
142
|
+
op.after,
|
|
143
|
+
allSteps([location.item]).map((step) => effectiveChecks(plan, step))
|
|
144
|
+
);
|
|
133
145
|
}
|
|
134
146
|
case 'remove': {
|
|
135
147
|
const location = locateItem(plan, op.id);
|