@anchrd/intel-api 0.27.0 → 0.29.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 +38 -0
- package/dist/adapters/db/db-audit.d.ts +34 -0
- package/dist/adapters/db/db-audit.js +89 -0
- package/dist/adapters/db/db-boards.d.ts +6 -0
- package/dist/adapters/db/db-boards.js +239 -0
- package/dist/adapters/db/db.js +38 -2
- package/dist/audit/audit.d.ts +2 -0
- package/dist/audit/audit.js +72 -0
- package/dist/audit/audit.types.d.ts +22 -0
- package/dist/audit/audit.types.js +1 -0
- package/dist/boards/boards.d.ts +30 -0
- package/dist/boards/boards.js +288 -0
- package/dist/boards/boards.types.d.ts +76 -0
- package/dist/boards/boards.types.js +1 -0
- package/dist/http/http.js +67 -0
- package/dist/http/http.types.d.ts +4 -0
- package/dist/intel/intel.js +4 -0
- package/dist/intel/intel.types.d.ts +4 -0
- package/dist/mcp/mcp.js +72 -0
- package/dist/mcp/mcp.types.d.ts +4 -0
- package/dist/nodes/nodes.js +224 -9
- package/dist/nodes/nodes.types.d.ts +39 -0
- package/migrations/0022_a_cursor_over_the_journal.sql +14 -0
- package/migrations/0023_a_board_and_its_tasks.sql +116 -0
- package/migrations/0024_the_archive_is_a_column.sql +33 -0
- package/package.json +2 -2
package/dist/nodes/nodes.js
CHANGED
|
@@ -62,6 +62,119 @@ function decodeBase64(value) {
|
|
|
62
62
|
throw new IntelError(400, "attachment_invalid", "Attachment content is not valid base64");
|
|
63
63
|
}
|
|
64
64
|
}
|
|
65
|
+
/**
|
|
66
|
+
* Where a kind may be filed, and it is one rule read by two callers — `create` and the move inside
|
|
67
|
+
* `update` (#377).
|
|
68
|
+
*
|
|
69
|
+
* ⚠️ **Three cases, and the first version had only one of them.** It handled "wrong parent that
|
|
70
|
+
* exists" and left two doors open, both of which produced exactly the state the rule exists against:
|
|
71
|
+
*
|
|
72
|
+
* * **No parent at all.** `parentId: null` is the ROOT, and the root is not a board. The first
|
|
73
|
+
* version skipped the check entirely for `null`, so `node_create({ kind: "task", parentId:
|
|
74
|
+
* null })` made a task with no board — and `attachToBoard` did not run either, for the same
|
|
75
|
+
* reason. A task on no board, created through the front door.
|
|
76
|
+
* * **Right kind, wrong board.** Every `kind === "board"` was accepted as a destination, so a
|
|
77
|
+
* task could be moved to ANOTHER board while its `board_tasks.board_id` stayed behind. That
|
|
78
|
+
* divergence was reachable only by writing D1 by hand before this rule existed (the join in
|
|
79
|
+
* `db-boards.ts` guards against it); the first version of this rule opened a real code path to
|
|
80
|
+
* it. **A change that widens what is allowed has to be read for what it now permits, not only
|
|
81
|
+
* for what it now refuses.**
|
|
82
|
+
*
|
|
83
|
+
* ⚠️ The refusal names the KIND in the sentence rather than a bare "not a folder". Somebody
|
|
84
|
+
* dragging a card into a folder has to learn what went wrong, not that something did.
|
|
85
|
+
*/
|
|
86
|
+
/**
|
|
87
|
+
* ⚠️ **Nothing may be filed under an ARCHIVED node**, and this is not tidiness — it is what keeps
|
|
88
|
+
* the purge honest.
|
|
89
|
+
*
|
|
90
|
+
* A parent may be archived while it is empty; `task_has_subtasks` only refuses one that still has
|
|
91
|
+
* live children. Without this rule it could then GAIN a live child afterwards, and `purge` — which
|
|
92
|
+
* asks only whether the node itself is archived — walks the whole subtree and takes that child
|
|
93
|
+
* with it. It would disappear for good without ever being named in the confirmation.
|
|
94
|
+
*
|
|
95
|
+
* Found in review of #669, against my own sentence claiming purge needed no rule of its own. That
|
|
96
|
+
* sentence was true only as long as an archived node could never gain a live child, and nothing
|
|
97
|
+
* made it true.
|
|
98
|
+
*/
|
|
99
|
+
/**
|
|
100
|
+
* The board a node would be filed on: the board itself, or the board of the task it hangs under
|
|
101
|
+
* (#669).
|
|
102
|
+
*
|
|
103
|
+
* ⚠️ **One function, because there are three callers and they must not drift.** The create path,
|
|
104
|
+
* the move and the idempotent replay all ask the same question, and the replay was written when a
|
|
105
|
+
* task's parent was always its board — it passed `parentId` straight through as the board id. With
|
|
106
|
+
* nesting that files a subtask on a "board" that is another card, and `board_tasks.board_id` has no
|
|
107
|
+
* foreign key saying otherwise. Found in review of #669: the main path had been corrected and this
|
|
108
|
+
* one had not, which is the shape of `anchrd/intel#457` — two places answering one question, only
|
|
109
|
+
* one of them told about the change.
|
|
110
|
+
*/
|
|
111
|
+
async function boardFor(deps, actor, parent) {
|
|
112
|
+
if (parent === null)
|
|
113
|
+
return null;
|
|
114
|
+
if (parent.kind === "board")
|
|
115
|
+
return parent.id;
|
|
116
|
+
// ⚠️ Only a TASK can have a board, and asking for anything else costs a D1 round trip on every
|
|
117
|
+
// write in the whole tree — a document filed in a folder would pay for a board feature. The
|
|
118
|
+
// question is not "do we know the board" but "can this parent have one at all".
|
|
119
|
+
if (parent.kind !== "task")
|
|
120
|
+
return null;
|
|
121
|
+
return (await deps.boardOfTask?.(actor, parent.id)) ?? null;
|
|
122
|
+
}
|
|
123
|
+
function refuseArchivedParent(childKind, parent) {
|
|
124
|
+
// ⚠️ Tasks only, and the narrowness is deliberate. The same hole exists for a folder — archive an
|
|
125
|
+
// empty one, file a document in it, purge — but it is OLDER than this ticket and fixing it here
|
|
126
|
+
// would change the behaviour of the whole tree out of a board ticket, with no test outside
|
|
127
|
+
// `boards.int.ts` covering it. It is #679, which also asks what such data already exists.
|
|
128
|
+
//
|
|
129
|
+
// ⚠️ A reference without its number is not a reference: the review of #677 searched for this
|
|
130
|
+
// ticket and could not find it, because this comment did not name it.
|
|
131
|
+
if (childKind !== "task")
|
|
132
|
+
return;
|
|
133
|
+
if (parent === null || parent.archivedAt === null)
|
|
134
|
+
return;
|
|
135
|
+
throw new IntelError(409, "parent_archived", `This ${parent.kind} is archived, so nothing new can be filed under it. Restore it first.`);
|
|
136
|
+
}
|
|
137
|
+
function refuseWrongParent(childKind, parent, boards) {
|
|
138
|
+
if (childKind === "task") {
|
|
139
|
+
if (parent === null) {
|
|
140
|
+
throw new IntelError(409, "task_belongs_to_a_board", "A task lives on a board. The top level is not one, so there is nowhere for it to sit there.");
|
|
141
|
+
}
|
|
142
|
+
// ⚠️ Since #669 a task may sit under a task — the hierarchy is the node tree. Everything else
|
|
143
|
+
// is still refused by name.
|
|
144
|
+
if (parent.kind !== "board" && parent.kind !== "task") {
|
|
145
|
+
throw new IntelError(409, "task_belongs_to_a_board", `A task lives on a board and cannot be filed under a ${parent.kind}. Move the board instead, or make a new task on the board you want it on.`);
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* ⚠️ **The same BOARD, not the same parent node — and that difference is what #669 changed.**
|
|
149
|
+
* Until then the two were interchangeable, because a task's parent always WAS its board; the
|
|
150
|
+
* check compared parent ids and was right by accident. With nesting, moving a card under a
|
|
151
|
+
* sibling changes its parent legitimately while the board stays the same, and comparing ids
|
|
152
|
+
* would refuse a move that is allowed.
|
|
153
|
+
*
|
|
154
|
+
* The rule it protects is unchanged: `board_tasks.board_id` does not travel with a move through
|
|
155
|
+
* the node path, so a card handed to another board would answer from one board's query while
|
|
156
|
+
* its row names the other — and the card would show up on neither, or on both.
|
|
157
|
+
*/
|
|
158
|
+
if (boards?.destination === null || boards?.destination === undefined) {
|
|
159
|
+
throw new IntelError(409, "task_belongs_to_a_board", "This destination has no board, so a card cannot be filed under it.");
|
|
160
|
+
}
|
|
161
|
+
if (boards.current !== undefined &&
|
|
162
|
+
boards.current !== null &&
|
|
163
|
+
boards.destination !== boards.current) {
|
|
164
|
+
throw new IntelError(409, "task_cannot_change_board", "A card cannot be handed to another board this way. Make it on the board you want it on; its position, status and dependencies belong to the board it was filed on.");
|
|
165
|
+
}
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
// The root takes everything else — that is what a top-level folder or document is.
|
|
169
|
+
if (parent === null)
|
|
170
|
+
return;
|
|
171
|
+
if (parent.kind === "board") {
|
|
172
|
+
throw new IntelError(409, "board_holds_only_tasks", `A board holds tasks and nothing else; a ${childKind} cannot be filed on one.`);
|
|
173
|
+
}
|
|
174
|
+
if (parent.kind !== "folder") {
|
|
175
|
+
throw new IntelError(409, "parent_not_folder", "A node's parent must be a folder");
|
|
176
|
+
}
|
|
177
|
+
}
|
|
65
178
|
export function createNodes(deps) {
|
|
66
179
|
function mergeSearchResults(lexical, semantic, semanticScores, limit) {
|
|
67
180
|
const merged = new Map();
|
|
@@ -446,13 +559,47 @@ export function createNodes(deps) {
|
|
|
446
559
|
},
|
|
447
560
|
async create(actor, input) {
|
|
448
561
|
const existingId = await deps.repository.findIdempotentNode(actor.id, "node.create", input.idempotencyKey);
|
|
449
|
-
if (existingId)
|
|
450
|
-
|
|
562
|
+
if (existingId) {
|
|
563
|
+
const existing = await requireVisible(actor, existingId);
|
|
564
|
+
/**
|
|
565
|
+
* ⚠️ **The filing runs on the REPLAY path too, and that is what makes a retry a repair.**
|
|
566
|
+
*
|
|
567
|
+
* `insertNode` commits the node, its idempotency key and its audit row in one batch;
|
|
568
|
+
* `attachToBoard` is a separate statement after it. If that one fails — a transient D1
|
|
569
|
+
* error is enough — the node exists and its board row does not, and `create` throws. The
|
|
570
|
+
* obvious response is the same call again with the same key, and until this line that call
|
|
571
|
+
* returned here **without filing anything**: a task that is a task by kind, sits under a
|
|
572
|
+
* board, and appears on no board, permanently, with nothing reporting it.
|
|
573
|
+
*
|
|
574
|
+
* `attach` is `ON CONFLICT DO NOTHING`, so running it on a node that is already filed costs
|
|
575
|
+
* one statement and changes nothing. That asymmetry is why the repair belongs here rather
|
|
576
|
+
* than in a sweeper somebody has to remember to write.
|
|
577
|
+
*/
|
|
578
|
+
if (existing.kind === "task" && existing.parentId !== null) {
|
|
579
|
+
// ⚠️ The BOARD, resolved the same way the create path resolves it. Passing `parentId`
|
|
580
|
+
// straight through was right only while a task's parent was always its board.
|
|
581
|
+
const board = await boardFor(deps, actor, await requireVisible(actor, existing.parentId));
|
|
582
|
+
if (board !== null) {
|
|
583
|
+
await deps.attachToBoard?.(actor, existing.id, board, deps.now().toISOString());
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
return existing;
|
|
587
|
+
}
|
|
451
588
|
if (input.parentId !== null && !(await deps.repository.can(actor, input.parentId, "write"))) {
|
|
452
589
|
throw new IntelError(403, "node_forbidden", "Parent folder cannot be edited");
|
|
453
590
|
}
|
|
591
|
+
// ⚠️ The same rule on the way IN, and it runs for the ROOT as well. Guarding only a named
|
|
592
|
+
// parent leaves `parentId: null` open, and that is not a smaller hole: the task is created,
|
|
593
|
+
// `attachToBoard` skips it for the same reason, and the card belongs to no board at all.
|
|
594
|
+
const destinationParent = input.parentId === null ? null : await requireVisible(actor, input.parentId);
|
|
595
|
+
const destinationBoard = await boardFor(deps, actor, destinationParent);
|
|
596
|
+
// ⚠️ Kind first, archive second. A task under an archived FOLDER is refused for the deeper
|
|
597
|
+
// reason — a task lives on a board — and saying "restore the folder first" would send the
|
|
598
|
+
// reader to do something that changes nothing: restored, the folder still cannot hold a task.
|
|
599
|
+
refuseWrongParent(input.kind, destinationParent, { destination: destinationBoard });
|
|
600
|
+
refuseArchivedParent(input.kind, destinationParent);
|
|
454
601
|
const timestamp = deps.now().toISOString();
|
|
455
|
-
|
|
602
|
+
const created = await deps.repository.insertNode({
|
|
456
603
|
node: {
|
|
457
604
|
id: deps.id(),
|
|
458
605
|
parentId: input.parentId,
|
|
@@ -469,6 +616,27 @@ export function createNodes(deps) {
|
|
|
469
616
|
idempotencyKey: input.idempotencyKey,
|
|
470
617
|
auditId: deps.id(),
|
|
471
618
|
});
|
|
619
|
+
/**
|
|
620
|
+
* ⚠️ **A task created through the plain node path still gets its board row** (#648). Without
|
|
621
|
+
* this an agent calling `node_create` under a board makes a node that is a task by kind, has
|
|
622
|
+
* no row in `board_tasks`, and therefore appears on NO board — with nothing reporting an
|
|
623
|
+
* error. It is exactly the shape of failure this repository keeps writing rules about: not a
|
|
624
|
+
* refusal, an absence.
|
|
625
|
+
*
|
|
626
|
+
* ⚠️ It runs AFTER the node exists and is its own statement rather than part of the batch.
|
|
627
|
+
* The node is the truth; the board row is derived from it. A pass that dies in between leaves
|
|
628
|
+
* a task nobody filed — visible through `node_get`, repairable by filing it — while the other
|
|
629
|
+
* order would leave a board row pointing at a node that was never written.
|
|
630
|
+
*
|
|
631
|
+
* The port is optional because the node service must not require a board to exist: the CLI
|
|
632
|
+
* and the bundle importer build one without that half of the world.
|
|
633
|
+
*/
|
|
634
|
+
// ⚠️ `destinationBoard`, not `input.parentId`. Since #669 the parent may be another task, and
|
|
635
|
+
// filing the card under its parent's ID would put a subtask on a "board" that is a card.
|
|
636
|
+
if (created.kind === "task" && destinationBoard !== null) {
|
|
637
|
+
await deps.attachToBoard?.(actor, created.id, destinationBoard, timestamp);
|
|
638
|
+
}
|
|
639
|
+
return created;
|
|
472
640
|
},
|
|
473
641
|
async save(actor, input) {
|
|
474
642
|
const existingId = await deps.repository.findIdempotentNode(actor.id, "node.save", input.idempotencyKey);
|
|
@@ -779,17 +947,43 @@ export function createNodes(deps) {
|
|
|
779
947
|
const replayedId = await deps.repository.findIdempotentNode(actor.id, "node.update", input.idempotencyKey);
|
|
780
948
|
if (replayedId)
|
|
781
949
|
return await requireVisible(actor, replayedId);
|
|
782
|
-
|
|
950
|
+
// ⚠️ `undefined` means "do not move" and is the only value that skips this. An explicit
|
|
951
|
+
// `null` IS a move — to the root — and has to be checked like any other destination; the
|
|
952
|
+
// first version of this treated the two the same and let a card be dragged to the top level.
|
|
953
|
+
if (input.parentId !== undefined) {
|
|
783
954
|
if (input.parentId === current.id) {
|
|
784
955
|
throw new IntelError(409, "move_cycle", "A node cannot contain itself");
|
|
785
956
|
}
|
|
786
|
-
const parent = await requireVisible(actor, input.parentId);
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
957
|
+
const parent = input.parentId === null ? null : await requireVisible(actor, input.parentId);
|
|
958
|
+
/**
|
|
959
|
+
* ⚠️ **The write check on the destination comes FIRST, before the board is resolved.**
|
|
960
|
+
* `boardOfTask` asks with the verb `write`, so for a destination this actor may only read
|
|
961
|
+
* it answers `null` — and the refusal below would then say "this destination has no board"
|
|
962
|
+
* about a card that has one perfectly well. The honest answer is 403, and it is the order
|
|
963
|
+
* `packages/api/CLAUDE.md` prescribes anyway: capability, then resource, then action.
|
|
964
|
+
*/
|
|
965
|
+
if (parent !== null && !(await deps.repository.can(actor, parent.id, "write"))) {
|
|
791
966
|
throw new IntelError(403, "node_forbidden", "Destination folder cannot be edited");
|
|
792
967
|
}
|
|
968
|
+
const destinationBoard = await boardFor(deps, actor, parent);
|
|
969
|
+
/**
|
|
970
|
+
* ⚠️ **A board that cannot be resolved is refused, not skipped.** Without this line an
|
|
971
|
+
* installation whose board port is absent — or one where the actor may not write the
|
|
972
|
+
* board — would answer `undefined`, the comparison below would read that as "not a move
|
|
973
|
+
* between boards", and a card could be handed to any board at all. The integration test
|
|
974
|
+
* `refuses the three ways a card could leave its board` caught exactly that: the test
|
|
975
|
+
* harness did not wire the port, and door 3 stood open again.
|
|
976
|
+
*/
|
|
977
|
+
const currentBoard = current.kind === "task" ? ((await deps.boardOfTask?.(actor, current.id)) ?? null) : null;
|
|
978
|
+
if (current.kind === "task" && currentBoard === null) {
|
|
979
|
+
throw new IntelError(409, "task_cannot_change_board", "This card's board cannot be resolved, so it cannot be moved. A card belongs to the board it was filed on.");
|
|
980
|
+
}
|
|
981
|
+
// Kind first, archive second — see the same pair in `create`.
|
|
982
|
+
refuseWrongParent(current.kind, parent, {
|
|
983
|
+
destination: destinationBoard,
|
|
984
|
+
current: currentBoard,
|
|
985
|
+
});
|
|
986
|
+
refuseArchivedParent(current.kind, parent);
|
|
793
987
|
}
|
|
794
988
|
const updatedAt = deps.now().toISOString();
|
|
795
989
|
const updated = await deps.repository.updateNode({
|
|
@@ -837,6 +1031,27 @@ export function createNodes(deps) {
|
|
|
837
1031
|
// A replay does nothing a second time and answers with what the first run made.
|
|
838
1032
|
if (replayedId)
|
|
839
1033
|
return await requireVisible(actor, replayedId);
|
|
1034
|
+
/**
|
|
1035
|
+
* ⚠️ **A task with live subtasks is refused, not archived with them** (#669).
|
|
1036
|
+
* `destructive.md`: *verweigern schlägt kaskadieren* — a cascade turns one confirmed click
|
|
1037
|
+
* into an unknown number of disappearances, and the question that was asked ("archive this
|
|
1038
|
+
* card") cannot then name what actually vanishes.
|
|
1039
|
+
*
|
|
1040
|
+
* ⚠️ It is refused only ON THE WAY IN. Restoring a parent must stay possible, or a card
|
|
1041
|
+
* archived together with its children by an older path could never come back.
|
|
1042
|
+
*
|
|
1043
|
+
* The other half — `purge` — needs no rule of its own, but ONLY together with
|
|
1044
|
+
* `refuseArchivedParent` below: only an archived node may be purged, a parent cannot reach
|
|
1045
|
+
* that state while a child is live, and nothing new may be filed under it afterwards. Take
|
|
1046
|
+
* any one of the three away and a live card can be deleted for good without being named —
|
|
1047
|
+
* which is what the review of #669 found in the first version of this sentence.
|
|
1048
|
+
*/
|
|
1049
|
+
if (input.archived && current.kind === "task") {
|
|
1050
|
+
const children = await deps.repository.countLiveChildren(current.id);
|
|
1051
|
+
if (children > 0) {
|
|
1052
|
+
throw new IntelError(409, "task_has_subtasks", `This card still has ${children} subtask(s). Archive them first, or move them somewhere else — archiving a card does not archive what hangs under it.`);
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
840
1055
|
const updatedAt = deps.now().toISOString();
|
|
841
1056
|
const updated = await deps.repository.archiveNode({
|
|
842
1057
|
nodeId: current.id,
|
|
@@ -55,6 +55,23 @@ export interface NodeRepository {
|
|
|
55
55
|
limit: number;
|
|
56
56
|
}): Promise<BoundedChildren>;
|
|
57
57
|
getVisible(actor: Actor, nodeId: string): Promise<Node | null>;
|
|
58
|
+
/**
|
|
59
|
+
* How many live children this node has — **every kind, tasks included** (#669).
|
|
60
|
+
*
|
|
61
|
+
* ⚠️ **`listVisibleBounded` cannot answer this**, and the difference is invisible at the call
|
|
62
|
+
* site: that query runs through `visibleChildren`, which cuts `kind = 'task'` out of every level
|
|
63
|
+
* so a board shows no cards in the tree (D66). Asked about a task, it therefore answers `0` for
|
|
64
|
+
* every task that ever existed — a guard built on it would never fire once, and nothing would
|
|
65
|
+
* report that. Two queries with similar names are two different questions
|
|
66
|
+
* (`destructive.md`, `anchrd/intel#457`).
|
|
67
|
+
*
|
|
68
|
+
* ⚠️ **It takes no actor, and that is the second half of the same lesson.** A grant can sit on a
|
|
69
|
+
* single node (ADR-0004 §2), so a child may be invisible to whoever archives its parent. Counted
|
|
70
|
+
* through the visibility predicate, such a child reads as `0`, the refusal does not fire, the
|
|
71
|
+
* parent becomes purgeable — and the child is deleted for good without ever being named. The
|
|
72
|
+
* question here is "is anything still hanging under this node", not "what may this actor see".
|
|
73
|
+
*/
|
|
74
|
+
countLiveChildren(nodeId: string): Promise<number>;
|
|
58
75
|
listVisibleSubtree(actor: Actor, rootId: string | null): Promise<SubtreeNode[]>;
|
|
59
76
|
can(actor: Actor, nodeId: string, verb: ResourceVerb): Promise<boolean>;
|
|
60
77
|
findIdempotentNode(actorId: string, operation: "node.create" | "node.save" | "node.append" | "node.update" | "node.archive" | "node.share" | "node.revoke" | SnapshotOperation, idempotencyKey: string): Promise<string | null>;
|
|
@@ -213,6 +230,28 @@ export interface NodeAttachmentBody {
|
|
|
213
230
|
}
|
|
214
231
|
export interface NodesDeps {
|
|
215
232
|
repository: NodeRepository;
|
|
233
|
+
/**
|
|
234
|
+
* Files a freshly created `task` node on the board it was created under (#648).
|
|
235
|
+
*
|
|
236
|
+
* ⚠️ Optional on purpose, and the optionality is the decision rather than a convenience: the node
|
|
237
|
+
* service is the one path every node takes, including in the CLI and the bundle importer, and
|
|
238
|
+
* neither of those has a board half of the world. A required port would make "create a node" fail
|
|
239
|
+
* where boards are not wired — and the answer to a missing board is that a task keeps its node
|
|
240
|
+
* and gets filed later, never that the node is refused.
|
|
241
|
+
*/
|
|
242
|
+
attachToBoard?(actor: Actor, taskId: string, boardId: string, occurredAt: string): Promise<void>;
|
|
243
|
+
/**
|
|
244
|
+
* Which board a task belongs to (#669).
|
|
245
|
+
*
|
|
246
|
+
* ⚠️ Needed since a task may sit under another task: the destination of a move is then not the
|
|
247
|
+
* board itself, and "does this card change boards" cannot be answered from the node row alone.
|
|
248
|
+
*
|
|
249
|
+
* Optional for the same reason as `attachToBoard` — the CLI and the bundle importer have no
|
|
250
|
+
* board half. ⚠️ **Where it is absent, filing a task UNDER a task is refused** rather than
|
|
251
|
+
* allowed unchecked: the rule it would skip is the one that keeps a card from silently changing
|
|
252
|
+
* boards.
|
|
253
|
+
*/
|
|
254
|
+
boardOfTask?(actor: Actor, taskId: string): Promise<string | null>;
|
|
216
255
|
content: ContentStore;
|
|
217
256
|
id(): string;
|
|
218
257
|
now(): Date;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
-- A change feed reads the journal in the order it happened, not by resource. The two indexes from
|
|
2
|
+
-- 0000 both lead with a resource or an actor, so neither can serve "everything since position X":
|
|
3
|
+
-- SQLite would have to scan and sort the whole table on every poll.
|
|
4
|
+
--
|
|
5
|
+
-- ⚠️ Both columns, in the order the cursor compares them. `occurred_at` alone is not enough and the
|
|
6
|
+
-- shortfall is invisible: two events written in the same millisecond — the normal case inside one
|
|
7
|
+
-- batch, not the exception — are indistinguishable, and a reader continuing past the timestamp
|
|
8
|
+
-- skips the second one with no error and no log. The tie-break on `id` is what makes the position
|
|
9
|
+
-- stable, and the index has to carry it or the sort falls back to a scan (anchrd/intel#620).
|
|
10
|
+
--
|
|
11
|
+
-- `resource_type` leads because every query names exactly one kind: today only node events can be
|
|
12
|
+
-- listed at all, and flow events need a different visibility check before they could be.
|
|
13
|
+
CREATE INDEX audit_events_feed_idx
|
|
14
|
+
ON audit_events(resource_type, occurred_at, id);
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
-- Two more node kinds, `board` and `task`, and the two side tables that carry what a node row must
|
|
2
|
+
-- not (D66, anchrd/intel#376).
|
|
3
|
+
--
|
|
4
|
+
-- ⚠️ BOTH words in ONE rebuild. Adding them in two migrations would be the same risky operation
|
|
5
|
+
-- twice, and this one has gone wrong before: `0005` committed with every row of `node_links` gone.
|
|
6
|
+
--
|
|
7
|
+
-- The split this migration encodes: IDENTITY through `kind`, FIELDS through a side table.
|
|
8
|
+
-- * `kind` because the tree has to hide `task` on EVERY level query. As a kind that is a condition
|
|
9
|
+
-- on a column the query already carries; as a `document` plus a side-table row it would be a
|
|
10
|
+
-- join on the hottest path in the interface.
|
|
11
|
+
-- * the side table because `status`, `assignee` and the dates are FILTER columns. In the `nodes`
|
|
12
|
+
-- row they would stand empty for every other kind.
|
|
13
|
+
--
|
|
14
|
+
-- The recipe below is the one in `packages/api/CLAUDE.md`, *Rebuilding `nodes` is a recipe*. All
|
|
15
|
+
-- four points, and each is one a previous migration got wrong first:
|
|
16
|
+
-- 1. the new table is created under its FINAL name — never `ALTER TABLE … RENAME`, which rewrites
|
|
17
|
+
-- every `REFERENCES` clause and drags the children onto the table about to be dropped;
|
|
18
|
+
-- 2. `node_links` is carried out and back with `INSERT OR IGNORE`, because it is the one child
|
|
19
|
+
-- declared `ON DELETE CASCADE` and `DROP TABLE nodes` fires an implicit `DELETE FROM`;
|
|
20
|
+
-- 3. `PRAGMA defer_foreign_keys`, never `foreign_keys = OFF` — D1 ignores the latter over its HTTP
|
|
21
|
+
-- API while miniflare honours it, so a green test here would prove nothing about production;
|
|
22
|
+
-- 4. the columns are named on BOTH sides of `INSERT … SELECT`, so a future reordering cannot turn
|
|
23
|
+
-- every title into an owner id and still commit.
|
|
24
|
+
PRAGMA defer_foreign_keys = TRUE;
|
|
25
|
+
|
|
26
|
+
CREATE TABLE nodes_carry AS SELECT * FROM nodes;
|
|
27
|
+
CREATE TABLE node_links_carry AS SELECT * FROM node_links;
|
|
28
|
+
|
|
29
|
+
DROP TABLE nodes;
|
|
30
|
+
|
|
31
|
+
CREATE TABLE nodes (
|
|
32
|
+
id TEXT PRIMARY KEY NOT NULL,
|
|
33
|
+
parent_id TEXT REFERENCES nodes(id),
|
|
34
|
+
kind TEXT NOT NULL CHECK (kind IN ('folder', 'document', 'attachment', 'table', 'board', 'task')),
|
|
35
|
+
title TEXT NOT NULL CHECK (length(title) BETWEEN 1 AND 240),
|
|
36
|
+
description TEXT CHECK (description IS NULL OR length(description) <= 2000),
|
|
37
|
+
owner_id TEXT NOT NULL,
|
|
38
|
+
current_version_id TEXT,
|
|
39
|
+
created_at TEXT NOT NULL,
|
|
40
|
+
updated_at TEXT NOT NULL,
|
|
41
|
+
archived_at TEXT
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
INSERT INTO nodes (
|
|
45
|
+
id, parent_id, kind, title, description, owner_id,
|
|
46
|
+
current_version_id, created_at, updated_at, archived_at
|
|
47
|
+
)
|
|
48
|
+
SELECT
|
|
49
|
+
id, parent_id, kind, title, description, owner_id,
|
|
50
|
+
current_version_id, created_at, updated_at, archived_at
|
|
51
|
+
FROM nodes_carry;
|
|
52
|
+
|
|
53
|
+
INSERT OR IGNORE INTO node_links SELECT * FROM node_links_carry;
|
|
54
|
+
|
|
55
|
+
DROP TABLE nodes_carry;
|
|
56
|
+
DROP TABLE node_links_carry;
|
|
57
|
+
|
|
58
|
+
CREATE INDEX nodes_parent_idx ON nodes(parent_id, archived_at, title);
|
|
59
|
+
CREATE INDEX nodes_owner_idx ON nodes(owner_id, archived_at);
|
|
60
|
+
|
|
61
|
+
-- The board's own configuration: which columns it has and in what order. It is one row per board
|
|
62
|
+
-- node, and it exists because a list of statuses is not a node property — every other kind would
|
|
63
|
+
-- carry it empty.
|
|
64
|
+
--
|
|
65
|
+
-- ⚠️ `ON DELETE CASCADE` here and on `board_tasks` below, and that is deliberate in a repository
|
|
66
|
+
-- whose rule is *refuse rather than cascade*. The rule protects a CONTAINER from taking its contents
|
|
67
|
+
-- down with it; these two rows are not contents but the node's own fields, split off only because
|
|
68
|
+
-- SQLite has no per-kind columns. A board whose row here outlived it would be a configuration for a
|
|
69
|
+
-- board that does not exist.
|
|
70
|
+
CREATE TABLE boards (
|
|
71
|
+
node_id TEXT PRIMARY KEY NOT NULL REFERENCES nodes(id) ON DELETE CASCADE,
|
|
72
|
+
statuses_json TEXT NOT NULL DEFAULT '[]',
|
|
73
|
+
created_at TEXT NOT NULL,
|
|
74
|
+
updated_at TEXT NOT NULL
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
-- What a task carries beyond a node. Every column here is one somebody filters or sorts by — that
|
|
78
|
+
-- is the whole reason they are not in the body.
|
|
79
|
+
--
|
|
80
|
+
-- ⚠️ `position` is REAL, not INTEGER. Dragging a card between two neighbours then needs no rewrite
|
|
81
|
+
-- of the whole column: the new value is the midpoint of the two. With integers every drop would
|
|
82
|
+
-- renumber the rows below it, and two people dropping at once would fight over rows neither touched.
|
|
83
|
+
CREATE TABLE board_tasks (
|
|
84
|
+
node_id TEXT PRIMARY KEY NOT NULL REFERENCES nodes(id) ON DELETE CASCADE,
|
|
85
|
+
board_id TEXT NOT NULL REFERENCES nodes(id),
|
|
86
|
+
status TEXT NOT NULL,
|
|
87
|
+
assignee_id TEXT,
|
|
88
|
+
labels_json TEXT NOT NULL DEFAULT '[]',
|
|
89
|
+
start_date TEXT,
|
|
90
|
+
due_date TEXT,
|
|
91
|
+
-- "waits for", as a COLUMN and not as a sentence in the body. The DoD of #376 says it in one
|
|
92
|
+
-- line: relations are columns the text points at, never the other way round. A dependency living
|
|
93
|
+
-- in prose cannot be asked "what is blocked right now", and `node_links` cannot answer it either
|
|
94
|
+
-- — that table says two nodes are related, not that one waits for the other.
|
|
95
|
+
--
|
|
96
|
+
-- ⚠️ It points at `nodes`, not at `board_tasks`. A task may wait for something that is not a
|
|
97
|
+
-- task: a document that has to be approved first, a board that has to be finished. Narrowing it
|
|
98
|
+
-- to the side table would decide today that only tasks can block, and nothing asked for that.
|
|
99
|
+
depends_on TEXT REFERENCES nodes(id),
|
|
100
|
+
position REAL NOT NULL DEFAULT 0,
|
|
101
|
+
created_at TEXT NOT NULL,
|
|
102
|
+
updated_at TEXT NOT NULL
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
-- The board view reads one column at a time, ordered. Both columns of the sort are in the index, so
|
|
106
|
+
-- the read needs no sorting pass — the same reason `audit_events_feed_idx` carries its tie-break
|
|
107
|
+
-- (anchrd/intel#620).
|
|
108
|
+
CREATE INDEX board_tasks_column_idx ON board_tasks(board_id, status, position);
|
|
109
|
+
|
|
110
|
+
-- "What is assigned to me, soonest first" — across every board, which is why `assignee_id` leads
|
|
111
|
+
-- and `board_id` does not appear.
|
|
112
|
+
CREATE INDEX board_tasks_assignee_idx ON board_tasks(assignee_id, due_date);
|
|
113
|
+
|
|
114
|
+
-- "What is blocked by this node" — the direction the interface asks in. Without it the question is
|
|
115
|
+
-- a scan of every task on every board.
|
|
116
|
+
CREATE INDEX board_tasks_depends_idx ON board_tasks(depends_on) WHERE depends_on IS NOT NULL;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
-- The archive as a column every board has (D68, #674).
|
|
2
|
+
--
|
|
3
|
+
-- ⚠️ Only the VISIBILITY is stored. Which cards are in the archive is `nodes.archived_at` and
|
|
4
|
+
-- nothing else — a second column holding "is archived" would be the two truths D68 exists against.
|
|
5
|
+
-- A card is in the archive column BECAUSE it is archived; dropping it there archives it, and
|
|
6
|
+
-- pulling it out restores it into the working column `board_tasks.status` still names.
|
|
7
|
+
--
|
|
8
|
+
-- ⚠️ Default 1: the column is there on every board unless somebody hides it. A board that predates
|
|
9
|
+
-- this migration therefore shows it too, which is the decided behaviour rather than an accident —
|
|
10
|
+
-- an archive nobody can see is an archive nobody empties.
|
|
11
|
+
ALTER TABLE boards ADD COLUMN archive_visible INTEGER NOT NULL DEFAULT 1;
|
|
12
|
+
|
|
13
|
+
-- ⚠️ **Existing data.** Until this migration `BoardColumn.id` was a free string: a board could
|
|
14
|
+
-- configure a column called `archived` itself. From here the name is reserved, and without this
|
|
15
|
+
-- step such a board would show TWO columns with one id — the stored one and the derived one — and
|
|
16
|
+
-- the cards of both would be indistinguishable.
|
|
17
|
+
--
|
|
18
|
+
-- ⚠️ **Every row with that status, not only the ones whose board still configures it.** Columns are
|
|
19
|
+
-- REPLACED rather than merged, so a board may have used the column and dropped it again; those
|
|
20
|
+
-- cards still carry `status = 'archived'` while `statuses_json` has long forgotten it. Left alone
|
|
21
|
+
-- they would surface in the archive column with `archived_at` still `null` — a card that looks
|
|
22
|
+
-- archived, is not, and cannot be restored, because there is nothing to restore.
|
|
23
|
+
--
|
|
24
|
+
-- The new name is deliberately ugly, because it is meant to be seen: whoever reads it knows
|
|
25
|
+
-- something was renamed here and can put it right in the settings.
|
|
26
|
+
UPDATE board_tasks SET status = 'archived_column_renamed' WHERE status = 'archived';
|
|
27
|
+
|
|
28
|
+
UPDATE boards
|
|
29
|
+
SET statuses_json = replace(statuses_json, '"id":"archived"', '"id":"archived_column_renamed"')
|
|
30
|
+
WHERE node_id IN (
|
|
31
|
+
SELECT b.node_id FROM boards b, json_each(b.statuses_json)
|
|
32
|
+
WHERE json_extract(json_each.value, '$.id') = 'archived'
|
|
33
|
+
);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@anchrd/intel-api",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.29.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"repository": {
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
},
|
|
44
44
|
"dependencies": {
|
|
45
45
|
"@anchrd/gate-sdk": "^0.21.0",
|
|
46
|
-
"@anchrd/intel-contract": "^0.
|
|
46
|
+
"@anchrd/intel-contract": "^0.24.0",
|
|
47
47
|
"@cfworker/json-schema": "^4.1.1",
|
|
48
48
|
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
49
49
|
"fflate": "^0.8.3",
|