kanbo-cli 0.2.1 → 0.3.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 (85) hide show
  1. package/CHANGELOG.md +173 -0
  2. package/README.md +16 -47
  3. package/dist/{better-sqlite3-fU3G4xnX.cjs → better-sqlite3-DPdSZ6sH.cjs} +244 -247
  4. package/dist/cli-main.cjs +51208 -0
  5. package/dist/cli.cjs +32 -45216
  6. package/dist/dist-IHRTF-b5.cjs +2552 -0
  7. package/dist/lib/{tool-names-WfH4IURz.d.cts → approval-BsBk2xVr.d.cts} +72 -3
  8. package/dist/lib/{tool-names-3q_GEZej.d.mts → approval-LRZ6ontC.d.mts} +72 -3
  9. package/dist/lib/{board-store-BvWn9xTB.d.mts → board-store-D_Mpfb84.d.mts} +75 -2
  10. package/dist/lib/{board-store-BfszzAJB.d.cts → board-store-Dx-mw2I6.d.cts} +75 -2
  11. package/dist/lib/index.cjs +8 -2
  12. package/dist/lib/index.d.cts +202 -91
  13. package/dist/lib/index.d.mts +202 -91
  14. package/dist/lib/index.mjs +3 -3
  15. package/dist/lib/{manifest-D9nCRtXM.cjs → manifest-QPoQl8tn.cjs} +6881 -1556
  16. package/dist/lib/{manifest-DHV2fRdO.mjs → manifest-v2e9x_C8.mjs} +6795 -1556
  17. package/dist/lib/mcp/index.cjs +156 -21
  18. package/dist/lib/mcp/index.d.cts +140 -5
  19. package/dist/lib/mcp/index.d.mts +140 -5
  20. package/dist/lib/mcp/index.mjs +152 -22
  21. package/dist/lib/{migrate-DnMjTCvu.cjs → migrate-Dca5WdYd.cjs} +144 -14
  22. package/dist/lib/{migrate-BhYi9cXw.mjs → migrate-DtBeW3Fs.mjs} +134 -16
  23. package/dist/lib/{migrations-path-Ciwan537.mjs → migrations-path-ClaYRNwk.mjs} +5 -7
  24. package/dist/lib/{migrations-path-CZxVOgeD.cjs → migrations-path-DBCE17dT.cjs} +5 -7
  25. package/dist/lib/postgres/index.cjs +2 -4
  26. package/dist/lib/postgres/index.d.cts +2 -3
  27. package/dist/lib/postgres/index.d.mts +2 -3
  28. package/dist/lib/postgres/index.mjs +2 -4
  29. package/dist/lib/{roles.sql-CvyCGt38.mjs → roles.sql-BoOZSkBp.mjs} +96 -5
  30. package/dist/lib/{roles.sql-B9rt-x4o.cjs → roles.sql-E58x7mLS.cjs} +95 -4
  31. package/dist/lib/schema-D0MyXcej.d.cts +1 -2
  32. package/dist/lib/schema-D0MyXcej.d.mts +1 -2
  33. package/dist/lib/schema-epoch-ZyumVL_A.d.cts +1 -2
  34. package/dist/lib/schema-epoch-ZyumVL_A.d.mts +1 -2
  35. package/dist/lib/sqlite/index.cjs +2 -2
  36. package/dist/lib/sqlite/index.d.cts +3 -4
  37. package/dist/lib/sqlite/index.d.mts +3 -4
  38. package/dist/lib/sqlite/index.mjs +2 -2
  39. package/dist/lib/sqlite/schema.cjs +0 -2
  40. package/dist/lib/sqlite/schema.mjs +0 -2
  41. package/package.json +13 -10
  42. package/dist/better-sqlite3-fU3G4xnX.cjs.map +0 -1
  43. package/dist/cli.cjs.map +0 -1
  44. package/dist/dist-BsCwQFEl.cjs +0 -989
  45. package/dist/dist-BsCwQFEl.cjs.map +0 -1
  46. package/dist/lib/manifest-D9nCRtXM.cjs.map +0 -1
  47. package/dist/lib/manifest-DHV2fRdO.mjs.map +0 -1
  48. package/dist/lib/mcp/index.cjs.map +0 -1
  49. package/dist/lib/mcp/index.mjs.map +0 -1
  50. package/dist/lib/migrate-BhYi9cXw.mjs.map +0 -1
  51. package/dist/lib/migrate-DnMjTCvu.cjs.map +0 -1
  52. package/dist/lib/migrations-path-CZxVOgeD.cjs.map +0 -1
  53. package/dist/lib/migrations-path-Ciwan537.mjs.map +0 -1
  54. package/dist/lib/postgres/index.cjs.map +0 -1
  55. package/dist/lib/postgres/index.mjs.map +0 -1
  56. package/dist/lib/roles.sql-B9rt-x4o.cjs.map +0 -1
  57. package/dist/lib/roles.sql-CvyCGt38.mjs.map +0 -1
  58. package/dist/lib/sqlite/schema.cjs.map +0 -1
  59. package/dist/lib/sqlite/schema.mjs.map +0 -1
  60. package/drizzle-postgres/0000_board.sql +0 -135
  61. package/drizzle-postgres/0001_history_order.sql +0 -2
  62. package/drizzle-postgres/0002_issue_pull_requests.sql +0 -16
  63. package/drizzle-postgres/0003_milestone_start_date.sql +0 -1
  64. package/drizzle-postgres/0004_status_entry_rules.sql +0 -1
  65. package/drizzle-postgres/0005_run_external_session_ref.sql +0 -1
  66. package/drizzle-postgres/README.md +0 -61
  67. package/drizzle-postgres/meta/0000_snapshot.json +0 -1078
  68. package/drizzle-postgres/meta/0001_snapshot.json +0 -1090
  69. package/drizzle-postgres/meta/0002_snapshot.json +0 -1239
  70. package/drizzle-postgres/meta/0003_snapshot.json +0 -1245
  71. package/drizzle-postgres/meta/0004_snapshot.json +0 -1251
  72. package/drizzle-postgres/meta/0005_snapshot.json +0 -1257
  73. package/drizzle-postgres/meta/_journal.json +0 -48
  74. package/drizzle-sqlite/0000_board.sql +0 -133
  75. package/drizzle-sqlite/0001_issue_pull_requests.sql +0 -15
  76. package/drizzle-sqlite/0002_milestone_start_date.sql +0 -1
  77. package/drizzle-sqlite/0003_status_entry_rules.sql +0 -1
  78. package/drizzle-sqlite/0004_run_external_session_ref.sql +0 -1
  79. package/drizzle-sqlite/README.md +0 -67
  80. package/drizzle-sqlite/meta/0000_snapshot.json +0 -946
  81. package/drizzle-sqlite/meta/0001_snapshot.json +0 -1054
  82. package/drizzle-sqlite/meta/0002_snapshot.json +0 -1061
  83. package/drizzle-sqlite/meta/0003_snapshot.json +0 -1068
  84. package/drizzle-sqlite/meta/0004_snapshot.json +0 -1075
  85. package/drizzle-sqlite/meta/_journal.json +0 -41
