@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.
- package/dist/adapters/cloudflare/cloudflare.js +1 -0
- package/dist/adapters/cloudflare-api/cloudflare-api.js +153 -44
- package/dist/adapters/db/db-indexing.js +79 -0
- package/dist/adapters/db/db.js +66 -27
- package/dist/adapters/openid/openid.js +70 -10
- package/dist/adapters/semantic-index/semantic-index.js +97 -17
- package/dist/adapters/semantic-index/semantic-index.types.d.ts +20 -1
- package/dist/bundle/bundle.js +46 -1
- package/dist/http/http.js +4 -0
- package/dist/indexing/indexing.js +177 -17
- package/dist/indexing/indexing.types.d.ts +1 -0
- package/dist/mcp/mcp.js +48 -34
- package/dist/nodes/board/board.d.ts +46 -0
- package/dist/nodes/board/board.js +475 -8
- package/dist/nodes/board/board.types.d.ts +7 -0
- package/dist/nodes/document-links/document-links.js +12 -1
- package/dist/nodes/nodes.js +152 -16
- package/dist/nodes/nodes.types.d.ts +61 -3
- package/dist/tools/tools.js +7 -1
- package/migrations/0009_no_context_policy.sql +15 -0
- package/migrations/0017_a_vector_per_card.sql +38 -0
- package/migrations/0018_no_context_policy_at_last.sql +90 -0
- package/package.json +2 -2
|
@@ -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
|
|
20
|
-
//
|
|
21
|
-
//
|
|
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
|
|
552
|
+
return mintedKey(others.at(-1)?.order ?? null, null);
|
|
218
553
|
afterIndex = others.indexOf(last);
|
|
219
554
|
}
|
|
220
|
-
|
|
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
|
|
566
|
+
? nextOrderAbove(others, afterIndex)
|
|
225
567
|
: null;
|
|
226
|
-
return
|
|
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
|
-
|
|
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) {
|