@softure-ai/db 0.0.0-stage → 0.1.6
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/CHANGELOG.md +16 -0
- package/LICENSE +21 -0
- package/README.md +383 -2
- package/dist/cli/bin.d.ts +3 -0
- package/dist/cli/bin.d.ts.map +1 -0
- package/dist/cli/bin.js +5 -0
- package/dist/cli/bin.js.map +1 -0
- package/dist/cli/command.d.ts +8 -0
- package/dist/cli/command.d.ts.map +1 -0
- package/dist/cli/command.js +38 -0
- package/dist/cli/command.js.map +1 -0
- package/dist/cli/index.d.ts +3 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/index.js +4 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/cli/run.d.ts +27 -0
- package/dist/cli/run.d.ts.map +1 -0
- package/dist/cli/run.js +205 -0
- package/dist/cli/run.js.map +1 -0
- package/dist/client.d.ts +73 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +90 -0
- package/dist/client.js.map +1 -0
- package/dist/configured.d.ts +31 -0
- package/dist/configured.d.ts.map +1 -0
- package/dist/configured.js +74 -0
- package/dist/configured.js.map +1 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +13 -0
- package/dist/index.js.map +1 -0
- package/dist/migrations/adopt.d.ts +33 -0
- package/dist/migrations/adopt.d.ts.map +1 -0
- package/dist/migrations/adopt.js +97 -0
- package/dist/migrations/adopt.js.map +1 -0
- package/dist/migrations/apply.d.ts +35 -0
- package/dist/migrations/apply.d.ts.map +1 -0
- package/dist/migrations/apply.js +115 -0
- package/dist/migrations/apply.js.map +1 -0
- package/dist/migrations/export.d.ts +10 -0
- package/dist/migrations/export.d.ts.map +1 -0
- package/dist/migrations/export.js +58 -0
- package/dist/migrations/export.js.map +1 -0
- package/dist/migrations/exported.d.ts +20 -0
- package/dist/migrations/exported.d.ts.map +1 -0
- package/dist/migrations/exported.js +61 -0
- package/dist/migrations/exported.js.map +1 -0
- package/dist/migrations/files.d.ts +21 -0
- package/dist/migrations/files.d.ts.map +1 -0
- package/dist/migrations/files.js +112 -0
- package/dist/migrations/files.js.map +1 -0
- package/dist/migrations/introspect.d.ts +6 -0
- package/dist/migrations/introspect.d.ts.map +1 -0
- package/dist/migrations/introspect.js +117 -0
- package/dist/migrations/introspect.js.map +1 -0
- package/dist/migrations/ledger.d.ts +27 -0
- package/dist/migrations/ledger.d.ts.map +1 -0
- package/dist/migrations/ledger.js +45 -0
- package/dist/migrations/ledger.js.map +1 -0
- package/dist/migrations/migrator.d.ts +97 -0
- package/dist/migrations/migrator.d.ts.map +1 -0
- package/dist/migrations/migrator.js +301 -0
- package/dist/migrations/migrator.js.map +1 -0
- package/dist/migrations/problems.d.ts +93 -0
- package/dist/migrations/problems.d.ts.map +1 -0
- package/dist/migrations/problems.js +62 -0
- package/dist/migrations/problems.js.map +1 -0
- package/dist/migrations/reference.d.ts +23 -0
- package/dist/migrations/reference.d.ts.map +1 -0
- package/dist/migrations/reference.js +64 -0
- package/dist/migrations/reference.js.map +1 -0
- package/dist/migrations/session.d.ts +15 -0
- package/dist/migrations/session.d.ts.map +1 -0
- package/dist/migrations/session.js +56 -0
- package/dist/migrations/session.js.map +1 -0
- package/dist/migrations/stubs.d.ts +23 -0
- package/dist/migrations/stubs.d.ts.map +1 -0
- package/dist/migrations/stubs.js +99 -0
- package/dist/migrations/stubs.js.map +1 -0
- package/dist/shared.d.ts +9 -0
- package/dist/shared.d.ts.map +1 -0
- package/dist/shared.js +32 -0
- package/dist/shared.js.map +1 -0
- package/dist/testing.d.ts +23 -0
- package/dist/testing.d.ts.map +1 -0
- package/dist/testing.js +62 -0
- package/dist/testing.js.map +1 -0
- package/package.json +73 -4
- package/src/cli/bin.ts +5 -0
- package/src/cli/command.ts +47 -0
- package/src/cli/index.ts +3 -0
- package/src/cli/run.ts +250 -0
- package/src/client.ts +151 -0
- package/src/configured.ts +109 -0
- package/src/index.ts +46 -0
- package/src/migrations/adopt.ts +136 -0
- package/src/migrations/apply.ts +135 -0
- package/src/migrations/export.ts +66 -0
- package/src/migrations/exported.ts +82 -0
- package/src/migrations/files.ts +129 -0
- package/src/migrations/introspect.ts +165 -0
- package/src/migrations/ledger.ts +75 -0
- package/src/migrations/migrator.ts +403 -0
- package/src/migrations/problems.ts +99 -0
- package/src/migrations/reference.ts +78 -0
- package/src/migrations/session.ts +74 -0
- package/src/migrations/stubs.ts +139 -0
- package/src/shared.ts +36 -0
- package/src/testing.ts +80 -0
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// Stubs of the app's own tables for the adoption reference schema (issue #170). Module SQL may
|
|
2
|
+
// reference an app table (`REFERENCES public.app_users (id)`), which the scratch database the
|
|
3
|
+
// reference is built on does not have. `readAppTables` copies, from the live database, every table
|
|
4
|
+
// outside the schemas the migrator owns: its columns (type only: no defaults, NOT NULL, checks or
|
|
5
|
+
// foreign keys, which can point at things the scratch lacks), its primary key, unique constraints
|
|
6
|
+
// and plain unique indexes, plus the enum types of those schemas. Domains, extension types and
|
|
7
|
+
// other objects are not copied; a column of such a type is skipped and reported.
|
|
8
|
+
import type { MigrationSession } from "./session.js";
|
|
9
|
+
|
|
10
|
+
/** One DDL statement for the scratch database, with the object it creates for reports. */
|
|
11
|
+
export interface StubStatement {
|
|
12
|
+
/** `schema.table`, `schema.table.column`, `schema.table.constraint` or `schema.type`, unquoted. */
|
|
13
|
+
readonly object: string;
|
|
14
|
+
readonly sql: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** The app's tables as statements to replay, in a runnable order. */
|
|
18
|
+
export interface AppCatalog {
|
|
19
|
+
readonly statements: readonly StubStatement[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Names come back quoted by Postgres (`quote_ident`, `format('%I')`), so no DDL below concatenates
|
|
23
|
+
// a raw identifier. Each query takes the excluded schemas as $1.
|
|
24
|
+
const IN_APP_SCHEMA = (alias: string): string =>
|
|
25
|
+
`${alias}.nspname <> ALL($1::text[]) AND ${alias}.nspname <> 'information_schema' AND ${alias}.nspname NOT LIKE 'pg\\_%'`;
|
|
26
|
+
|
|
27
|
+
const NOT_IN_EXTENSION = (catalog: string, alias: string): string =>
|
|
28
|
+
`NOT EXISTS (SELECT 1 FROM pg_depend dep WHERE dep.classid = '${catalog}'::regclass AND dep.objid = ${alias}.oid AND dep.deptype = 'e')`;
|
|
29
|
+
|
|
30
|
+
const QUERIES = {
|
|
31
|
+
schemas: `
|
|
32
|
+
SELECT DISTINCT n.nspname AS name, quote_ident(n.nspname) AS quoted
|
|
33
|
+
FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
34
|
+
WHERE c.relkind IN ('r', 'p') AND ${IN_APP_SCHEMA("n")} AND ${NOT_IN_EXTENSION("pg_class", "c")}
|
|
35
|
+
ORDER BY 1`,
|
|
36
|
+
enums: `
|
|
37
|
+
SELECT n.nspname || '.' || t.typname AS object, format('%I.%I', n.nspname, t.typname) AS quoted,
|
|
38
|
+
(SELECT string_agg(quote_literal(e.enumlabel), ', ' ORDER BY e.enumsortorder) FROM pg_enum e WHERE e.enumtypid = t.oid) AS labels
|
|
39
|
+
FROM pg_type t JOIN pg_namespace n ON n.oid = t.typnamespace
|
|
40
|
+
WHERE t.typtype = 'e' AND ${IN_APP_SCHEMA("n")} AND ${NOT_IN_EXTENSION("pg_type", "t")}
|
|
41
|
+
ORDER BY 1`,
|
|
42
|
+
tables: `
|
|
43
|
+
SELECT n.nspname || '.' || c.relname AS object, format('%I.%I', n.nspname, c.relname) AS quoted
|
|
44
|
+
FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
45
|
+
WHERE c.relkind IN ('r', 'p') AND ${IN_APP_SCHEMA("n")} AND ${NOT_IN_EXTENSION("pg_class", "c")}
|
|
46
|
+
ORDER BY 1`,
|
|
47
|
+
columns: `
|
|
48
|
+
SELECT n.nspname || '.' || c.relname AS table_object, format('%I.%I', n.nspname, c.relname) AS table_quoted,
|
|
49
|
+
a.attname AS name, quote_ident(a.attname) AS quoted, format_type(a.atttypid, a.atttypmod) AS type
|
|
50
|
+
FROM pg_attribute a
|
|
51
|
+
JOIN pg_class c ON c.oid = a.attrelid
|
|
52
|
+
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
53
|
+
WHERE c.relkind IN ('r', 'p') AND a.attnum > 0 AND NOT a.attisdropped
|
|
54
|
+
AND ${IN_APP_SCHEMA("n")} AND ${NOT_IN_EXTENSION("pg_class", "c")}
|
|
55
|
+
ORDER BY 1, a.attnum`,
|
|
56
|
+
keys: `
|
|
57
|
+
SELECT n.nspname || '.' || c.relname AS table_object, format('%I.%I', n.nspname, c.relname) AS table_quoted,
|
|
58
|
+
con.conname AS name, quote_ident(con.conname) AS quoted, pg_get_constraintdef(con.oid) AS definition
|
|
59
|
+
FROM pg_constraint con
|
|
60
|
+
JOIN pg_class c ON c.oid = con.conrelid
|
|
61
|
+
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
62
|
+
WHERE con.contype IN ('p', 'u') AND c.relkind IN ('r', 'p')
|
|
63
|
+
AND ${IN_APP_SCHEMA("n")} AND ${NOT_IN_EXTENSION("pg_class", "c")}
|
|
64
|
+
ORDER BY 1, con.contype, 3`,
|
|
65
|
+
// A unique index that backs no key constraint. A foreign key records the index it references in
|
|
66
|
+
// `conindid` too, so only key constraints count.
|
|
67
|
+
uniqueIndexes: `
|
|
68
|
+
SELECT n.nspname || '.' || c.relname AS table_object, i.relname AS name, pg_get_indexdef(i.oid) AS definition
|
|
69
|
+
FROM pg_index x
|
|
70
|
+
JOIN pg_class i ON i.oid = x.indexrelid
|
|
71
|
+
JOIN pg_class c ON c.oid = x.indrelid
|
|
72
|
+
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
73
|
+
WHERE x.indisunique AND x.indpred IS NULL AND x.indexprs IS NULL AND c.relkind IN ('r', 'p')
|
|
74
|
+
AND NOT EXISTS (SELECT 1 FROM pg_constraint con WHERE con.conindid = x.indexrelid AND con.contype IN ('p', 'u', 'x'))
|
|
75
|
+
AND ${IN_APP_SCHEMA("n")} AND ${NOT_IN_EXTENSION("pg_class", "c")}
|
|
76
|
+
ORDER BY 1, 2`,
|
|
77
|
+
} as const;
|
|
78
|
+
|
|
79
|
+
interface ColumnRow {
|
|
80
|
+
table_object: string;
|
|
81
|
+
table_quoted: string;
|
|
82
|
+
name: string;
|
|
83
|
+
quoted: string;
|
|
84
|
+
type: string;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
interface KeyRow {
|
|
88
|
+
table_object: string;
|
|
89
|
+
table_quoted: string;
|
|
90
|
+
name: string;
|
|
91
|
+
quoted: string;
|
|
92
|
+
definition: string;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The app's tables in every schema except `pg_*`, `information_schema` and `excludedSchemas` (the
|
|
97
|
+
* ledger, the enabled modules' schemas, the app migrator's own). Runs in its own read-only transaction.
|
|
98
|
+
*/
|
|
99
|
+
export async function readAppTables(session: MigrationSession, excludedSchemas: readonly string[]): Promise<AppCatalog> {
|
|
100
|
+
await session.exec("BEGIN READ ONLY; SET LOCAL search_path = pg_catalog;");
|
|
101
|
+
try {
|
|
102
|
+
const params = [[...excludedSchemas]];
|
|
103
|
+
const schemas = await session.query<{ name: string; quoted: string }>(QUERIES.schemas, params);
|
|
104
|
+
const enums = await session.query<{ object: string; quoted: string; labels: string }>(QUERIES.enums, params);
|
|
105
|
+
const tables = await session.query<{ object: string; quoted: string }>(QUERIES.tables, params);
|
|
106
|
+
const columns = await session.query<ColumnRow>(QUERIES.columns, params);
|
|
107
|
+
const keys = await session.query<KeyRow>(QUERIES.keys, params);
|
|
108
|
+
const indexes = await session.query<{ table_object: string; name: string; definition: string }>(QUERIES.uniqueIndexes, params);
|
|
109
|
+
return {
|
|
110
|
+
statements: [
|
|
111
|
+
...schemas.map((row) => ({ object: row.name, sql: `CREATE SCHEMA IF NOT EXISTS ${row.quoted}` })),
|
|
112
|
+
...enums.map((row) => ({ object: row.object, sql: `CREATE TYPE ${row.quoted} AS ENUM (${row.labels})` })),
|
|
113
|
+
...tables.map((row) => ({ object: row.object, sql: `CREATE TABLE ${row.quoted} ()` })),
|
|
114
|
+
...columns.map((row) => ({ object: `${row.table_object}.${row.name}`, sql: `ALTER TABLE ${row.table_quoted} ADD COLUMN ${row.quoted} ${row.type}` })),
|
|
115
|
+
...keys.map((row) => ({ object: `${row.table_object}.${row.name}`, sql: `ALTER TABLE ${row.table_quoted} ADD CONSTRAINT ${row.quoted} ${row.definition}` })),
|
|
116
|
+
...indexes.map((row) => ({ object: `${row.table_object}.${row.name}`, sql: row.definition })),
|
|
117
|
+
],
|
|
118
|
+
};
|
|
119
|
+
} finally {
|
|
120
|
+
await session.exec("ROLLBACK");
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Replays the catalog on the scratch database, each statement on its own: a statement that fails
|
|
126
|
+
* (a type the scratch lacks, a key on a skipped column) is skipped, so a module that never
|
|
127
|
+
* references that table still builds its reference. Returns the skipped objects with the reason.
|
|
128
|
+
*/
|
|
129
|
+
export async function createAppStubs(session: MigrationSession, catalog: AppCatalog): Promise<string[]> {
|
|
130
|
+
const skipped: string[] = [];
|
|
131
|
+
for (const statement of catalog.statements) {
|
|
132
|
+
try {
|
|
133
|
+
await session.exec(statement.sql);
|
|
134
|
+
} catch (error) {
|
|
135
|
+
skipped.push(`${statement.object}: ${error instanceof Error ? error.message : String(error)}`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return skipped;
|
|
139
|
+
}
|
package/src/shared.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// One database handle per URL for the whole server process, shared by every module's Next.js
|
|
2
|
+
// adapter and the app. Package code cannot import the app's own handle, and `next dev` re-evaluates
|
|
3
|
+
// modules on every change, so the handles live on `globalThis` under a `Symbol.for` key: each
|
|
4
|
+
// reload, and each copy of this file in separate server bundles, finds the same pool.
|
|
5
|
+
import { createDatabase, type CreateDatabaseOptions, type DatabaseHandle } from "./client.js";
|
|
6
|
+
|
|
7
|
+
const HANDLES_KEY = Symbol.for("@softure-ai/db/shared-handles");
|
|
8
|
+
|
|
9
|
+
type HandleHost = Record<typeof HANDLES_KEY, Map<string, Promise<DatabaseHandle>> | undefined>;
|
|
10
|
+
|
|
11
|
+
const host = globalThis as unknown as HandleHost;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The process-wide handle for `url`, opened on first use. `options` apply only to that first call.
|
|
15
|
+
* A failed open is not kept, so the next call tries again.
|
|
16
|
+
*/
|
|
17
|
+
export function getSharedDatabase(url: string, options: CreateDatabaseOptions = {}): Promise<DatabaseHandle> {
|
|
18
|
+
const handles = (host[HANDLES_KEY] ??= new Map<string, Promise<DatabaseHandle>>());
|
|
19
|
+
let handle = handles.get(url);
|
|
20
|
+
if (handle === undefined) {
|
|
21
|
+
handle = createDatabase(url, options);
|
|
22
|
+
handles.set(url, handle);
|
|
23
|
+
handle.catch(() => handles.delete(url));
|
|
24
|
+
}
|
|
25
|
+
return handle;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Closes and forgets every shared handle. For tests and graceful shutdown. */
|
|
29
|
+
export async function closeSharedDatabases(): Promise<void> {
|
|
30
|
+
const handles = host[HANDLES_KEY];
|
|
31
|
+
if (handles === undefined) return;
|
|
32
|
+
const opened = [...handles.values()];
|
|
33
|
+
handles.clear();
|
|
34
|
+
const results = await Promise.allSettled(opened);
|
|
35
|
+
await Promise.all(results.flatMap((result) => (result.status === "fulfilled" ? [result.value.close()] : [])));
|
|
36
|
+
}
|
package/src/testing.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// Test databases for module unit tests (docs/02-module-standard.md §10): a real Postgres (PGlite)
|
|
2
|
+
// with the real migrations of the given modules. Migrating costs about two seconds, so each
|
|
3
|
+
// module set is migrated once per test worker into a template, and every call starts from a copy
|
|
4
|
+
// of the template's data folder (FIRE_TRACKER `src/db/test-db.ts`). An app with its own migrations
|
|
5
|
+
// passes the same `app` hooks (and baseline) as to `migrate`, so module tables can reference app
|
|
6
|
+
// tables and a module the app's history already creates is adopted in the template.
|
|
7
|
+
import { PGlite } from "@electric-sql/pglite";
|
|
8
|
+
import type { AnySoftureModule } from "@softure-ai/core";
|
|
9
|
+
import { createPgliteHandle, type PgliteClientDatabase } from "./client.js";
|
|
10
|
+
import { migrate, type AppMigrationHook, type AppMigrations } from "./migrations/migrator.js";
|
|
11
|
+
import { describeProblem } from "./migrations/problems.js";
|
|
12
|
+
|
|
13
|
+
export interface TestDatabase {
|
|
14
|
+
readonly db: PgliteClientDatabase;
|
|
15
|
+
readonly client: PGlite;
|
|
16
|
+
readonly close: () => Promise<void>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface CreateTestDatabaseOptions {
|
|
20
|
+
/**
|
|
21
|
+
* The app's own migrations, run around the module files as `migrate` runs them. The template is
|
|
22
|
+
* cached per hook object: define the hooks once, at module level, not inline per call.
|
|
23
|
+
*/
|
|
24
|
+
readonly app?: AppMigrations;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const templates = new Map<string, Promise<Blob | File>>();
|
|
28
|
+
const hookIds = new WeakMap<AppMigrationHook, number>();
|
|
29
|
+
let nextHookId = 1;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* A fresh, migrated, in-memory database per call. List the module under test and the modules it
|
|
33
|
+
* depends on. Throws when a migration (a module file or an app hook) fails: that is a bug in the
|
|
34
|
+
* module package or the app's migrations.
|
|
35
|
+
*/
|
|
36
|
+
export async function createTestDatabase(modules: readonly AnySoftureModule[] = [], options: CreateTestDatabaseOptions = {}): Promise<TestDatabase> {
|
|
37
|
+
const client = new PGlite({ loadDataDir: await getTemplate(modules, options.app ?? {}) });
|
|
38
|
+
const handle = await createPgliteHandle(client);
|
|
39
|
+
return { db: handle.db, client, close: handle.close };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function getTemplate(modules: readonly AnySoftureModule[], app: AppMigrations): Promise<Blob | File> {
|
|
43
|
+
const moduleKey = modules.map((module) => `${module.id}@${module.migrations?.dir.href ?? "-"}`).join("|");
|
|
44
|
+
const baselineKey = Object.entries(app.baseline ?? {}).sort(([left], [right]) => left.localeCompare(right)).map(([id, through]) => `${id}:${through}`).join(",");
|
|
45
|
+
const key = `${moduleKey}#before:${identifyHook(app.before)}#after:${identifyHook(app.after)}#baseline:${baselineKey}`;
|
|
46
|
+
let template = templates.get(key);
|
|
47
|
+
if (template === undefined) {
|
|
48
|
+
template = buildTemplate(modules, app);
|
|
49
|
+
templates.set(key, template);
|
|
50
|
+
// A failed build is not cached: the next call reports the problem again.
|
|
51
|
+
template.catch(() => templates.delete(key));
|
|
52
|
+
}
|
|
53
|
+
return template;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Hooks are functions, so the key holds an id per hook object rather than its source text.
|
|
57
|
+
function identifyHook(hook: AppMigrationHook | undefined): string {
|
|
58
|
+
if (hook === undefined) {
|
|
59
|
+
return "-";
|
|
60
|
+
}
|
|
61
|
+
let id = hookIds.get(hook);
|
|
62
|
+
if (id === undefined) {
|
|
63
|
+
id = nextHookId++;
|
|
64
|
+
hookIds.set(hook, id);
|
|
65
|
+
}
|
|
66
|
+
return String(id);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function buildTemplate(modules: readonly AnySoftureModule[], app: AppMigrations): Promise<Blob | File> {
|
|
70
|
+
const handle = await createPgliteHandle(new PGlite());
|
|
71
|
+
try {
|
|
72
|
+
const result = await migrate(handle, { modules, app });
|
|
73
|
+
if (!result.ok) {
|
|
74
|
+
throw new Error(`createTestDatabase: migrations failed:\n${result.problems.map(describeProblem).join("\n")}`);
|
|
75
|
+
}
|
|
76
|
+
return await handle.client.dumpDataDir("none");
|
|
77
|
+
} finally {
|
|
78
|
+
await handle.close();
|
|
79
|
+
}
|
|
80
|
+
}
|