@@ -1,11 +1,22 @@
1
- import { a as BoardError, i as BOARD_TABLES_ADDED_WITHIN_EPOCH, n as resolveBoardMigrationsPath, t as hasMigrationJournal } from "./migrations-path-Ciwan537.mjs";
1
+ import { a as BoardError, i as BOARD_TABLES_ADDED_WITHIN_EPOCH, n as resolveBoardMigrationsPath, t as hasMigrationJournal } from "./migrations-path-ClaYRNwk.mjs";
2
2
  import { boardSqliteSchema, issueComments, issueFieldChanges, issueMilestones, issuePullRequests, issueRelations, issueRuns, issueStatuses, issues, kanbanMeta } from "./sqlite/schema.mjs";
3
3
  import { createRequire } from "node:module";
4
- import { and, asc, desc, eq, getTableColumns, inArray, isNotNull, or, sql } from "drizzle-orm";
4
+ import { and, asc, desc, eq, getTableColumns, gte, inArray, isNotNull, isNull, or, sql } from "drizzle-orm";
5
5
  import { getTableConfig } from "drizzle-orm/sqlite-core";
6
6
  import { dirname } from "node:path";
7
7
  import { fileURLToPath } from "node:url";
8
8
  import { migrate } from "drizzle-orm/better-sqlite3/migrator";
9
+ //#region src/domain/fold-case.ts
10
+ /**
11
+ * Text as a search compares it: every letter lowercased by Unicode's rules, not
12
+ * only A–Z — `Über` and `über` are one word. What Postgres `ilike` does on
13
+ * either side of a board search; SQLite's own `like` folds ASCII alone, so a
14
+ * board file compares through this function instead (`kanbo_fold`).
15
+ */
16
+ function foldCase(text) {
17
+ return text.toLowerCase();
18
+ }
19
+ //#endregion
9
20
  //#region src/sqlite/transaction.ts
10
21
  /**
11
22
  * The better-sqlite3 handle Drizzle keeps beside the query builder. `drizzle()`
@@ -162,6 +173,82 @@ function selectRunProjection(database, scope) {
162
173
  }));
163
174
  }
164
175
  /**
176
+ * One page of the cards a query picks, and how many it picks in all, in one
177
+ * statement: `count(*) over ()` is taken over every picked row before `limit`
178
+ * and `offset` cut the page out of them. Only a page past the last card — no
179
+ * row to carry the count — asks a second time.
180
+ */
181
+ function selectCardPage(database, query) {
182
+ if (query.statusIds?.length === 0 || query.priorities?.length === 0) return {
183
+ cards: [],
184
+ total: 0
185
+ };
186
+ if (query.text !== void 0) registerFoldFunction(database);
187
+ const where = and(...cardQueryPredicates(query));
188
+ const rows = database.select({
189
+ ...getTableColumns(issues),
190
+ pickedTotal: sql`count(*) over ()`
191
+ }).from(issues).where(where).orderBy(issues.order, desc(issues.createdAt), issues.id).limit(query.limit ?? Number.MAX_SAFE_INTEGER).offset(query.offset ?? 0).all();
192
+ if (rows.length === 0) return {
193
+ cards: [],
194
+ total: (query.offset ?? 0) > 0 ? database.select({ count: sql`count(*)` }).from(issues).where(where).get()?.count ?? 0 : 0
195
+ };
196
+ return {
197
+ cards: rows.map(({ pickedTotal: _total, ...card }) => card),
198
+ total: rows[0].pickedTotal
199
+ };
200
+ }
201
+ /** The connections `kanbo_fold` is registered on already. */
202
+ const foldingConnections = /* @__PURE__ */ new WeakSet();
203
+ /**
204
+ * Teach the connection `kanbo_fold(text)`, the search's `foldCase`, once.
205
+ *
206
+ * SQLite's `like` and `lower()` fold A–Z and nothing else, so `über` would not
207
+ * find `Über` on a board file while Postgres `ilike` and the HTTP transport find
208
+ * it. The function is registered on the connection the statement runs on —
209
+ * the host may have opened it, not this package — and stays one statement.
210
+ */
211
+ function registerFoldFunction(database) {
212
+ const connection = readSqliteConnection(database);
213
+ if (foldingConnections.has(connection)) return;
214
+ connection.function("kanbo_fold", { deterministic: true }, (value) => typeof value === "string" ? foldCase(value) : value);
215
+ foldingConnections.add(connection);
216
+ }
217
+ function cardQueryPredicates(query) {
218
+ const running = sql`${issues.id} in (select ${issueRuns.issueId} from ${issueRuns} where ${issueRuns.state} = 'running')`;
219
+ const text = foldCase(query.text ?? "");
220
+ return [
221
+ eq(issues.workspaceId, query.workspaceId),
222
+ query.statusIds === void 0 ? void 0 : inArray(issues.statusId, [...query.statusIds]),
223
+ query.waitingForPerson === void 0 ? void 0 : query.waitingForPerson ? eq(issues.waitingFor, "human") : isNull(issues.waitingFor),
224
+ query.parentIssueId === void 0 ? void 0 : eq(issues.parentIssueId, query.parentIssueId),
225
+ query.hasActiveRun === void 0 ? void 0 : query.hasActiveRun ? running : sql`not ${running}`,
226
+ query.returned === void 0 ? void 0 : query.returned ? returnedCard(running) : sql`not ${returnedCard(running)}`,
227
+ query.text === void 0 ? void 0 : sql`(instr(kanbo_fold(${issues.id}), ${text}) > 0 or instr(kanbo_fold(${issues.title}), ${text}) > 0 or instr(kanbo_fold(coalesce(${issues.description}, '')), ${text}) > 0)`,
228
+ query.updatedSince === void 0 ? void 0 : gte(issues.updatedAt, query.updatedSince),
229
+ ...(query.labels ?? []).map((label) => sql`exists (select 1 from json_each(${issues.labels}) where value = ${label})`),
230
+ query.priorities === void 0 ? void 0 : inArray(issues.priority, [...query.priorities])
231
+ ];
232
+ }
233
+ /**
234
+ * A card a person sent back that nobody picked up again (`BoardCardQuery.returned`):
235
+ * not waiting, not running, and its last decision a return — no approval or
236
+ * return written after it, `rowid` breaking a tie inside one second the way
237
+ * `readApproval`'s comment order does.
238
+ */
239
+ function returnedCard(running) {
240
+ return sql`(${issues.waitingFor} is null and not ${running} and exists (
241
+ select 1 from ${issueComments} as decision
242
+ where decision.issue_id = ${issues.id} and decision.author_kind = 'system.returned'
243
+ and not exists (
244
+ select 1 from ${issueComments} as later
245
+ where later.issue_id = decision.issue_id
246
+ and later.author_kind in ('system.approved', 'system.returned')
247
+ and (later.created_at > decision.created_at or (later.created_at = decision.created_at and later.rowid > decision.rowid))
248
+ )
249
+ ))`;
250
+ }
251
+ /**
165
252
  * Bind the store's statements to one SQLite handle. `resolveDatabase` is called
166
253
  * per statement rather than captured, so the store follows a host that swaps its
167
254
  * database out (a server that resets its infrastructure between tests, say).
@@ -173,6 +260,7 @@ function createSqliteBoardStoreCore(resolveDatabase) {
173
260
  listAllInBoardOrder: () => resolveDatabase().select().from(issueStatuses).orderBy(issueStatuses.workspaceId, issueStatuses.order).all(),
174
261
  listAll: () => resolveDatabase().select().from(issueStatuses).all(),
175
262
  findById: (statusId) => resolveDatabase().select().from(issueStatuses).where(eq(issueStatuses.id, statusId)).get() ?? null,
263
+ lockById: (statusId) => resolveDatabase().select().from(issueStatuses).where(eq(issueStatuses.id, statusId)).get() ?? null,
176
264
  findInWorkspace: (workspaceId, statusId) => resolveDatabase().select().from(issueStatuses).where(sql`${issueStatuses.workspaceId} = ${workspaceId} AND ${issueStatuses.id} = ${statusId}`).get() ?? null,
177
265
  countByWorkspace: (workspaceId) => {
178
266
  return resolveDatabase().select({ count: sql`count(*)` }).from(issueStatuses).where(eq(issueStatuses.workspaceId, workspaceId)).get()?.count ?? 0;
@@ -205,11 +293,17 @@ function createSqliteBoardStoreCore(resolveDatabase) {
205
293
  issues: {
206
294
  listInBoardOrder: (workspaceId) => {
207
295
  const predicates = [workspaceId ? eq(issues.workspaceId, workspaceId) : void 0].filter((predicate) => predicate !== void 0);
208
- return resolveDatabase().select().from(issues).where(predicates.length > 0 ? and(...predicates) : void 0).orderBy(issues.order, desc(issues.createdAt)).all();
296
+ return resolveDatabase().select().from(issues).where(predicates.length > 0 ? and(...predicates) : void 0).orderBy(issues.order, desc(issues.createdAt), issues.id).all();
209
297
  },
210
298
  listNewestFirst: () => resolveDatabase().select().from(issues).orderBy(desc(issues.createdAt)).all(),
211
299
  listAll: () => resolveDatabase().select().from(issues).all(),
212
300
  listByWorkspace: (workspaceId) => resolveDatabase().select().from(issues).where(eq(issues.workspaceId, workspaceId)).all(),
301
+ listPage: (query) => selectCardPage(resolveDatabase(), query),
302
+ countByMilestone: (workspaceId) => resolveDatabase().select({
303
+ milestoneId: sql`${issues.milestoneId}`,
304
+ statusId: issues.statusId,
305
+ count: sql`count(*)`
306
+ }).from(issues).where(and(eq(issues.workspaceId, workspaceId), isNotNull(issues.milestoneId))).groupBy(issues.milestoneId, issues.statusId).all(),
213
307
  listByIds: (issueIds) => resolveDatabase().select().from(issues).where(inArray(issues.id, issueIds)).all(),
214
308
  findById: (issueId) => resolveDatabase().select().from(issues).where(eq(issues.id, issueId)).get() ?? null,
215
309
  findInWorkspace: (workspaceId, issueId) => resolveDatabase().select().from(issues).where(and(eq(issues.id, issueId), eq(issues.workspaceId, workspaceId))).get() ?? null,
@@ -248,6 +342,15 @@ function createSqliteBoardStoreCore(resolveDatabase) {
248
342
  },
249
343
  comments: {
250
344
  listByIssue: (issueId) => resolveDatabase().select().from(issueComments).where(eq(issueComments.issueId, issueId)).orderBy(asc(issueComments.createdAt), COMMENT_INSERTION_ORDER).all(),
345
+ listLatestByIssues: (issueIds, authorKinds) => {
346
+ if (issueIds.length === 0 || authorKinds?.length === 0) return [];
347
+ const database = resolveDatabase();
348
+ const ranked = database.select({
349
+ id: issueComments.id,
350
+ commentRank: sql`row_number() over (partition by ${issueComments.issueId} order by ${issueComments.createdAt} desc, ${issueComments}.rowid desc)`.as("comment_rank")
351
+ }).from(issueComments).where(and(inArray(issueComments.issueId, [...issueIds]), authorKinds === void 0 ? void 0 : inArray(issueComments.authorKind, [...authorKinds]))).as("ranked");
352
+ return database.select(getTableColumns(issueComments)).from(issueComments).innerJoin(ranked, eq(ranked.id, issueComments.id)).where(eq(ranked.commentRank, 1)).all();
353
+ },
251
354
  findById: (commentId) => resolveDatabase().select().from(issueComments).where(eq(issueComments.id, commentId)).get() ?? null,
252
355
  findByDedupeKey: (issueId, dedupeKey) => resolveDatabase().select().from(issueComments).where(and(eq(issueComments.issueId, issueId), eq(issueComments.dedupeKey, dedupeKey))).get() ?? null,
253
356
  listByDedupeKeyPrefix: (issueId, prefix) => resolveDatabase().select().from(issueComments).where(and(eq(issueComments.issueId, issueId), sql`substr(${issueComments.dedupeKey}, 1, ${prefix.length}) = ${prefix}`)).orderBy(asc(issueComments.createdAt), COMMENT_INSERTION_ORDER).all(),
@@ -354,6 +457,7 @@ function createSqliteBoardStore(options) {
354
457
  listAllInBoardOrder: async () => core.statuses.listAllInBoardOrder(),
355
458
  listAll: async () => core.statuses.listAll(),
356
459
  findById: async (statusId) => core.statuses.findById(statusId),
460
+ lockById: async (statusId) => core.statuses.lockById(statusId),
357
461
  findInWorkspace: async (workspaceId, statusId) => core.statuses.findInWorkspace(workspaceId, statusId),
358
462
  countByWorkspace: async (workspaceId) => core.statuses.countByWorkspace(workspaceId),
359
463
  create: async (values) => core.statuses.create(values),
@@ -375,6 +479,8 @@ function createSqliteBoardStore(options) {
375
479
  listNewestFirst: async () => core.issues.listNewestFirst(),
376
480
  listAll: async () => core.issues.listAll(),
377
481
  listByWorkspace: async (workspaceId) => core.issues.listByWorkspace(workspaceId),
482
+ listPage: async (query) => core.issues.listPage(query),
483
+ countByMilestone: async (workspaceId) => core.issues.countByMilestone(workspaceId),
378
484
  listByIds: async (issueIds) => core.issues.listByIds(issueIds),
379
485
  findById: async (issueId) => core.issues.findById(issueId),
380
486
  findInWorkspace: async (workspaceId, issueId) => core.issues.findInWorkspace(workspaceId, issueId),
@@ -391,6 +497,7 @@ function createSqliteBoardStore(options) {
391
497
  },
392
498
  comments: {
393
499
  listByIssue: async (issueId) => core.comments.listByIssue(issueId),
500
+ listLatestByIssues: async (issueIds, authorKinds) => core.comments.listLatestByIssues(issueIds, authorKinds),
394
501
  findById: async (commentId) => core.comments.findById(commentId),
395
502
  findByDedupeKey: async (issueId, dedupeKey) => core.comments.findByDedupeKey(issueId, dedupeKey),
396
503
  listByDedupeKeyPrefix: async (issueId, prefix) => core.comments.listByDedupeKeyPrefix(issueId, prefix),
@@ -448,25 +555,35 @@ function createSqliteBoardStore(options) {
448
555
  };
449
556
  return store;
450
557
  }
558
+ /** The logger a board handle is opened with: the counter while one is on, otherwise none. */
559
+ function boardQueryLogger() {}
451
560
  //#endregion
