@anchrd/intel-api 0.12.5 → 0.14.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.
@@ -1,6 +1,251 @@
1
- import { ArchivedBoardStatusId, BoardDefaultStatuses, BoardMaxTaskDepth, } from "@anchrd/intel-contract";
1
+ import { ArchivedBoardStatusId, BoardDefaultStatuses, BoardMaxTaskDepth, MaxBoardTaskOrderLength, } from "@anchrd/intel-contract";
2
2
  import { generateKeyBetween } from "fractional-indexing";
3
3
  import { IntelError } from "../../shared/intel-error/intel-error.js";
4
+ /**
5
+ * Statuses that answer whether standing in them means finished (anchrd/intel#311).
6
+ *
7
+ * ⚠️ This is the ONE place the old positional rule survives, and it survives as a data migration
8
+ * rather than as a reading. A board written before `terminal` existed has no answer to "which
9
+ * columns mean finished", and the honest answer is the one that board already behaved as: the last
10
+ * column before `archived`, plus the shelf. Filling `false` everywhere instead would quietly declare
11
+ * that nothing on any existing board is done — every finished task would become an open blocker
12
+ * overnight. Filling `true` everywhere would be the same lie the other way round.
13
+ *
14
+ * ⚠️ It touches only what is missing. A status that already carries the flag keeps it, whatever the
15
+ * position rule would have said — otherwise the migration would undo somebody's configuration every
16
+ * time their board was read.
17
+ *
18
+ * `undefined` means "nothing to do", which is what keeps an untouched document identical rather
19
+ * than merely equal.
20
+ */
21
+ function upgradedStatuses(statuses) {
22
+ if (!Array.isArray(statuses))
23
+ return undefined;
24
+ const entries = statuses.filter((status) => typeof status === "object" && status !== null);
25
+ if (entries.every((status) => "terminal" in status))
26
+ return undefined;
27
+ // The end of the work as the board behaved before the flag: the last column that is not the shelf,
28
+ // read in the stored order rather than in array order, because `order` is what a surface draws by.
29
+ const working = entries
30
+ .filter((status) => status.id !== ArchivedBoardStatusId)
31
+ .sort((left, right) => Number(left.order ?? 0) - Number(right.order ?? 0));
32
+ const lastWorking = working.at(-1)?.id;
33
+ return statuses.map((status) => {
34
+ if (typeof status !== "object" || status === null || "terminal" in status)
35
+ return status;
36
+ const entry = status;
37
+ return {
38
+ ...entry,
39
+ terminal: entry.id === ArchivedBoardStatusId || entry.id === lastWorking,
40
+ };
41
+ });
42
+ }
43
+ /**
44
+ * The three task lists that have to be distinct, and how a stored entry is recognised again.
45
+ *
46
+ * ⚠️ `labels` are compared TRIMMED and the two id lists are not, and that mirrors the schema
47
+ * exactly: `BoardTaskLabel` carries `.trim()`, `BoardTaskId` and `IntelId` do not. Comparing raw
48
+ * here would leave `["Bug ", "Bug"]` untouched — and zod, which trims each entry BEFORE the
49
+ * distinctness check runs on the array, would then see one word twice and refuse the board. That is
50
+ * precisely the `board_unreadable` this function exists to prevent, arrived at through the fold.
51
+ */
52
+ const distinctTaskLists = [
53
+ { field: "dependsOn", identity: (value) => value },
54
+ {
55
+ field: "labels",
56
+ identity: (value) => (typeof value === "string" ? value.trim() : value),
57
+ },
58
+ { field: "references", identity: (value) => value },
59
+ ];
60
+ /**
61
+ * Tasks whose lists name nothing twice (anchrd/intel#318).
62
+ *
63
+ * ⚠️ `dependsOn`, `labels` and `references` are refused at the write boundary now, and that refusal
64
+ * reaches nothing already in R2 — boards have been writable since `c29fd12` and `board_task_update`
65
+ * took a repeat without complaint. A stored board carrying one would meet the strict `BoardTask` and
66
+ * answer `board_unreadable`: the whole board lost over a value that has no reading, which is the
67
+ * trap #311 walked into from the other side.
68
+ *
69
+ * ⚠️ Folded here and refused there, and the difference is not inconsistency — it is who is being
70
+ * answered. A caller handed back a shorter list than it sent learns nothing and repeats itself; a
71
+ * stored document has no caller to tell, and dropping the board instead would punish a reader for
72
+ * what some writer did months ago.
73
+ *
74
+ * ⚠️ The FIRST mention keeps its place. Every surface draws these lists in the order they stand in.
75
+ */
76
+ function dedupedTaskLists(tasks) {
77
+ if (!Array.isArray(tasks))
78
+ return undefined;
79
+ let folded = false;
80
+ const next = tasks.map((task) => {
81
+ if (typeof task !== "object" || task === null)
82
+ return task;
83
+ const entry = task;
84
+ const lists = distinctTaskLists.flatMap(({ field, identity }) => {
85
+ const list = entry[field];
86
+ if (!Array.isArray(list))
87
+ return [];
88
+ const seen = new Set();
89
+ const distinct = list.filter((value) => {
90
+ const key = identity(value);
91
+ if (seen.has(key))
92
+ return false;
93
+ seen.add(key);
94
+ return true;
95
+ });
96
+ return distinct.length === list.length ? [] : [[field, distinct]];
97
+ });
98
+ if (lists.length === 0)
99
+ return task;
100
+ folded = true;
101
+ return { ...entry, ...Object.fromEntries(lists) };
102
+ });
103
+ return folded ? next : undefined;
104
+ }
105
+ /**
106
+ * A stored board brought up to the current schema, before it is validated.
107
+ *
108
+ * ⚠️ It runs before `BoardDocument.parse`, on `unknown`, because after that parse a missing field or
109
+ * a repeated id is already a refusal — and `parseStoredBoard` is deliberately loud
110
+ * (`board_unreadable`), so an un-upgraded board would not read as "old", it would read as corrupt.
111
+ *
112
+ * The upgrade is persisted by the next write of any kind, because every write serialises the whole
113
+ * document. Nothing has to be migrated ahead of time and nothing has to be re-migrated.
114
+ */
115
+ export function upgradeStoredBoard(parsed) {
116
+ if (typeof parsed !== "object" || parsed === null)
117
+ return parsed;
118
+ const document = parsed;
119
+ const statuses = upgradedStatuses(document.statuses);
120
+ const tasks = dedupedTaskLists(document.tasks);
121
+ if (statuses === undefined && tasks === undefined)
122
+ return parsed;
123
+ return {
124
+ ...document,
125
+ ...(statuses !== undefined && { statuses }),
126
+ ...(tasks !== undefined && { tasks }),
127
+ };
128
+ }
129
+ /**
130
+ * The first id a board document uses for two different things (anchrd/intel#321).
131
+ *
132
+ * ⚠️ Deliberately NOT a rule on `BoardDocument`, and that is the whole decision. A schema rule would
133
+ * reach every parse of a stored body — `parseStoredBoard`, `indexing.ts`, the link reader — and a
134
+ * board imported before this existed would stop answering with `board_unreadable`: the whole board
135
+ * gone over something that costs one view. It is the trap #311 walked into, and #318 walked into
136
+ * from the other side.
137
+ *
138
+ * ⚠️ And NOT folded in `upgradeStoredBoard` either, which is where the sibling rules of #318 went. A
139
+ * repeated `dependsOn`, label or reference carries no information, so dropping the second mention
140
+ * loses nothing. Two tasks under one id are two whole tasks — titles, dates, descriptions — and a
141
+ * fold on the READ is written back by the next save of any kind, because every write serialises the
142
+ * whole document. That is one task gone for good, for every board, without anybody having asked.
143
+ * The UI folds the same pair for a DRAWING (`orderedTasks`), which costs nothing: the document keeps
144
+ * both, and repairing the pair brings the second card back.
145
+ *
146
+ * ⚠️ What this does NOT rescue, and it is worth knowing: `replaced` matches a task by id, so the
147
+ * first `board_task_update` against such a pair writes the SAME task into both entries. The stored
148
+ * board is then two identical tasks rather than two different ones. That is a consequence of
149
+ * addressing a board by task id at all (#285) and predates this rule; the reason the rule is at the
150
+ * import is to stop the pair from existing, not to make it survivable. A board that already holds
151
+ * one is repaired by `repairTaskIds` below — asked for, never done on the quiet — and the refusal
152
+ * here names it, because "your file is wrong" without a way out is where anchrd/intel#341 started.
153
+ *
154
+ * So it is asked exactly where there is a caller to answer: the bundle import, the one door a board
155
+ * document written elsewhere comes in through. Everything else mints task ids itself (`deps.id()`)
156
+ * and takes its status list through `ConfigureBoardInput`, which has demanded distinct ids since
157
+ * #285.
158
+ */
159
+ export function repeatedBoardId(document) {
160
+ for (const list of ["statuses", "tasks"]) {
161
+ const seen = new Set();
162
+ for (const entry of document[list]) {
163
+ if (seen.has(entry.id))
164
+ return { list, id: entry.id };
165
+ seen.add(entry.id);
166
+ }
167
+ }
168
+ return null;
169
+ }
170
+ /**
171
+ * The two digits a key is extended with when `fractional-indexing` will not read its bounds
172
+ * (anchrd/intel#359).
173
+ *
174
+ * ⚠️ Both are chosen rather than convenient. `V` is the middle of the character class, so an
175
+ * extension leaves room on either side of itself, and it deliberately is not `0`: a fraction ending
176
+ * in zero is exactly what `generateKeyBetween` refuses to read afterwards. `0` is therefore used
177
+ * only where it is the one digit that still fits under an upper bound.
178
+ */
179
+ const SmallestOrderDigit = "0";
180
+ const MiddleOrderDigit = "V";
181
+ /**
182
+ * A key strictly between two stored ones, spelled with nothing but the character class
183
+ * (anchrd/intel#359).
184
+ *
185
+ * ⚠️ It is the second half of `orderBetween`, reached only when the library refused, and it rests
186
+ * on the one property `BoardTaskOrder` really guarantees and `byOrder` already sorts by: keys are
187
+ * compared as strings.
188
+ *
189
+ * Two cases, and they are the whole alphabet:
190
+ *
191
+ * - `below` shares no prefix with `above`. Then the two already differ at a digit `above` wins, and
192
+ * anything appended to `below` keeps losing at that same digit — so `below + "V"` is between them
193
+ * however long either of them is.
194
+ * - `below` IS a prefix of `above`. Then every key after `below` starts with `below`, and the
195
+ * smallest one that exists is `below + "0"`. If `above` is that key, the character class holds
196
+ * NOTHING in between — `"a0"` and `"a00"` is the pair this ticket is named after — and the answer
197
+ * is `null`, which the callers turn into a refusal rather than a card placed somewhere else.
198
+ */
199
+ function extendedOrder(below, above) {
200
+ if (above === null)
201
+ return `${below}${MiddleOrderDigit}`;
202
+ if (below >= above)
203
+ return null;
204
+ if (!above.startsWith(below))
205
+ return `${below}${MiddleOrderDigit}`;
206
+ return above === `${below}${SmallestOrderDigit}` ? null : `${below}${SmallestOrderDigit}`;
207
+ }
208
+ /**
209
+ * A key strictly between two stored ones, or `null` when no key exists there (anchrd/intel#359).
210
+ *
211
+ * ⚠️ This is where "who decides what a valid order key is" is answered, and the answer is
212
+ * `BoardTaskOrder` — the character class — and NOT `fractional-indexing`. The two disagreed, and
213
+ * the disagreement WAS the bug: a stored board may spell `"0"`, `"a00"`, `"zzz"` or `"A"`, the
214
+ * library reads none of them, and it says so with a bare `Error` rather than an `IntelError` — a
215
+ * `500 internal_error` on an ordinary `board_task_add`, with one task on the board and no drag
216
+ * involved.
217
+ *
218
+ * ⚠️ Narrowing the schema to what the library reads was the other way round, and it is the trap
219
+ * #311, #318 and #321 each walked into from a different side: that would be a rule on the STORED
220
+ * document, and a stored board body is parsed in FOUR places — `parseStoredBoard`, `indexing.ts`,
221
+ * `document-links.ts` and the bundle import. A board carrying one such key would stop being
222
+ * drawable, searchable, linkable and movable, all at once, over a value that is server-assigned and
223
+ * that no caller ever sent.
224
+ *
225
+ * ⚠️ So the library is ASKED and not trusted. It is the only thing that knows how to keep keys
226
+ * short and dense, which is what every healthy board lives on, and it stays the first answer. But
227
+ * when it refuses a bound, that is a statement about the bound and not about the board, and the
228
+ * fallback carries on with string comparison alone.
229
+ *
230
+ * ⚠️ `null` is an answer and not a failure, and it is the one thing callers may not paper over. It
231
+ * means the character class holds no key in that interval at all, so the only alternatives are
232
+ * refusing and sliding a card past another one in silence — and the second is the mistake the
233
+ * second review round of anchrd/intel#341 produced.
234
+ *
235
+ * ⚠️ The `catch` drops the library's message on purpose, and this is not the swallowed cause
236
+ * `.claude/review-rules.md` names: nothing here is being reported to anybody. `generateKeyBetween`
237
+ * throws to say "I do not read this bound", which is a QUESTION ANSWERED and the whole reason the
238
+ * second half exists. The refusals a caller actually sees are built below from `null`, a state this
239
+ * function decided, and they name which side is at fault and what to do about it.
240
+ */
241
+ function orderBetween(below, above) {
242
+ try {
243
+ return generateKeyBetween(below, above);
244
+ }
245
+ catch {
246
+ return extendedOrder(below ?? "", above);
247
+ }
248
+ }
4
249
  /**
5
250
  * Every board operation, applied to the document rather than to the file (#285).
6
251
  *
@@ -16,9 +261,14 @@ import { IntelError } from "../../shared/intel-error/intel-error.js";
16
261
  */
