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.
- package/LICENSE +21 -0
- package/README.md +79 -0
- package/dist/better-sqlite3-fU3G4xnX.cjs +2254 -0
- package/dist/better-sqlite3-fU3G4xnX.cjs.map +1 -0
- package/dist/cli.cjs +42949 -0
- package/dist/cli.cjs.map +1 -0
- package/dist/dist-Cd6wg8pp.cjs +942 -0
- package/dist/dist-Cd6wg8pp.cjs.map +1 -0
- package/dist/drizzle-postgres/0000_board.sql +135 -0
- package/dist/drizzle-postgres/0001_history_order.sql +2 -0
- package/dist/drizzle-postgres/0002_issue_pull_requests.sql +16 -0
- package/dist/drizzle-postgres/0003_milestone_start_date.sql +1 -0
- package/dist/drizzle-postgres/0004_status_entry_rules.sql +1 -0
- package/dist/drizzle-postgres/0005_run_external_session_ref.sql +1 -0
- package/dist/drizzle-postgres/README.md +61 -0
- package/dist/drizzle-postgres/meta/0000_snapshot.json +1078 -0
- package/dist/drizzle-postgres/meta/0001_snapshot.json +1090 -0
- package/dist/drizzle-postgres/meta/0002_snapshot.json +1239 -0
- package/dist/drizzle-postgres/meta/0003_snapshot.json +1245 -0
- package/dist/drizzle-postgres/meta/0004_snapshot.json +1251 -0
- package/dist/drizzle-postgres/meta/0005_snapshot.json +1257 -0
- package/dist/drizzle-postgres/meta/_journal.json +48 -0
- package/dist/drizzle-sqlite/0000_board.sql +133 -0
- package/dist/drizzle-sqlite/0001_issue_pull_requests.sql +15 -0
- package/dist/drizzle-sqlite/0002_milestone_start_date.sql +1 -0
- package/dist/drizzle-sqlite/0003_status_entry_rules.sql +1 -0
- package/dist/drizzle-sqlite/0004_run_external_session_ref.sql +1 -0
- package/dist/drizzle-sqlite/README.md +67 -0
- package/dist/drizzle-sqlite/meta/0000_snapshot.json +946 -0
- package/dist/drizzle-sqlite/meta/0001_snapshot.json +1054 -0
- package/dist/drizzle-sqlite/meta/0002_snapshot.json +1061 -0
- package/dist/drizzle-sqlite/meta/0003_snapshot.json +1068 -0
- package/dist/drizzle-sqlite/meta/0004_snapshot.json +1075 -0
- package/dist/drizzle-sqlite/meta/_journal.json +41 -0
- package/dist/lib/board-store-B16YarvX.d.mts +281 -0
- package/dist/lib/board-store-BHHo_ZlR.d.cts +281 -0
- package/dist/lib/index.cjs +340 -0
- package/dist/lib/index.cjs.map +1 -0
- package/dist/lib/index.d.cts +1608 -0
- package/dist/lib/index.d.mts +1608 -0
- package/dist/lib/index.mjs +284 -0
- package/dist/lib/index.mjs.map +1 -0
- package/dist/lib/manifest-C0EcwwgO.mjs +7866 -0
- package/dist/lib/manifest-C0EcwwgO.mjs.map +1 -0
- package/dist/lib/manifest-CpZOcB_6.cjs +8272 -0
- package/dist/lib/manifest-CpZOcB_6.cjs.map +1 -0
- package/dist/lib/mcp/index.cjs +184 -0
- package/dist/lib/mcp/index.cjs.map +1 -0
- package/dist/lib/mcp/index.d.cts +346 -0
- package/dist/lib/mcp/index.d.mts +346 -0
- package/dist/lib/mcp/index.mjs +173 -0
- package/dist/lib/mcp/index.mjs.map +1 -0
- package/dist/lib/migrate-BhYi9cXw.mjs +656 -0
- package/dist/lib/migrate-BhYi9cXw.mjs.map +1 -0
- package/dist/lib/migrate-DnMjTCvu.cjs +709 -0
- package/dist/lib/migrate-DnMjTCvu.cjs.map +1 -0
- package/dist/lib/migrations-path-CZxVOgeD.cjs +109 -0
- package/dist/lib/migrations-path-CZxVOgeD.cjs.map +1 -0
- package/dist/lib/migrations-path-Ciwan537.mjs +80 -0
- package/dist/lib/migrations-path-Ciwan537.mjs.map +1 -0
- package/dist/lib/postgres/index.cjs +56 -0
- package/dist/lib/postgres/index.cjs.map +1 -0
- package/dist/lib/postgres/index.d.cts +3403 -0
- package/dist/lib/postgres/index.d.mts +3403 -0
- package/dist/lib/postgres/index.mjs +35 -0
- package/dist/lib/postgres/index.mjs.map +1 -0
- package/dist/lib/roles.sql-B9rt-x4o.cjs +1033 -0
- package/dist/lib/roles.sql-B9rt-x4o.cjs.map +1 -0
- package/dist/lib/roles.sql-CvyCGt38.mjs +920 -0
- package/dist/lib/roles.sql-CvyCGt38.mjs.map +1 -0
- package/dist/lib/schema-epoch-ZyumVL_A.d.cts +16 -0
- package/dist/lib/schema-epoch-ZyumVL_A.d.mts +16 -0
- package/dist/lib/schema-fsHqNAip.d.cts +3612 -0
- package/dist/lib/schema-fsHqNAip.d.mts +3612 -0
- package/dist/lib/sqlite/index.cjs +23 -0
- package/dist/lib/sqlite/index.d.cts +175 -0
- package/dist/lib/sqlite/index.d.mts +175 -0
- package/dist/lib/sqlite/index.mjs +4 -0
- package/dist/lib/sqlite/schema.cjs +266 -0
- package/dist/lib/sqlite/schema.cjs.map +1 -0
- package/dist/lib/sqlite/schema.d.cts +2 -0
- package/dist/lib/sqlite/schema.d.mts +2 -0
- package/dist/lib/sqlite/schema.mjs +256 -0
- package/dist/lib/sqlite/schema.mjs.map +1 -0
- package/dist/lib/tool-names-9cmCn0gP.d.cts +112 -0
- package/dist/lib/tool-names-Dxg1UTUl.d.mts +112 -0
- package/dist/page/board.css +477 -0
- package/dist/page/board.js +2 -0
- package/drizzle-postgres/0000_board.sql +135 -0
- package/drizzle-postgres/0001_history_order.sql +2 -0
- package/drizzle-postgres/0002_issue_pull_requests.sql +16 -0
- package/drizzle-postgres/0003_milestone_start_date.sql +1 -0
- package/drizzle-postgres/0004_status_entry_rules.sql +1 -0
- package/drizzle-postgres/0005_run_external_session_ref.sql +1 -0
- package/drizzle-postgres/README.md +61 -0
- package/drizzle-postgres/meta/0000_snapshot.json +1078 -0
- package/drizzle-postgres/meta/0001_snapshot.json +1090 -0
- package/drizzle-postgres/meta/0002_snapshot.json +1239 -0
- package/drizzle-postgres/meta/0003_snapshot.json +1245 -0
- package/drizzle-postgres/meta/0004_snapshot.json +1251 -0
- package/drizzle-postgres/meta/0005_snapshot.json +1257 -0
- package/drizzle-postgres/meta/_journal.json +48 -0
- package/drizzle-sqlite/0000_board.sql +133 -0
- package/drizzle-sqlite/0001_issue_pull_requests.sql +15 -0
- package/drizzle-sqlite/0002_milestone_start_date.sql +1 -0
- package/drizzle-sqlite/0003_status_entry_rules.sql +1 -0
- package/drizzle-sqlite/0004_run_external_session_ref.sql +1 -0
- package/drizzle-sqlite/README.md +67 -0
- package/drizzle-sqlite/meta/0000_snapshot.json +946 -0
- package/drizzle-sqlite/meta/0001_snapshot.json +1054 -0
- package/drizzle-sqlite/meta/0002_snapshot.json +1061 -0
- package/drizzle-sqlite/meta/0003_snapshot.json +1068 -0
- package/drizzle-sqlite/meta/0004_snapshot.json +1075 -0
- package/drizzle-sqlite/meta/_journal.json +41 -0
- 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
|