feat(db): mesh data model — meshes, members, invites, audit log
- pgSchema "mesh" with 4 tables isolating the peer mesh domain - Enums: visibility, transport, tier, role - audit_log is metadata-only (E2E encryption enforced at broker/client) - Cascade on mesh delete, soft-delete via archivedAt/revokedAt Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
89
packages/db/src/scripts/backfill-customers.ts
Normal file
89
packages/db/src/scripts/backfill-customers.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
#!/usr/bin/env tsx
|
||||
/**
|
||||
* Backfill customer records for existing users without them.
|
||||
* Run once after deploying the new schema.
|
||||
*
|
||||
* Usage: pnpm with-env pnpm dlx tsx packages/db/src/scripts/backfill-customers.ts
|
||||
*/
|
||||
|
||||
import { eq, isNull } from "drizzle-orm";
|
||||
|
||||
import { generateId } from "@turbostarter/shared/utils";
|
||||
|
||||
import { creditTransaction, customer, user } from "../schema";
|
||||
import { db } from "../server";
|
||||
|
||||
const DEFAULT_CREDITS = 100;
|
||||
|
||||
async function backfillCustomers() {
|
||||
console.log("Starting customer backfill...\n");
|
||||
|
||||
// Find users without customer records using a left join
|
||||
const usersWithoutCustomers = await db
|
||||
.select({
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
})
|
||||
.from(user)
|
||||
.leftJoin(customer, eq(user.id, customer.userId))
|
||||
.where(isNull(customer.id));
|
||||
|
||||
console.log(
|
||||
`Found ${usersWithoutCustomers.length} users without customer records\n`,
|
||||
);
|
||||
|
||||
if (usersWithoutCustomers.length === 0) {
|
||||
console.log("No users to backfill. Done!");
|
||||
return;
|
||||
}
|
||||
|
||||
let created = 0;
|
||||
let errors = 0;
|
||||
|
||||
for (const u of usersWithoutCustomers) {
|
||||
const customerId = generateId();
|
||||
|
||||
try {
|
||||
await db.transaction(async (tx) => {
|
||||
await tx.insert(customer).values({
|
||||
id: customerId,
|
||||
userId: u.id,
|
||||
customerId: `backfill_${u.id}`,
|
||||
status: "active",
|
||||
plan: "free",
|
||||
credits: DEFAULT_CREDITS,
|
||||
});
|
||||
|
||||
await tx.insert(creditTransaction).values({
|
||||
id: generateId(),
|
||||
customerId,
|
||||
amount: DEFAULT_CREDITS,
|
||||
type: "signup",
|
||||
reason: "Backfill: Welcome credits for existing user",
|
||||
balanceAfter: DEFAULT_CREDITS,
|
||||
});
|
||||
});
|
||||
|
||||
console.log(`✓ Created customer for ${u.email} (${u.name})`);
|
||||
created++;
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`✗ Failed for ${u.email}:`,
|
||||
error instanceof Error ? error.message : error,
|
||||
);
|
||||
errors++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\nBackfill complete!`);
|
||||
console.log(` Created: ${created}`);
|
||||
console.log(` Errors: ${errors}`);
|
||||
}
|
||||
|
||||
backfillCustomers()
|
||||
.then(() => process.exit(0))
|
||||
.catch((error) => {
|
||||
console.error("Backfill failed:", error);
|
||||
process.exit(1);
|
||||
});
|
||||
18
packages/db/src/scripts/reset.ts
Normal file
18
packages/db/src/scripts/reset.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { reset } from "drizzle-seed";
|
||||
|
||||
import { logger } from "@turbostarter/shared/logger";
|
||||
|
||||
import * as schema from "../schema";
|
||||
import { db } from "../server";
|
||||
|
||||
async function main() {
|
||||
await reset(db, schema);
|
||||
|
||||
logger.info("Database reset successfully!");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
logger.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
12
packages/db/src/scripts/seed.ts
Normal file
12
packages/db/src/scripts/seed.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { logger } from "@turbostarter/shared/logger";
|
||||
|
||||
function main() {
|
||||
/**
|
||||
* Place your seeding logic here
|
||||
*/
|
||||
|
||||
logger.info("Database seeded successfully!");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main();
|
||||
101
packages/db/src/scripts/status.ts
Normal file
101
packages/db/src/scripts/status.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { sql } from "drizzle-orm";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
import { logger } from "@turbostarter/shared/logger";
|
||||
|
||||
import { db } from "../server";
|
||||
|
||||
interface JournalEntry {
|
||||
idx: number;
|
||||
version: string;
|
||||
when: number;
|
||||
tag: string;
|
||||
breakpoints: boolean;
|
||||
}
|
||||
|
||||
interface JournalFile {
|
||||
version: string;
|
||||
dialect: string;
|
||||
entries?: JournalEntry[];
|
||||
}
|
||||
|
||||
const JOURNAL_PATH = path.resolve("migrations/meta/_journal.json");
|
||||
|
||||
function loadJournalEntries(): JournalEntry[] {
|
||||
if (!fs.existsSync(JOURNAL_PATH)) {
|
||||
throw new Error(`Migrations journal not found at ${JOURNAL_PATH}`);
|
||||
}
|
||||
|
||||
const journalFile = fs.readFileSync(JOURNAL_PATH, "utf-8");
|
||||
const parsed = JSON.parse(journalFile) as JournalFile;
|
||||
|
||||
return [...(parsed.entries ?? [])].sort((a, b) => a.idx - b.idx);
|
||||
}
|
||||
|
||||
function toTimestamp(value: unknown): number | null {
|
||||
if (typeof value === "number") {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (typeof value === "string") {
|
||||
const parsed = Number(value);
|
||||
return Number.isNaN(parsed) ? null : parsed;
|
||||
}
|
||||
|
||||
if (typeof value === "bigint") {
|
||||
return Number(value);
|
||||
}
|
||||
|
||||
if (value instanceof Date) {
|
||||
return value.getTime();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function fetchAppliedMigrationTimestamps(): Promise<number[]> {
|
||||
const result = await db.execute(
|
||||
sql`SELECT created_at FROM "drizzle"."__drizzle_migrations" ORDER BY created_at`,
|
||||
);
|
||||
|
||||
return Array.from(result)
|
||||
.map((row) => toTimestamp((row as { created_at?: unknown }).created_at))
|
||||
.filter((timestamp): timestamp is number => timestamp !== null);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const journalEntries = loadJournalEntries();
|
||||
const appliedTimestamps = await fetchAppliedMigrationTimestamps();
|
||||
const appliedTimestampSet = new Set(appliedTimestamps);
|
||||
|
||||
const appliedMigrations = journalEntries.filter((entry) =>
|
||||
appliedTimestampSet.has(entry.when),
|
||||
);
|
||||
const pendingMigrations = journalEntries.filter(
|
||||
(entry) => !appliedTimestampSet.has(entry.when),
|
||||
);
|
||||
|
||||
logger.info("\nApplied migrations:");
|
||||
if (appliedMigrations.length === 0) {
|
||||
logger.info("(none)");
|
||||
} else {
|
||||
appliedMigrations.forEach((entry) => logger.info(`- ${entry.tag}`));
|
||||
}
|
||||
|
||||
logger.info("\nPending migrations:");
|
||||
if (pendingMigrations.length === 0) {
|
||||
logger.info("(none)");
|
||||
} else {
|
||||
pendingMigrations.forEach((entry) => logger.info(`- ${entry.tag}`));
|
||||
}
|
||||
}
|
||||
|
||||
void main()
|
||||
.catch((err) => {
|
||||
logger.error(err);
|
||||
process.exit(1);
|
||||
})
|
||||
.then(() => {
|
||||
process.exit(0);
|
||||
});
|
||||
Reference in New Issue
Block a user