17
262
  export function createBoard(deps) {
18
263
  function byOrder(left, right) {
19
- // The id breaks a tie that cannot happen: two keys are distinct by construction. It is here so
20
- // the sort is total, because an unstable order would make two readers of the same board
21
- // disagree about which card comes first.
264
+ // ⚠️ The id breaks a tie that DOES happen, which is why it is not merely insurance
265
+ // (anchrd/intel#359). A board bundle written elsewhere may name one key twice, and a placement
266
+ // whose bounds are two neighbours the caller named but that are not next to each other can mint
267
+ // one that a card between them already holds — the second is anchrd/intel#380 and is not fixed
268
+ // here. What every placement taking its bounds from the nearest keys ACTUALLY there cannot do
269
+ // is create a repeat: such a key is strictly inside an interval no card occupies. The tie is
270
+ // broken at all because the sort has to be total — an unstable order would make two readers of
271
+ // the same board disagree about which card comes first.
22
272
  if (left.order === right.order)
23
273
  return left.id < right.id ? -1 : 1;
24
274
  return left.order < right.order ? -1 : 1;
@@ -185,6 +435,91 @@ export function createBoard(deps) {
185
435
  for (const id of stack)
186
436
  visit(id);
187
437
  }
438
+ /**
439
+ * The key a placement gets, or the refusal that names the way out (anchrd/intel#359).
440
+ *
441
+ * ⚠️ Everything that mints a key for a placement comes through here, so that the two answers
442
+ * `orderBetween` can give — a key, or "no key exists there" — are turned into an `IntelError`
443
+ * once rather than at every call site. What must never come out of it is a bare `Error`: that is
444
+ * `500 internal_error` at the surface, on a board whose keys somebody else wrote.
445
+ *
446
+ * ⚠️ The length is checked on BOTH paths, not only on the extension. `generateKeyBetween` happily
447
+ * answers a 64-character key with a 65-character one, and a key one over
448
+ * `MaxBoardTaskOrderLength` would be stored and then refused by the very next read
449
+ * (`board_unreadable`) — the whole board lost over one added card.
450
+ *
451
+ * ⚠️ The first refusal names the crowded neighbour by its ORDER KEY rather than by its id, the
452
+ * same way the repair's does: the key is what made it the neighbour, and `board_get` answers with
453
+ * both. The second cannot — a key that is already as long as one may be leaves no interval and so
454
+ * no single task to point at — so it names the move instead of the card.
455
+ */
456
+ function mintedKey(below, above) {
457
+ const key = orderBetween(below, above);
458
+ if (key === null) {
459
+ // ⚠️ Two sentences, because they are two different situations and only one of them can point
460
+ // at a card. Equal bounds mean the two neighbours ARE one place, and naming "the task whose
461
+ // order is a0" would name both of them; either one moving is what opens the place.
462
+ const reason = below === above
463
+ ? `The tasks named as neighbours both carry the order key “${below}”, so there is no place between them. Move either of them with board_task_move`
464
+ : `No order key fits ${below === null ? `before “${above}”` : `between “${below}” and “${above}”`}, so this task has nowhere to go. Move the task whose order is “${above}” with board_task_move`;
465
+ throw new IntelError(409, "board_task_order_unreadable", `${reason}, then place this one again.`);
466
+ }
467
+ if (key.length > MaxBoardTaskOrderLength) {
468
+ throw new IntelError(409, "board_task_order_unreadable", "The order key beside this place is already as long as an order key may be, so there is no room left after it. Move the neighbouring task with board_task_move, then place this one again.");
469
+ }
470
+ return key;
471
+ }
472
+ /**
473
+ * The next key that is really ABOVE the one at `index`, rather than the next task in the list.
474
+ *
475
+ * ⚠️ The difference only exists on a board whose keys came out of a file, and there it is the
476
+ * whole point: two cards under one key have no order between them — `BoardTaskOrder` says so and
477
+ * `byOrder` next door assumes it away — so "between them" is not a place, and a run of equal keys
478
+ * is ONE place rather than several. Taking `others[index + 1]` blindly hands `generateKeyBetween`
479
+ * a lower bound that is not below its upper, which is the bare `Error: >= ` this ticket is about.
480
+ *
481
+ * ⚠️ Skipping the run moves nothing: the new card lands after the whole run instead of inside it,
482
+ * and every card that was on the board keeps its key.
483
+ */
484
+ function nextOrderAbove(others, index) {
485
+ const below = others[index]?.order;
486
+ if (below === undefined)
487
+ return null;
488
+ return others.slice(index + 1).find((task) => task.order > below)?.order ?? null;
489
+ }
490
+ /**
491
+ * The mirror of `nextOrderAbove`: the nearest key really BELOW the one at `index`.
492
+ *
493
+ * ⚠️ It is what a placement that names only a SUCCESSOR has to stand on, and forgetting it was a
494
+ * bug of its own rather than a detail of anchrd/intel#359. A drop at the top of a column names no
495
+ * predecessor (`board-kanban.ts` sends `rest[landing - 1] ?? null`), and answering that with "no
496
+ * lower bound at all" hands `generateKeyBetween` the whole key space: `(null, "a1")` mints `"a0"`
497
+ * — a key the card below may already hold, in this column or another. Intel then writes two tasks
498
+ * under one order key on an ordinary drag, which is the very state anchrd/intel#341 and this
499
+ * ticket exist to answer.
500
+ *
501
+ * ⚠️ Without it the extension is worse than wrong, it is silent: `extendedOrder("", above)` sees
502
+ * `above.startsWith("")` and answers `"0"`, the smallest key the character class can spell, so a
503
+ * card dropped above one task lands above NONE of them and nothing says so.
504
+ *
505
+ * ⚠️ Against a run of equal keys it answers the key below the WHOLE run, so the new card lands
506
+ * before all of them — the exact mirror of what `nextOrderAbove` does upward, and for the same
507
+ * reason: cards sharing a key have no order between them, so the run is one place.
508
+ *
509
+ * ⚠️ It answers about the bound the caller did NOT name. Where they named both and the two are
510
+ * not next to each other, the bounds are their cards and a key between them can be one that a
511
+ * card in the gap already holds — anchrd/intel#380, not closed here.
512
+ */
513
+ function previousOrderBelow(others, index) {
514
+ const above = others[index]?.order;
515
+ if (above === undefined)
516
+ return null;
517
+ // `reverse` mutates, and the slice it mutates is this call's own copy.
518
+ return (others
519
+ .slice(0, index)
520
+ .reverse()
521
+ .find((task) => task.order < above)?.order ?? null);
522
+ }
188
523
  /**
189
524
  * The order key for a task's new place.
190
525
  *
@@ -214,16 +549,55 @@ export function createBoard(deps) {
214
549
  const column = others.filter((task) => task.status === placement.status && task.parentId === placement.parentId);
215
550
  const last = column.at(-1);
216
551
  if (!last)
217
- return generateKeyBetween(others.at(-1)?.order ?? null, null);
552
+ return mintedKey(others.at(-1)?.order ?? null, null);
218
553
  afterIndex = others.indexOf(last);
219
554
  }
220
- const lower = afterIndex === null ? null : (others[afterIndex]?.order ?? null);
555
+ // Each bound is the named neighbour where the caller gave one, and the nearest key really on
556
+ // the other side of the other neighbour where they did not. Reading an absent bound as "the end
557
+ // of the key space" is what let a drop at the top of a column mint a key another card holds.
558
+ const lower = afterIndex !== null
559
+ ? (others[afterIndex]?.order ?? null)
560
+ : beforeIndex !== null
561
+ ? previousOrderBelow(others, beforeIndex)
562
+ : null;
221
563
  const upper = beforeIndex !== null
222
564
  ? (others[beforeIndex]?.order ?? null)
223
565
  : afterIndex !== null
224
- ? (others[afterIndex + 1]?.order ?? null)
566
+ ? nextOrderAbove(others, afterIndex)
225
567
  : null;
226
- return generateKeyBetween(lower, upper);
568
+ return mintedKey(lower, upper);
569
+ }
570
+ /**
571
+ * A key strictly after `below` and strictly under `above`, for the repair (anchrd/intel#341).
572
+ *
573
+ * ⚠️ The same `orderBetween` the placements use, because there is one answer to "how is an order
574
+ * key made" and it should not be given twice — this only says it in the repair's own words. What
575
+ * differs is the sentence a caller reads: they came here from a refused import, not from a drag.
576
+ *
577
+ * ⚠️ Refused rather than written, in both directions. Over `MaxBoardTaskOrderLength` the key would
578
+ * be stored and then rejected by the very next read (`board_unreadable`); with no key in the
579
+ * interval at all it would take a silent slide past the task above to carry on. Either way the
580
+ * board keeps its pair and hears why.
581
+ *
582
+ * ⚠️ And the refusal says what to DO, for the same reason the refused import does: the crowded
583
+ * neighbour is usually one `board_task_move` from being out of the way, and the repair then goes
584
+ * through. Since anchrd/intel#359 that move is a real way out rather than a second 500 — it meets
585
+ * the same unreadable keys and answers them, which is what closed the one dead end this exit did
586
+ * not reach.
587
+ *
588
+ * ⚠️ Nothing checks the key against the ones the board already holds, and nothing needs to:
589
+ * `above` is the SMALLEST key above `below`, so every other key on the board is at or below
590
+ * `below` or at or above `above`, and `orderBetween` answers strictly inside that interval.
591
+ */
592
+ function keyAfter(below, above) {
593
+ const key = orderBetween(below, above);
594
+ if (key === null) {
595
+ throw new IntelError(409, "board_task_order_unreadable", `Nothing fits between the repeated task and the one ordered after it. Move the task whose order is “${above}” with board_task_move, then repair again.`);
596
+ }
597
+ if (key.length > MaxBoardTaskOrderLength) {
598
+ throw new IntelError(409, "board_task_order_unreadable", "The order key beside the repeated task is already as long as an order key may be, so a repair has nowhere to put the second entry");
599
+ }
600
+ return key;
227
601
  }
228
602
  function replaced(board, task) {
229
603
  return {
@@ -260,6 +634,11 @@ export function createBoard(deps) {
260
634
  id: status.id,
261
635
  label: status.label,
262
636
  order: index,
637
+ // ⚠️ The shelf is always terminal and the boundary refuses an explicit `false` for it, so
638
+ // this is not a silent correction of something a caller asked for — it is the same fact
639
+ // stated where the document is built. Everything else defaults to "not finished": a new
640
+ // column is work, and a column that ends work is a thing somebody says on purpose (#311).
641
+ terminal: status.id === ArchivedBoardStatusId ? true : (status.terminal ?? false),
263
642
  }));
264
643
  return { statuses: next, tasks: board.tasks };
265
644
  },
@@ -354,6 +733,94 @@ export function createBoard(deps) {
354
733
  });