452
561
  //#region src/sqlite/open-database.ts
453
562
  const require = createRequire(import.meta.url);
454
563
  /** The tables a board file must already carry for this package to read it. */
455
564
  const REQUIRED_BOARD_TABLES = ["issue_runs", "kanban_meta"];
456
- /** What a caller is told when the native module a board file needs is not installed. */
457
- const BETTER_SQLITE3_MISSING_MESSAGE = "Install better-sqlite3 to use a board file";
458
565
  /**
459
- * `better-sqlite3` is an optional peer dependency (ruling 5): a Postgres-only
460
- * or capabilities-only install carries no native module, and importing
461
- * `kanbo/sqlite` for a type or for `runSqliteTransaction` should not
462
- * demand one either. So the require happens here, the one place a board file
463
- * is actually opened, rather than at the top of this module.
566
+ * What a caller is told when the native module a board file needs does not
567
+ * load: which Node, system and processor it was tried on — a prebuilt module
568
+ * exists for most, not all — what the loader said, and how to get it back.
569
+ */
570
+ function betterSqlite3MissingMessage(cause) {
571
+ const said = cause instanceof Error ? cause.message.split("\n")[0] : String(cause);
572
+ return `Could not load better-sqlite3, which kanbo opens board files with, on Node ${process.version} (${process.platform}-${process.arch}): ${said}. Reinstall kanbo with npm install -g kanbo-cli, which brings it along; a program that uses kanbo as a library installs better-sqlite3 itself.`;
573
+ }
574
+ /**
575
+ * `better-sqlite3` is an optional dependency (ruling 5): an install where it
576
+ * could not be built carries no native module, and a Postgres-only or
577
+ * capabilities-only use — or importing `kanbo/sqlite` for a type or for
578
+ * `runSqliteTransaction` — should not demand one. So the require happens here,
579
+ * the one place a board file is actually opened, rather than at the top of
580
+ * this module.
464
581
  */
