@apso/cli 0.30.0 → 0.31.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.
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Database Import — conversion layer
3
+ *
4
+ * Turns an `IntrospectedSchema` (read from a live Postgres/Supabase database)
5
+ * into a `.apsorc`-shaped object. This is a pure function: same input always
6
+ * yields the same output and report, with no I/O — which keeps it exhaustively
7
+ * unit-testable.
8
+ *
9
+ * The reverse type map here is the inverse of `fieldTypeToColumnType`
10
+ * (src/lib/utils/field.ts). The FK-to-relationship logic mirrors how the
11
+ * platform derives FK column names in `getRelationshipIdField`
12
+ * (src/lib/utils/relationships/parse.ts) so imported relationships round-trip.
13
+ */
14
+ import { ImportReport, IntrospectedColumn, IntrospectedSchema } from "./types";
15
+ export interface ApsorcImportFieldOutput {
16
+ name: string;
17
+ type: string;
18
+ nullable?: boolean;
19
+ unique?: boolean;
20
+ default?: unknown;
21
+ index?: boolean;
22
+ values?: string[];
23
+ length?: number;
24
+ precision?: number;
25
+ scale?: number;
26
+ primary?: boolean;
27
+ }
28
+ export interface ApsorcImportEntityOutput {
29
+ name: string;
30
+ primaryKeyType?: "serial" | "uuid" | "text";
31
+ created_at?: boolean;
32
+ updated_at?: boolean;
33
+ fields?: ApsorcImportFieldOutput[];
34
+ uniques?: Array<{
35
+ fields: string[];
36
+ name?: string;
37
+ }>;
38
+ indexes?: Array<{
39
+ fields: string[];
40
+ unique?: boolean;
41
+ }>;
42
+ }
43
+ export interface ApsorcImportRelationshipOutput {
44
+ from: string;
45
+ to: string;
46
+ type: "OneToMany" | "ManyToOne" | "ManyToMany" | "OneToOne";
47
+ to_name?: string;
48
+ nullable?: boolean;
49
+ bi_directional?: boolean;
50
+ cascadeDelete?: boolean;
51
+ }
52
+ export interface ApsorcImportOutput {
53
+ version: number;
54
+ rootFolder: string;
55
+ apiType: string;
56
+ entities: ApsorcImportEntityOutput[];
57
+ relationships: ApsorcImportRelationshipOutput[];
58
+ }
59
+ interface TypeResolution {
60
+ type: string;
61
+ /** True when the mapping loses information (arrays, unknown types). */
62
+ lossy?: "array" | "defaulted";
63
+ }
64
+ /**
65
+ * Resolve a column's `.apsorc` field type from its Postgres type. Postgres
66
+ * arrays (udt_name starting with "_") collapse to `array`; user enums become
67
+ * `enum`; unknown types fall back to `text`.
68
+ */
69
+ export declare function pgTypeToApsorcType(col: IntrospectedColumn): TypeResolution;
70
+ /**
71
+ * Translate a raw Postgres default expression into an `.apsorc` default value.
72
+ * Returns `{ drop: true }` when the default cannot be represented (function
73
+ * calls, sequence/uuid generators). Auto-timestamp and sequence defaults are
74
+ * dropped silently by callers that handle them structurally.
75
+ */
76
+ export declare function parseDefault(raw: string | null, apsorcType: string): {
77
+ value?: unknown;
78
+ drop?: boolean;
79
+ };
80
+ /**
81
+ * Derive the relationship `to_name` so the platform regenerates a FK column
82
+ * matching the source column. Returns `undefined` when the default naming
83
+ * (`${camelCase(target)}Id`) already produces the source column. Sets
84
+ * `unmapped: true` when no name round-trips to the source column.
85
+ */
86
+ export declare function deriveToName(sourceColumn: string, targetTable: string): {
87
+ toName?: string;
88
+ unmapped?: boolean;
89
+ };
90
+ /**
91
+ * Convert an introspected Postgres schema into a `.apsorc`-shaped object plus a
92
+ * report of everything that could not be represented losslessly.
93
+ */
94
+ export declare function pgToApsorc(schema: IntrospectedSchema): {
95
+ apsorc: ApsorcImportOutput;
96
+ report: ImportReport;
97
+ };
98
+ /**
99
+ * Invariant guard: every emitted field type must be a recognized `.apsorc`
100
+ * column type, so code generation never silently falls back to varchar.
101
+ * Returns the list of any offending types (empty when valid).
102
+ */
103
+ export declare function findUnknownEmittedTypes(output: ApsorcImportOutput): string[];
104
+ export {};
@@ -0,0 +1,399 @@
1
+ "use strict";
2
+ /**
3
+ * Database Import — conversion layer
4
+ *
5
+ * Turns an `IntrospectedSchema` (read from a live Postgres/Supabase database)
6
+ * into a `.apsorc`-shaped object. This is a pure function: same input always
7
+ * yields the same output and report, with no I/O — which keeps it exhaustively
8
+ * unit-testable.
9
+ *
10
+ * The reverse type map here is the inverse of `fieldTypeToColumnType`
11
+ * (src/lib/utils/field.ts). The FK-to-relationship logic mirrors how the
12
+ * platform derives FK column names in `getRelationshipIdField`
13
+ * (src/lib/utils/relationships/parse.ts) so imported relationships round-trip.
14
+ */
15
+ Object.defineProperty(exports, "__esModule", { value: true });
16
+ exports.findUnknownEmittedTypes = exports.pgToApsorc = exports.deriveToName = exports.parseDefault = exports.pgTypeToApsorcType = void 0;
17
+ const casing_1 = require("../utils/casing");
18
+ const field_1 = require("../utils/field");
19
+ const relationships_1 = require("../utils/relationships");
20
+ /** Maps a Postgres udt_name to the closest `.apsorc` field type. */
21
+ const udtToApsorcType = {
22
+ int2: "smallint",
23
+ int4: "integer",
24
+ int8: "bigint",
25
+ float4: "real",
26
+ float8: "double",
27
+ numeric: "numeric",
28
+ money: "money",
29
+ bool: "boolean",
30
+ varchar: "varchar",
31
+ bpchar: "char",
32
+ text: "text",
33
+ uuid: "uuid",
34
+ json: "json",
35
+ jsonb: "jsonb",
36
+ date: "date",
37
+ timestamp: "timestamp",
38
+ timestamptz: "timestamptz",
39
+ time: "time",
40
+ timetz: "timetz",
41
+ bytea: "bytea",
42
+ xml: "xml",
43
+ inet: "inet",
44
+ cidr: "inet",
45
+ interval: "interval",
46
+ tsvector: "tsvector",
47
+ int4range: "int4range",
48
+ // PostGIS — pass-through (these are also keys in fieldTypeToColumnType)
49
+ point: "point",
50
+ geometry: "geometry",
51
+ geography: "geography",
52
+ };
53
+ const TIMESTAMP_UDTS = new Set(["timestamp", "timestamptz"]);
54
+ const AUTO_TIMESTAMP_DEFAULTS = new Set([
55
+ "now()",
56
+ "current_timestamp",
57
+ "transaction_timestamp()",
58
+ "clock_timestamp()",
59
+ ]);
60
+ /**
61
+ * Resolve a column's `.apsorc` field type from its Postgres type. Postgres
62
+ * arrays (udt_name starting with "_") collapse to `array`; user enums become
63
+ * `enum`; unknown types fall back to `text`.
64
+ */
65
+ function pgTypeToApsorcType(col) {
66
+ if (col.isEnum) {
67
+ return { type: "enum" };
68
+ }
69
+ if (col.dataType === "ARRAY" || col.udtName.startsWith("_")) {
70
+ return { type: "array", lossy: "array" };
71
+ }
72
+ const mapped = udtToApsorcType[col.udtName];
73
+ if (mapped) {
74
+ return { type: mapped };
75
+ }
76
+ return { type: "text", lossy: "defaulted" };
77
+ }
78
+ exports.pgTypeToApsorcType = pgTypeToApsorcType;
79
+ /**
80
+ * Translate a raw Postgres default expression into an `.apsorc` default value.
81
+ * Returns `{ drop: true }` when the default cannot be represented (function
82
+ * calls, sequence/uuid generators). Auto-timestamp and sequence defaults are
83
+ * dropped silently by callers that handle them structurally.
84
+ */
85
+ function parseDefault(raw, apsorcType) {
86
+ if (raw === null)
87
+ return {};
88
+ const expr = raw.trim();
89
+ const lower = expr.toLowerCase();
90
+ // Sequence / generator / function defaults can't be a literal .apsorc value.
91
+ if (lower.startsWith("nextval(") ||
92
+ lower.startsWith("gen_random_uuid(") ||
93
+ lower.startsWith("uuid_generate_v4(") ||
94
+ AUTO_TIMESTAMP_DEFAULTS.has(lower)) {
95
+ return { drop: true };
96
+ }
97
+ // Strip a trailing ::type cast, e.g. 'active'::text or 0::integer.
98
+ const withoutCast = expr.replace(/::[\s\w".[\]]+$/, "").trim();
99
+ // Quoted string literal.
100
+ if (withoutCast.startsWith("'") && withoutCast.endsWith("'")) {
101
+ const inner = withoutCast.slice(1, -1).replace(/''/g, "'");
102
+ return { value: inner };
103
+ }
104
+ if (lower === "true" || lower === "false") {
105
+ return { value: lower === "true" };
106
+ }
107
+ if (/^-?\d+(\.\d+)?$/.test(withoutCast)) {
108
+ if (apsorcType === "numeric" || apsorcType === "decimal") {
109
+ return { value: withoutCast };
110
+ }
111
+ return { value: Number(withoutCast) };
112
+ }
113
+ // Anything else (function calls, complex expressions) — drop with a warning.
114
+ return { drop: true };
115
+ }
116
+ exports.parseDefault = parseDefault;
117
+ /**
118
+ * Derive the relationship `to_name` so the platform regenerates a FK column
119
+ * matching the source column. Returns `undefined` when the default naming
120
+ * (`${camelCase(target)}Id`) already produces the source column. Sets
121
+ * `unmapped: true` when no name round-trips to the source column.
122
+ */
123
+ function deriveToName(sourceColumn, targetTable) {
124
+ // The platform always generates camelCase FK columns, while Postgres/Supabase
125
+ // columns are typically snake_case. Compare on the camelCased form so a
126
+ // snake_case source that is semantically identical isn't flagged as a mismatch.
127
+ const source = (0, casing_1.camelCase)(sourceColumn);
128
+ const defaultColumn = (0, relationships_1.getRelationshipIdField)({
129
+ name: targetTable,
130
+ type: "ManyToOne",
131
+ });
132
+ if (defaultColumn === source) {
133
+ return {};
134
+ }
135
+ // Strip a trailing Id / _id and use the remainder as the reference name.
136
+ const base = sourceColumn.replace(/(_id|Id)$/, "");
137
+ if (base && base !== sourceColumn) {
138
+ const candidate = (0, casing_1.camelCase)(base);
139
+ const roundTrip = (0, relationships_1.getRelationshipIdField)({
140
+ name: targetTable,
141
+ type: "ManyToOne",
142
+ referenceName: candidate,
143
+ });
144
+ if (roundTrip === source) {
145
+ return { toName: candidate };
146
+ }
147
+ }
148
+ // Best-effort: emit a reference name but flag that the generated FK column
149
+ // may not match the source column exactly.
150
+ return { toName: (0, casing_1.camelCase)(base || sourceColumn), unmapped: true };
151
+ }
152
+ exports.deriveToName = deriveToName;
153
+ function emptyReport(schema) {
154
+ return {
155
+ tablesImported: [],
156
+ relationships: 0,
157
+ viewsSkipped: schema.skipped.views,
158
+ systemSchemasSkipped: schema.skipped.systemSchemas,
159
+ warnings: {
160
+ arraysLossy: [],
161
+ compositePks: [],
162
+ compositeFks: [],
163
+ nonStandardPks: [],
164
+ noPrimaryKey: [],
165
+ typesDefaulted: [],
166
+ fkColumnNameUnmapped: [],
167
+ defaultsDropped: [],
168
+ joinTablesDetected: [],
169
+ },
170
+ };
171
+ }
172
+ function isJoinTable(table) {
173
+ const singleColumnFkNames = table.foreignKeys
174
+ .filter((fk) => fk.columns.length === 1)
175
+ .map((fk) => fk.columns[0]);
176
+ return (table.foreignKeys.length === 2 &&
177
+ singleColumnFkNames.length === 2 &&
178
+ table.primaryKey.length === 2 &&
179
+ table.primaryKey.every((c) => singleColumnFkNames.includes(c)));
180
+ }
181
+ /**
182
+ * Convert an introspected Postgres schema into a `.apsorc`-shaped object plus a
183
+ * report of everything that could not be represented losslessly.
184
+ */
185
+ function pgToApsorc(schema) {
186
+ const report = emptyReport(schema);
187
+ const enumLabels = new Map(schema.enums.map((e) => [e.name, e.labels]));
188
+ const entities = [];
189
+ const relationships = [];
190
+ for (const table of schema.tables) {
191
+ report.tablesImported.push(table.name);
192
+ if (isJoinTable(table)) {
193
+ report.warnings.joinTablesDetected.push(table.name);
194
+ }
195
+ const entity = { name: table.name };
196
+ // Columns covered by a single-column FK are materialized by the
197
+ // relationship, so they must not also be emitted as scalar fields.
198
+ const fkColumns = new Map();
199
+ for (const fk of table.foreignKeys) {
200
+ if (fk.columns.length === 1) {
201
+ fkColumns.set(fk.columns[0], fk);
202
+ }
203
+ else {
204
+ report.warnings.compositeFks.push(table.name);
205
+ }
206
+ }
207
+ // --- Primary key handling ---
208
+ const pk = table.primaryKey;
209
+ const pkSet = new Set(pk);
210
+ let pkAsField = false;
211
+ if (pk.length === 1) {
212
+ const pkCol = table.columns.find((c) => c.name === pk[0]);
213
+ if (pk[0] === "id" && pkCol) {
214
+ const t = pgTypeToApsorcType(pkCol).type;
215
+ if (t === "uuid")
216
+ entity.primaryKeyType = "uuid";
217
+ else if (t === "text" || t === "varchar")
218
+ entity.primaryKeyType = "text";
219
+ // integer/bigint/smallint id => default serial, omit primaryKeyType.
220
+ }
221
+ else {
222
+ // Single, non-"id" PK: emit it as a primary field.
223
+ pkAsField = true;
224
+ report.warnings.nonStandardPks.push(table.name);
225
+ }
226
+ }
227
+ else if (pk.length > 1) {
228
+ pkAsField = true;
229
+ report.warnings.compositePks.push(table.name);
230
+ }
231
+ else {
232
+ report.warnings.noPrimaryKey.push(table.name);
233
+ }
234
+ // --- created_at / updated_at detection ---
235
+ const autoTimestamps = new Set();
236
+ for (const name of ["created_at", "updated_at"]) {
237
+ const col = table.columns.find((c) => c.name === name);
238
+ if (col && TIMESTAMP_UDTS.has(col.udtName)) {
239
+ entity[name] = true;
240
+ autoTimestamps.add(name);
241
+ }
242
+ }
243
+ // --- Fields ---
244
+ const uniqueSingle = new Set();
245
+ const uniqueComposite = [];
246
+ for (const uc of table.uniqueConstraints) {
247
+ if (uc.columns.length === 1)
248
+ uniqueSingle.add(uc.columns[0]);
249
+ else
250
+ uniqueComposite.push({ fields: uc.columns, name: uc.name });
251
+ }
252
+ const indexSingle = new Set();
253
+ const indexComposite = [];
254
+ for (const idx of table.indexes) {
255
+ const cols = idx.columns;
256
+ // Skip indexes that merely back the PK or a unique constraint.
257
+ const backsPk = cols.length === pk.length && cols.every((c) => pkSet.has(c));
258
+ const backsUnique = table.uniqueConstraints.some((uc) => uc.columns.length === cols.length &&
259
+ uc.columns.every((c) => cols.includes(c)));
260
+ if (backsPk || backsUnique)
261
+ continue;
262
+ if (cols.length === 1)
263
+ indexSingle.add(cols[0]);
264
+ else
265
+ indexComposite.push({ fields: cols, unique: idx.unique });
266
+ }
267
+ const fields = [];
268
+ const sortedColumns = [...table.columns].sort((a, b) => a.ordinal - b.ordinal);
269
+ for (const col of sortedColumns) {
270
+ // Skip columns represented elsewhere.
271
+ if (fkColumns.has(col.name))
272
+ continue;
273
+ if (autoTimestamps.has(col.name))
274
+ continue;
275
+ if (pk.length === 1 && col.name === pk[0] && !pkAsField)
276
+ continue;
277
+ const resolved = pgTypeToApsorcType(col);
278
+ if (resolved.lossy === "array") {
279
+ report.warnings.arraysLossy.push(`${table.name}.${col.name}`);
280
+ }
281
+ else if (resolved.lossy === "defaulted") {
282
+ report.warnings.typesDefaulted.push({
283
+ column: `${table.name}.${col.name}`,
284
+ udt: col.udtName,
285
+ });
286
+ }
287
+ const field = {
288
+ name: col.name,
289
+ type: resolved.type,
290
+ };
291
+ if (col.nullable)
292
+ field.nullable = true;
293
+ if (uniqueSingle.has(col.name))
294
+ field.unique = true;
295
+ if (indexSingle.has(col.name))
296
+ field.index = true;
297
+ if (pkSet.has(col.name) && pkAsField)
298
+ field.primary = true;
299
+ if ((resolved.type === "varchar" || resolved.type === "char") && col.charMaxLength)
300
+ field.length = col.charMaxLength;
301
+ if (resolved.type === "numeric" || resolved.type === "decimal") {
302
+ if (col.numericPrecision)
303
+ field.precision = col.numericPrecision;
304
+ if (col.numericScale !== null)
305
+ field.scale = col.numericScale;
306
+ }
307
+ if (resolved.type === "enum") {
308
+ const labels = col.enumTypeName
309
+ ? enumLabels.get(col.enumTypeName)
310
+ : undefined;
311
+ if (labels)
312
+ field.values = labels;
313
+ }
314
+ // Defaults (skip for PK columns — those are implicit/sequence-driven).
315
+ if (!(pkSet.has(col.name) && !pkAsField)) {
316
+ const parsed = parseDefault(col.default, resolved.type);
317
+ if (parsed.drop && col.default !== null) {
318
+ report.warnings.defaultsDropped.push(`${table.name}.${col.name}`);
319
+ }
320
+ else if ("value" in parsed) {
321
+ if (resolved.type === "enum") {
322
+ // Only keep an enum default that is one of the allowed labels.
323
+ if (field.values && field.values.includes(String(parsed.value))) {
324
+ field.default = parsed.value;
325
+ }
326
+ else {
327
+ report.warnings.defaultsDropped.push(`${table.name}.${col.name}`);
328
+ }
329
+ }
330
+ else {
331
+ field.default = parsed.value;
332
+ }
333
+ }
334
+ }
335
+ fields.push(field);
336
+ }
337
+ if (fields.length > 0)
338
+ entity.fields = fields;
339
+ if (uniqueComposite.length > 0)
340
+ entity.uniques = uniqueComposite;
341
+ if (indexComposite.length > 0)
342
+ entity.indexes = indexComposite;
343
+ entities.push(entity);
344
+ // --- Foreign keys -> ManyToOne relationships ---
345
+ for (const fk of table.foreignKeys) {
346
+ if (fk.columns.length !== 1)
347
+ continue; // composite FK handled above
348
+ const sourceColumn = fk.columns[0];
349
+ const col = table.columns.find((c) => c.name === sourceColumn);
350
+ const rel = {
351
+ from: table.name,
352
+ to: fk.referencedTable,
353
+ type: "ManyToOne",
354
+ };
355
+ const { toName, unmapped } = deriveToName(sourceColumn, fk.referencedTable);
356
+ if (toName)
357
+ rel.to_name = toName;
358
+ if (unmapped) {
359
+ report.warnings.fkColumnNameUnmapped.push(`${table.name}.${sourceColumn}`);
360
+ }
361
+ if (col === null || col === void 0 ? void 0 : col.nullable)
362
+ rel.nullable = true;
363
+ if (fk.onDelete === "CASCADE")
364
+ rel.cascadeDelete = true;
365
+ relationships.push(rel);
366
+ }
367
+ }
368
+ report.relationships = relationships.length;
369
+ return {
370
+ apsorc: {
371
+ version: 2,
372
+ rootFolder: "src",
373
+ apiType: "rest",
374
+ entities,
375
+ relationships,
376
+ },
377
+ report,
378
+ };
379
+ }
380
+ exports.pgToApsorc = pgToApsorc;
381
+ /**
382
+ * Invariant guard: every emitted field type must be a recognized `.apsorc`
383
+ * column type, so code generation never silently falls back to varchar.
384
+ * Returns the list of any offending types (empty when valid).
385
+ */
386
+ function findUnknownEmittedTypes(output) {
387
+ var _a;
388
+ const unknown = [];
389
+ for (const entity of output.entities) {
390
+ for (const field of (_a = entity.fields) !== null && _a !== void 0 ? _a : []) {
391
+ if (field.type === "enum")
392
+ continue; // enum dispatched separately
393
+ if (!(field.type in field_1.fieldTypeToColumnType))
394
+ unknown.push(field.type);
395
+ }
396
+ }
397
+ return unknown;
398
+ }
399
+ exports.findUnknownEmittedTypes = findUnknownEmittedTypes;
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Database Import — intermediate model
3
+ *
4
+ * These types are the contract between the introspection layer (which reads a
5
+ * live Postgres database) and the conversion layer (which turns that into a
6
+ * `.apsorc`). Keeping the introspector behind the `Introspector` interface lets
7
+ * the conversion logic and the command be unit-tested with a fake source,
8
+ * without a real database connection.
9
+ */
10
+ /** ON DELETE referential action of a foreign key. */
11
+ export type OnDeleteAction = "CASCADE" | "RESTRICT" | "SET NULL" | "NO ACTION" | "SET DEFAULT";
12
+ /** A single introspected column. */
13
+ export interface IntrospectedColumn {
14
+ name: string;
15
+ /** pg_catalog udt_name, e.g. "int4", "varchar", "_text", "timestamptz". */
16
+ udtName: string;
17
+ /** information_schema.data_type, used to detect "USER-DEFINED" (enum) and "ARRAY". */
18
+ dataType: string;
19
+ nullable: boolean;
20
+ /** Raw default expression, e.g. "nextval('...')", "now()", "'active'::text". */
21
+ default: string | null;
22
+ charMaxLength: number | null;
23
+ numericPrecision: number | null;
24
+ numericScale: number | null;
25
+ ordinal: number;
26
+ /** True when the column's type is a user-defined enum. */
27
+ isEnum: boolean;
28
+ /** Links to IntrospectedEnum.name when isEnum is true. */
29
+ enumTypeName?: string;
30
+ }
31
+ /** A foreign key constraint (may be composite). */
32
+ export interface IntrospectedFk {
33
+ /** Local column(s) participating in the FK. */
34
+ columns: string[];
35
+ referencedTable: string;
36
+ referencedColumns: string[];
37
+ onDelete: OnDeleteAction;
38
+ }
39
+ /** A unique constraint (may be composite). */
40
+ export interface IntrospectedUnique {
41
+ name: string;
42
+ columns: string[];
43
+ }
44
+ /** A secondary index (may be composite). */
45
+ export interface IntrospectedIndex {
46
+ name: string;
47
+ columns: string[];
48
+ unique: boolean;
49
+ }
50
+ /** A user-defined enum type and its labels (in sort order). */
51
+ export interface IntrospectedEnum {
52
+ name: string;
53
+ labels: string[];
54
+ }
55
+ /** A single introspected base table. */
56
+ export interface IntrospectedTable {
57
+ name: string;
58
+ columns: IntrospectedColumn[];
59
+ /** Ordered primary-key column names ([] if the table has no primary key). */
60
+ primaryKey: string[];
61
+ foreignKeys: IntrospectedFk[];
62
+ uniqueConstraints: IntrospectedUnique[];
63
+ indexes: IntrospectedIndex[];
64
+ }
65
+ /** The full result of introspecting one Postgres schema. */
66
+ export interface IntrospectedSchema {
67
+ schema: string;
68
+ tables: IntrospectedTable[];
69
+ enums: IntrospectedEnum[];
70
+ /** Objects intentionally not imported, surfaced in the summary report. */
71
+ skipped: {
72
+ views: string[];
73
+ systemSchemas: string[];
74
+ };
75
+ }
76
+ /**
77
+ * The boundary the command depends on. The real implementation
78
+ * (PgIntrospector) connects to Postgres; tests provide a fake.
79
+ */
80
+ export interface Introspector {
81
+ introspect(schema: string): Promise<IntrospectedSchema>;
82
+ }
83
+ /**
84
+ * Everything the import couldn't represent losslessly, plus counts, used to
85
+ * print a summary the user can review before/after writing the `.apsorc`.
86
+ */
87
+ export interface ImportReport {
88
+ tablesImported: string[];
89
+ relationships: number;
90
+ viewsSkipped: string[];
91
+ systemSchemasSkipped: string[];
92
+ warnings: {
93
+ /** "table.column" whose Postgres array type was reduced to text. */
94
+ arraysLossy: string[];
95
+ /** Tables with composite primary keys (emitted as primary:true fields). */
96
+ compositePks: string[];
97
+ /** Tables with composite foreign keys (kept as scalar columns, no relationship). */
98
+ compositeFks: string[];
99
+ /** Tables whose single PK is not named "id" (emitted as a primary:true field). */
100
+ nonStandardPks: string[];
101
+ /** Tables with no primary key at all. */
102
+ noPrimaryKey: string[];
103
+ /** Columns whose type was unknown and defaulted to text. */
104
+ typesDefaulted: Array<{
105
+ column: string;
106
+ udt: string;
107
+ }>;
108
+ /** FK columns whose name could not be round-tripped to a relationship name. */
109
+ fkColumnNameUnmapped: string[];
110
+ /** "table.column" defaults that were dropped (unsupported expressions). */
111
+ defaultsDropped: string[];
112
+ /** Tables that look like pure join tables (candidates for manual ManyToMany). */
113
+ joinTablesDetected: string[];
114
+ };
115
+ }
@@ -0,0 +1,11 @@
1
+ "use strict";
2
+ /**
3
+ * Database Import — intermediate model
4
+ *
5
+ * These types are the contract between the introspection layer (which reads a
6
+ * live Postgres database) and the conversion layer (which turns that into a
7
+ * `.apsorc`). Keeping the introspector behind the `Introspector` interface lets
8
+ * the conversion logic and the command be unit-tested with a fake source,
9
+ * without a real database connection.
10
+ */
11
+ Object.defineProperty(exports, "__esModule", { value: true });