355
734
  return { board: { statuses: board.statuses, tasks }, deleted: removed.size };
356
735
  },
736
+ /**
737
+ * The way out of a board that names one task id twice (anchrd/intel#341).
738
+ *
739
+ * ⚠️ Nobody chooses which of the two survives, because nothing is lost: both entries stay,
740
+ * whole, and only the id of the later one changes. That is the entire repair, and it is what
741
+ * lets it happen at all — #321 refused to fold such a pair on the READ precisely because a fold
742
+ * throws one of two real tasks away, and the next save of any kind writes that away for good.
743
+ * Here somebody asked, one field of one entry moves, and the answer says exactly which.
744
+ *
745
+ * ⚠️ The FIRST stored entry keeps the id, and that is not a coin toss. Every read here already
746
+ * means that one by it: `taskOf` is a `find`, so `updateTask`, `moveTask` and `deleteTask` have
747
+ * always taken the first entry's content as "the task with this id". Renumbering the first
748
+ * instead would hand the id to a task the server never meant by it, and every `parentId` and
749
+ * `dependsOn` on the board would silently start pointing at a different card.
750
+ *
751
+ * ⚠️ The UI's fold picks a different entry, and that is not a disagreement to repair.
752
+ * `orderedTasks` (packages/ui) folds AFTER sorting, so the card on screen is whichever of the
753
+ * pair would be drawn first — which can be the second stored one. That rule is about a DRAWING
754
+ * and #321 chose it so the card a reader clicks is the one `byId` answers with; this one is
755
+ * about what the id MEANS, and the two only differ for as long as the pair exists. After the
756
+ * repair every id is distinct, both cards are drawn, and their titles say which is which.
757
+ *
758
+ * ⚠️ `parentId` and `dependsOn` are left exactly as they stand, on both entries and on every
759
+ * other task — they are NOT copied onto the renumbered one. Two tasks cannot inherit one edge:
760
+ * a `parentId` is one id, so "both" is not a thing the document can say. What the pointers
761
+ * meant while the pair existed is what they mean now — the entry that kept the id — and the
762
+ * renumbered task therefore comes out with nothing hanging off it. That is visible on the
763
+ * board, and `board_task_move` moves a subtask under it in one call; inventing the edges here
764
+ * would be the silent change this repair exists to avoid.
765
+ *
766
+ * ⚠️ The `order` key is re-minted with the id, and it HAS to be. A duplicate is a copy, so both
767
+ * entries usually carry the same fractional index — harmless while they were one card, and a
768
+ * broken board the moment they are two: `BoardTaskOrder` says a shared key leaves two tasks with
769
+ * no defined order at all, and `byOrder` next door assumes keys are distinct by construction.
770
+ * Both cards are drawn after the repair, so a drop between them sends the pair as its two
771
+ * neighbours and `generateKeyBetween` refuses a lower bound that is not below its upper — an
772
+ * `Error: >= ` rather than an `IntelError`, which is a 500 on an ordinary drag. Re-minting is
773
+ * not content anybody gave up: `order` is server-assigned and a caller can never send one, and
774
+ * the key is minted where the entry already stood.
775
+ *
776
+ * ⚠️ It is minted against the next key STRICTLY above, not against the neighbour. A file that
777
+ * repeats an id can repeat an order key three times over, and anchoring on an equal key would
778
+ * make the way out throw exactly what it exists to prevent.
779
+ *
780
+ * ⚠️ Neither `requireParent` nor `requireDependencies` runs. Such a board arrived through a
781
+ * bundle written elsewhere and may break other rules too — a dependency on a task it does not
782
+ * hold, a chain deeper than `BoardMaxTaskDepth` — and refusing the repair over one of those
783
+ * would leave the board stuck for a reason the caller did not come about. Nothing here can
784
+ * create one of THOSE either: a fresh id has nothing pointing at it, so no cycle can close
785
+ * through the task that changed and no chain grows.
786
+ *
787
+ * ⚠️ There is no sibling for `statuses`, and none is needed: `configure` writes the whole list
788
+ * and `ConfigureBoardInput` has demanded distinct ids since #285, so one `board_configure` with
789
+ * the repeated id spelled differently repairs a duplicate column — with the caller choosing the
790
+ * label, which is a thing only they can know.
791
+ */
792
+ repairTaskIds(board) {
793
+ const seen = new Set();
794
+ const renumbered = [];
795
+ // Both grow as keys are minted: `generateKeyBetween` is deterministic, so a third entry under
796
+ // one id anchored on the same key as the second would be handed the second's key back. It
797
+ // anchors on the last one minted for that id instead, which is also what keeps the entries in
798
+ // the order the file listed them.
799
+ const orders = board.tasks.map((task) => task.order);
800
+ const lastMinted = new Map();
801
+ const tasks = board.tasks.map((task) => {
802
+ if (!seen.has(task.id)) {
803
+ seen.add(task.id);
804
+ return task;
805
+ }
806
+ const below = lastMinted.get(task.id) ?? task.order;
807
+ const above = orders.filter((order) => order > below).sort();
808
+ const order = keyAfter(below, above.at(0) ?? null);
809
+ orders.push(order);
810
+ lastMinted.set(task.id, order);
811
+ const renamed = { ...task, id: deps.id(), order };
812
+ seen.add(renamed.id);
813
+ renumbered.push({ previousId: task.id, task: renamed });
814
+ return renamed;
815
+ });
816
+ // Refused rather than answered with an empty list: a caller sent here by a refused import
817
+ // learns that this board is not the one holding the pair, and a board that needs nothing does
818
+ // not get a version, an audit event and a reindex for a document that did not change.
819
+ if (renumbered.length === 0) {
820
+ throw new IntelError(409, "board_task_ids_distinct", "This board names every task once, so there is nothing to repair");
821
+ }
822
+ return { board: { statuses: board.statuses, tasks }, renumbered };
823
+ },
357
824
  sorted,