465
582
  function loadBetterSqlite3() {
466
583
  try {
467
584
  return require("better-sqlite3");
468
585
  } catch (error) {
469
- throw new Error(BETTER_SQLITE3_MISSING_MESSAGE, { cause: error });
586
+ throw new Error(betterSqlite3MissingMessage(error), { cause: error });
470
587
  }
471
588
  }
472
589
  /**
@@ -479,7 +596,7 @@ function loadBetterSqlite3() {
479
596
  * this module, `kanbo capabilities` and `kanbo/mcp` included, and the
480
597
  * friendly message above becomes unreachable. A dynamic import keeps the whole
481
598
  * chain behind the one call that actually opens a file, which is what makes the
482
- * optional peer honest. `createRequire` would be the smaller change and is the
599
+ * optional dependency honest. `createRequire` would be the smaller change and is the
483
600
  * wrong one: it would load drizzle's CJS copy beside the ESM one this package
484
601
  * already holds, and it would resolve from `node_modules`, which the packaged
485
602
  * `dist/cli.cjs` deliberately does not have beside it.
@@ -512,7 +629,10 @@ async function openBoardDatabase(path) {
512
629
  connection.pragma("busy_timeout = 5000");
513
630
  connection.pragma("synchronous = NORMAL");
514
631
  return {
515
- database: drizzle(connection, { schema: boardSqliteSchema }),
632
+ database: drizzle(connection, {
633
+ schema: boardSqliteSchema,
634
+ logger: void 0
635
+ }),
516
636
  connection,
517
637
  close: () => connection.close()
518
638
  };
@@ -651,6 +771,4 @@ function migrateBoardFile(database) {
651
771
  migrate(database, { migrationsFolder: getBoardSqliteMigrationsPath() });
652
772
  }
653
773
  //#endregion
654
- export { openBoardDatabase as a, createSqliteBoardStore as c, markBoardFileOwnedByKanbo as i, runSqliteTransaction as l, migrateBoardFile as n, readBoardFileContents as o, assertBoardSchema as r, readBoardFileOwner as s, getBoardSqliteMigrationsPath as t };
655
-
656
- //# sourceMappingURL=migrate-BhYi9cXw.mjs.map
774
+ export { openBoardDatabase as a, boardQueryLogger as c, foldCase as d, markBoardFileOwnedByKanbo as i, createSqliteBoardStore as l, migrateBoardFile as n, readBoardFileContents as o, assertBoardSchema as r, readBoardFileOwner as s, getBoardSqliteMigrationsPath as t, runSqliteTransaction as u };
@@ -44,9 +44,9 @@ const BOARD_TABLES_ADDED_WITHIN_EPOCH = ["issue_pull_requests"];
44
44
  * The folder is found relative to the file asking for it, because the same code
45
45
  * runs from two layouts: from `src/<dialect>/` in a checkout, where the folder
46
46
  * sits at the package root, and from `dist/cli.cjs` in an installed build, where
47
- * the build has copied it in beside the bundle. The packaged command ships no
48
- * `node_modules` and no package root — `dist/` is the whole of it — so the copy
49
- * is the only migrations folder there is.
47
+ * the build has copied it in beside the bundle. The npm tarball and the packaged
48
+ * command alike ship only that copy — `dist/` is the whole of it — so it is the
49
+ * only migrations folder an installed kanbo has.
50
50
  *
51
51
  * Kept apart from the migrators on purpose: this is a packaging question, and
52
52
  * two dialects ask it about two folders. The caller passes its own `here`, so
@@ -57,7 +57,7 @@ const JOURNAL_FILE = join("meta", "_journal.json");
57
57
  /**
58
58
  * The first of the layouts that is actually there, as seen from `here`.
59
59
  *
60
- * Pure, and takes its own `exists`, so all three layouts can be asked about on
60
+ * Pure, and takes its own `exists`, so every layout can be asked about on
61
61
  * a machine that only has one of them. When none is there the source layout is
62
62
  * handed back, so the migrator's complaint names the folder a developer is
63
63
  * missing rather than one that only exists inside a packaged app.
@@ -66,7 +66,7 @@ function resolveBoardMigrationsPath(here, directory, exists) {
66
66
  const candidates = [
67
67
  join(here, "..", "..", directory),
68
68
  join(here, directory),
69
- join(here, "..", "..", "..", directory)
69
+ join(here, "..", directory)
70
70
  ];
71
71
  return candidates.find(exists) ?? candidates[0];
72
72
  }
@@ -76,5 +76,3 @@ function hasMigrationJournal(folder) {
76
76
  }
77
77
  //#endregion
78
78
  export { BoardError as a, BOARD_TABLES_ADDED_WITHIN_EPOCH as i, resolveBoardMigrationsPath as n, BOARD_SCHEMA_EPOCH as r, hasMigrationJournal as t };
79
-
80
- //# sourceMappingURL=migrations-path-Ciwan537.mjs.map
@@ -44,9 +44,9 @@ const BOARD_TABLES_ADDED_WITHIN_EPOCH = ["issue_pull_requests"];
44
44
  * The folder is found relative to the file asking for it, because the same code
45
45
  * runs from two layouts: from `src/<dialect>/` in a checkout, where the folder
46
46
  * sits at the package root, and from `dist/cli.cjs` in an installed build, where
47
- * the build has copied it in beside the bundle. The packaged command ships no
48
- * `node_modules` and no package root — `dist/` is the whole of it — so the copy
49
- * is the only migrations folder there is.
47
+ * the build has copied it in beside the bundle. The npm tarball and the packaged
48
+ * command alike ship only that copy — `dist/` is the whole of it — so it is the
49
+ * only migrations folder an installed kanbo has.
50
50
  *
51
51
  * Kept apart from the migrators on purpose: this is a packaging question, and
52
52
  * two dialects ask it about two folders. The caller passes its own `here`, so
@@ -57,7 +57,7 @@ const JOURNAL_FILE = (0, node_path.join)("meta", "_journal.json");
57
57
  /**
58
58
  * The first of the layouts that is actually there, as seen from `here`.
59
59
  *
60
- * Pure, and takes its own `exists`, so all three layouts can be asked about on
60
+ * Pure, and takes its own `exists`, so every layout can be asked about on
61
61
  * a machine that only has one of them. When none is there the source layout is
62
62
  * handed back, so the migrator's complaint names the folder a developer is
63
63
  * missing rather than one that only exists inside a packaged app.
@@ -66,7 +66,7 @@ function resolveBoardMigrationsPath(here, directory, exists) {
66
66
  const candidates = [
67
67
  (0, node_path.join)(here, "..", "..", directory),
68
68
  (0, node_path.join)(here, directory),
69
- (0, node_path.join)(here, "..", "..", "..", directory)
69
+ (0, node_path.join)(here, "..", directory)
70
70
  ];
71
71
  return candidates.find(exists) ?? candidates[0];
72
72
  }
@@ -105,5 +105,3 @@ Object.defineProperty(exports, "resolveBoardMigrationsPath", {
105
105
  return resolveBoardMigrationsPath;
106
106
  }
107
107
  });
108
-
109
- //# sourceMappingURL=migrations-path-CZxVOgeD.cjs.map
@@ -1,6 +1,6 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
- const require_migrations_path = require("../migrations-path-CZxVOgeD.cjs");
3
- const require_roles_sql = require("../roles.sql-B9rt-x4o.cjs");
2
+ const require_migrations_path = require("../migrations-path-DBCE17dT.cjs");
3
+ const require_roles_sql = require("../roles.sql-E58x7mLS.cjs");
4
4
  let drizzle_orm = require("drizzle-orm");
5
5
  let drizzle_orm_postgres_js_migrator = require("drizzle-orm/postgres-js/migrator");
6
6
  let drizzle_orm_pglite_migrator = require("drizzle-orm/pglite/migrator");
@@ -52,5 +52,3 @@ exports.issueStatuses = require_roles_sql.issueStatuses;
52
52
  exports.issues = require_roles_sql.issues;
53
53
  exports.kanbanMeta = require_roles_sql.kanbanMeta;
54
54
  exports.migrateBoardDatabase = migrateBoardDatabase;
55
-
56
- //# sourceMappingURL=index.cjs.map
@@ -1,4 +1,4 @@
1
- import { a as BoardStore } from "../board-store-BfszzAJB.cjs";
1
+ import { c as BoardStore } from "../board-store-Dx-mw2I6.cjs";
2
2
  import { t as BOARD_SCHEMA_EPOCH } from "../schema-epoch-ZyumVL_A.cjs";
3
3
  import { PgDatabase, PgQueryResultHKT } from "drizzle-orm/pg-core";
4
4
  import { PgliteDatabase } from "drizzle-orm/pglite";
@@ -3399,5 +3399,4 @@ export declare const boardPostgresSchema: {
3399
3399
  /** The table names a Postgres board must carry, for the guard that refuses a database without them. */
