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:
parent
0fd2cef2cf
commit
d06d1e99ec
7 changed files with 173 additions and 32 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
26
src/lib/cards/number.test.ts
Normal file
26
src/lib/cards/number.test.ts
Normal 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
19
src/lib/cards/number.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue