222 lines
7.4 KiB
TypeScript
222 lines
7.4 KiB
TypeScript
|
|
/**
|
||
|
|
* Migration — 2026-07-26-fix-quote-counter-and-reassign-numbers.ts
|
||
|
|
*
|
||
|
|
* Background
|
||
|
|
* ----------
|
||
|
|
* The `quote_counters.last_seq` field for `(year=2026, month=7)` was
|
||
|
|
* seeded with a junk integer (20 260 720 260 710 456 — looks like a
|
||
|
|
* string-concat payload from an old format function). PocketBase's
|
||
|
|
* `last_seq+1` autoincrement happily kept adding 1, but the formatted
|
||
|
|
* quote_number came out as `Q20260720260720260710456`, and large
|
||
|
|
* `last_seq` values got serialised back to the JS client as
|
||
|
|
* `Q2026072.0260720260720263e+22`.
|
||
|
|
*
|
||
|
|
* Two flavours of bad rows exist in `quotes.quote_number`:
|
||
|
|
* - `Q010433`-style legacy 6-digit format (no year/month prefix)
|
||
|
|
* — most rows, from before the counter existed
|
||
|
|
* - `Q2026072.0260720260720263e+22` style — written by the broken
|
||
|
|
* counter, JSON-serialised giant number
|
||
|
|
*
|
||
|
|
* What this migration does
|
||
|
|
* ------------------------
|
||
|
|
* Default mode (no args, or `--dry-run`) → prints the full before/after
|
||
|
|
* diff and exits without writing. Pass `--apply` to actually write.
|
||
|
|
*
|
||
|
|
* 1. Load every quote, sort by `created` ASC.
|
||
|
|
* 2. Reassign each one a clean, sortable, unique quote_number of the
|
||
|
|
* form `Q{YYYY}{MM}{NNN}` starting at `Q202607001`.
|
||
|
|
* `formatQuoteNumber` (mirrored here) pads `seq` to >= 3 digits so
|
||
|
|
* `seq=1..200` → `001..200`, no truncation.
|
||
|
|
* 3. Update the `quote_counters` row for `(2026, 7)` to `last_seq=200`
|
||
|
|
* so the next call to `/api/v1/quotes/next-number` returns
|
||
|
|
* `Q202607201`.
|
||
|
|
*
|
||
|
|
* Idempotent: rerunning always lands on the same target numbers.
|
||
|
|
*
|
||
|
|
* External refs (email subjects, PDF footers, paste-in-chat) to the
|
||
|
|
* old `Q010433`-style numbers will no longer resolve.
|
||
|
|
*/
|
||
|
|
import PocketBase from "pocketbase";
|
||
|
|
import { readFileSync } from "node:fs";
|
||
|
|
import { resolve } from "node:path";
|
||
|
|
|
||
|
|
// ── env loading ────────────────────────────────────────────────────────
|
||
|
|
function loadEnvFile(name: string) {
|
||
|
|
try {
|
||
|
|
const raw = readFileSync(resolve(process.cwd(), name), "utf8");
|
||
|
|
for (const line of raw.split("\n")) {
|
||
|
|
const trimmed = line.trim();
|
||
|
|
if (!trimmed || trimmed.startsWith("#")) continue;
|
||
|
|
const m = trimmed.match(/^([A-Z0-9_]+)\s*=\s*(.+)$/i);
|
||
|
|
if (m && !process.env[m[1]]) {
|
||
|
|
process.env[m[1]] = m[2].replace(/^["']|["']$/g, "").trim();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
} catch {
|
||
|
|
/* fine — rely on shell env */
|
||
|
|
}
|
||
|
|
}
|
||
|
|
loadEnvFile(".env");
|
||
|
|
loadEnvFile(".env.local");
|
||
|
|
|
||
|
|
const URL = process.env.NEXT_PUBLIC_POCKETBASE_URL;
|
||
|
|
const EMAIL = process.env.PB_ADMIN_EMAIL;
|
||
|
|
const PASSWORD = process.env.PB_ADMIN_PASSWORD;
|
||
|
|
if (!URL || !EMAIL || !PASSWORD) {
|
||
|
|
throw new Error(
|
||
|
|
"Set NEXT_PUBLIC_POCKETBASE_URL, PB_ADMIN_EMAIL, PB_ADMIN_PASSWORD (.env.local).",
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
async function authedPb(): Promise<PocketBase> {
|
||
|
|
const pb = new PocketBase(URL!);
|
||
|
|
await pb
|
||
|
|
.collection("_superusers")
|
||
|
|
.authWithPassword(EMAIL!, PASSWORD!);
|
||
|
|
return pb;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Mirror of src/lib/server/quotes-service.ts — kept inline so the
|
||
|
|
// migration doesn't depend on the Next build output.
|
||
|
|
function formatQuoteNumber(year: number, month: number, seq: number): string {
|
||
|
|
return `Q${year}${String(month).padStart(2, "0")}${String(seq).padStart(3, "0")}`;
|
||
|
|
}
|
||
|
|
|
||
|
|
type QuoteRec = {
|
||
|
|
id: string;
|
||
|
|
quote_number: string;
|
||
|
|
status: string;
|
||
|
|
created: string;
|
||
|
|
};
|
||
|
|
|
||
|
|
async function main() {
|
||
|
|
const apply = process.argv.includes("--apply");
|
||
|
|
const dryRun = !apply;
|
||
|
|
console.log(
|
||
|
|
dryRun
|
||
|
|
? "=== DRY RUN — no writes ===\n"
|
||
|
|
: "=== APPLY MODE — live writes ===\n",
|
||
|
|
);
|
||
|
|
|
||
|
|
const pb = await authedPb();
|
||
|
|
|
||
|
|
// All quotes, sorted by created ASC (oldest first).
|
||
|
|
const all = (await pb
|
||
|
|
.collection("quotes")
|
||
|
|
.getFullList({ sort: "created" })) as QuoteRec[];
|
||
|
|
console.log(`Loaded ${all.length} quote rows.\n`);
|
||
|
|
|
||
|
|
// Assign each row a unique target Q{YYYY}{MM}{NNN}, starting at 001.
|
||
|
|
const year = 2026;
|
||
|
|
const month = 7;
|
||
|
|
const usedTargets = new Set<string>();
|
||
|
|
const changes: Array<{
|
||
|
|
id: string;
|
||
|
|
created: string;
|
||
|
|
from: string;
|
||
|
|
to: string;
|
||
|
|
}> = [];
|
||
|
|
let seq = 0; // human numbering starts at 1 (seq+1 below)
|
||
|
|
for (const q of all) {
|
||
|
|
seq++;
|
||
|
|
let target = formatQuoteNumber(year, month, seq);
|
||
|
|
// Walk forward if the target slot is already taken (shouldn't happen
|
||
|
|
// on a clean run, but future-reruns / partial-state runs are safe).
|
||
|
|
while (usedTargets.has(target)) {
|
||
|
|
seq++;
|
||
|
|
target = formatQuoteNumber(year, month, seq);
|
||
|
|
}
|
||
|
|
usedTargets.add(target);
|
||
|
|
|
||
|
|
const from = q.quote_number || "";
|
||
|
|
if (from === target) continue; // already correct
|
||
|
|
changes.push({ id: q.id, created: q.created, from, to: target });
|
||
|
|
}
|
||
|
|
|
||
|
|
console.log("Plan:");
|
||
|
|
console.log(" Total rows: ", all.length);
|
||
|
|
console.log(" Already-clean: ", all.length - changes.length);
|
||
|
|
console.log(" To update: ", changes.length);
|
||
|
|
console.log(" Next free slot: ", formatQuoteNumber(year, month, seq + 1));
|
||
|
|
if (changes.length) {
|
||
|
|
console.log("\nFirst 10 changes (old → new):");
|
||
|
|
changes.slice(0, 10).forEach((c) =>
|
||
|
|
console.log(
|
||
|
|
` ${c.id} ${c.created} ${JSON.stringify(c.from).padEnd(36)} → ${c.to}`,
|
||
|
|
),
|
||
|
|
);
|
||
|
|
if (changes.length > 10) console.log(` … and ${changes.length - 10} more`);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Counter row update
|
||
|
|
const counters = (await pb
|
||
|
|
.collection("quote_counters")
|
||
|
|
.getFullList()) as Array<{
|
||
|
|
id: string;
|
||
|
|
year: number;
|
||
|
|
month: number;
|
||
|
|
last_seq: unknown;
|
||
|
|
}>;
|
||
|
|
const counterRow = counters.find((c) => c.year === year && c.month === month);
|
||
|
|
// Counter `last_seq` should equal the highest seq we just used. Next
|
||
|
|
// call's `last_seq+1` returns that + 1, formatted as the next slot.
|
||
|
|
const newLastSeq = seq;
|
||
|
|
console.log("\nCounter:");
|
||
|
|
if (counterRow) {
|
||
|
|
console.log(
|
||
|
|
` current (year=${year} month=${month}): last_seq = ${JSON.stringify(
|
||
|
|
counterRow.last_seq,
|
||
|
|
)} (id=${counterRow.id})`,
|
||
|
|
);
|
||
|
|
} else {
|
||
|
|
console.log(` no row for (${year}, ${month}); will create one`);
|
||
|
|
}
|
||
|
|
console.log(
|
||
|
|
` → planned set last_seq = ${newLastSeq} (next quote = ${formatQuoteNumber(
|
||
|
|
year,
|
||
|
|
month,
|
||
|
|
newLastSeq + 1,
|
||
|
|
)})`,
|
||
|
|
);
|
||
|
|
|
||
|
|
if (dryRun) {
|
||
|
|
console.log("\nDry run complete. Re-run with --apply to execute.");
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── APPLY ───────────────────────────────────────────────────────────
|
||
|
|
console.log("\nApplying updates…");
|
||
|
|
let updated = 0;
|
||
|
|
for (const c of changes) {
|
||
|
|
try {
|
||
|
|
await pb.collection("quotes").update(c.id, { quote_number: c.to });
|
||
|
|
updated++;
|
||
|
|
if (updated % 50 === 0) console.log(` …${updated}/${changes.length}`);
|
||
|
|
} catch (e: unknown) {
|
||
|
|
console.error(
|
||
|
|
` FAILED ${c.id} (${c.from} → ${c.to}): ${(e as Error).message}`,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
console.log(`Updated ${updated}/${changes.length} quote rows.`);
|
||
|
|
|
||
|
|
if (counterRow) {
|
||
|
|
await pb
|
||
|
|
.collection("quote_counters")
|
||
|
|
.update(counterRow.id, { last_seq: newLastSeq });
|
||
|
|
console.log(`Counter row (${year}-${month}) last_seq → ${newLastSeq}.`);
|
||
|
|
} else {
|
||
|
|
await pb
|
||
|
|
.collection("quote_counters")
|
||
|
|
.create({ year, month, last_seq: newLastSeq });
|
||
|
|
console.log(`Counter row (${year}-${month}) created with last_seq=${newLastSeq}.`);
|
||
|
|
}
|
||
|
|
|
||
|
|
console.log("\nDone.");
|
||
|
|
}
|
||
|
|
|
||
|
|
main().catch((e: unknown) => {
|
||
|
|
console.error("\nMigration failed:", (e as Error).message);
|
||
|
|
process.exit(1);
|
||
|
|
});
|