kanbo-cli 0.1.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.
Files changed (115) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +79 -0
  3. package/dist/better-sqlite3-fU3G4xnX.cjs +2254 -0
  4. package/dist/better-sqlite3-fU3G4xnX.cjs.map +1 -0
  5. package/dist/cli.cjs +42949 -0
  6. package/dist/cli.cjs.map +1 -0
  7. package/dist/dist-Cd6wg8pp.cjs +942 -0
  8. package/dist/dist-Cd6wg8pp.cjs.map +1 -0
  9. package/dist/drizzle-postgres/0000_board.sql +135 -0
  10. package/dist/drizzle-postgres/0001_history_order.sql +2 -0
  11. package/dist/drizzle-postgres/0002_issue_pull_requests.sql +16 -0
  12. package/dist/drizzle-postgres/0003_milestone_start_date.sql +1 -0
  13. package/dist/drizzle-postgres/0004_status_entry_rules.sql +1 -0
  14. package/dist/drizzle-postgres/0005_run_external_session_ref.sql +1 -0
  15. package/dist/drizzle-postgres/README.md +61 -0
  16. package/dist/drizzle-postgres/meta/0000_snapshot.json +1078 -0
  17. package/dist/drizzle-postgres/meta/0001_snapshot.json +1090 -0
  18. package/dist/drizzle-postgres/meta/0002_snapshot.json +1239 -0
  19. package/dist/drizzle-postgres/meta/0003_snapshot.json +1245 -0
  20. package/dist/drizzle-postgres/meta/0004_snapshot.json +1251 -0
  21. package/dist/drizzle-postgres/meta/0005_snapshot.json +1257 -0
  22. package/dist/drizzle-postgres/meta/_journal.json +48 -0
  23. package/dist/drizzle-sqlite/0000_board.sql +133 -0
  24. package/dist/drizzle-sqlite/0001_issue_pull_requests.sql +15 -0
  25. package/dist/drizzle-sqlite/0002_milestone_start_date.sql +1 -0
  26. package/dist/drizzle-sqlite/0003_status_entry_rules.sql +1 -0
  27. package/dist/drizzle-sqlite/0004_run_external_session_ref.sql +1 -0
  28. package/dist/drizzle-sqlite/README.md +67 -0
  29. package/dist/drizzle-sqlite/meta/0000_snapshot.json +946 -0
  30. package/dist/drizzle-sqlite/meta/0001_snapshot.json +1054 -0
  31. package/dist/drizzle-sqlite/meta/0002_snapshot.json +1061 -0
  32. package/dist/drizzle-sqlite/meta/0003_snapshot.json +1068 -0
  33. package/dist/drizzle-sqlite/meta/0004_snapshot.json +1075 -0
  34. package/dist/drizzle-sqlite/meta/_journal.json +41 -0
  35. package/dist/lib/board-store-B16YarvX.d.mts +281 -0
  36. package/dist/lib/board-store-BHHo_ZlR.d.cts +281 -0
  37. package/dist/lib/index.cjs +340 -0
  38. package/dist/lib/index.cjs.map +1 -0
  39. package/dist/lib/index.d.cts +1608 -0
  40. package/dist/lib/index.d.mts +1608 -0
  41. package/dist/lib/index.mjs +284 -0
  42. package/dist/lib/index.mjs.map +1 -0
  43. package/dist/lib/manifest-C0EcwwgO.mjs +7866 -0
  44. package/dist/lib/manifest-C0EcwwgO.mjs.map +1 -0
  45. package/dist/lib/manifest-CpZOcB_6.cjs +8272 -0
  46. package/dist/lib/manifest-CpZOcB_6.cjs.map +1 -0
  47. package/dist/lib/mcp/index.cjs +184 -0
  48. package/dist/lib/mcp/index.cjs.map +1 -0
  49. package/dist/lib/mcp/index.d.cts +346 -0
  50. package/dist/lib/mcp/index.d.mts +346 -0
  51. package/dist/lib/mcp/index.mjs +173 -0
  52. package/dist/lib/mcp/index.mjs.map +1 -0
  53. package/dist/lib/migrate-BhYi9cXw.mjs +656 -0
  54. package/dist/lib/migrate-BhYi9cXw.mjs.map +1 -0
  55. package/dist/lib/migrate-DnMjTCvu.cjs +709 -0
  56. package/dist/lib/migrate-DnMjTCvu.cjs.map +1 -0
  57. package/dist/lib/migrations-path-CZxVOgeD.cjs +109 -0
  58. package/dist/lib/migrations-path-CZxVOgeD.cjs.map +1 -0
  59. package/dist/lib/migrations-path-Ciwan537.mjs +80 -0
  60. package/dist/lib/migrations-path-Ciwan537.mjs.map +1 -0
  61. package/dist/lib/postgres/index.cjs +56 -0
  62. package/dist/lib/postgres/index.cjs.map +1 -0
  63. package/dist/lib/postgres/index.d.cts +3403 -0
  64. package/dist/lib/postgres/index.d.mts +3403 -0
  65. package/dist/lib/postgres/index.mjs +35 -0
  66. package/dist/lib/postgres/index.mjs.map +1 -0
  67. package/dist/lib/roles.sql-B9rt-x4o.cjs +1033 -0
  68. package/dist/lib/roles.sql-B9rt-x4o.cjs.map +1 -0
  69. package/dist/lib/roles.sql-CvyCGt38.mjs +920 -0
  70. package/dist/lib/roles.sql-CvyCGt38.mjs.map +1 -0
  71. package/dist/lib/schema-epoch-ZyumVL_A.d.cts +16 -0
  72. package/dist/lib/schema-epoch-ZyumVL_A.d.mts +16 -0
  73. package/dist/lib/schema-fsHqNAip.d.cts +3612 -0
  74. package/dist/lib/schema-fsHqNAip.d.mts +3612 -0
  75. package/dist/lib/sqlite/index.cjs +23 -0
  76. package/dist/lib/sqlite/index.d.cts +175 -0
  77. package/dist/lib/sqlite/index.d.mts +175 -0
  78. package/dist/lib/sqlite/index.mjs +4 -0
  79. package/dist/lib/sqlite/schema.cjs +266 -0
  80. package/dist/lib/sqlite/schema.cjs.map +1 -0
  81. package/dist/lib/sqlite/schema.d.cts +2 -0
  82. package/dist/lib/sqlite/schema.d.mts +2 -0
  83. package/dist/lib/sqlite/schema.mjs +256 -0
  84. package/dist/lib/sqlite/schema.mjs.map +1 -0
  85. package/dist/lib/tool-names-9cmCn0gP.d.cts +112 -0
  86. package/dist/lib/tool-names-Dxg1UTUl.d.mts +112 -0
  87. package/dist/page/board.css +477 -0
  88. package/dist/page/board.js +2 -0
  89. package/drizzle-postgres/0000_board.sql +135 -0
  90. package/drizzle-postgres/0001_history_order.sql +2 -0
  91. package/drizzle-postgres/0002_issue_pull_requests.sql +16 -0
  92. package/drizzle-postgres/0003_milestone_start_date.sql +1 -0
  93. package/drizzle-postgres/0004_status_entry_rules.sql +1 -0
  94. package/drizzle-postgres/0005_run_external_session_ref.sql +1 -0
  95. package/drizzle-postgres/README.md +61 -0
  96. package/drizzle-postgres/meta/0000_snapshot.json +1078 -0
  97. package/drizzle-postgres/meta/0001_snapshot.json +1090 -0
  98. package/drizzle-postgres/meta/0002_snapshot.json +1239 -0
  99. package/drizzle-postgres/meta/0003_snapshot.json +1245 -0
  100. package/drizzle-postgres/meta/0004_snapshot.json +1251 -0
  101. package/drizzle-postgres/meta/0005_snapshot.json +1257 -0
  102. package/drizzle-postgres/meta/_journal.json +48 -0
  103. package/drizzle-sqlite/0000_board.sql +133 -0
  104. package/drizzle-sqlite/0001_issue_pull_requests.sql +15 -0
  105. package/drizzle-sqlite/0002_milestone_start_date.sql +1 -0
  106. package/drizzle-sqlite/0003_status_entry_rules.sql +1 -0
  107. package/drizzle-sqlite/0004_run_external_session_ref.sql +1 -0
  108. package/drizzle-sqlite/README.md +67 -0
  109. package/drizzle-sqlite/meta/0000_snapshot.json +946 -0
  110. package/drizzle-sqlite/meta/0001_snapshot.json +1054 -0
  111. package/drizzle-sqlite/meta/0002_snapshot.json +1061 -0
  112. package/drizzle-sqlite/meta/0003_snapshot.json +1068 -0
  113. package/drizzle-sqlite/meta/0004_snapshot.json +1075 -0
  114. package/drizzle-sqlite/meta/_journal.json +41 -0
  115. package/package.json +147 -0
