@softure-ai/db 0.1.5 → 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.
Files changed (63) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/README.md +215 -21
  3. package/dist/cli/run.d.ts +8 -1
  4. package/dist/cli/run.d.ts.map +1 -1
  5. package/dist/cli/run.js +71 -21
  6. package/dist/cli/run.js.map +1 -1
  7. package/dist/client.d.ts +39 -8
  8. package/dist/client.d.ts.map +1 -1
  9. package/dist/client.js +64 -8
  10. package/dist/client.js.map +1 -1
  11. package/dist/configured.d.ts +31 -0
  12. package/dist/configured.d.ts.map +1 -0
  13. package/dist/configured.js +74 -0
  14. package/dist/configured.js.map +1 -0
  15. package/dist/index.d.ts +3 -2
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js +3 -2
  18. package/dist/index.js.map +1 -1
  19. package/dist/migrations/adopt.d.ts +8 -1
  20. package/dist/migrations/adopt.d.ts.map +1 -1
  21. package/dist/migrations/adopt.js +53 -87
  22. package/dist/migrations/adopt.js.map +1 -1
  23. package/dist/migrations/apply.d.ts +35 -0
  24. package/dist/migrations/apply.d.ts.map +1 -0
  25. package/dist/migrations/apply.js +115 -0
  26. package/dist/migrations/apply.js.map +1 -0
  27. package/dist/migrations/migrator.d.ts +55 -27
  28. package/dist/migrations/migrator.d.ts.map +1 -1
  29. package/dist/migrations/migrator.js +187 -103
  30. package/dist/migrations/migrator.js.map +1 -1
  31. package/dist/migrations/problems.d.ts +9 -0
  32. package/dist/migrations/problems.d.ts.map +1 -1
  33. package/dist/migrations/problems.js +9 -3
  34. package/dist/migrations/problems.js.map +1 -1
  35. package/dist/migrations/reference.d.ts +23 -0
  36. package/dist/migrations/reference.d.ts.map +1 -0
  37. package/dist/migrations/reference.js +64 -0
  38. package/dist/migrations/reference.js.map +1 -0
  39. package/dist/migrations/session.d.ts +2 -1
  40. package/dist/migrations/session.d.ts.map +1 -1
  41. package/dist/migrations/session.js +19 -2
  42. package/dist/migrations/session.js.map +1 -1
  43. package/dist/migrations/stubs.d.ts +23 -0
  44. package/dist/migrations/stubs.d.ts.map +1 -0
  45. package/dist/migrations/stubs.js +99 -0
  46. package/dist/migrations/stubs.js.map +1 -0
  47. package/dist/testing.d.ts +11 -2
  48. package/dist/testing.d.ts.map +1 -1
  49. package/dist/testing.js +28 -9
  50. package/dist/testing.js.map +1 -1
  51. package/package.json +22 -5
  52. package/src/cli/run.ts +84 -23
  53. package/src/client.ts +88 -16
  54. package/src/configured.ts +109 -0
  55. package/src/index.ts +9 -0
  56. package/src/migrations/adopt.ts +65 -100
  57. package/src/migrations/apply.ts +135 -0
  58. package/src/migrations/migrator.ts +246 -128
  59. package/src/migrations/problems.ts +13 -5
  60. package/src/migrations/reference.ts +78 -0
  61. package/src/migrations/session.ts +28 -2
  62. package/src/migrations/stubs.ts +139 -0
  63. package/src/testing.ts +38 -10
@@ -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/testing.ts CHANGED
@@ -1,11 +1,13 @@
1
1
  // Test databases for module unit tests (docs/02-module-standard.md §10): a real Postgres (PGlite)
2
2
  // with the real migrations of the given modules. Migrating costs about two seconds, so each
3
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`).
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.
5
7
  import { PGlite } from "@electric-sql/pglite";
6
8
  import type { AnySoftureModule } from "@softure-ai/core";
7
9
  import { createPgliteHandle, type PgliteClientDatabase } from "./client.js";
8
- import { migrate } from "./migrations/migrator.js";
10
+ import { migrate, type AppMigrationHook, type AppMigrations } from "./migrations/migrator.js";
9
11
  import { describeProblem } from "./migrations/problems.js";
10
12
 
11
13
  export interface TestDatabase {
@@ -14,23 +16,36 @@ export interface TestDatabase {
14
16
  readonly close: () => Promise<void>;
15
17
  }
16
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
+
17
27
  const templates = new Map<string, Promise<Blob | File>>();
28
+ const hookIds = new WeakMap<AppMigrationHook, number>();
29
+ let nextHookId = 1;
18
30
 
19
31
  /**
20
32
  * A fresh, migrated, in-memory database per call. List the module under test and the modules it
21
- * depends on. Throws when a migration is invalid or fails: that is a bug in the module package.
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.
22
35
  */
23
- export async function createTestDatabase(modules: readonly AnySoftureModule[] = []): Promise<TestDatabase> {
24
- const client = new PGlite({ loadDataDir: await getTemplate(modules) });
36
+ export async function createTestDatabase(modules: readonly AnySoftureModule[] = [], options: CreateTestDatabaseOptions = {}): Promise<TestDatabase> {
37
+ const client = new PGlite({ loadDataDir: await getTemplate(modules, options.app ?? {}) });
25
38
  const handle = await createPgliteHandle(client);
26
39
  return { db: handle.db, client, close: handle.close };
27
40
  }
28
41
 
29
- function getTemplate(modules: readonly AnySoftureModule[]): Promise<Blob | File> {
30
- const key = modules.map((module) => `${module.id}@${module.migrations?.dir.href ?? "-"}`).join("|");
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}`;
31
46
  let template = templates.get(key);
32
47
  if (template === undefined) {
33
- template = buildTemplate(modules);
48
+ template = buildTemplate(modules, app);
34
49
  templates.set(key, template);
35
50
  // A failed build is not cached: the next call reports the problem again.
36
51
  template.catch(() => templates.delete(key));
@@ -38,10 +53,23 @@ function getTemplate(modules: readonly AnySoftureModule[]): Promise<Blob | File>
38
53
  return template;
39
54
  }
40
55
 
41
- async function buildTemplate(modules: readonly AnySoftureModule[]): Promise<Blob | File> {
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> {
42
70
  const handle = await createPgliteHandle(new PGlite());
43
71
  try {
44
- const result = await migrate(handle, { modules });
72
+ const result = await migrate(handle, { modules, app });
45
73
  if (!result.ok) {
46
74
  throw new Error(`createTestDatabase: migrations failed:\n${result.problems.map(describeProblem).join("\n")}`);
47
75
  }