@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.
@@ -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
- return await requireVisible(actor, existingId);
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
- return await deps.repository.insertNode({
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
- if (input.parentId !== undefined && input.parentId !== null) {
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
- if (parent.kind !== "folder") {
788
- throw new IntelError(409, "parent_not_folder", "A node's parent must be a folder");
789
- }
790
- if (!(await deps.repository.can(actor, parent.id, "write"))) {
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.27.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.22.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",