@trylight/app 0.1.8 → 0.2.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.
- package/README.md +15 -22
- package/dist/auth/config.d.ts +5 -0
- package/dist/auth/config.js +16 -0
- package/dist/auth/context.d.ts +11 -0
- package/dist/auth/context.js +1 -0
- package/dist/auth/core.d.ts +64 -0
- package/dist/auth/core.js +50 -0
- package/dist/auth/db.d.ts +7 -0
- package/dist/auth/db.js +57 -0
- package/dist/auth/lookup.d.ts +47 -0
- package/dist/auth/lookup.js +120 -0
- package/dist/auth/mode.d.ts +8 -0
- package/dist/auth/mode.js +52 -0
- package/dist/auth/next-path.d.ts +1 -0
- package/dist/auth/next-path.js +10 -0
- package/dist/auth/plaza.d.ts +68 -0
- package/dist/auth/plaza.js +154 -0
- package/dist/auth/problems.d.ts +4 -0
- package/dist/auth/problems.js +36 -0
- package/dist/auth/users.d.ts +8 -0
- package/dist/auth/users.js +25 -0
- package/dist/brand.generated.d.ts +2 -0
- package/dist/brand.generated.js +3 -0
- package/dist/chat/paths.d.ts +10 -0
- package/dist/chat/paths.js +10 -0
- package/dist/chat/server.d.ts +24 -0
- package/dist/chat/server.js +161 -0
- package/dist/client.d.ts +517 -19
- package/dist/client.js +244 -24
- package/dist/db/cli.js +3 -40
- package/dist/db/migrations.d.ts +2 -2
- package/dist/db/migrations.js +2 -2
- package/dist/entities.d.ts +3 -1
- package/dist/entities.js +1 -1
- package/dist/invoke/contracts.d.ts +28 -0
- package/dist/invoke/contracts.js +1 -0
- package/dist/invoke/error.d.ts +11 -0
- package/dist/invoke/error.js +13 -0
- package/dist/invoke/index.d.ts +73 -0
- package/dist/invoke/index.js +274 -0
- package/dist/invoke/paths.d.ts +2 -0
- package/dist/invoke/paths.js +2 -0
- package/dist/invoke/server.d.ts +45 -0
- package/dist/invoke/server.js +259 -0
- package/dist/platform/index.d.ts +71 -0
- package/dist/platform/index.js +80 -0
- package/dist/platform/pane-bridge.d.ts +1 -0
- package/dist/platform/pane-bridge.js +216 -0
- package/dist/platform/server-fn-door.d.ts +27 -0
- package/dist/platform/server-fn-door.js +32 -0
- package/dist/platform.d.ts +2 -2
- package/dist/platform.js +3 -3
- package/dist/request-error.d.ts +10 -0
- package/dist/request-error.js +29 -0
- package/dist/schema.d.ts +1 -1
- package/dist/server/db-retry.d.ts +7 -0
- package/dist/server/db-retry.js +58 -0
- package/dist/server/entities.js +18 -3
- package/dist/server/health.d.ts +17 -0
- package/dist/server/health.js +42 -0
- package/dist/server/index.d.ts +7 -6
- package/dist/server/index.js +7 -6
- package/dist/server/sentry-tunnel.d.ts +6 -0
- package/dist/server/sentry-tunnel.js +52 -0
- package/dist/server/session.d.ts +1 -1
- package/dist/server/session.js +2 -2
- package/dist/uuid.d.ts +1 -0
- package/dist/uuid.js +7 -0
- package/package.json +4 -4
- package/dist/db/baseline.d.ts +0 -36
- package/dist/db/baseline.js +0 -439
package/dist/server/index.js
CHANGED
|
@@ -3,16 +3,17 @@
|
|
|
3
3
|
// images, contracts, chat). Server-only.
|
|
4
4
|
import { drizzle } from "drizzle-orm/postgres-js";
|
|
5
5
|
import postgres from "postgres";
|
|
6
|
-
import { createMiiAuthCore } from "
|
|
7
|
-
import { createMiiChatHandler } from "
|
|
8
|
-
import { createMiiInvoke } from "
|
|
6
|
+
import { createMiiAuthCore } from "../auth/core.js";
|
|
7
|
+
import { createMiiChatHandler } from "../chat/server.js";
|
|
8
|
+
import { createMiiInvoke } from "../invoke/server.js";
|
|
9
9
|
import { createEntitiesHandler } from "./entities.js";
|
|
10
10
|
import { env } from "./env.js";
|
|
11
11
|
import { createImageHandler } from "./images.js";
|
|
12
12
|
import { readSession, signIn } from "./session.js";
|
|
13
|
-
export { cookieAttributes, safeNext, SESSION_COOKIE } from "
|
|
14
|
-
export { defineContracts, InvocationError } from "
|
|
15
|
-
export { createHealthHandler
|
|
13
|
+
export { cookieAttributes, safeNext, SESSION_COOKIE } from "../auth/core.js";
|
|
14
|
+
export { defineContracts, InvocationError } from "../invoke/server.js";
|
|
15
|
+
export { createHealthHandler } from "./health.js";
|
|
16
|
+
export { createSentryTunnelHandler } from "./sentry-tunnel.js";
|
|
16
17
|
/** Connections one server process holds open to the app's database. */
|
|
17
18
|
const POOL_MAX = 5;
|
|
18
19
|
export function createLightServer(options) {
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export type SentryTunnelOptions = {
|
|
2
|
+
/** Test hook; defaults to global fetch. */
|
|
3
|
+
readonly fetch?: typeof fetch;
|
|
4
|
+
};
|
|
5
|
+
/** POST for /monitoring: forward the envelope, answer what Sentry answered. */
|
|
6
|
+
export declare function createSentryTunnelHandler({ fetch: doFetch }?: SentryTunnelOptions): (request: Request) => Promise<Response>;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// The Sentry tunnel: browsers post error envelopes to this app's own
|
|
2
|
+
// /monitoring route, which forwards them to Sentry — because ad-blockers
|
|
3
|
+
// block *.sentry.io from the browser, and a platform whose error reporting
|
|
4
|
+
// dies to a browser extension is blind exactly where it matters.
|
|
5
|
+
//
|
|
6
|
+
// The envelope's own header names the DSN, so the route needs no
|
|
7
|
+
// configuration; it forwards only to hosts that are plainly Sentry's, so an
|
|
8
|
+
// app cannot be turned into an open relay by a crafted envelope.
|
|
9
|
+
import { z } from "zod/v4";
|
|
10
|
+
const ALLOWED_HOST = /(^|\.)(sentry\.io|ingest\.sentry\.io|ingest\.[a-z0-9-]+\.sentry\.io)$/;
|
|
11
|
+
/** The envelope's header line: the DSN it is for, whatever else it says. */
|
|
12
|
+
const envelopeHeader = z.object({ dsn: z.string() });
|
|
13
|
+
/** POST for /monitoring: forward the envelope, answer what Sentry answered. */
|
|
14
|
+
export function createSentryTunnelHandler({ fetch: doFetch = fetch } = {}) {
|
|
15
|
+
return async function tunnel(request) {
|
|
16
|
+
const envelope = await request.text();
|
|
17
|
+
const newline = envelope.indexOf("\n");
|
|
18
|
+
const headerLine = newline === -1 ? envelope : envelope.slice(0, newline);
|
|
19
|
+
let parsed;
|
|
20
|
+
try {
|
|
21
|
+
parsed = JSON.parse(headerLine);
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return Response.json({ error: "Not a Sentry envelope." }, { status: 400 });
|
|
25
|
+
}
|
|
26
|
+
const header = envelopeHeader.safeParse(parsed);
|
|
27
|
+
if (!header.success) {
|
|
28
|
+
return Response.json({ error: "Envelope names no DSN." }, { status: 400 });
|
|
29
|
+
}
|
|
30
|
+
let dsn;
|
|
31
|
+
try {
|
|
32
|
+
dsn = new URL(header.data.dsn);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return Response.json({ error: "Not a Sentry envelope." }, { status: 400 });
|
|
36
|
+
}
|
|
37
|
+
if (!ALLOWED_HOST.test(dsn.hostname)) {
|
|
38
|
+
// A DSN pointing anywhere else would make this route an open relay.
|
|
39
|
+
return Response.json({ error: "DSN host is not Sentry." }, { status: 400 });
|
|
40
|
+
}
|
|
41
|
+
const projectId = dsn.pathname.replace(/^\/+/, "");
|
|
42
|
+
if (!/^\d+$/.test(projectId)) {
|
|
43
|
+
return Response.json({ error: "DSN names no project." }, { status: 400 });
|
|
44
|
+
}
|
|
45
|
+
const upstream = await doFetch(`https://${dsn.hostname}/api/${projectId}/envelope/`, {
|
|
46
|
+
method: "POST",
|
|
47
|
+
headers: { "Content-Type": "application/x-sentry-envelope" },
|
|
48
|
+
body: envelope
|
|
49
|
+
});
|
|
50
|
+
return new Response(null, { status: upstream.status });
|
|
51
|
+
};
|
|
52
|
+
}
|
package/dist/server/session.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type MiiAuthCore, type MiiUser } from "
|
|
1
|
+
import { type MiiAuthCore, type MiiUser } from "../auth/core.js";
|
|
2
2
|
export type SessionUser = Pick<MiiUser, "id" | "name" | "email" | "avatarUrl" | "role">;
|
|
3
3
|
export type SessionState = {
|
|
4
4
|
user: SessionUser | null;
|
package/dist/server/session.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Who is asking, as the app's door sees it, and signing in through the platform.
|
|
2
|
-
import { signInWithPlaza } from "
|
|
3
|
-
import { readCookie } from "
|
|
2
|
+
import { signInWithPlaza } from "../auth/core.js";
|
|
3
|
+
import { readCookie } from "../chat/server.js";
|
|
4
4
|
import { env } from "./env.js";
|
|
5
5
|
/** The session a request's cookie names, and whether the app is members-only. */
|
|
6
6
|
export async function readSession(auth, request) {
|
package/dist/uuid.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function isUuid(value: string): boolean;
|
package/dist/uuid.js
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { z } from "zod/v4";
|
|
2
|
+
const uuidSchema = z.uuid();
|
|
3
|
+
// The platform's invocation ids are uuid columns: an id of any
|
|
4
|
+
// other shape names nothing, so it is refused here rather than forwarded.
|
|
5
|
+
export function isUuid(value) {
|
|
6
|
+
return uuidSchema.safeParse(value).success;
|
|
7
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@trylight/app",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "The Light SDK every v2 org app depends on: entities (Drizzle tables with a typed client), sign-in, contracts and in-app chat.",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"type": "module",
|
|
@@ -47,8 +47,9 @@
|
|
|
47
47
|
"typecheck": "tsc --noEmit",
|
|
48
48
|
"lint": "eslint src test --max-warnings 0",
|
|
49
49
|
"test": "node --test --import tsx \"test/**/*.test.ts\"",
|
|
50
|
-
"check": "pnpm run typecheck && pnpm run lint && pnpm run test && pnpm run build",
|
|
51
|
-
"prepack": "pnpm run build"
|
|
50
|
+
"check": "tsx scripts/write-brand.ts --check && pnpm run typecheck && pnpm run lint && pnpm run test && pnpm run build",
|
|
51
|
+
"prepack": "pnpm run build",
|
|
52
|
+
"brand": "tsx scripts/write-brand.ts"
|
|
52
53
|
},
|
|
53
54
|
"peerDependencies": {
|
|
54
55
|
"@tanstack/react-query": ">=5.69",
|
|
@@ -60,7 +61,6 @@
|
|
|
60
61
|
"zod": ">=4"
|
|
61
62
|
},
|
|
62
63
|
"dependencies": {
|
|
63
|
-
"@shubh90/app-runtime": "^0.10.3",
|
|
64
64
|
"drizzle-kit": "0.31.11",
|
|
65
65
|
"drizzle-zod": "^0.8.3",
|
|
66
66
|
"ipaddr.js": "^2.5.0",
|
package/dist/db/baseline.d.ts
DELETED
|
@@ -1,36 +0,0 @@
|
|
|
1
|
-
import { type Snapshot } from "./migrations.js";
|
|
2
|
-
export type Baseline = {
|
|
3
|
-
/** Each app table written to db/schema as an entity(), with the entity() columns it lacks (the first sync adds them). */
|
|
4
|
-
tables: {
|
|
5
|
-
name: string;
|
|
6
|
-
lacks: string[];
|
|
7
|
-
}[];
|
|
8
|
-
/** App tables entity() can't key or say exactly, and why: plain tables in db/schema. */
|
|
9
|
-
plain: {
|
|
10
|
-
name: string;
|
|
11
|
-
why: string;
|
|
12
|
-
}[];
|
|
13
|
-
/** Platform tables left as they are. */
|
|
14
|
-
skipped: string[];
|
|
15
|
-
/** Other things drizzle-kit found (views, sequences…) that db/schema doesn't hold. */
|
|
16
|
-
leftOut: string[];
|
|
17
|
-
/** What the first `light-db sync` will apply. */
|
|
18
|
-
pending: string[];
|
|
19
|
-
};
|
|
20
|
-
/** Why the database can't become db/schema as it is; nothing was written. */
|
|
21
|
-
export declare class BaselineRefused extends Error {
|
|
22
|
-
}
|
|
23
|
-
export declare function baseline(root: string, databaseUrl: string, schemaModule?: string): Promise<Baseline>;
|
|
24
|
-
/** Runs drizzle-kit pull into a temporary folder: the tables as TypeScript, and drizzle's snapshot of them. */
|
|
25
|
-
export declare function pull(databaseUrl: string): {
|
|
26
|
-
source: string;
|
|
27
|
-
snapshot: Snapshot;
|
|
28
|
-
};
|
|
29
|
-
/**
|
|
30
|
-
* What drizzle-kit pull writes wrong: an empty-string default as `.default(')`,
|
|
31
|
-
* and a column of a type it can't parse (tsvector, bytea) as `unknown(...)`
|
|
32
|
-
* under a TODO naming the type, which becomes a custom type of that name.
|
|
33
|
-
*/
|
|
34
|
-
export declare function repairPull(text: string): string;
|
|
35
|
-
/** What a sync would run to make the `live` tables (as pulled) into `recorded`'s; none when they match. */
|
|
36
|
-
export declare function driftFrom(live: Snapshot, recorded: Snapshot): Promise<string[]>;
|
package/dist/db/baseline.js
DELETED
|
@@ -1,439 +0,0 @@
|
|
|
1
|
-
// `light-db baseline`: an older app's live tables become db/schema. drizzle-kit
|
|
2
|
-
// pull reads the database; each app table becomes a file with its real
|
|
3
|
-
// columns, and a first migration records the tables exactly as they are (its
|
|
4
|
-
// SQL commented out: they already exist). A table entity() can key becomes an
|
|
5
|
-
// entity(), so the next sync adds only the entity() columns it lacks; any
|
|
6
|
-
// other stays the plain table pull wrote, which no sync changes.
|
|
7
|
-
import { spawnSync } from "node:child_process";
|
|
8
|
-
import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
|
9
|
-
import { tmpdir } from "node:os";
|
|
10
|
-
import { dirname, join } from "node:path";
|
|
11
|
-
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
12
|
-
import { generateDrizzleJson, generateMigration } from "drizzle-kit/api";
|
|
13
|
-
import postgres from "postgres";
|
|
14
|
-
import { z } from "zod/v4";
|
|
15
|
-
import { getTableName, is } from "drizzle-orm";
|
|
16
|
-
import { PgTable } from "drizzle-orm/pg-core";
|
|
17
|
-
import * as sdkSchema from "../schema.js";
|
|
18
|
-
import { EMPTY_SNAPSHOT, SCHEMA_ENTRY, plan, snapshotSchema, writeMigration } from "./migrations.js";
|
|
19
|
-
/** The SDK's own tables (`lightChanges`, `lightImages`…): every app's db/schema exports them. */
|
|
20
|
-
const SDK_TABLES = Object.entries(sdkSchema).flatMap(([name, value]) => (is(value, PgTable) ? [{ name, table: getTableName(value) }] : []));
|
|
21
|
-
/**
|
|
22
|
-
* Tables the platform keeps rather than the app: app-runtime's sign-in, the
|
|
23
|
-
* Vite template's AI actions (record jobs, AI searches, intro bans), image
|
|
24
|
-
* copies and migration log (org-app-template-vite/migrations), the Next.js
|
|
25
|
-
* template's settings, analytics and migration log, and v2's archive and SDK
|
|
26
|
-
* tables. Baseline leaves them in the database as they are.
|
|
27
|
-
*/
|
|
28
|
-
const PLATFORM_TABLES = [
|
|
29
|
-
"mii_auth_users",
|
|
30
|
-
"mii_auth_sessions",
|
|
31
|
-
"record_jobs",
|
|
32
|
-
"ai_searches",
|
|
33
|
-
"excluded_people",
|
|
34
|
-
"mii_images",
|
|
35
|
-
"_migrations",
|
|
36
|
-
"app_migrations",
|
|
37
|
-
"app_settings",
|
|
38
|
-
"analytics_events",
|
|
39
|
-
"event_coverage",
|
|
40
|
-
"person_aliases",
|
|
41
|
-
"light_archive",
|
|
42
|
-
...SDK_TABLES.map((sdk) => sdk.table)
|
|
43
|
-
];
|
|
44
|
-
/** What every table baseline writes lets through `app.table(...)`; the agent narrows it per table. */
|
|
45
|
-
const ACCESS = '{ read: "door", write: "door" }';
|
|
46
|
-
/** Why the database can't become db/schema as it is; nothing was written. */
|
|
47
|
-
export class BaselineRefused extends Error {
|
|
48
|
-
}
|
|
49
|
-
const pulledColumn = z.looseObject({ type: z.string(), primaryKey: z.boolean() });
|
|
50
|
-
const pulledTable = z.looseObject({
|
|
51
|
-
name: z.string(),
|
|
52
|
-
schema: z.string(),
|
|
53
|
-
columns: z.record(z.string(), pulledColumn),
|
|
54
|
-
compositePrimaryKeys: z.record(z.string(), z.looseObject({ columns: z.array(z.string()) })),
|
|
55
|
-
foreignKeys: z.record(z.string(), z.looseObject({ tableTo: z.string(), columnsFrom: z.array(z.string()) }))
|
|
56
|
-
});
|
|
57
|
-
const pulledTables = z.record(z.string(), pulledTable);
|
|
58
|
-
/** `export const <name> = <kind>("<dbName>"`, the head of each declaration drizzle-kit pull writes. */
|
|
59
|
-
const DECLARATION = /^export const (\w+) = (\w+)\("([^"]+)"/;
|
|
60
|
-
const declarationParts = z.tuple([z.string(), z.string(), z.string()]);
|
|
61
|
-
export async function baseline(root, databaseUrl, schemaModule = "@trylight/app/schema") {
|
|
62
|
-
const dbDir = join(root, "db");
|
|
63
|
-
if (existsSync(dbDir)) {
|
|
64
|
-
throw new BaselineRefused("db/ already exists. Baseline writes db/schema and db/migrations from the database, once; move db/ aside first.");
|
|
65
|
-
}
|
|
66
|
-
const pulled = pull(databaseUrl);
|
|
67
|
-
const { specialOpclasses, hiddenConstraints, keys } = await catalogFacts(databaseUrl);
|
|
68
|
-
if (hiddenConstraints.length > 0) {
|
|
69
|
-
throw new BaselineRefused(`The database role baseline reads with can't see the constraints of ${hiddenConstraints.join(", ")}: Postgres shows them only to a role with REFERENCES (or more) on the table, ` +
|
|
70
|
-
"which the platform grants an app's reader (scripts/grant-reader-references.ts). Nothing was written.");
|
|
71
|
-
}
|
|
72
|
-
const { snapshot, added } = addKeys(pulled.snapshot, keys);
|
|
73
|
-
const live = pulledTables.parse(snapshot.tables);
|
|
74
|
-
const declarations = pulled.source
|
|
75
|
-
.split(/\n(?=export const )/)
|
|
76
|
-
.slice(1)
|
|
77
|
-
.map(readDeclaration)
|
|
78
|
-
.map((declaration) => writeKeys(declaration, added));
|
|
79
|
-
const tables = declarations.filter((d) => d.kind === "pgTable" && !PLATFORM_TABLES.includes(d.dbName));
|
|
80
|
-
const enums = declarations.filter((d) => d.kind === "pgEnum");
|
|
81
|
-
const skipped = declarations.filter((d) => d.kind === "pgTable" && PLATFORM_TABLES.includes(d.dbName)).map((d) => d.dbName);
|
|
82
|
-
const leftOut = declarations.filter((d) => d.kind !== "pgTable" && d.kind !== "pgEnum").map((d) => `${d.dbName} (${d.kind})`);
|
|
83
|
-
const standard = await standardSnapshot();
|
|
84
|
-
const judged = await Promise.all(tables.map((table) => entityMisfit(liveTable(live, table.dbName), standard).then((why) => ({ name: table.dbName, why }))));
|
|
85
|
-
const plain = judged.flatMap(({ name, why }) => (why === null ? [] : [{ name, why }]));
|
|
86
|
-
const entities = new Set(judged.filter(({ why }) => why === null).map(({ name }) => name));
|
|
87
|
-
const refusals = [
|
|
88
|
-
...tables.flatMap((table) => refusalsFor(liveTable(live, table.dbName))),
|
|
89
|
-
...specialOpclasses
|
|
90
|
-
.filter((index) => tables.some((table) => table.dbName === index.table))
|
|
91
|
-
.map((index) => `- ${index.table}: index ${index.index} uses operator class ${index.opclass}, which db/schema can't carry (drizzle-kit reads index operator classes wrongly). ` +
|
|
92
|
-
"Recreate it without one in the old app's own migrations (the CLI's `app migrate` applies them), or leave it out and add it back in db/schema after the move, then run baseline again.")
|
|
93
|
-
];
|
|
94
|
-
if (refusals.length > 0) {
|
|
95
|
-
throw new BaselineRefused(`These tables can't go into db/schema as they are; nothing was written:\n${refusals.join("\n")}`);
|
|
96
|
-
}
|
|
97
|
-
const pgCore = importedNames(pulled.source);
|
|
98
|
-
mkdirSync(join(root, "db", "schema"), { recursive: true });
|
|
99
|
-
try {
|
|
100
|
-
for (const table of tables) {
|
|
101
|
-
const file = tableFile(table, liveTable(live, table.dbName), entities.has(table.dbName), { pgCore, tables, enums, schemaModule });
|
|
102
|
-
writeFileSync(join(root, "db", "schema", `${table.dbName}.ts`), file);
|
|
103
|
-
}
|
|
104
|
-
if (enums.length > 0) {
|
|
105
|
-
writeFileSync(join(root, "db", "schema", "enums.ts"), enumsFile(enums));
|
|
106
|
-
}
|
|
107
|
-
writeFileSync(join(root, SCHEMA_ENTRY), indexFile(tables, enums.length > 0, schemaModule));
|
|
108
|
-
const schema = await import(pathToFileURL(join(root, SCHEMA_ENTRY)).href);
|
|
109
|
-
const full = snapshotSchema.parse(JSON.parse(JSON.stringify(generateDrizzleJson(schema, EMPTY_SNAPSHOT.id))));
|
|
110
|
-
const recorded = asTheyAre(full, live);
|
|
111
|
-
// What entity() says must be exactly what the database has.
|
|
112
|
-
const drift = await driftFrom(snapshot, recorded);
|
|
113
|
-
if (drift.length > 0) {
|
|
114
|
-
throw new BaselineRefused(`db/schema can't say these tables exactly as the database has them; nothing was written. A sync would run:\n${drift.join("\n")}\n` +
|
|
115
|
-
"Change them in the old app's own migrations (the CLI's `app migrate` applies them) so they fit, then run baseline again.");
|
|
116
|
-
}
|
|
117
|
-
const created = await generateMigration(EMPTY_SNAPSHOT, recorded);
|
|
118
|
-
const commented = [
|
|
119
|
-
"-- light-db baseline: the app's tables as they were when it moved to db/schema.",
|
|
120
|
-
"-- They already exist, so nothing here runs; the schema's history starts from them.",
|
|
121
|
-
...created.join("\n").split("\n").map((line) => `-- ${line}`)
|
|
122
|
-
].join("\n");
|
|
123
|
-
writeMigration(root, { statements: [commented], archive: [], snapshot: recorded }, "baseline");
|
|
124
|
-
const pending = await plan(root, schema);
|
|
125
|
-
return {
|
|
126
|
-
tables: tables
|
|
127
|
-
.filter((table) => entities.has(table.dbName))
|
|
128
|
-
.map((table) => ({ name: table.dbName, lacks: lacking(liveTable(live, table.dbName)) })),
|
|
129
|
-
plain,
|
|
130
|
-
skipped,
|
|
131
|
-
leftOut,
|
|
132
|
-
pending: pending.statements
|
|
133
|
-
};
|
|
134
|
-
}
|
|
135
|
-
catch (error) {
|
|
136
|
-
rmSync(dbDir, { recursive: true, force: true });
|
|
137
|
-
throw error;
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
/**
|
|
141
|
-
* What pull can't tell: indexes with a non-default operator class, tables
|
|
142
|
-
* whose constraints this role can't see, and every table's primary key and
|
|
143
|
-
* unique constraints (see addKeys).
|
|
144
|
-
*/
|
|
145
|
-
async function catalogFacts(databaseUrl) {
|
|
146
|
-
const sql = postgres(databaseUrl, { max: 1, onnotice: () => undefined });
|
|
147
|
-
try {
|
|
148
|
-
const specialOpclasses = await sql `
|
|
149
|
-
select t.relname as table, i.relname as index, o.opcname as opclass
|
|
150
|
-
from pg_index x
|
|
151
|
-
join pg_class i on i.oid = x.indexrelid
|
|
152
|
-
join pg_class t on t.oid = x.indrelid
|
|
153
|
-
join pg_namespace n on n.oid = t.relnamespace
|
|
154
|
-
cross join lateral unnest(x.indclass::oid[]) as c(oid)
|
|
155
|
-
join pg_opclass o on o.oid = c.oid
|
|
156
|
-
where n.nspname = 'public' and not o.opcdefault`;
|
|
157
|
-
const hidden = await sql `
|
|
158
|
-
select c.relname as table from pg_class c join pg_namespace n on n.oid = c.relnamespace
|
|
159
|
-
where n.nspname = 'public' and c.relkind in ('r', 'p') and not has_table_privilege(c.oid, 'REFERENCES')
|
|
160
|
-
order by 1`;
|
|
161
|
-
// pg_constraint and pg_attribute show every table's constraints to any role, owner or not.
|
|
162
|
-
const keys = await sql `
|
|
163
|
-
select t.relname as table, c.conname as name, c.contype::text as kind,
|
|
164
|
-
array_agg(a.attname::text order by k.position) as columns
|
|
165
|
-
from pg_constraint c
|
|
166
|
-
join pg_class t on t.oid = c.conrelid
|
|
167
|
-
join pg_namespace n on n.oid = t.relnamespace
|
|
168
|
-
cross join lateral unnest(c.conkey) with ordinality as k(attnum, position)
|
|
169
|
-
join pg_attribute a on a.attrelid = c.conrelid and a.attnum = k.attnum
|
|
170
|
-
where n.nspname = 'public' and c.contype in ('p', 'u')
|
|
171
|
-
group by t.relname, c.conname, c.contype
|
|
172
|
-
order by 1, 2`;
|
|
173
|
-
return { specialOpclasses, hiddenConstraints: hidden.map((row) => row.table), keys };
|
|
174
|
-
}
|
|
175
|
-
finally {
|
|
176
|
-
await sql.end();
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
/** Runs drizzle-kit pull into a temporary folder: the tables as TypeScript, and drizzle's snapshot of them. */
|
|
180
|
-
export function pull(databaseUrl) {
|
|
181
|
-
const dir = mkdtempSync(join(tmpdir(), "light-db-baseline-"));
|
|
182
|
-
try {
|
|
183
|
-
const settings = { dialect: "postgresql", out: "./out", schemaFilter: ["public"], introspect: { casing: "preserve" } };
|
|
184
|
-
const config = join(dir, "drizzle.config.mjs");
|
|
185
|
-
// The URL stays in the environment, out of the file and the process list.
|
|
186
|
-
writeFileSync(config, `export default { ...${JSON.stringify(settings)}, dbCredentials: { url: process.env.DATABASE_URL } };\n`);
|
|
187
|
-
const drizzleKit = join(dirname(fileURLToPath(import.meta.resolve("drizzle-kit"))), "bin.cjs");
|
|
188
|
-
const run = spawnSync(process.execPath, [drizzleKit, "pull", "--config", config], {
|
|
189
|
-
cwd: dir,
|
|
190
|
-
env: { ...process.env, DATABASE_URL: databaseUrl },
|
|
191
|
-
encoding: "utf8"
|
|
192
|
-
});
|
|
193
|
-
const out = join(dir, "out");
|
|
194
|
-
if (run.status !== 0 || !existsSync(join(out, "schema.ts"))) {
|
|
195
|
-
throw new Error(`drizzle-kit pull failed:\n${run.stdout}${run.stderr}`);
|
|
196
|
-
}
|
|
197
|
-
const [snapshotName] = readdirSync(join(out, "meta")).filter((file) => file.endsWith("_snapshot.json"));
|
|
198
|
-
if (snapshotName === undefined)
|
|
199
|
-
throw new Error(`drizzle-kit pull wrote no snapshot:\n${run.stdout}`);
|
|
200
|
-
return {
|
|
201
|
-
source: readFileSync(join(out, "schema.ts"), "utf8"),
|
|
202
|
-
snapshot: snapshotSchema.parse(JSON.parse(readFileSync(join(out, "meta", snapshotName), "utf8")))
|
|
203
|
-
};
|
|
204
|
-
}
|
|
205
|
-
finally {
|
|
206
|
-
rmSync(dir, { recursive: true, force: true });
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
|
-
/**
|
|
210
|
-
* pull reads keys from information_schema.constraint_column_usage, which Postgres fills only for tables
|
|
211
|
-
* the role owns, so as an app's reader it finds none. Adds each key its snapshot lacks, as pull records
|
|
212
|
-
* it for an owner; returns those keys for writeKeys.
|
|
213
|
-
*/
|
|
214
|
-
function addKeys(snapshot, keys) {
|
|
215
|
-
const tables = structuredClone(snapshot.tables);
|
|
216
|
-
const added = [];
|
|
217
|
-
for (const key of keys) {
|
|
218
|
-
const table = tables[`public.${key.table}`];
|
|
219
|
-
if (table === undefined)
|
|
220
|
-
throw new Error(`drizzle-kit pull didn't read ${key.table}, which has key ${key.name}.`);
|
|
221
|
-
const column = keyColumn(key);
|
|
222
|
-
if (key.kind === "u") {
|
|
223
|
-
if (key.name in table.uniqueConstraints)
|
|
224
|
-
continue;
|
|
225
|
-
table.uniqueConstraints[key.name] = { name: key.name, columns: key.columns, nullsNotDistinct: false };
|
|
226
|
-
}
|
|
227
|
-
else if (column === null) {
|
|
228
|
-
if (key.name in table.compositePrimaryKeys)
|
|
229
|
-
continue;
|
|
230
|
-
table.compositePrimaryKeys[key.name] = { name: key.name, columns: key.columns };
|
|
231
|
-
}
|
|
232
|
-
else {
|
|
233
|
-
const pulled = pulledColumn.parse(table.columns[column]);
|
|
234
|
-
if (pulled.primaryKey)
|
|
235
|
-
continue;
|
|
236
|
-
table.columns[column] = { ...pulled, primaryKey: true };
|
|
237
|
-
}
|
|
238
|
-
added.push(key);
|
|
239
|
-
}
|
|
240
|
-
return { snapshot: { ...snapshot, tables }, added };
|
|
241
|
-
}
|
|
242
|
-
/** A primary key's one column, which pull puts on the column itself; null for a composite key. */
|
|
243
|
-
function keyColumn(key) {
|
|
244
|
-
const [column, ...rest] = key.columns;
|
|
245
|
-
return key.kind === "p" && column !== undefined && rest.length === 0 ? column : null;
|
|
246
|
-
}
|
|
247
|
-
/** A pulled table with the keys addKeys added to its snapshot, written as pull writes them. */
|
|
248
|
-
function writeKeys(declaration, added) {
|
|
249
|
-
const keys = added.filter((key) => key.table === declaration.dbName);
|
|
250
|
-
if (declaration.kind !== "pgTable" || keys.length === 0)
|
|
251
|
-
return declaration;
|
|
252
|
-
const lines = declaration.text.split("\n");
|
|
253
|
-
const entries = [];
|
|
254
|
-
for (const key of keys) {
|
|
255
|
-
const column = keyColumn(key);
|
|
256
|
-
const columns = key.columns.map((name) => `table.${name}`).join(", ");
|
|
257
|
-
if (key.kind === "u") {
|
|
258
|
-
entries.push(`\tunique("${key.name}").on(${columns}),`);
|
|
259
|
-
}
|
|
260
|
-
else if (column === null) {
|
|
261
|
-
entries.push(`\tprimaryKey({ columns: [${columns}], name: "${key.name}"}),`);
|
|
262
|
-
}
|
|
263
|
-
else {
|
|
264
|
-
const at = lines.findIndex((line) => line.startsWith(`\t${column}: `) && line.endsWith(","));
|
|
265
|
-
const line = lines[at];
|
|
266
|
-
if (line === undefined)
|
|
267
|
-
throw new Error(`drizzle-kit pull wrote ${declaration.dbName}.${column} in a shape baseline can't read:\n${declaration.text}`);
|
|
268
|
-
lines[at] = `${line.slice(0, -1)}.primaryKey(),`;
|
|
269
|
-
}
|
|
270
|
-
}
|
|
271
|
-
let text = lines.join("\n");
|
|
272
|
-
if (entries.length > 0) {
|
|
273
|
-
const config = entries.join("\n");
|
|
274
|
-
if (text.endsWith("\n]);"))
|
|
275
|
-
text = `${text.slice(0, -"\n]);".length)}\n${config}\n]);`;
|
|
276
|
-
else if (text.endsWith("\n});"))
|
|
277
|
-
text = `${text.slice(0, -");".length)}, (table) => [\n${config}\n]);`;
|
|
278
|
-
else
|
|
279
|
-
throw new Error(`drizzle-kit pull wrote ${declaration.dbName} in a shape baseline can't read:\n${declaration.text}`);
|
|
280
|
-
}
|
|
281
|
-
return { ...declaration, text };
|
|
282
|
-
}
|
|
283
|
-
function readDeclaration(text) {
|
|
284
|
-
const match = DECLARATION.exec(text);
|
|
285
|
-
if (match === null)
|
|
286
|
-
throw new Error(`drizzle-kit pull wrote a declaration baseline can't read:\n${text}`);
|
|
287
|
-
const [name, kind, dbName] = declarationParts.parse(match.slice(1));
|
|
288
|
-
return { name, kind, dbName, text: text.trimEnd() };
|
|
289
|
-
}
|
|
290
|
-
function liveTable(live, name) {
|
|
291
|
-
const table = live[`public.${name}`];
|
|
292
|
-
if (table === undefined)
|
|
293
|
-
throw new Error(`drizzle-kit pull wrote ${name} without its snapshot.`);
|
|
294
|
-
return table;
|
|
295
|
-
}
|
|
296
|
-
function refusalsFor(table) {
|
|
297
|
-
return Object.values(table.foreignKeys)
|
|
298
|
-
.filter((key) => PLATFORM_TABLES.includes(key.tableTo))
|
|
299
|
-
.map((key) => `- ${table.name}.${key.columnsFrom.join(", ")} references ${key.tableTo}, a platform table db/schema doesn't hold. ` +
|
|
300
|
-
"Drop that foreign key in the old app's own migrations (the CLI's `app migrate` applies them; the column and its values stay), then run baseline again.");
|
|
301
|
-
}
|
|
302
|
-
/** The standard columns as entity() declares them, in drizzle-kit's snapshot form. */
|
|
303
|
-
async function standardSnapshot() {
|
|
304
|
-
const probe = { probe: sdkSchema.entity("probe", {}, { read: "door", write: "door" }) };
|
|
305
|
-
const snapshot = snapshotSchema.parse(JSON.parse(JSON.stringify(generateDrizzleJson(probe, EMPTY_SNAPSHOT.id))));
|
|
306
|
-
const table = snapshot.tables["public.probe"];
|
|
307
|
-
if (table === undefined)
|
|
308
|
-
throw new Error("drizzle-kit wrote no snapshot of entity()'s columns.");
|
|
309
|
-
return table;
|
|
310
|
-
}
|
|
311
|
-
/**
|
|
312
|
-
* Why entity() can't say this table, or null when it can: it needs a lone
|
|
313
|
-
* `id uuid` key, and each standard column it already has exactly as entity()
|
|
314
|
-
* declares it (a sync would otherwise change it).
|
|
315
|
-
*/
|
|
316
|
-
async function entityMisfit(table, standard) {
|
|
317
|
-
const id = table.columns.id;
|
|
318
|
-
if (id === undefined || id.type !== "uuid" || !id.primaryKey || Object.keys(table.compositePrimaryKeys).length > 0) {
|
|
319
|
-
return "not keyed by a lone `id uuid` primary key";
|
|
320
|
-
}
|
|
321
|
-
const shared = sdkSchema.STANDARD_COLUMNS.filter((column) => column in table.columns);
|
|
322
|
-
const only = (columns) => ({
|
|
323
|
-
"public.probe": { ...standard, columns: Object.fromEntries(shared.map((column) => [column, columns[column]])) }
|
|
324
|
-
});
|
|
325
|
-
const changes = await generateMigration({ ...EMPTY_SNAPSHOT, tables: only(table.columns) }, { ...EMPTY_SNAPSHOT, tables: only(standard.columns) });
|
|
326
|
-
if (changes.length === 0)
|
|
327
|
-
return null;
|
|
328
|
-
return `entity() would change it: ${changes.map((change) => change.replaceAll('"probe"', `"${table.name}"`)).join(" ")}`;
|
|
329
|
-
}
|
|
330
|
-
/** The entity() columns a live table doesn't have yet. */
|
|
331
|
-
function lacking(table) {
|
|
332
|
-
return sdkSchema.STANDARD_COLUMNS.filter((column) => !(column in table.columns));
|
|
333
|
-
}
|
|
334
|
-
/** The names drizzle-kit pull imported from pg-core. */
|
|
335
|
-
function importedNames(source) {
|
|
336
|
-
const match = /^import \{ ([^}]+) \} from "drizzle-orm\/pg-core"/m.exec(source);
|
|
337
|
-
if (match === null)
|
|
338
|
-
throw new Error(`drizzle-kit pull wrote no pg-core import baseline can read:\n${source}`);
|
|
339
|
-
return z.string().parse(match[1]).split(",").map((name) => name.trim());
|
|
340
|
-
}
|
|
341
|
-
// Not after a dot: a column's `.primaryKey()` imports nothing.
|
|
342
|
-
const uses = (text, name, after) => new RegExp(`(?<![.\\w])${name}${after}`).test(text);
|
|
343
|
-
/**
|
|
344
|
-
* One pulled `pgTable` as a db/schema file: an `entity()` (which declares its
|
|
345
|
-
* standard columns itself) when it fits one, else the table as pulled.
|
|
346
|
-
*/
|
|
347
|
-
function tableFile(table, live, asEntity, context) {
|
|
348
|
-
const standard = new RegExp(`^\\t(${sdkSchema.STANDARD_COLUMNS.join("|")}): `);
|
|
349
|
-
const lines = repairPull(table.text)
|
|
350
|
-
.split("\n")
|
|
351
|
-
.filter((line) => !asEntity || !standard.test(line))
|
|
352
|
-
// pull names each index column's operator class, often the wrong one; baseline refused any that isn't the default.
|
|
353
|
-
.map((line) => (line.includes('.using("btree"') ? line.replace(/\.op\("[^"]*"\)/g, "") : line));
|
|
354
|
-
let body = lines.join("\n");
|
|
355
|
-
if (asEntity) {
|
|
356
|
-
body = body.replace(`= pgTable("${table.dbName}", {`, `= entity("${table.dbName}", {`);
|
|
357
|
-
body = body.endsWith("\n});")
|
|
358
|
-
? `${body.slice(0, -"\n});".length)}\n}, ${ACCESS});`
|
|
359
|
-
: body.replace("\n}, (table) => [", `\n}, ${ACCESS}, (table) => [`);
|
|
360
|
-
if (!body.includes(ACCESS))
|
|
361
|
-
throw new Error(`drizzle-kit pull wrote ${table.dbName} in a shape baseline can't read:\n${table.text}`);
|
|
362
|
-
}
|
|
363
|
-
const pgCore = [...new Set([...context.pgCore, "customType", "primaryKey", "unique"])].filter((name) => uses(body, name, "[<(]"));
|
|
364
|
-
const referenced = new Set(Object.values(live.foreignKeys).map((key) => key.tableTo));
|
|
365
|
-
const others = context.tables.filter((other) => other !== table && referenced.has(other.dbName));
|
|
366
|
-
const types = new Set(Object.values(live.columns).map((column) => column.type));
|
|
367
|
-
const enums = context.enums.filter((e) => types.has(e.dbName));
|
|
368
|
-
return [
|
|
369
|
-
...(asEntity ? [`import { entity } from "${context.schemaModule}";`] : []),
|
|
370
|
-
...(pgCore.length > 0 ? [`import { ${pgCore.join(", ")} } from "drizzle-orm/pg-core";`] : []),
|
|
371
|
-
...(uses(body, "sql", "`") ? ['import { sql } from "drizzle-orm";'] : []),
|
|
372
|
-
...(enums.length > 0 ? [`import { ${enums.map((e) => e.name).join(", ")} } from "./enums";`] : []),
|
|
373
|
-
...others.map((other) => `import { ${other.name} } from "./${other.dbName}";`),
|
|
374
|
-
"",
|
|
375
|
-
body,
|
|
376
|
-
""
|
|
377
|
-
].join("\n");
|
|
378
|
-
}
|
|
379
|
-
/**
|
|
380
|
-
* What drizzle-kit pull writes wrong: an empty-string default as `.default(')`,
|
|
381
|
-
* and a column of a type it can't parse (tsvector, bytea) as `unknown(...)`
|
|
382
|
-
* under a TODO naming the type, which becomes a custom type of that name.
|
|
383
|
-
*/
|
|
384
|
-
export function repairPull(text) {
|
|
385
|
-
return text
|
|
386
|
-
.replaceAll(".default(')", '.default("")')
|
|
387
|
-
.replace(/\t\/\/ TODO: failed to parse database type '([^']+)'\n(\t\w+: )unknown\(/g, (_, type, column) => `${column}customType<{ data: unknown }>({ dataType: () => ${JSON.stringify(type)} })(`);
|
|
388
|
-
}
|
|
389
|
-
function enumsFile(enums) {
|
|
390
|
-
return ['import { pgEnum } from "drizzle-orm/pg-core";', "", ...enums.map((e) => e.text), ""].join("\n");
|
|
391
|
-
}
|
|
392
|
-
function indexFile(tables, hasEnums, schemaModule) {
|
|
393
|
-
return [
|
|
394
|
-
"// The app's tables: one file per table in this folder, each exported here.",
|
|
395
|
-
"// Writing a file here is the migration (see AGENTS.md).",
|
|
396
|
-
`export { ${SDK_TABLES.map((sdk) => sdk.name).join(", ")} } from "${schemaModule}";`,
|
|
397
|
-
...(hasEnums ? ['export * from "./enums";'] : []),
|
|
398
|
-
...tables.map((table) => `export * from "./${table.dbName}";`),
|
|
399
|
-
""
|
|
400
|
-
].join("\n");
|
|
401
|
-
}
|
|
402
|
-
/** The schema's snapshot as the database has it: its app tables, less the entity() columns they lack. */
|
|
403
|
-
function asTheyAre(full, live) {
|
|
404
|
-
const tables = {};
|
|
405
|
-
for (const [key, table] of Object.entries(full.tables)) {
|
|
406
|
-
const now = live[key];
|
|
407
|
-
if (now === undefined)
|
|
408
|
-
continue;
|
|
409
|
-
tables[key] = { ...table, columns: Object.fromEntries(Object.entries(table.columns).filter(([column]) => column in now.columns)) };
|
|
410
|
-
}
|
|
411
|
-
return { ...full, tables };
|
|
412
|
-
}
|
|
413
|
-
/** What a sync would run to make the `live` tables (as pulled) into `recorded`'s; none when they match. */
|
|
414
|
-
export function driftFrom(live, recorded) {
|
|
415
|
-
return generateMigration(comparable(live, recorded), comparable(recorded, recorded));
|
|
416
|
-
}
|
|
417
|
-
/**
|
|
418
|
-
* A snapshot with only what db/schema holds (`recorded`'s tables and enums),
|
|
419
|
-
* btree operator classes aside: pull misreads them, and the default is what
|
|
420
|
-
* the files say.
|
|
421
|
-
*/
|
|
422
|
-
function comparable(snapshot, recorded) {
|
|
423
|
-
const tables = {};
|
|
424
|
-
for (const key of Object.keys(recorded.tables)) {
|
|
425
|
-
const table = snapshot.tables[key];
|
|
426
|
-
if (table === undefined)
|
|
427
|
-
throw new Error(`${key} is in db/schema but not in the database drizzle-kit read.`);
|
|
428
|
-
tables[key] = { ...table, indexes: Object.fromEntries(Object.entries(table.indexes).map(([name, index]) => [name, withoutBtreeOpclass(index)])) };
|
|
429
|
-
}
|
|
430
|
-
return { ...snapshot, tables, schemas: {}, sequences: {}, roles: {}, policies: {}, views: {} };
|
|
431
|
-
}
|
|
432
|
-
const btreeIndex = z.looseObject({ method: z.literal("btree"), columns: z.array(z.record(z.string(), z.unknown())) });
|
|
433
|
-
function withoutBtreeOpclass(index) {
|
|
434
|
-
const parsed = btreeIndex.safeParse(index);
|
|
435
|
-
if (!parsed.success)
|
|
436
|
-
return index;
|
|
437
|
-
const columns = parsed.data.columns.map((column) => Object.fromEntries(Object.entries(column).filter(([key]) => key !== "opclass")));
|
|
438
|
-
return { ...parsed.data, columns };
|
|
439
|
-
}
|