@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 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
- const bytes = crypto.getRandomValues(new Uint8Array(6));
8
- return Array.from(bytes, (byte) => ID_ALPHABET[byte % ID_ALPHABET.length]).join('');
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 < 1000; 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
- throw new Error('Could not mint a free plan item id');
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 ?? mint() });
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 ?? mint() });
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 ?? mint(), items: item.items.map((child) => (child.type === 'text' ? toText(child) : toStep(child))) });
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, type PlanLocation, locateItem, findItem, allItems, allSteps, isParentStep, effectiveChecks, isFinishedOutcome, deriveState, stepState, type PlanProgress, planProgress, leafSteps, activeStepIds, holdsPersonState, structureProblem } from './tree.ts';
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, locateItem, findItem, allItems, allSteps, isParentStep, effectiveChecks, isFinishedOutcome, deriveState, stepState, planProgress, leafSteps, activeStepIds, holdsPersonState, structureProblem } from './tree.js';
5
+ export { refuse, findItem, allItems, allSteps, isParentStep, effectiveChecks, isFinishedOutcome, stepState, planProgress, leafSteps, activeStepIds } from './tree.js';
@@ -28,14 +28,20 @@ function mayState(plan, step, state, actor) {
28
28
  }
29
29
  return ALLOWED;
30
30
  }
31
- /* Adding under a leaf takes its state away, which may not happen to a state a person set. */
32
- function mayHoldChildren(plan, under) {
33
- if (under === undefined) {
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
- const location = locateItem(plan, under);
37
- if (location?.item.type === 'step' && !isParentStep(location.item) && holdsPersonState(location.item)) {
38
- return refuse('set-by-person', `A person set "${under}" to ${location.item.state}; a sub-step would take that state away`);
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
- if (!locateItem(plan, op.id)) {
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.23.1",
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
- const bytes = crypto.getRandomValues(new Uint8Array(6));
47
- return Array.from(bytes, (byte) => ID_ALPHABET[byte % ID_ALPHABET.length]).join('');
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 < 1000; 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
- throw new Error('Could not mint a free plan item id');
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 ?? mint() });
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 ?? mint() });
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 ?? mint(), items: item.items.map((child) => (child.type === 'text' ? toText(child) : toStep(child))) });
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';
@@ -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
- /* Adding under a leaf takes its state away, which may not happen to a state a person set. */
40
- function mayHoldChildren(plan: Plan, under: string | undefined): PlanVerdict {
41
- if (under === undefined) {
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
- const location = locateItem(plan, under);
45
- if (location?.item.type === 'step' && !isParentStep(location.item) && holdsPersonState(location.item)) {
46
- return refuse('set-by-person', `A person set "${under}" to ${location.item.state}; a sub-step would take that state away`);
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
- if (!locateItem(plan, op.id)) {
135
+ const location = locateItem(plan, op.id);
136
+ if (!location) {
130
137
  return missingItem(op.id);
131
138
  }
132
- return mayHoldChildren(plan, op.under);
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);