3400
3400
  export declare const BOARD_POSTGRES_TABLES: readonly ['issue_statuses', 'issue_milestones', 'issues', 'issue_comments', 'issue_runs', 'issue_relations', 'issue_field_changes', 'issue_pull_requests', 'kanban_meta'];
3401
3401
  //#endregion
3402
- export { BOARD_SCHEMA_EPOCH, type BoardPostgresDatabase, type MigratablePostgresDatabase };
3403
- //# sourceMappingURL=index.d.cts.map
3402
+ export { BOARD_SCHEMA_EPOCH, type BoardPostgresDatabase, type MigratablePostgresDatabase };
@@ -1,4 +1,4 @@
1
- import { a as BoardStore } from "../board-store-BvWn9xTB.mjs";
1
+ import { c as BoardStore } from "../board-store-D_Mpfb84.mjs";
2
2
  import { t as BOARD_SCHEMA_EPOCH } from "../schema-epoch-ZyumVL_A.mjs";
3
3
  import { PgDatabase, PgQueryResultHKT } from "drizzle-orm/pg-core";
4
4
  import { PostgresJsDatabase } from "drizzle-orm/postgres-js";
@@ -3399,5 +3399,4 @@ export declare const boardPostgresSchema: {
3399
3399
  /** The table names a Postgres board must carry, for the guard that refuses a database without them. */
3400
3400
  export declare const BOARD_POSTGRES_TABLES: readonly ['issue_statuses', 'issue_milestones', 'issues', 'issue_comments', 'issue_runs', 'issue_relations', 'issue_field_changes', 'issue_pull_requests', 'kanban_meta'];
3401
3401
  //#endregion
3402
- export { BOARD_SCHEMA_EPOCH, type BoardPostgresDatabase, type MigratablePostgresDatabase };
3403
- //# sourceMappingURL=index.d.mts.map
3402
+ export { BOARD_SCHEMA_EPOCH, type BoardPostgresDatabase, type MigratablePostgresDatabase };
@@ -1,5 +1,5 @@
1
- import { r as BOARD_SCHEMA_EPOCH } from "../migrations-path-Ciwan537.mjs";
2
- import { _ as issueStatuses, a as BOARD_REFUSAL_ERRCODE, c as getBoardMigrationsPath, d as boardPostgresSchema, f as issueComments, g as issueRuns, h as issueRelations, i as BOARD_PERSON_ROLE, l as createPostgresBoardStore, m as issueMilestones, n as AGENT_WAITING_REFUSAL, o as BOARD_ROLE_SQL, p as issueFieldChanges, r as BOARD_AGENT_ROLE, s as assertBoardSchema, t as AGENT_APPROVAL_COMMENT_REFUSAL, u as BOARD_POSTGRES_TABLES, v as issues, y as kanbanMeta } from "../roles.sql-CvyCGt38.mjs";
1
+ import { r as BOARD_SCHEMA_EPOCH } from "../migrations-path-ClaYRNwk.mjs";
2
+ import { _ as issueStatuses, a as BOARD_REFUSAL_ERRCODE, c as getBoardMigrationsPath, d as boardPostgresSchema, f as issueComments, g as issueRuns, h as issueRelations, i as BOARD_PERSON_ROLE, l as createPostgresBoardStore, m as issueMilestones, n as AGENT_WAITING_REFUSAL, o as BOARD_ROLE_SQL, p as issueFieldChanges, r as BOARD_AGENT_ROLE, s as assertBoardSchema, t as AGENT_APPROVAL_COMMENT_REFUSAL, u as BOARD_POSTGRES_TABLES, v as issues, y as kanbanMeta } from "../roles.sql-BoOZSkBp.mjs";
3
3
  import { entityKind } from "drizzle-orm";
4
4
  import { migrate } from "drizzle-orm/postgres-js/migrator";
5
5
  import { migrate as migrate$1 } from "drizzle-orm/pglite/migrator";
@@ -31,5 +31,3 @@ function isPgliteDatabase(database) {
31
31
  }
32
32
  //#endregion
33
33
  export { AGENT_APPROVAL_COMMENT_REFUSAL, AGENT_WAITING_REFUSAL, BOARD_AGENT_ROLE, BOARD_PERSON_ROLE, BOARD_POSTGRES_TABLES, BOARD_REFUSAL_ERRCODE, BOARD_ROLE_SQL, BOARD_SCHEMA_EPOCH, assertBoardSchema, boardPostgresSchema, createPostgresBoardStore, getBoardMigrationsPath, issueComments, issueFieldChanges, issueMilestones, issueRelations, issueRuns, issueStatuses, issues, kanbanMeta, migrateBoardDatabase };
34
-
35
- //# sourceMappingURL=index.mjs.map
@@ -1,8 +1,18 @@
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";
1
+ import { a as BoardError, i as BOARD_TABLES_ADDED_WITHIN_EPOCH, n as resolveBoardMigrationsPath, t as hasMigrationJournal } from "./migrations-path-ClaYRNwk.mjs";
2
+ import { and, asc, desc, eq, getTableColumns, gte, inArray, isNotNull, isNull, or, sql } from "drizzle-orm";
3
3
  import { bigint, bigserial, getTableConfig, index, integer, pgSchema, pgTable, text, uniqueIndex } from "drizzle-orm/pg-core";
4
4
  import { dirname } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
+ //#region src/domain/like-pattern.ts
7
+ /**
8
+ * A `like` pattern that finds `text` anywhere in a value, the text matched as
9
+ * written: `%`, `_` and the escape character itself are escaped with `\`, the
10
+ * character a statement names in `escape '\'`.
11
+ */
12
+ function containsLikePattern(text) {
13
+ return `%${text.replace(/[\\%_]/g, (character) => `\\${character}`)}%`;
14
+ }
15
+ //#endregion
6
16
  //#region src/postgres/schema.ts
7
17
  /**
8
18
  * The board, as a Postgres database holds it.
@@ -348,6 +358,71 @@ async function selectRunProjection(database, scope) {
348
358
  }));
349
359
  }
350
360
  /**
361
+ * One page of the cards a query picks, and how many it picks in all, in one
362
+ * statement — the SQLite store's read, with the count cast to `int` (Postgres
363
+ * counts in `bigint`) and text matched with `ilike`, which folds every letter.
364
+ */
365
+ async function selectCardPage(database, query) {
366
+ if (query.statusIds?.length === 0 || query.priorities?.length === 0) return {
367
+ cards: [],
368
+ total: 0
369
+ };
370
+ const where = and(...cardQueryPredicates(query));
371
+ const page = database.select({
372
+ ...getTableColumns(issues),
373
+ pickedTotal: sql`cast(count(*) over () as int)`
374
+ }).from(issues).where(where).orderBy(issues.order, desc(issues.createdAt), issues.id).offset(query.offset ?? 0).$dynamic();
375
+ const rows = await (query.limit === void 0 ? page : page.limit(query.limit));
376
+ if (rows.length === 0) {
377
+ if ((query.offset ?? 0) === 0) return {
378
+ cards: [],
379
+ total: 0
380
+ };
381
+ const [row] = await database.select({ count: sql`cast(count(*) as int)` }).from(issues).where(where);
382
+ return {
383
+ cards: [],
384
+ total: row?.count ?? 0
385
+ };
386
+ }
387
+ return {
388
+ cards: rows.map(({ pickedTotal: _total, ...card }) => card),
389
+ total: rows[0].pickedTotal
390
+ };
391
+ }
392
+ function cardQueryPredicates(query) {
393
+ const running = sql`${issues.id} in (select ${issueRuns.issueId} from ${issueRuns} where ${issueRuns.state} = 'running')`;
394
+ const pattern = containsLikePattern(query.text ?? "");
395
+ return [
396
+ eq(issues.workspaceId, query.workspaceId),
397
+ query.statusIds === void 0 ? void 0 : inArray(issues.statusId, [...query.statusIds]),
398
+ query.waitingForPerson === void 0 ? void 0 : query.waitingForPerson ? eq(issues.waitingFor, "human") : isNull(issues.waitingFor),
399
+ query.parentIssueId === void 0 ? void 0 : eq(issues.parentIssueId, query.parentIssueId),
400
+ query.hasActiveRun === void 0 ? void 0 : query.hasActiveRun ? running : sql`not ${running}`,
401
+ query.returned === void 0 ? void 0 : query.returned ? returnedCard(running) : sql`not ${returnedCard(running)}`,
402
+ query.text === void 0 ? void 0 : sql`(${issues.id} ilike ${pattern} escape '\\' or ${issues.title} ilike ${pattern} escape '\\' or coalesce(${issues.description}, '') ilike ${pattern} escape '\\')`,
403
+ query.updatedSince === void 0 ? void 0 : gte(issues.updatedAt, query.updatedSince),
404
+ ...(query.labels ?? []).map((label) => sql`cast(${issues.labels} as jsonb) @> cast(${JSON.stringify([label])} as jsonb)`),
405
+ query.priorities === void 0 ? void 0 : inArray(issues.priority, [...query.priorities])
406
+ ];
407
+ }
408
+ /**
409
+ * A card a person sent back that nobody picked up again (`BoardCardQuery.returned`):
410
+ * not waiting, not running, and its last decision a return — `seq` breaking a
411
+ * tie inside one second where SQLite reads `rowid`.
412
+ */
413
+ function returnedCard(running) {
414
+ return sql`(${issues.waitingFor} is null and not ${running} and exists (
415
+ select 1 from ${issueComments} as decision
416
+ where decision.issue_id = ${issues.id} and decision.author_kind = 'system.returned'
417
+ and not exists (
418
+ select 1 from ${issueComments} as later
419
+ where later.issue_id = decision.issue_id
420
+ and later.author_kind in ('system.approved', 'system.returned')
421
+ and (later.created_at > decision.created_at or (later.created_at = decision.created_at and later.seq > decision.seq))
422
+ )
423
+ ))`;
424
+ }
425
+ /**
351
426
  * The board's statements against one Postgres handle — a connection, or a
352
427
  * transaction on one.
353
428
  *
@@ -365,6 +440,10 @@ function createStoreOver(database) {
365
440
  const [row] = await database.select().from(issueStatuses).where(eq(issueStatuses.id, statusId)).limit(1);
366
441
  return row ?? null;
367
442
  },
443
+ lockById: async (statusId) => {
444
+ const [row] = await database.select().from(issueStatuses).where(eq(issueStatuses.id, statusId)).limit(1).for("update");
445
+ return row ?? null;
446
+ },
368
447
  findInWorkspace: async (workspaceId, statusId) => {
369
448
  const [row] = await database.select().from(issueStatuses).where(and(eq(issueStatuses.workspaceId, workspaceId), eq(issueStatuses.id, statusId))).limit(1);
370
449
  return row ?? null;
@@ -408,10 +487,16 @@ function createStoreOver(database) {
408
487
  }
409
488
  },
410
489
  issues: {
411
- listInBoardOrder: async (workspaceId) => await database.select().from(issues).where(workspaceId ? eq(issues.workspaceId, workspaceId) : void 0).orderBy(issues.order, desc(issues.createdAt)),
490
+ listInBoardOrder: async (workspaceId) => await database.select().from(issues).where(workspaceId ? eq(issues.workspaceId, workspaceId) : void 0).orderBy(issues.order, desc(issues.createdAt), issues.id),
412
491
  listNewestFirst: async () => await database.select().from(issues).orderBy(desc(issues.createdAt)),
413
492
  listAll: async () => await database.select().from(issues),
414
493
  listByWorkspace: async (workspaceId) => await database.select().from(issues).where(eq(issues.workspaceId, workspaceId)),
494
+ listPage: async (query) => await selectCardPage(database, query),
495
+ countByMilestone: async (workspaceId) => await database.select({
496
+ milestoneId: sql`${issues.milestoneId}`,
497
+ statusId: issues.statusId,
498
+ count: sql`cast(count(*) as int)`
499
+ }).from(issues).where(and(eq(issues.workspaceId, workspaceId), isNotNull(issues.milestoneId))).groupBy(issues.milestoneId, issues.statusId),
415
500
  listByIds: async (issueIds) => await database.select().from(issues).where(inArray(issues.id, issueIds)),
416
501
  findById: async (issueId) => {
417
502
  const [row] = await database.select().from(issues).where(eq(issues.id, issueId)).limit(1);
@@ -481,6 +566,14 @@ function createStoreOver(database) {
481
566
  },
482
567
  comments: {
483
568
  listByIssue: async (issueId) => (await database.select().from(issueComments).where(eq(issueComments.issueId, issueId)).orderBy(asc(issueComments.createdAt), asc(issueComments.seq))).map(toIssueComment),
569
+ listLatestByIssues: async (issueIds, authorKinds) => {
570
+ if (issueIds.length === 0 || authorKinds?.length === 0) return [];
571
+ const ranked = database.select({
572
+ id: issueComments.id,
573
+ commentRank: sql`cast(row_number() over (partition by ${issueComments.issueId} order by ${issueComments.createdAt} desc, ${issueComments.seq} desc) as int)`.as("comment_rank")
574
+ }).from(issueComments).where(and(inArray(issueComments.issueId, [...issueIds]), authorKinds === void 0 ? void 0 : inArray(issueComments.authorKind, [...authorKinds]))).as("ranked");
575
+ return (await database.select(getTableColumns(issueComments)).from(issueComments).innerJoin(ranked, eq(ranked.id, issueComments.id)).where(eq(ranked.commentRank, 1))).map(toIssueComment);
576
+ },
484
577
  findById: async (commentId) => {
485
578
  const [row] = await database.select().from(issueComments).where(eq(issueComments.id, commentId)).limit(1);
486
579
  return row ? toIssueComment(row) : null;
@@ -916,5 +1009,3 @@ create trigger kanban_guard_approval_comment_updates
916
1009
  `;
917
1010
  //#endregion
918
1011
  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