358
825
  };
359
826
  }
@@ -27,5 +27,12 @@ export interface BoardOperations {
27
27
  board: BoardDocument;
28
28
  deleted: number;
29
29
  };
30
+ repairTaskIds(board: BoardDocument): {
31
+ board: BoardDocument;
32
+ renumbered: {
33
+ previousId: string;
34
+ task: BoardTask;
35
+ }[];
36
+ };
30
37
  sorted(tasks: readonly BoardTask[]): BoardTask[];
31
38
  }
@@ -1,4 +1,5 @@
1
1
  import { BlockNoteDocument, BlockNoteMediaType, BoardDocument, BoardMediaType, DocumentLinkInlineType, } from "@anchrd/intel-contract";
2
+ import { upgradeStoredBoard } from "../board/board.js";
2
3
  function isRecord(value) {
3
4
  return typeof value === "object" && value !== null;
4
5
  }
@@ -52,7 +53,17 @@ export function documentLinkTargets(mediaType, content) {
52
53
  }
53
54
  const found = new Set();
54
55
  if (mediaType === BoardMediaType) {
55
- const board = BoardDocument.safeParse(parsed);
56
+ /**
57
+ * ⚠️ Upgraded first, like `parseStoredBoard` and `indexing.ts` — this is the FOURTH place a
58
+ * board body is parsed, and the only one that cannot complain (anchrd/intel#321).
59
+ *
60
+ * A board it fails to parse simply has no links, which reads exactly like a board that points
61
+ * at nothing — and the reconciliation this feeds removes the links a body no longer names, so
62
+ * an unparsed old board would quietly leave the link graph. Every caller hands it a body written
63
+ * moments earlier today, so it has never met one; the upgrade is what keeps that from being the
64
+ * only reason.
65
+ */
66
+ const board = BoardDocument.safeParse(upgradeStoredBoard(parsed));
56
67
  if (!board.success)
57
68
  return [];
58
69
  for (const task of board.data.tasks) {