feat(cards): per-project card numbers for commit references

- number field on cards, unique per project
- backfilled existing cards in created order
- both create paths (owner action, client API) assign on creation
- seed script is idempotent across re-runs
This commit is contained in:
Dave 2026-09-16 13:20:24 +02:00
parent 0fd2cef2cf
commit d06d1e99ec
7 changed files with 173 additions and 32 deletions

View file

@ -12,6 +12,7 @@ import PocketBase from "pocketbase";
import { buildColumnRecords, assertColumnInvariants } from "../src/lib/columns/seed";
import { SPACING } from "../src/lib/ordering/fractional";
import { nextCardNumber } from "../src/lib/cards/number";
const PB_URL = process.env.NEXT_PUBLIC_PB_URL ?? "http://127.0.0.1:8090";
@ -122,11 +123,38 @@ async function main() {
{ title: "Delivery note grouping", column: "Done", priority: "low" },
];
// Pre-fetch existing numbers so re-seeding keeps the original numbering
// and only fills in the gap when a card is missing.
const existingNumbers = (
await pb
.collection("cards")
.getFullList({ filter: `project = "${project.id}"`, fields: "number" })
).map((c) => Number(c.number) || 0);
let position = SPACING;
for (const card of cards) {
const cardNumber = nextCardNumber(existingNumbers);
existingNumbers.push(cardNumber);
await upsert(
pb,
"cards",
`project = "${project.id}" && title = "${card.title}"`,
{
project: project.id,
column: columns[card.column],
title: card.title,
description: "",
position,
priority: card.priority,
created_by_role: "owner",
accepted: true,
archived: false,
number: cardNumber,
}
);
position += SPACING;
}
`project = "${project.id}" && title = "${card.title}"`,
{
project: project.id,

View file

@ -133,6 +133,93 @@ async function applyRules(
console.log(` = rules for ${name}`);
}
async function ensureProjectIngestFields(pb: PocketBase): Promise<void> {
const projects = await pb.collections.getOne("projects");
const have = new Set(projects.fields.map((f: { name: string }) => f.name));
const additions = [
{ name: "webhook_secret", type: "text", required: false, max: 80 },
{ name: "last_ingest_at", type: "date", required: false },
];
const fields = [...(projects.fields as unknown[])];
let changed = false;
for (const field of additions) {
if (!have.has(field.name)) {
fields.push({ ...field, presentable: false, hidden: false, system: false });
changed = true;
}
}
if (changed) {
await pb.collections.update(projects.id, { fields });
console.log(
` + projects fields: ${additions
.map((f) => f.name)
.join(", ")}`
);
} else {
console.log(" = projects ingest fields already present");
}
}
async function ensureCardNumbers(pb: PocketBase): Promise<void> {
const cards = await pb.collections.getOne("cards");
const have = new Set(cards.fields.map((f: { name: string }) => f.name));
if (!have.has("number")) {
const fields = [...(cards.fields as unknown[])];
fields.push({
name: "number",
type: "number",
required: false,
onlyInt: true,
presentable: false,
hidden: false,
system: false,
});
await pb.collections.update(cards.id, { fields });
console.log(" + cards field: number");
} else {
console.log(" = cards.number already present");
}
// Backfill per project, oldest card first, so numbering matches the order
// the work was created in rather than the order PocketBase returns.
const projects = await pb.collection("projects").getFullList({ fields: "id" });
for (const project of projects) {
const all = await pb.collection("cards").getFullList({
filter: `project = "${project.id}"`,
sort: "created",
fields: "id,number",
});
let next = 1;
for (const card of all) {
if (typeof card.number === "number" && card.number > 0) {
next = Math.max(next, card.number + 1);
continue;
}
await pb.collection("cards").update(card.id, { number: next });
next += 1;
}
}
console.log(" = card numbers backfilled");
// A unique index makes "never reused" enforceable rather than hoped for.
const cardsCollection = await pb.collections.getOne("cards");
const indexes = new Set((cardsCollection.indexes as string[]) ?? []);
const unique = "CREATE UNIQUE INDEX idx_cards_project_number ON cards (project, number)";
if (!indexes.has(unique)) {
indexes.add(unique);
await pb.collections.update(cardsCollection.id, {
indexes: [...indexes],
});
console.log(" + unique index idx_cards_project_number");
}
}
async function main(): Promise<void> {
const pb = new PocketBase(PB_URL);
await authAdmin(pb);
@ -627,38 +714,10 @@ async function main(): Promise<void> {
// Add ingest-related fields to projects (only if missing — the projects
// collection already exists from earlier migrations).
const projects = await pb.collections.getOne("projects");
const haveProjectFields = new Set(
projects.fields.map((f: { name: string }) => f.name)
);
const projectFieldAdditions: Array<Record<string, unknown>> = [];
if (!haveProjectFields.has("webhook_secret")) {
projectFieldAdditions.push({
name: "webhook_secret",
type: "text",
required: false,
max: 80,
});
}
if (!haveProjectFields.has("last_ingest_at")) {
projectFieldAdditions.push({
name: "last_ingest_at",
type: "date",
required: false,
});
}
if (projectFieldAdditions.length > 0) {
await pb.collections.update(projects.id, {
fields: [...(projects.fields as unknown[]), ...projectFieldAdditions],
});
console.log(
` + projects fields: ${projectFieldAdditions
.map((f) => (f as { name: string }).name)
.join(", ")}`
);
} else {
console.log(" = projects ingest fields already present");
}
await ensureProjectIngestFields(pb);
// Add the card number field and backfill existing cards.
await ensureCardNumbers(pb);
console.log("Done.");
}

View file

@ -6,6 +6,7 @@ import { getServerPb } from "@/lib/pb/server";
import { requireSession } from "@/lib/auth/session";
import { logActivity } from "@/app/actions/activity";
import { positionBetween } from "@/lib/ordering/fractional";
import { nextCardNumber } from "@/lib/cards/number";
import type { CardRecord, ColumnRecord } from "@/lib/pb/types";
/**

View file

@ -89,6 +89,11 @@ export async function POST(request: Request): Promise<Response> {
})
.catch(() => null);
const numbered = await pb.collection("cards").getFullList<CardRecord>({
filter: `project = "${projectId}"`,
fields: "number",
});
const card = await pb.collection("cards").create<CardRecord>({
project: projectId,
column: inbox.id,
@ -100,6 +105,7 @@ export async function POST(request: Request): Promise<Response> {
created_by_role: "client",
accepted: false,
archived: false,
number: nextCardNumber(numbered.map((c) => c.number)),
});
await pb.collection("activity").create({

View file

@ -0,0 +1,26 @@
import { describe, it, expect } from "vitest";
import { nextCardNumber } from "@/lib/cards/number";
describe("nextCardNumber", () => {
it("starts at 1 for a project with no cards", () => {
expect(nextCardNumber([])).toBe(1);
});
it("returns one past the highest existing number", () => {
expect(nextCardNumber([1, 2, 3])).toBe(4);
});
it("does not reuse a number freed by a deletion", () => {
// Reusing 2 would point an old commit's [#2] at a different card.
expect(nextCardNumber([1, 3])).toBe(4);
});
it("ignores zero, negatives and non-integers left by bad data", () => {
expect(nextCardNumber([0, -5, 2])).toBe(3);
expect(nextCardNumber([Number.NaN, 4])).toBe(5);
});
it("handles an unsorted list", () => {
expect(nextCardNumber([7, 2, 5])).toBe(8);
});
});

19
src/lib/cards/number.ts Normal file
View file

@ -0,0 +1,19 @@
/**
* Per-project card numbers.
*
* Cards need a short identifier a person can type into a commit message —
* `feat(stock): inline editing [#42]`. PocketBase ids like `2e8izsch98bbc6l`
* cannot serve that purpose.
*
* Numbers are never reused. A freed number would silently repoint an old
* commit's reference at an unrelated card, and git history cannot be corrected
* after the fact.
*/
export function nextCardNumber(existing: number[]): number {
let highest = 0;
for (const value of existing) {
if (!Number.isInteger(value)) continue;
if (value > highest) highest = value;
}
return highest + 1;
}

View file

@ -77,6 +77,8 @@ export interface CardRecord extends BaseRecord {
start_date: string;
scope_rule: string;
archived: boolean;
/** Per-project sequential number, for `[#42]` references in commit messages. */
number: number;
}
export interface SubtaskRecord extends BaseRecord {