@@ -0,0 +1,920 @@
1
+ import { a as BoardError, i as BOARD_TABLES_ADDED_WITHIN_EPOCH, n as resolveBoardMigrationsPath, t as hasMigrationJournal } from "./migrations-path-Ciwan537.mjs";
2
+ import { and, asc, desc, eq, getTableColumns, inArray, isNotNull, or, sql } from "drizzle-orm";
3
+ import { bigint, bigserial, getTableConfig, index, integer, pgSchema, pgTable, text, uniqueIndex } from "drizzle-orm/pg-core";
4
+ import { dirname } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ //#region src/postgres/schema.ts
7
+ /**
8
+ * The board, as a Postgres database holds it.
9
+ *
10
+ * This is the same logical schema as `src/sqlite/schema.ts` — same column
11
+ * names, same nullability, same enum members, same index names — written for a
12
+ * second dialect rather than a second design. The store reads and writes both
13
+ * through one `BoardStore` interface, so anything that drifts here shows up as
14
+ * a card that reads differently depending on where its board lives.
15
+ *
16
+ * Two deliberate differences from the SQLite file:
17
+ *
18
+ * - **Unix seconds are `bigint`, read as `number`.** SQLite stores them in an
19
+ * `integer` wide enough for them; Postgres's `integer` is 32-bit, and
20
+ * `mode: 'number'` keeps the value a JavaScript number so the store hands
21
+ * `ops` and `domain` exactly what the SQLite store hands them.
22
+ * - **`seq` on `issue_runs`, `issue_comments`, `issue_field_changes` and
23
+ * `issue_pull_requests`.**
24
+ * SQLite orders those four by `rowid` — insertion order, which two rows
25
+ * written in the same second still separate. Postgres has no `rowid`, and its
26
+ * heap order is free to change (a row written last can land in the slot a
27
+ * deleted one freed), so the board carries its own order: a `bigserial` those
28
+ * queries order by, and the only columns here that have no SQLite
29
+ * counterpart.
30
+ *
31
+ * What used to be a third difference is one no longer: `workspace_id`,
32
+ * `delegate_agent_id`, `delegate_provider_target_id` and
33
+ * `source_chat_session_id` are plain text here *and* in the file (ruling 5-1),
34
+ * because a database holding only the board was never the only home without a
35
+ * `workspaces` table to point at.
36
+ *
37
+ * `labels` and `context_refs` stay `text` holding JSON rather than becoming
38
+ * `jsonb`: they are encoded and decoded above the store, and a second
39
+ * representation would be a second thing to keep in step for nothing.
40
+ */
41
+ /** `unixepoch()` in the dialect that has no such function. */
42
+ const unixNow = sql`(extract(epoch from now())::bigint)`;
43
+ const textPk = () => text("id").primaryKey();
44
+ const createdAt = () => ({ createdAt: bigint("created_at", { mode: "number" }).notNull().default(unixNow) });
45
+ const timestamps = () => ({
46
+ ...createdAt(),
47
+ updatedAt: bigint("updated_at", { mode: "number" }).notNull().default(unixNow)
48
+ });
49
+ const issueStatuses = pgTable("issue_statuses", {
50
+ id: textPk(),
51
+ workspaceId: text("workspace_id").notNull(),
52
+ name: text("name").notNull(),
53
+ description: text("description"),
54
+ color: text("color"),
55
+ category: text("category", { enum: [
56
+ "triage",
57
+ "backlog",
58
+ "unstarted",
59
+ "started",
60
+ "completed",
61
+ "canceled"
62
+ ] }).notNull().default("unstarted"),
63
+ order: integer("order").notNull().default(0),
64
+ /** A JSON array of `ENTRY_RULES`, or `null` for none — see the SQLite schema. */
65
+ entryRules: text("entry_rules"),
66
+ ...createdAt()
67
+ }, (table) => [index("issue_statuses_workspace_id_idx").on(table.workspaceId), uniqueIndex("issue_statuses_workspace_name_unique").on(table.workspaceId, table.name)]);
68
+ const issueMilestones = pgTable("issue_milestones", {
69
+ id: textPk(),
70
+ workspaceId: text("workspace_id").notNull(),
71
+ title: text("title").notNull(),
72
+ description: text("description"),
73
+ /** When the milestone, read as a sprint, begins (unix seconds); null for a plain milestone. */
74
+ startDate: bigint("start_date", { mode: "number" }),
75
+ dueDate: bigint("due_date", { mode: "number" }),
76
+ status: text("status", { enum: ["open", "closed"] }).notNull().default("open"),
77
+ ...timestamps()
78
+ }, (table) => [index("issue_milestones_workspace_id_idx").on(table.workspaceId)]);
79
+ const issues = pgTable("issues", {
80
+ id: textPk(),
81
+ workspaceId: text("workspace_id").notNull(),
82
+ number: integer("number").notNull(),
83
+ statusId: text("status_id").references(() => issueStatuses.id, { onDelete: "set null" }),
84
+ milestoneId: text("milestone_id").references(() => issueMilestones.id, { onDelete: "set null" }),
85
+ parentIssueId: text("parent_issue_id").references(() => issues.id, { onDelete: "set null" }),
86
+ title: text("title").notNull(),
87
+ description: text("description"),
88
+ priority: text("priority", { enum: [
89
+ "none",
90
+ "low",
91
+ "medium",
92
+ "high",
93
+ "urgent"
94
+ ] }).notNull().default("none"),
95
+ labels: text("labels").notNull().default("[]"),
96
+ assigneeKind: text("assignee_kind"),
97
+ assigneeId: text("assignee_id"),
98
+ dueDate: bigint("due_date", { mode: "number" }),
99
+ createdByKind: text("created_by_kind", { enum: [
100
+ "user",
101
+ "agent",
102
+ "system",
103
+ "provider-target"
104
+ ] }).notNull().default("user"),
105
+ createdById: text("created_by_id").notNull().default("__self__"),
106
+ sourceChatSessionId: text("source_chat_session_id"),
107
+ delegateAgentId: text("delegate_agent_id"),
108
+ delegateProviderTargetId: text("delegate_provider_target_id"),
109
+ contextRefs: text("context_refs").notNull().default("[]"),
110
+ statusLine: text("status_line"),
111
+ waitingFor: text("waiting_for", { enum: ["human"] }),
112
+ executionMode: text("execution_mode", { enum: ["worktree", "main"] }).notNull().default("worktree"),
113
+ order: integer("order").notNull().default(0),
114
+ ...timestamps()
115
+ }, (table) => [
116
+ index("issues_workspace_id_idx").on(table.workspaceId),
117
+ uniqueIndex("issues_workspace_number_unique").on(table.workspaceId, table.number),
118
+ index("issues_status_id_idx").on(table.statusId),
119
+ index("issues_milestone_id_idx").on(table.milestoneId),
120
+ index("issues_parent_issue_id_idx").on(table.parentIssueId),
121
+ index("issues_delegate_agent_id_idx").on(table.delegateAgentId),
122
+ index("issues_delegate_provider_target_id_idx").on(table.delegateProviderTargetId)
123
+ ]);
124
+ const issueComments = pgTable("issue_comments", {
125
+ id: textPk(),
126
+ issueId: text("issue_id").notNull().references(() => issues.id, { onDelete: "cascade" }),
127
+ content: text("content").notNull(),
128
+ authorKind: text("author_kind", { enum: [
129
+ "user",
130
+ "agent",
131
+ "provider-target",
132
+ "system",
133
+ "system.delegated",
134
+ "system.undelegated",
135
+ "system.run",
136
+ "system.approved",
137
+ "system.returned",
138
+ "system.pr"
139
+ ] }).notNull().default("user"),
140
+ authorId: text("author_id"),
141
+ sourceChatSessionId: text("source_chat_session_id"),
142
+ agentActivityId: text("agent_activity_id"),
143
+ dedupeKey: text("dedupe_key"),
144
+ ...createdAt(),
145
+ /** Insertion order, standing in for the `rowid` the SQLite board orders comments by. */
146
+ seq: bigserial("seq", { mode: "number" }).notNull()
147
+ }, (table) => [index("issue_comments_issue_id_idx").on(table.issueId), uniqueIndex("issue_comments_issue_dedupe_idx").on(table.issueId, table.dedupeKey)]);
148
+ const issueRuns = pgTable("issue_runs", {
149
+ id: textPk(),
150
+ issueId: text("issue_id").notNull().references(() => issues.id, { onDelete: "cascade" }),
151
+ launchedByKind: text("launched_by_kind", { enum: [
152
+ "user",
153
+ "agent",
154
+ "external"
155
+ ] }).notNull().default("user"),
156
+ launchedById: text("launched_by_id"),
157
+ agentId: text("agent_id"),
158
+ agentName: text("agent_name").notNull(),
159
+ host: text("host"),
160
+ executionMode: text("execution_mode", { enum: ["worktree", "main"] }).notNull().default("worktree"),
161
+ branch: text("branch"),
162
+ worktreePath: text("worktree_path"),
163
+ state: text("state", { enum: [
164
+ "running",
165
+ "finished",
166
+ "failed",
167
+ "stopped"
168
+ ] }).notNull().default("running"),
169
+ chatSessionId: text("chat_session_id"),
170
+ /** Where an external agent's own log of this run lives — `claude:<id>` or `codex:<id>` (ruling 7-1), `null` when it never said. */
171
+ externalSessionRef: text("external_session_ref"),
172
+ startedAt: bigint("started_at", { mode: "number" }).notNull().default(unixNow),
173
+ endedAt: bigint("ended_at", { mode: "number" }),
174
+ /** Insertion order, standing in for the `rowid` the SQLite board orders runs by. */
175
+ seq: bigserial("seq", { mode: "number" }).notNull()
176
+ }, (table) => [
177
+ index("issue_runs_issue_id_idx").on(table.issueId),
178
+ index("issue_runs_issue_state_idx").on(table.issueId, table.state),
179
+ index("issue_runs_chat_session_idx").on(table.chatSessionId)
180
+ ]);
181
+ const issueRelations = pgTable("issue_relations", {
182
+ id: textPk(),
183
+ sourceIssueId: text("source_issue_id").notNull().references(() => issues.id, { onDelete: "cascade" }),
184
+ targetIssueId: text("target_issue_id").notNull().references(() => issues.id, { onDelete: "cascade" }),
185
+ type: text("type", { enum: [
186
+ "blocks",
187
+ "duplicates",
188
+ "relates_to"
189
+ ] }).notNull(),
190
+ ...createdAt()
191
+ }, (table) => [
192
+ index("issue_relations_source_issue_id_idx").on(table.sourceIssueId),
193
+ index("issue_relations_target_issue_id_idx").on(table.targetIssueId),
194
+ uniqueIndex("issue_relations_pair_type_unique").on(table.sourceIssueId, table.targetIssueId, table.type)
195
+ ]);
196
+ const issueFieldChanges = pgTable("issue_field_changes", {
197
+ id: textPk(),
198
+ issueId: text("issue_id").notNull().references(() => issues.id, { onDelete: "cascade" }),
199
+ field: text("field").notNull(),
200
+ fromValue: text("from_value"),
201
+ toValue: text("to_value"),
202
+ actorKind: text("actor_kind", { enum: [
203
+ "user",
204
+ "agent",
205
+ "provider-target",
206
+ "system"
207
+ ] }).notNull().default("user"),
208
+ actorId: text("actor_id"),
209
+ sourceChatSessionId: text("source_chat_session_id"),
210
+ ...createdAt(),
211
+ /** Insertion order, standing in for the `rowid` the SQLite board orders changes by. */
212
+ seq: bigserial("seq", { mode: "number" }).notNull()
213
+ }, (table) => [index("issue_field_changes_issue_id_idx").on(table.issueId)]);
214
+ /** A pull request someone said belongs to a card — see the SQLite twin for what the row is and is not. */
215
+ const issuePullRequests = pgTable("issue_pull_requests", {
216
+ id: textPk(),
217
+ issueId: text("issue_id").notNull().references(() => issues.id, { onDelete: "cascade" }),
218
+ owner: text("owner").notNull(),
219
+ repo: text("repo").notNull(),
220
+ number: integer("number").notNull(),
221
+ url: text("url").notNull(),
222
+ createdByKind: text("created_by_kind", { enum: [
223
+ "user",
224
+ "agent",
225
+ "provider-target",
226
+ "system"
227
+ ] }).notNull(),
228
+ createdById: text("created_by_id"),
229
+ ...createdAt(),
230
+ /** Insertion order, standing in for the `rowid` the SQLite board orders a card's links by. */
231
+ seq: bigserial("seq", { mode: "number" }).notNull()
232
+ }, (table) => [uniqueIndex("issue_pull_requests_issue_pr_unique").on(table.issueId, table.owner, table.repo, table.number), index("issue_pull_requests_pr_idx").on(table.owner, table.repo, table.number)]);
233
+ /**
234
+ * The counters the board keeps for its readers, exactly as the SQLite file
235
+ * keeps them: `board` is bumped once per write transaction that changed
236
+ * something, and `schema_epoch` records the generation the board was created
237
+ * with.
238
+ */
239
+ const kanbanMeta = pgTable("kanban_meta", {
240
+ key: text("key").primaryKey(),
241
+ revision: integer("revision").notNull().default(0),
242
+ updatedAt: bigint("updated_at", { mode: "number" }).notNull().default(unixNow)
243
+ });
244
+ /** Every table of the board, in the order a reader of the schema wants them. */
245
+ const boardPostgresSchema = {
246
+ issueStatuses,
247
+ issueMilestones,
248
+ issues,
249
+ issueComments,
250
+ issueRuns,
251
+ issueRelations,
252
+ issueFieldChanges,
253
+ issuePullRequests,
254
+ kanbanMeta
255
+ };
256
+ /** The table names a Postgres board must carry, for the guard that refuses a database without them. */
257
+ const BOARD_POSTGRES_TABLES = [
258
+ "issue_statuses",
259
+ "issue_milestones",
260
+ "issues",
261
+ "issue_comments",
262
+ "issue_runs",
263
+ "issue_relations",
264
+ "issue_field_changes",
265
+ "issue_pull_requests",
266
+ "kanban_meta"
267
+ ];
268
+ //#endregion
269
+ //#region src/postgres/board-store.postgres.ts
270
+ /**
271
+ * The run without `seq`.
272
+ *
273
+ * `seq` is this dialect's answer to SQLite's `rowid` — the board's own record of
274
+ * which of two runs started in the same second was written first. It is a fact
275
+ * about the storage rather than about the run, so it is taken off here instead
276
+ * of leaking into a type shared with a board that has no such column.
277
+ */
278
+ function toIssueRun({ seq: _seq, ...run }) {
279
+ return run;
280
+ }
281
+ /** The comment without `seq`, for the same reason a run is handed over without it. */
282
+ function toIssueComment({ seq: _seq, ...comment }) {
283
+ return comment;
284
+ }
285
+ /** The field change without `seq`, for the same reason. */
286
+ function toIssueFieldChange({ seq: _seq, ...change }) {
287
+ return change;
288
+ }
289
+ /** The pull-request link without `seq`, for the same reason. */
290
+ function toIssuePullRequest({ seq: _seq, ...link }) {
291
+ return link;
292
+ }
293
+ /** A link with its card's workspace, without `seq`. */
294
+ function toLinkedPullRequest({ seq: _seq, ...link }) {
295
+ return link;
296
+ }
297
+ /**
298
+ * The run facts of every card in scope, in one statement.
299
+ *
300
+ * The same shape as the SQLite store's: `count(*)` counts the card's runs on
301
+ * every one of its rows and `row_number()` ranks them — a `running` run first,
302
+ * then the newest start, then the row written last — so reading only rank 1
303
+ * leaves one row per card. Two differences from the file, both dialect: the
304
+ * tie-break reads `seq` where SQLite reads `rowid`, and both window counters are
305
+ * cast to `int` because Postgres counts in `bigint`, which a driver is free to
306
+ * hand back as a string.
307
+ */
308
+ async function selectRunProjection(database, scope) {
309
+ if ("issueIds" in scope && scope.issueIds.length === 0) return [];
310
+ const cards = "workspaceId" in scope ? inArray(issueRuns.issueId, database.select({ id: issues.id }).from(issues).where(eq(issues.workspaceId, scope.workspaceId))) : inArray(issueRuns.issueId, scope.issueIds);
311
+ const ranked = database.select({
312
+ issueId: issueRuns.issueId,
313
+ attemptCount: sql`cast(count(*) over (partition by ${issueRuns.issueId}) as int)`.as("attempt_count"),
314
+ id: issueRuns.id,
315
+ agentId: issueRuns.agentId,
316
+ agentName: issueRuns.agentName,
317
+ state: issueRuns.state,
318
+ branch: issueRuns.branch,
319
+ executionMode: issueRuns.executionMode,
320
+ chatSessionId: issueRuns.chatSessionId,
321
+ startedAt: issueRuns.startedAt,
322
+ cardRank: sql`cast(row_number() over (partition by ${issueRuns.issueId} order by (${issueRuns.state} = 'running') desc, ${issueRuns.startedAt} desc, ${issueRuns.seq} desc) as int)`.as("card_rank")
323
+ }).from(issueRuns).where(cards).as("ranked");
324
+ return (await database.select({
325
+ issueId: ranked.issueId,
326
+ attemptCount: ranked.attemptCount,
327
+ id: ranked.id,
328
+ agentId: ranked.agentId,
329
+ agentName: ranked.agentName,
330
+ state: ranked.state,
331
+ branch: ranked.branch,
332
+ executionMode: ranked.executionMode,
333
+ chatSessionId: ranked.chatSessionId,
334
+ startedAt: ranked.startedAt
335
+ }).from(ranked).where(eq(ranked.cardRank, 1))).map((row) => ({
336
+ issueId: row.issueId,
337
+ attemptCount: row.attemptCount,
338
+ activeRun: row.state === "running" ? {
339
+ id: row.id,
340
+ agentId: row.agentId,
341
+ agentName: row.agentName,
342
+ state: row.state,
343
+ branch: row.branch,
344
+ executionMode: row.executionMode,
345
+ chatSessionId: row.chatSessionId,
346
+ startedAt: row.startedAt
347
+ } : null
348
+ }));
349
+ }
350
+ /**
351
+ * The board's statements against one Postgres handle — a connection, or a
352
+ * transaction on one.
353
+ *
354
+ * `transaction` builds a second store over the transactional handle drizzle
355
+ * hands it, which is the one structural difference from the SQLite store: there
356
+ * a single connection *is* the transaction, so the store hands back itself.
357
+ */
358
+ function createStoreOver(database) {
359
+ return {
360
+ statuses: {
361
+ listByWorkspace: async (workspaceId) => await database.select().from(issueStatuses).where(eq(issueStatuses.workspaceId, workspaceId)).orderBy(issueStatuses.order),
362
+ listAllInBoardOrder: async () => await database.select().from(issueStatuses).orderBy(issueStatuses.workspaceId, issueStatuses.order),
363
+ listAll: async () => await database.select().from(issueStatuses),
364
+ findById: async (statusId) => {
365
+ const [row] = await database.select().from(issueStatuses).where(eq(issueStatuses.id, statusId)).limit(1);
366
+ return row ?? null;
367
+ },
368
+ findInWorkspace: async (workspaceId, statusId) => {
369
+ const [row] = await database.select().from(issueStatuses).where(and(eq(issueStatuses.workspaceId, workspaceId), eq(issueStatuses.id, statusId))).limit(1);
370
+ return row ?? null;
371
+ },
372
+ countByWorkspace: async (workspaceId) => {
373
+ const [row] = await database.select({ count: sql`cast(count(*) as int)` }).from(issueStatuses).where(eq(issueStatuses.workspaceId, workspaceId));
374
+ return row?.count ?? 0;
375
+ },
376
+ create: async (values) => {
377
+ const [row] = await database.insert(issueStatuses).values(values).returning();
378
+ return row;
379
+ },
380
+ update: async (statusId, patch) => {
381
+ await database.update(issueStatuses).set(patch).where(eq(issueStatuses.id, statusId));
382
+ },
383
+ delete: async (statusId) => {
384
+ await database.delete(issueStatuses).where(eq(issueStatuses.id, statusId));
385
+ }
386
+ },
387
+ milestones: {
388
+ listNewestFirst: async (workspaceId) => await database.select().from(issueMilestones).where(workspaceId ? eq(issueMilestones.workspaceId, workspaceId) : void 0).orderBy(desc(issueMilestones.createdAt)),
389
+ listByWorkspace: async (workspaceId) => await database.select().from(issueMilestones).where(eq(issueMilestones.workspaceId, workspaceId)),
390
+ listAll: async () => await database.select().from(issueMilestones),
391
+ findById: async (milestoneId) => {
392
+ const [row] = await database.select().from(issueMilestones).where(eq(issueMilestones.id, milestoneId)).limit(1);
393
+ return row ?? null;
394
+ },
395
+ findInWorkspace: async (workspaceId, milestoneId) => {
396
+ const [row] = await database.select().from(issueMilestones).where(and(eq(issueMilestones.id, milestoneId), eq(issueMilestones.workspaceId, workspaceId))).limit(1);
397
+ return row ?? null;
398
+ },
399
+ create: async (values) => {
400
+ const [row] = await database.insert(issueMilestones).values(values).returning();
401
+ return row;
402
+ },
403
+ update: async (milestoneId, patch) => {
404
+ await database.update(issueMilestones).set(patch).where(eq(issueMilestones.id, milestoneId));
405
+ },
406
+ delete: async (milestoneId) => {
407
+ await database.delete(issueMilestones).where(eq(issueMilestones.id, milestoneId));
408
+ }
409
+ },
410
+ issues: {
411
+ listInBoardOrder: async (workspaceId) => await database.select().from(issues).where(workspaceId ? eq(issues.workspaceId, workspaceId) : void 0).orderBy(issues.order, desc(issues.createdAt)),
412
+ listNewestFirst: async () => await database.select().from(issues).orderBy(desc(issues.createdAt)),
413
+ listAll: async () => await database.select().from(issues),
414
+ listByWorkspace: async (workspaceId) => await database.select().from(issues).where(eq(issues.workspaceId, workspaceId)),
415
+ listByIds: async (issueIds) => await database.select().from(issues).where(inArray(issues.id, issueIds)),
416
+ findById: async (issueId) => {
417
+ const [row] = await database.select().from(issues).where(eq(issues.id, issueId)).limit(1);
418
+ return row ?? null;
419
+ },
420
+ findInWorkspace: async (workspaceId, issueId) => {
421
+ const [row] = await database.select().from(issues).where(and(eq(issues.id, issueId), eq(issues.workspaceId, workspaceId))).limit(1);
422
+ return row ?? null;
423
+ },
424
+ findByNumber: async (workspaceId, number) => {
425
+ const [row] = await database.select().from(issues).where(and(eq(issues.workspaceId, workspaceId), eq(issues.number, number))).limit(1);
426
+ return row ?? null;
427
+ },
428
+ maxNumber: async (workspaceId) => {
429
+ const [row] = await database.select({ maxNumber: sql`cast(coalesce(max(${issues.number}), 0) as int)` }).from(issues).where(eq(issues.workspaceId, workspaceId));
430
+ return row?.maxNumber ?? 0;
431
+ },
432
+ maxOrder: async (workspaceId) => {
433
+ const [row] = await database.select({ maxOrder: sql`cast(coalesce(max(${issues.order}), 0) as int)` }).from(issues).where(eq(issues.workspaceId, workspaceId));
434
+ return row?.maxOrder ?? 0;
435
+ },
436
+ /**
437
+ * The one insert the board expects to be refused sometimes.
438
+ *
439
+ * Two writers can pick the same card number, and the unique index turns
440
+ * the loser's insert into an error that the numbering answers by reading
441
+ * the board again. Postgres refuses to read anything from a transaction a
442
+ * statement has failed in, so this insert runs inside a savepoint of its
443
+ * own: when it is refused, the savepoint rolls back and the caller's
444
+ * transaction is still there to be read. A board in a file needs nothing
445
+ * of the sort — SQLite leaves a transaction usable after a failed
446
+ * statement — which is why this lives in the store and not in `ops`.
447
+ */
448
+ create: async (values) => {
449
+ const [row] = await database.transaction(async (tx) => await tx.insert(issues).values(values).returning());
450
+ return row;
451
+ },
452
+ update: async (issueId, patch) => {
453
+ await database.update(issues).set(patch).where(eq(issues.id, issueId));
454
+ },
455
+ updateMany: async (issueIds, patch) => {
456
+ if (issueIds.length === 0) return 0;
457
+ return (await database.update(issues).set(patch).where(inArray(issues.id, issueIds)).returning({ id: issues.id })).length;
458
+ },
459
+ clearMilestoneReferences: async (milestoneId, updatedAt) => {
460
+ await database.update(issues).set({
461
+ milestoneId: null,
462
+ updatedAt
463
+ }).where(eq(issues.milestoneId, milestoneId));
464
+ },
465
+ clearParentReferences: async (parentIssueId, updatedAt) => {
466
+ await database.update(issues).set({
467
+ parentIssueId: null,
468
+ updatedAt
469
+ }).where(eq(issues.parentIssueId, parentIssueId));
470
+ },
471
+ clearDelegateReferences: async (delegate, updatedAt) => {
472
+ return (await database.update(issues).set({
473
+ delegateAgentId: null,
474
+ delegateProviderTargetId: null,
475
+ updatedAt
476
+ }).where("agentId" in delegate ? eq(issues.delegateAgentId, delegate.agentId) : eq(issues.delegateProviderTargetId, delegate.providerTargetId)).returning({ id: issues.id })).length;
477
+ },
478
+ delete: async (issueId) => {
479
+ await database.delete(issues).where(eq(issues.id, issueId));
480
+ }
481
+ },
482
+ comments: {
483
+ listByIssue: async (issueId) => (await database.select().from(issueComments).where(eq(issueComments.issueId, issueId)).orderBy(asc(issueComments.createdAt), asc(issueComments.seq))).map(toIssueComment),
484
+ findById: async (commentId) => {
485
+ const [row] = await database.select().from(issueComments).where(eq(issueComments.id, commentId)).limit(1);
486
+ return row ? toIssueComment(row) : null;
487
+ },
488
+ findByDedupeKey: async (issueId, dedupeKey) => {
489
+ const [row] = await database.select().from(issueComments).where(and(eq(issueComments.issueId, issueId), eq(issueComments.dedupeKey, dedupeKey))).limit(1);
490
+ return row ? toIssueComment(row) : null;
491
+ },
492
+ listByDedupeKeyPrefix: async (issueId, prefix) => (await database.select().from(issueComments).where(and(eq(issueComments.issueId, issueId), sql`starts_with(${issueComments.dedupeKey}, ${prefix})`)).orderBy(asc(issueComments.createdAt), asc(issueComments.seq))).map(toIssueComment),
493
+ create: async (values) => {
494
+ const [row] = await database.insert(issueComments).values(values).returning();
495
+ return toIssueComment(row);
496
+ },
497
+ createOnce: async (values) => {
498
+ const [inserted] = await database.insert(issueComments).values(values).onConflictDoNothing().returning();
499
+ if (inserted) return toIssueComment(inserted);
500
+ const [existing] = await database.select().from(issueComments).where(and(eq(issueComments.issueId, values.issueId), eq(issueComments.dedupeKey, values.dedupeKey))).limit(1);
501
+ if (!existing) throw new Error(`issue comment for ${values.issueId} was refused, and no comment carries dedupe key ${values.dedupeKey}`);
502
+ return toIssueComment(existing);
503
+ },
504
+ delete: async (commentId) => {
505
+ await database.delete(issueComments).where(eq(issueComments.id, commentId));
506
+ }
507
+ },
508
+ runs: {
509
+ listByIssue: async (issueId) => (await database.select().from(issueRuns).where(eq(issueRuns.issueId, issueId)).orderBy(asc(issueRuns.startedAt), asc(issueRuns.seq))).map(toIssueRun),
510
+ listRunning: async (issueId) => (await database.select().from(issueRuns).where(and(eq(issueRuns.issueId, issueId), eq(issueRuns.state, "running"))).orderBy(asc(issueRuns.startedAt), asc(issueRuns.seq))).map(toIssueRun),
511
+ listRunningWithSession: async () => (await database.select().from(issueRuns).where(and(eq(issueRuns.state, "running"), isNotNull(issueRuns.chatSessionId))).orderBy(asc(issueRuns.startedAt), asc(issueRuns.seq))).map(toIssueRun),
512
+ findById: async (runId) => {
513
+ const [row] = await database.select().from(issueRuns).where(eq(issueRuns.id, runId)).limit(1);
514
+ return row ? toIssueRun(row) : null;
515
+ },
516
+ findByChatSessionId: async (chatSessionId) => {
517
+ const [row] = await database.select().from(issueRuns).where(eq(issueRuns.chatSessionId, chatSessionId)).orderBy(desc(issueRuns.startedAt), desc(issueRuns.seq)).limit(1);
518
+ return row ? toIssueRun(row) : null;
519
+ },
520
+ countByIssue: async (issueId) => {
521
+ const [row] = await database.select({ count: sql`cast(count(*) as int)` }).from(issueRuns).where(eq(issueRuns.issueId, issueId));
522
+ return row?.count ?? 0;
523
+ },
524
+ create: async (values) => {
525
+ const [row] = await database.insert(issueRuns).values(values).returning();
526
+ return toIssueRun(row);
527
+ },
528
+ update: async (runId, patch) => {
529
+ await database.update(issueRuns).set(patch).where(eq(issueRuns.id, runId));
530
+ },
531
+ project: async (scope) => await selectRunProjection(database, scope)
532
+ },
533
+ relations: {
534
+ listByIssue: async (issueId) => await database.select().from(issueRelations).where(or(eq(issueRelations.sourceIssueId, issueId), eq(issueRelations.targetIssueId, issueId))),
535
+ findById: async (relationId) => {
536
+ const [row] = await database.select().from(issueRelations).where(eq(issueRelations.id, relationId)).limit(1);
537
+ return row ?? null;
538
+ },
539
+ findMatching: async (edge) => {
540
+ const [row] = await database.select().from(issueRelations).where(and(eq(issueRelations.type, edge.type), edge.type === "relates_to" ? or(and(eq(issueRelations.sourceIssueId, edge.sourceIssueId), eq(issueRelations.targetIssueId, edge.targetIssueId)), and(eq(issueRelations.sourceIssueId, edge.targetIssueId), eq(issueRelations.targetIssueId, edge.sourceIssueId))) : and(eq(issueRelations.sourceIssueId, edge.sourceIssueId), eq(issueRelations.targetIssueId, edge.targetIssueId)))).limit(1);
541
+ return row ?? null;
542
+ },
543
+ create: async (values) => {
544
+ const [row] = await database.insert(issueRelations).values(values).returning();
545
+ return row;
546
+ },
547
+ delete: async (relationId) => {
548
+ await database.delete(issueRelations).where(eq(issueRelations.id, relationId));
549
+ },
550
+ deleteByIssue: async (issueId) => {
551
+ await database.delete(issueRelations).where(or(eq(issueRelations.sourceIssueId, issueId), eq(issueRelations.targetIssueId, issueId)));
552
+ }
553
+ },
554
+ contextRefs: { replace: async (issueId, contextRefs, updatedAt) => {
555
+ await database.update(issues).set({
556
+ contextRefs,
557
+ updatedAt
558
+ }).where(eq(issues.id, issueId));
559
+ } },
560
+ fieldChanges: {
561
+ listByIssue: async (issueId) => (await database.select().from(issueFieldChanges).where(eq(issueFieldChanges.issueId, issueId)).orderBy(asc(issueFieldChanges.createdAt), asc(issueFieldChanges.seq))).map(toIssueFieldChange),
562
+ listByIssueField: async (issueId, field) => (await database.select().from(issueFieldChanges).where(and(eq(issueFieldChanges.issueId, issueId), eq(issueFieldChanges.field, field))).orderBy(asc(issueFieldChanges.createdAt), asc(issueFieldChanges.seq))).map(toIssueFieldChange),
563
+ create: async (values) => {
564
+ await database.insert(issueFieldChanges).values(values);
565
+ }
566
+ },
567
+ pullRequests: {
568
+ listByIssue: async (issueId) => (await database.select().from(issuePullRequests).where(eq(issuePullRequests.issueId, issueId)).orderBy(asc(issuePullRequests.createdAt), asc(issuePullRequests.seq))).map(toIssuePullRequest),
569
+ listLinked: async () => (await database.select({
570
+ ...getTableColumns(issuePullRequests),
571
+ workspaceId: issues.workspaceId
572
+ }).from(issuePullRequests).innerJoin(issues, eq(issues.id, issuePullRequests.issueId)).orderBy(asc(issuePullRequests.createdAt), asc(issuePullRequests.seq))).map(toLinkedPullRequest),
573
+ link: async (values) => {
574
+ const [inserted] = await database.insert(issuePullRequests).values(values).onConflictDoNothing().returning();
575
+ if (inserted) return toIssuePullRequest(inserted);
576
+ const [existing] = await database.select().from(issuePullRequests).where(and(eq(issuePullRequests.issueId, values.issueId), eq(issuePullRequests.owner, values.owner), eq(issuePullRequests.repo, values.repo), eq(issuePullRequests.number, values.number))).limit(1);
577
+ if (!existing) throw new Error(`pull request link for ${values.issueId} was refused, and the card names no ${values.owner}/${values.repo}#${values.number}`);
578
+ return toIssuePullRequest(existing);
579
+ },
580
+ unlink: async (issueId, linkId) => {
581
+ const [removed] = await database.delete(issuePullRequests).where(and(eq(issuePullRequests.id, linkId), eq(issuePullRequests.issueId, issueId))).returning();
582
+ return removed ? toIssuePullRequest(removed) : null;
583
+ }
584
+ },
585
+ meta: {
586
+ read: async (key) => {
587
+ const [row] = await database.select().from(kanbanMeta).where(eq(kanbanMeta.key, key)).limit(1);
588
+ return row ?? null;
589
+ },
590
+ bumpRevision: async (key, updatedAt) => {
591
+ await database.insert(kanbanMeta).values({
592
+ key,
593
+ revision: 1,
594
+ updatedAt
595
+ }).onConflictDoUpdate({
596
+ target: kanbanMeta.key,
597
+ set: {
598
+ revision: sql`${kanbanMeta.revision} + 1`,
599
+ updatedAt
600
+ }
601
+ });
602
+ }
603
+ },
604
+ /**
605
+ * Run `fn` inside one Postgres transaction, on the handle this store is
606
+ * bound to. A transaction opened on a transactional handle is a savepoint —
607
+ * drizzle writes that for us — so a nested write rolls back on its own
608
+ * without taking the outer one with it.
609
+ *
610
+ * `mode` reaches nothing here. `immediate` is SQLite's way of taking the
611
+ * file's write lock up front, and Postgres has no counterpart worth writing:
612
+ * it locks rows as they are written, and the two races the board actually
613
+ * has — two writers numbering a card, two writers bumping the change
614
+ * counter — are settled by the unique index on `(workspace_id, number)` and
615
+ * by the `on conflict do update` on `kanban_meta`. Nothing here takes a lock
616
+ * the statement itself would not.
617
+ */
618
+ transaction: async (fn, _options) => {
619
+ return await database.transaction(async (tx) => await fn(createStoreOver(tx)));
620
+ }
621
+ };
622
+ }
623
+ /**
624
+ * `BoardStore` backed by Postgres — the board in a database several machines
625
+ * share, rather than in a file one machine holds.
626
+ *
627
+ * It is written against `pg-core` and nothing below it, so the same store serves
628
+ * a real connection onto an external database and the embedded
629
+ * Postgres the tests run on. Every method answers what the SQLite store
630
+ * answers, down to the order of two rows written in the same second — `seq` on
631
+ * `issue_runs`, `issue_comments`, `issue_field_changes` and
632
+ * `issue_pull_requests` is what stands in for `rowid` there.
633
+ */
634
+ function createPostgresBoardStore(options) {
635
+ return createStoreOver(options.database);
636
+ }
637
+ //#endregion
638
+ //#region src/postgres/migrations-path.ts
639
+ /** The folder's name, wherever it has been put. */
640
+ const MIGRATIONS_DIRECTORY = "drizzle-postgres";
641
+ /**
642
+ * The board's Postgres migrations for the file that is running right now — the
643
+ * ones `kanbo migrate` applies to an external database. The two
644
+ * layouts this has to survive are `src/migrations-path.ts`'s subject.
645
+ */
646
+ function getBoardMigrationsPath() {
647
+ const here = dirname(fileURLToPath(import.meta.url));
648
+ return resolveBoardMigrationsPath(here, MIGRATIONS_DIRECTORY, hasMigrationJournal);
649
+ }
650
+ //#endregion
651
+ //#region src/postgres/open-database.ts
652
+ /** `information_schema.tables`, the first of the two views the guard reads. */
653
+ const informationSchemaTables = pgSchema("information_schema").table("tables", {
654
+ tableSchema: text("table_schema").notNull(),
655
+ tableName: text("table_name").notNull()
656
+ });
657
+ /** And `information_schema.columns`, for a board whose tables are all there but older inside. */
658
+ const informationSchemaColumns = pgSchema("information_schema").table("columns", {
659
+ tableSchema: text("table_schema").notNull(),
660
+ tableName: text("table_name").notNull(),
661
+ columnName: text("column_name").notNull()
662
+ });
663
+ /**
664
+ * Every column this build expects to find, read off the schema itself rather
665
+ * than listed again here — a column added to the schema and forgotten in a
666
+ * migration is exactly the drift this is looking for.
667
+ */
668
+ function expectedColumns() {
669
+ return Object.values(boardPostgresSchema).flatMap((table) => {
670
+ const config = getTableConfig(table);
671
+ return config.columns.map((column) => ({
672
+ table: config.name,
673
+ column: column.name
674
+ }));
675
+ });
676
+ }
677
+ /**
678
+ * Refuse a Postgres board this build cannot speak for, before any statement
679
+ * runs against it.
680
+ *
681
+ * An external database has no app of its own to migrate it — `kanbo migrate`
682
+ * is the only thing that ever creates the board here — so this guard answers
683
+ * three different mistakes with one code: a connection string pointing at a
684
+ * database that has no board in it at all, one pointing at a board whose tables
685
+ * are all there but which a later migration has since added to, and
686
+ * one pointing at a board written by another generation of this package.
687
+ *
688
+ * The middle case is the one a stamp alone cannot catch, and it comes in two
689
+ * shapes: a table a later migration added (`issue_pull_requests`, reported as
690
+ * `missingAddedTables` so a host does not read it as "no board here"), and a
691
+ * column one added. `BOARD_SCHEMA_EPOCH` says which *logical* board a database
692
+ * holds, and a migration that only adds a column this dialect needs for itself — `seq`, the insertion order Postgres
693
+ * has no `rowid` for — does not change that. Such a board would pass a stamp
694
+ * check and then fail on the first listing, statement by statement, so the
695
+ * columns are asked for by name instead.
696
+ */
697
+ async function assertBoardSchema(database) {
698
+ const present = new Set((await database.select({ name: informationSchemaTables.tableName }).from(informationSchemaTables).where(eq(informationSchemaTables.tableSchema, sql`current_schema()`))).map((row) => row.name));
699
+ const added = new Set(BOARD_TABLES_ADDED_WITHIN_EPOCH);
700
+ const missing = BOARD_POSTGRES_TABLES.filter((table) => !added.has(table) && !present.has(table));
701
+ if (missing.length > 0) throw new BoardError("board_schema_outdated", { missingTables: missing });
702
+ const missingAddedTables = BOARD_TABLES_ADDED_WITHIN_EPOCH.filter((table) => !present.has(table));
703
+ if (missingAddedTables.length > 0) throw new BoardError("board_schema_outdated", { missingAddedTables });
704
+ const columns = new Set((await database.select({
705
+ table: informationSchemaColumns.tableName,
706
+ column: informationSchemaColumns.columnName
707
+ }).from(informationSchemaColumns).where(eq(informationSchemaColumns.tableSchema, sql`current_schema()`))).map((row) => `${row.table}.${row.column}`));
708
+ const missingColumns = expectedColumns().filter(({ table, column }) => !columns.has(`${table}.${column}`)).map(({ table, column }) => `${table}.${column}`);
709
+ if (missingColumns.length > 0) throw new BoardError("board_schema_outdated", { missingColumns });
710
+ const [row] = await database.select({ revision: kanbanMeta.revision }).from(kanbanMeta).where(eq(kanbanMeta.key, "schema_epoch"));
711
+ if (row?.revision !== 1) throw new BoardError("board_schema_outdated", {
712
+ expected: 1,
713
+ found: row?.revision ?? null
714
+ });
715
+ }
716
+ //#endregion
717
+ //#region src/postgres/roles.sql.ts
718
+ /** The role a person connects as: the board, whole. */
719
+ const BOARD_PERSON_ROLE = "kanban_person";
720
+ /** The role an agent connects as: the board, minus the two decisions that are a person's. */
721
+ const BOARD_AGENT_ROLE = "kanban_agent";
722
+ /**
723
+ * What the database says when the agent role tries to take a card back from a
724
+ * person. The board's clients turn it into `board_approval_requires_user`;
725
+ * it is written here because by then the only thing left of the refusal is this
726
+ * sentence.
727
+ */
728
+ const AGENT_WAITING_REFUSAL = "kanbo: only a person may take a card out of waiting";
729
+ /** What the database says when the agent role tries to write a person's decision into the history. */
730
+ const AGENT_APPROVAL_COMMENT_REFUSAL = "kanbo: only a person may approve or return a card";
731
+ /**
732
+ * `insufficient_privilege` — the SQLSTATE every refusal above is raised with,
733
+ * and the one thing about them that survives a driver. A caller that maps the
734
+ * refusal to an answer of its own matches this rather than the sentence.
735
+ */
736
+ const BOARD_REFUSAL_ERRCODE = "42501";
737
+ /**
738
+ * The sequences behind the four `seq` columns — the board's stand-in for the
739
+ * `rowid` a file orders by. An insert into one of those tables draws from its
740
+ * sequence, so a role without `usage` on it cannot write the row at all.
741
+ */
742
+ const BOARD_SEQUENCES = [
743
+ "issue_runs_seq_seq",
744
+ "issue_comments_seq_seq",
745
+ "issue_field_changes_seq_seq",
746
+ "issue_pull_requests_seq_seq"
747
+ ];
748
+ /** The cards themselves — the one table the agent role may not delete from. */
749
+ const ISSUES_TABLE = "issues";
750
+ /** Everything else, which both roles hold outright. */
751
+ const AGENT_DELETABLE_TABLES = BOARD_POSTGRES_TABLES.filter((table) => table !== ISSUES_TABLE);
752
+ /** Every board table, as the SQL spells a list of them. */
753
+ function quoted(tables) {
754
+ return tables.map((table) => `"${table}"`).join(", ");
755
+ }
756
+ /**
757
+ * The two roles an external board is worked through, and the two rules the
758
+ * database itself keeps.
759
+ *
760
+ * A board in a file is protected by whoever can open the file. A board in a
761
+ * shared database is not: several people and several agents connect to it at
762
+ * once, from machines nobody else knows about, so the rule that approving is
763
+ * a person's job has to live somewhere they all pass through. That is here —
764
+ * below every client, in triggers that read `current_user`, so an agent holding
765
+ * a connection string still cannot do a person's two things:
766
+ *
767
+ * - take a card out of `waiting_for = 'human'`, which is how a person accepts
768
+ * or returns the work, and
769
+ * - write a `system.approved` or `system.returned` comment, which is how that
770
+ * decision is recorded — nor delete one, nor rewrite one, because a rule
771
+ * about who may write a row is worth nothing if the other side may erase or
772
+ * re-word it afterwards.
773
+ *
774
+ * The third rule is a grant rather than a trigger: the agent role may not
775
+ * delete a card at all. A trigger cannot answer this one, because a cascading
776
+ * delete is carried out as the owner of the table it reaches — `current_user`
777
+ * inside the comment guard is the owner, not the agent, so deleting the card
778
+ * would quietly take the decision on it with it. Nothing is lost by refusing:
779
+ * the board's own tools offer no way to delete a card (cancelling one is a move
780
+ * to another column), and a card that could be deleted and written again is a
781
+ * way around waiting for a person.
782
+ *
783
+ * The guards read `current_user`, which is the role the session *logged in as*:
784
+ * a login merely granted membership in `kanban_agent` is still itself, and the
785
+ * rules below do not fire for it. The header of the SQL says so to whoever
786
+ * applies it.
787
+ *
788
+ * Everything else about the board is the agent's to do. Both roles are created
789
+ * with `login` and **no password**: an administrator sets one with
790
+ * `alter role kanban_agent with password '…'` before a connection string is
791
+ * handed out, and no password is ever written by this package.
792
+ *
793
+ * Applying this twice is a no-op — `kanbo roles apply` is meant to be run
794
+ * again whenever the board moves or the rules change.
795
+ */
796
+ const BOARD_ROLE_SQL = `-- Two roles for one board: a person's and an agent's.
797
+ -- Apply as the owner of the board tables (or a superuser):
798
+ -- psql "$KANBO_DATABASE_URL" -f roles.sql
799
+ -- Neither role is given a password here. Set one before handing out a
800
+ -- connection string:
801
+ -- alter role ${BOARD_AGENT_ROLE} with password '…';
802
+ -- The agent role is granted no delete on "issues": see the comment on that
803
+ -- grant below.
804
+ -- Connect AS the role. The guards below read current_user, and a login that was
805
+ -- merely granted membership ("create role bot login; grant ${BOARD_AGENT_ROLE} to bot;")
806
+ -- keeps current_user = 'bot' — the guards then never fire and the board looks
807
+ -- configured while nothing is enforced. Either hand out ${BOARD_AGENT_ROLE}'s own
808
+ -- connection string, or have the session run "set role ${BOARD_AGENT_ROLE}" before it
809
+ -- touches the board.
810
+ do $$
811
+ begin
812
+ if not exists (select 1 from pg_catalog.pg_roles where rolname = '${BOARD_PERSON_ROLE}') then
813
+ create role "${BOARD_PERSON_ROLE}" login;
814
+ end if;
815
+ if not exists (select 1 from pg_catalog.pg_roles where rolname = '${BOARD_AGENT_ROLE}') then
816
+ create role "${BOARD_AGENT_ROLE}" login;
817
+ end if;
818
+ end
819
+ $$;
820
+
821
+ grant usage on schema public to "${BOARD_PERSON_ROLE}", "${BOARD_AGENT_ROLE}";
822
+
823
+ grant select, insert, update, delete on ${quoted(BOARD_POSTGRES_TABLES)}
824
+ to "${BOARD_PERSON_ROLE}";
825
+
826
+ -- The agent holds everything but the deletion of a card. A card carries its
827
+ -- history by cascade, and a cascading delete runs as the table's owner, so a
828
+ -- trigger on the comments cannot tell an agent's deletion from anyone's: the
829
+ -- only place to say it is here. The board's own tools never delete a card —
830
+ -- cancelling one is a move to another column — and a card that could be deleted
831
+ -- and written again is a way around waiting for a person.
832
+ grant select, insert, update, delete on ${quoted(AGENT_DELETABLE_TABLES)}
833
+ to "${BOARD_AGENT_ROLE}";
834
+
835
+ grant select, insert, update on "${ISSUES_TABLE}" to "${BOARD_AGENT_ROLE}";
836
+ revoke delete on "${ISSUES_TABLE}" from "${BOARD_AGENT_ROLE}";
837
+
838
+ grant usage, select on sequence ${quoted(BOARD_SEQUENCES)}
839
+ to "${BOARD_PERSON_ROLE}", "${BOARD_AGENT_ROLE}";
840
+
841
+ -- A person accepts or returns the work; an agent asks and waits.
842
+ create or replace function kanban_guard_waiting_for() returns trigger
843
+ language plpgsql as $$
844
+ begin
845
+ if current_user = '${BOARD_AGENT_ROLE}'
846
+ and old.waiting_for = 'human'
847
+ and new.waiting_for is distinct from old.waiting_for then
848
+ raise exception '${AGENT_WAITING_REFUSAL}' using errcode = '${BOARD_REFUSAL_ERRCODE}';
849
+ end if;
850
+ return new;
851
+ end;
852
+ $$;
853
+
854
+ drop trigger if exists kanban_guard_waiting_for on "issues";
855
+ create trigger kanban_guard_waiting_for
856
+ before update on "issues"
857
+ for each row execute function kanban_guard_waiting_for();
858
+
859
+ -- And the same decision, said in the card's history.
860
+ create or replace function kanban_guard_approval_comments() returns trigger
861
+ language plpgsql as $$
862
+ begin
863
+ if current_user = '${BOARD_AGENT_ROLE}'
864
+ and new.author_kind in ('system.approved', 'system.returned') then
865
+ raise exception '${AGENT_APPROVAL_COMMENT_REFUSAL}' using errcode = '${BOARD_REFUSAL_ERRCODE}';
866
+ end if;
867
+ return new;
868
+ end;
869
+ $$;
870
+
871
+ drop trigger if exists kanban_guard_approval_comments on "issue_comments";
872
+ create trigger kanban_guard_approval_comments
873
+ before insert on "issue_comments"
874
+ for each row execute function kanban_guard_approval_comments();
875
+
876
+ -- And the same decision cannot be erased either: a rule about who may write a
877
+ -- row is worth nothing if the other side may delete it afterwards.
878
+ create or replace function kanban_guard_approval_comment_deletes() returns trigger
879
+ language plpgsql as $$
880
+ begin
881
+ if current_user = '${BOARD_AGENT_ROLE}'
882
+ and old.author_kind in ('system.approved', 'system.returned') then
883
+ raise exception '${AGENT_APPROVAL_COMMENT_REFUSAL}' using errcode = '${BOARD_REFUSAL_ERRCODE}';
884
+ end if;
885
+ return old;
886
+ end;
887
+ $$;
888
+
889
+ drop trigger if exists kanban_guard_approval_comment_deletes on "issue_comments";
890
+ create trigger kanban_guard_approval_comment_deletes
891
+ before delete on "issue_comments"
892
+ for each row execute function kanban_guard_approval_comment_deletes();
893
+
894
+ -- Nor rewritten. A decision that can be re-worded, re-dated or turned into
895
+ -- something else is not a decision, and an update is how each of those is
896
+ -- spelled: "set content", "set created_at", "set author_kind = 'agent'". The
897
+ -- row is guarded coming and going — what it already is, and what it is being
898
+ -- made into — so the agent can neither take a person's decision away nor turn
899
+ -- its own comment into one.
900
+ create or replace function kanban_guard_approval_comment_updates() returns trigger
901
+ language plpgsql as $$
902
+ begin
903
+ if current_user = '${BOARD_AGENT_ROLE}'
904
+ and (old.author_kind in ('system.approved', 'system.returned')
905
+ or new.author_kind in ('system.approved', 'system.returned')) then
906
+ raise exception '${AGENT_APPROVAL_COMMENT_REFUSAL}' using errcode = '${BOARD_REFUSAL_ERRCODE}';
907
+ end if;
908
+ return new;
909
+ end;
910
+ $$;
911
+
912
+ drop trigger if exists kanban_guard_approval_comment_updates on "issue_comments";
913
+ create trigger kanban_guard_approval_comment_updates
914
+ before update on "issue_comments"
915
+ for each row execute function kanban_guard_approval_comment_updates();
916
+ `;
917
+ //#endregion
918
+ export { issueStatuses as _, BOARD_REFUSAL_ERRCODE as a, getBoardMigrationsPath as c, boardPostgresSchema as d, issueComments as f, issueRuns as g, issueRelations as h, BOARD_PERSON_ROLE as i, createPostgresBoardStore as l, issueMilestones as m, AGENT_WAITING_REFUSAL as n, BOARD_ROLE_SQL as o, issueFieldChanges as p, BOARD_AGENT_ROLE as r, assertBoardSchema as s, AGENT_APPROVAL_COMMENT_REFUSAL as t, BOARD_POSTGRES_TABLES as u, issues as v, kanbanMeta as y };
919
+
920
+ //# sourceMappingURL=roles.sql-CvyCGt38.mjs.map