agnes-cli 0.0.5 → 0.0.7
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 +29 -0
- package/agnes.config.example.ts +3 -0
- package/package.json +2 -2
- package/src/commands/generate.ts +2 -0
- package/src/commands/init.ts +4 -0
- package/src/config.ts +5 -0
- package/src/db.ts +1 -0
- package/src/diff.ts +6 -1
- package/src/generate.ts +18 -1
- package/src/introspect.ts +13 -7
- package/src/ir.ts +48 -10
- package/src/print.ts +93 -17
package/README.md
CHANGED
|
@@ -27,6 +27,7 @@ export default defineConfig({
|
|
|
27
27
|
url: process.env.DATABASE_URL!,
|
|
28
28
|
schema,
|
|
29
29
|
|
|
30
|
+
stripTimezone: false, // return timestamps as naive ISO (no tz offset)
|
|
30
31
|
schemas: ["public"], // PostgreSQL schemas to introspect (default: public)
|
|
31
32
|
pullMode: "singlefile", // "singlefile" | "multifile"
|
|
32
33
|
out: "./schema.ts", // where `pull` writes (a directory in multifile mode)
|
|
@@ -81,6 +82,21 @@ outside `public` get qualified physical names (`table(def, "auth.users")`):
|
|
|
81
82
|
schemas: ["public", "auth", "billing"],
|
|
82
83
|
```
|
|
83
84
|
|
|
85
|
+
Non-`public` tables are grouped one level deep by schema; `public` tables stay
|
|
86
|
+
at the top level. The group flattens to a dotted key you select by:
|
|
87
|
+
|
|
88
|
+
```ts
|
|
89
|
+
export const schema = {
|
|
90
|
+
users: table({ /* … */ }, "users"), // public → top level
|
|
91
|
+
legislativo: { // grouped by schema
|
|
92
|
+
etapas: table({ /* … */ }, "legislativo.etapas"),
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
db.select("users");
|
|
97
|
+
db.select("legislativo.etapas"); // dotted key = <schema>.<table>
|
|
98
|
+
```
|
|
99
|
+
|
|
84
100
|
With `pullMode: "multifile"`, `pull` writes one file per DB schema plus an
|
|
85
101
|
`index.ts` that merges them into a single `schema` export — `out` is then a
|
|
86
102
|
directory:
|
|
@@ -92,6 +108,11 @@ schema/
|
|
|
92
108
|
index.ts ← import { schema } from "./schema"
|
|
93
109
|
```
|
|
94
110
|
|
|
111
|
+
**Defaults & auto-increment.** `pull` renders defaults in the column's type
|
|
112
|
+
(`bool("x").default(true)`, `int("n").default(1)` — not strings). Serial /
|
|
113
|
+
identity / `AUTO_INCREMENT` columns become `.autoincrement()`, and SQL
|
|
114
|
+
expressions that can't be a literal (e.g. `CURRENT_TIMESTAMP`) are omitted.
|
|
115
|
+
|
|
95
116
|
### `migrate` — versioned SQL files
|
|
96
117
|
|
|
97
118
|
1. Diffs schema vs DB and, if there's drift, writes a timestamped file to
|
|
@@ -138,6 +159,14 @@ bun agnes generate # uses config.output
|
|
|
138
159
|
bun agnes generate --output src/db.js # override; JS output
|
|
139
160
|
```
|
|
140
161
|
|
|
162
|
+
## Timezones
|
|
163
|
+
|
|
164
|
+
Set `stripTimezone: true` (config, or `stripTimezone` on `AgnesClient.create`)
|
|
165
|
+
to get temporal columns back as **naive ISO strings** with no offset —
|
|
166
|
+
`"2026-07-01T12:00:00"` instead of `"2026-07-01T12:00:00+00:00"`. This sidesteps
|
|
167
|
+
the classic footgun where `new Date(value)` shifts the wall-clock time by the
|
|
168
|
+
runtime's local offset. Postgres only; MySQL/SQLite values are already naive.
|
|
169
|
+
|
|
141
170
|
## Options
|
|
142
171
|
|
|
143
172
|
| Flag | Applies to | Meaning |
|
package/agnes.config.example.ts
CHANGED
|
@@ -38,6 +38,9 @@ export default defineConfig({
|
|
|
38
38
|
url: process.env.DATABASE_URL ?? "postgres://user:pass@localhost/db",
|
|
39
39
|
schema,
|
|
40
40
|
|
|
41
|
+
// Return timestamps as naive ISO (no offset) to avoid the JS Date tz shift.
|
|
42
|
+
stripTimezone: false,
|
|
43
|
+
|
|
41
44
|
// PostgreSQL schemas to introspect (default: ["public"]). Tables outside the
|
|
42
45
|
// default schema get qualified physical names, e.g. table(def, "auth.users").
|
|
43
46
|
schemas: ["public"],
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agnes-cli",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.7",
|
|
4
4
|
"description": "Schema migration CLI for agnes-rs — push, pull and migrate from schema.ts",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
"test": "bun test"
|
|
24
24
|
},
|
|
25
25
|
"dependencies": {
|
|
26
|
-
"agnes-library": "0.0.
|
|
26
|
+
"agnes-library": "0.0.7"
|
|
27
27
|
},
|
|
28
28
|
"devDependencies": {
|
|
29
29
|
"@types/bun": "latest"
|
package/src/commands/generate.ts
CHANGED
|
@@ -29,6 +29,8 @@ function renderConfigObject(config: AgnesConfig): string {
|
|
|
29
29
|
];
|
|
30
30
|
if (config.maxConnections !== undefined)
|
|
31
31
|
lines.push(` maxConnections: ${config.maxConnections},`);
|
|
32
|
+
if (config.stripTimezone !== undefined)
|
|
33
|
+
lines.push(` stripTimezone: ${config.stripTimezone},`);
|
|
32
34
|
if (config.cache) {
|
|
33
35
|
const parts = [`enabled: ${config.cache.enabled}`];
|
|
34
36
|
if (config.cache.walPath !== undefined) parts.push(`walPath: ${JSON.stringify(config.cache.walPath)}`);
|
package/src/commands/init.ts
CHANGED
|
@@ -20,6 +20,10 @@ export default defineConfig({
|
|
|
20
20
|
// The schema object exported from your schema.ts.
|
|
21
21
|
schema,
|
|
22
22
|
|
|
23
|
+
// Return timestamps without a timezone offset (naive ISO, e.g. 2026-07-01T12:00:00).
|
|
24
|
+
// Avoids the JS Date tz-shift footgun (the classic Prisma problem). Postgres only.
|
|
25
|
+
stripTimezone: false,
|
|
26
|
+
|
|
23
27
|
// ── pull / push ──────────────────────────────────────────────────────────
|
|
24
28
|
// PostgreSQL schemas to introspect. "public" is the default; add more to
|
|
25
29
|
// pull/push tables that live outside it (they get qualified names, "auth.users").
|
package/src/config.ts
CHANGED
|
@@ -32,6 +32,11 @@ export interface AgnesConfig {
|
|
|
32
32
|
/** Directory for versioned migration files (default: ./migrations). */
|
|
33
33
|
migrationsDir?: string;
|
|
34
34
|
maxConnections?: number;
|
|
35
|
+
/**
|
|
36
|
+
* Return temporal values without a timezone offset (naive wall-clock ISO) —
|
|
37
|
+
* avoids the JS `Date` tz-shift footgun. Postgres only; defaults to false.
|
|
38
|
+
*/
|
|
39
|
+
stripTimezone?: boolean;
|
|
35
40
|
/** Cache config baked into the client generated by `agnes generate`. */
|
|
36
41
|
cache?: CacheConfig;
|
|
37
42
|
/**
|
package/src/db.ts
CHANGED
package/src/diff.ts
CHANGED
|
@@ -22,7 +22,12 @@ function byName<T extends { name: string }>(items: T[]): Map<string, T> {
|
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
function columnChanged(a: ColumnIR, b: ColumnIR): boolean {
|
|
25
|
-
return
|
|
25
|
+
return (
|
|
26
|
+
a.type !== b.type ||
|
|
27
|
+
a.nullable !== b.nullable ||
|
|
28
|
+
a.primary !== b.primary ||
|
|
29
|
+
!!a.autoincrement !== !!b.autoincrement
|
|
30
|
+
);
|
|
26
31
|
}
|
|
27
32
|
|
|
28
33
|
/**
|
package/src/generate.ts
CHANGED
|
@@ -3,7 +3,24 @@ import { qualifiedName, type ColumnIR, type ForeignKeyIR, type TableIR } from ".
|
|
|
3
3
|
import type { Operation } from "./diff";
|
|
4
4
|
|
|
5
5
|
function columnDef(dialect: Dialect, col: ColumnIR): string {
|
|
6
|
-
|
|
6
|
+
const name = ident(dialect, col.name);
|
|
7
|
+
const type = physicalType(dialect, col.type);
|
|
8
|
+
|
|
9
|
+
if (col.autoincrement) {
|
|
10
|
+
// SQLite only auto-increments an INTEGER PRIMARY KEY.
|
|
11
|
+
if (dialect === "sqlite") return `${name} INTEGER PRIMARY KEY AUTOINCREMENT`;
|
|
12
|
+
if (dialect === "postgres") {
|
|
13
|
+
let s = `${name} ${type} GENERATED BY DEFAULT AS IDENTITY`;
|
|
14
|
+
if (col.primary) s += " PRIMARY KEY";
|
|
15
|
+
return s;
|
|
16
|
+
}
|
|
17
|
+
// mysql
|
|
18
|
+
let s = `${name} ${type} NOT NULL AUTO_INCREMENT`;
|
|
19
|
+
if (col.primary) s += " PRIMARY KEY";
|
|
20
|
+
return s;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
let s = `${name} ${type}`;
|
|
7
24
|
if (col.primary) s += " PRIMARY KEY";
|
|
8
25
|
if (!col.nullable && !col.primary) s += " NOT NULL";
|
|
9
26
|
if (col.default !== undefined) s += ` DEFAULT ${defaultLiteral(dialect, col.default)}`;
|
package/src/introspect.ts
CHANGED
|
@@ -64,13 +64,19 @@ async function introspectPostgres(db: QueryClient, schemas: string[]): Promise<D
|
|
|
64
64
|
);
|
|
65
65
|
const pkSet = new Set(pks.map((r) => str(r.column_name)));
|
|
66
66
|
|
|
67
|
-
const columns: ColumnIR[] = cols.map((c) =>
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
67
|
+
const columns: ColumnIR[] = cols.map((c) => {
|
|
68
|
+
const rawDefault = c.column_default == null ? undefined : str(c.column_default);
|
|
69
|
+
// serial/identity columns default to nextval(...) — surface them as autoincrement.
|
|
70
|
+
const autoincrement = rawDefault != null && /nextval\(/i.test(rawDefault);
|
|
71
|
+
return {
|
|
72
|
+
name: str(c.column_name),
|
|
73
|
+
type: logicalType(str(c.data_type)),
|
|
74
|
+
nullable: str(c.is_nullable) === "YES",
|
|
75
|
+
primary: pkSet.has(str(c.column_name)),
|
|
76
|
+
default: autoincrement ? undefined : rawDefault,
|
|
77
|
+
autoincrement,
|
|
78
|
+
};
|
|
79
|
+
});
|
|
74
80
|
|
|
75
81
|
const idxRows = await db.query<Row>(
|
|
76
82
|
`SELECT i.relname AS index_name, a.attname AS column_name,
|
package/src/ir.ts
CHANGED
|
@@ -11,6 +11,8 @@ export interface ColumnIR {
|
|
|
11
11
|
primary: boolean;
|
|
12
12
|
/** default value as a literal; undefined = no default. */
|
|
13
13
|
default?: unknown;
|
|
14
|
+
/** DB assigns the value (serial/identity/AUTO_INCREMENT). Mutually exclusive with `default`. */
|
|
15
|
+
autoincrement?: boolean;
|
|
14
16
|
}
|
|
15
17
|
|
|
16
18
|
export interface IndexIR {
|
|
@@ -59,6 +61,7 @@ interface DslColumn {
|
|
|
59
61
|
primary?: boolean;
|
|
60
62
|
nullable?: boolean;
|
|
61
63
|
default?: unknown;
|
|
64
|
+
autoincrement?: boolean;
|
|
62
65
|
index?: { name: string; unique: boolean };
|
|
63
66
|
};
|
|
64
67
|
}
|
|
@@ -85,7 +88,8 @@ interface DslTableEntry {
|
|
|
85
88
|
tableName: string;
|
|
86
89
|
}
|
|
87
90
|
|
|
88
|
-
|
|
91
|
+
/** Entries may sit at the top level or be grouped one level deep by DB schema. */
|
|
92
|
+
export type DslSchema = Record<string, DslTableEntry | Record<string, DslTableEntry>>;
|
|
89
93
|
|
|
90
94
|
function fkName(table: string, column: string): string {
|
|
91
95
|
return `fk_${table}_${column}`;
|
|
@@ -97,16 +101,47 @@ function colName(def: Record<string, DslField>, key: string): string | undefined
|
|
|
97
101
|
return f && f._kind === "column" ? f.name : undefined;
|
|
98
102
|
}
|
|
99
103
|
|
|
104
|
+
function isTableEntry(v: unknown): v is DslTableEntry {
|
|
105
|
+
return (
|
|
106
|
+
typeof v === "object" &&
|
|
107
|
+
v !== null &&
|
|
108
|
+
typeof (v as DslTableEntry).tableName === "string" &&
|
|
109
|
+
typeof (v as DslTableEntry).def === "object"
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Flatten nested groups; pair each entry with its path key ("grp.tbl" or "tbl"). */
|
|
114
|
+
function flattenDsl(schema: DslSchema): { key: string; entry: DslTableEntry }[] {
|
|
115
|
+
const out: { key: string; entry: DslTableEntry }[] = [];
|
|
116
|
+
for (const k in schema) {
|
|
117
|
+
const v = schema[k];
|
|
118
|
+
if (isTableEntry(v)) {
|
|
119
|
+
out.push({ key: k, entry: v });
|
|
120
|
+
} else if (v && typeof v === "object") {
|
|
121
|
+
for (const p in v as Record<string, DslTableEntry>) {
|
|
122
|
+
const e = (v as Record<string, DslTableEntry>)[p];
|
|
123
|
+
if (isTableEntry(e)) out.push({ key: `${k}.${p}`, entry: e });
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return out;
|
|
128
|
+
}
|
|
129
|
+
|
|
100
130
|
/** Convert the user's schema DSL into the canonical DatabaseIR. */
|
|
101
131
|
export function schemaToIR(schema: DslSchema): DatabaseIR {
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
132
|
+
const entries = flattenDsl(schema);
|
|
133
|
+
|
|
134
|
+
// A relation target may reference either the TS path key or the physical
|
|
135
|
+
// table name — index by both so both hand-written and pulled schemas resolve.
|
|
136
|
+
const byRef = new Map<string, DslTableEntry>();
|
|
137
|
+
for (const { key, entry } of entries) {
|
|
138
|
+
byRef.set(key, entry);
|
|
139
|
+
byRef.set(entry.tableName, entry);
|
|
140
|
+
}
|
|
105
141
|
|
|
106
142
|
const ir: DatabaseIR = {};
|
|
107
143
|
|
|
108
|
-
for (const
|
|
109
|
-
const entry = schema[key]!;
|
|
144
|
+
for (const { entry } of entries) {
|
|
110
145
|
const def = entry.def;
|
|
111
146
|
const columns: ColumnIR[] = [];
|
|
112
147
|
const indexes: IndexIR[] = [];
|
|
@@ -115,12 +150,15 @@ export function schemaToIR(schema: DslSchema): DatabaseIR {
|
|
|
115
150
|
for (const fieldKey in def) {
|
|
116
151
|
const field = def[fieldKey]!;
|
|
117
152
|
if (field._kind === "column") {
|
|
153
|
+
const autoincrement = field.flags.autoincrement ?? false;
|
|
118
154
|
columns.push({
|
|
119
155
|
name: field.name,
|
|
120
156
|
type: field.type,
|
|
121
157
|
nullable: field.flags.nullable ?? false,
|
|
122
158
|
primary: field.flags.primary ?? false,
|
|
123
|
-
default
|
|
159
|
+
// Auto-increment supplies the value; never emit an explicit default too.
|
|
160
|
+
default: autoincrement ? undefined : field.flags.default,
|
|
161
|
+
autoincrement,
|
|
124
162
|
});
|
|
125
163
|
if (field.flags.index) {
|
|
126
164
|
indexes.push({
|
|
@@ -131,9 +169,9 @@ export function schemaToIR(schema: DslSchema): DatabaseIR {
|
|
|
131
169
|
}
|
|
132
170
|
} else if (field._kind === "one") {
|
|
133
171
|
const localCol = colName(def, field.localKey);
|
|
134
|
-
const
|
|
135
|
-
const refCol =
|
|
136
|
-
const refTable =
|
|
172
|
+
const targetEntry = byRef.get(field.target);
|
|
173
|
+
const refCol = targetEntry ? colName(targetEntry.def, field.targetKey) : undefined;
|
|
174
|
+
const refTable = targetEntry?.tableName;
|
|
137
175
|
if (localCol && refCol && refTable) {
|
|
138
176
|
foreignKeys.push({
|
|
139
177
|
name: fkName(entry.tableName, localCol),
|
package/src/print.ts
CHANGED
|
@@ -25,10 +25,42 @@ function onAction(rule: string): string {
|
|
|
25
25
|
return ON_ACTION[rule.toUpperCase()] ?? "OnAction.None";
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
28
|
+
/**
|
|
29
|
+
* Render a raw DB default into the `.default(...)` argument for a given column
|
|
30
|
+
* type, or `null` to omit it. Introspection hands us raw strings ("true", "1",
|
|
31
|
+
* "'x'::text", "CURRENT_TIMESTAMP"); we coerce them to the type the DSL expects
|
|
32
|
+
* and drop SQL expressions we can't represent as a literal.
|
|
33
|
+
*/
|
|
34
|
+
function renderDefault(type: ColumnType, raw: unknown): string | null {
|
|
35
|
+
if (raw === undefined || raw === null) return null;
|
|
36
|
+
const s = String(raw).trim();
|
|
37
|
+
// Strip a trailing type cast: 'x'::text, 5::integer, 'a'::character varying.
|
|
38
|
+
const cast = s.replace(/::[\w\s"]+$/, "").trim();
|
|
39
|
+
const quoted = cast.match(/^'([\s\S]*)'$/);
|
|
40
|
+
const inner = quoted ? quoted[1]!.replace(/''/g, "'") : undefined;
|
|
41
|
+
const scalar = inner ?? cast;
|
|
42
|
+
|
|
43
|
+
switch (type) {
|
|
44
|
+
case "bool": {
|
|
45
|
+
const t = scalar.toLowerCase();
|
|
46
|
+
if (t === "true" || t === "t" || t === "1") return "true";
|
|
47
|
+
if (t === "false" || t === "f" || t === "0") return "false";
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
case "int":
|
|
51
|
+
case "float": {
|
|
52
|
+
const n = Number(scalar);
|
|
53
|
+
return scalar !== "" && Number.isFinite(n) ? String(n) : null;
|
|
54
|
+
}
|
|
55
|
+
case "bigint":
|
|
56
|
+
return /^-?\d+$/.test(scalar) ? `${scalar}n` : null;
|
|
57
|
+
case "text":
|
|
58
|
+
case "json":
|
|
59
|
+
// Only string literals; skip expressions (CURRENT_TIMESTAMP, now(), …).
|
|
60
|
+
return inner !== undefined ? JSON.stringify(inner) : null;
|
|
61
|
+
default:
|
|
62
|
+
return null; // bytes
|
|
63
|
+
}
|
|
32
64
|
}
|
|
33
65
|
|
|
34
66
|
/** A qualified name → a valid JS identifier (for the `schema` object keys / relation keys). */
|
|
@@ -36,16 +68,34 @@ function idKey(name: string): string {
|
|
|
36
68
|
return name.replace(/[^A-Za-z0-9_]/g, "_");
|
|
37
69
|
}
|
|
38
70
|
|
|
39
|
-
|
|
71
|
+
/** Render the `table({...}, "physical")` expression. `pad` indents column lines. */
|
|
72
|
+
function tableBody(t: TableIR, pad: string): string {
|
|
40
73
|
const physical = qualifiedName(t.name, t.schema);
|
|
41
74
|
const fkByColumn = new Map(t.foreignKeys.map((fk) => [fk.column, fk]));
|
|
42
75
|
const lines: string[] = [];
|
|
43
76
|
|
|
77
|
+
// Relation keys must be unique and not collide with column keys. Derive from
|
|
78
|
+
// the local FK column (dropping a trailing _id) so two FKs to the same table
|
|
79
|
+
// (e.g. decisor_etapa_sim_id / _nao_id) get distinct, readable keys.
|
|
80
|
+
const usedKeys = new Set(t.columns.map((c) => c.name));
|
|
81
|
+
const relKeyFor = (fk: { column: string; refTable: string }): string => {
|
|
82
|
+
const base = idKey(fk.column.replace(/_?id$/i, "")) || idKey(fk.refTable);
|
|
83
|
+
let key = base;
|
|
84
|
+
for (let n = 2; usedKeys.has(key); n++) key = `${base}_${n}`;
|
|
85
|
+
usedKeys.add(key);
|
|
86
|
+
return key;
|
|
87
|
+
};
|
|
88
|
+
|
|
44
89
|
for (const col of t.columns) {
|
|
45
90
|
let expr = `${HELPER[col.type]}(${JSON.stringify(col.name)})`;
|
|
46
91
|
if (col.primary) expr += ".primary()";
|
|
47
92
|
else if (col.nullable) expr += ".nullable()";
|
|
48
|
-
if (col.
|
|
93
|
+
if (col.autoincrement) {
|
|
94
|
+
expr += ".autoincrement()";
|
|
95
|
+
} else {
|
|
96
|
+
const d = renderDefault(col.type, col.default);
|
|
97
|
+
if (d !== null) expr += `.default(${d})`;
|
|
98
|
+
}
|
|
49
99
|
for (const idx of t.indexes) {
|
|
50
100
|
if (idx.columns.length === 1 && idx.columns[0] === col.name) {
|
|
51
101
|
expr += idx.unique
|
|
@@ -53,28 +103,54 @@ function tableSource(t: TableIR): string {
|
|
|
53
103
|
: `.index(${JSON.stringify(idx.name)})`;
|
|
54
104
|
}
|
|
55
105
|
}
|
|
56
|
-
lines.push(
|
|
106
|
+
lines.push(`${pad} ${col.name}: ${expr},`);
|
|
57
107
|
}
|
|
58
108
|
|
|
59
109
|
for (const fk of fkByColumn.values()) {
|
|
60
|
-
|
|
61
|
-
const relKey = idKey(fk.refTable);
|
|
110
|
+
const relKey = relKeyFor(fk);
|
|
62
111
|
lines.push(
|
|
63
|
-
|
|
112
|
+
`${pad} ${relKey}: one(${JSON.stringify(fk.refTable)}, ${JSON.stringify(fk.column)}, ` +
|
|
64
113
|
`${JSON.stringify(fk.refColumn)}, ${onAction(fk.onUpdate)}, ${onAction(fk.onDelete)}),`,
|
|
65
114
|
);
|
|
66
115
|
}
|
|
67
116
|
|
|
68
|
-
return `
|
|
117
|
+
return `table({\n${lines.join("\n")}\n${pad}}, ${JSON.stringify(physical)})`;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const byQualified = (a: TableIR, b: TableIR) =>
|
|
121
|
+
qualifiedName(a.name, a.schema).localeCompare(qualifiedName(b.name, b.schema));
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Render the `{ ... }` body of `export const schema`. Default-schema tables sit
|
|
125
|
+
* at the top level (`users: table(...)`); tables from other schemas are grouped
|
|
126
|
+
* one level deep (`legislativo: { etapas: table(...) }`), which flattens to the
|
|
127
|
+
* dotted key `legislativo.etapas` — matching each table's physical name.
|
|
128
|
+
*/
|
|
129
|
+
function schemaObject(tables: TableIR[]): string {
|
|
130
|
+
const top: TableIR[] = [];
|
|
131
|
+
const groups = new Map<string, TableIR[]>();
|
|
132
|
+
for (const t of tables) {
|
|
133
|
+
if (!t.schema || t.schema === "public") top.push(t);
|
|
134
|
+
else (groups.get(t.schema) ?? groups.set(t.schema, []).get(t.schema)!).push(t);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const lines: string[] = [];
|
|
138
|
+
for (const t of top.sort(byQualified)) {
|
|
139
|
+
lines.push(` ${idKey(t.name)}: ${tableBody(t, " ")},`);
|
|
140
|
+
}
|
|
141
|
+
for (const schema of [...groups.keys()].sort()) {
|
|
142
|
+
const inner = groups
|
|
143
|
+
.get(schema)!
|
|
144
|
+
.sort(byQualified)
|
|
145
|
+
.map((t) => ` ${idKey(t.name)}: ${tableBody(t, " ")},`)
|
|
146
|
+
.join("\n");
|
|
147
|
+
lines.push(` ${idKey(schema)}: {\n${inner}\n },`);
|
|
148
|
+
}
|
|
149
|
+
return `export const schema = {\n${lines.join("\n")}\n};\n`;
|
|
69
150
|
}
|
|
70
151
|
|
|
71
152
|
function schemaBlock(tables: TableIR[]): string {
|
|
72
|
-
|
|
73
|
-
.slice()
|
|
74
|
-
.sort((a, b) => qualifiedName(a.name, a.schema).localeCompare(qualifiedName(b.name, b.schema)))
|
|
75
|
-
.map(tableSource)
|
|
76
|
-
.join("\n");
|
|
77
|
-
return `export const schema = {\n${body}\n};\n`;
|
|
153
|
+
return schemaObject(tables);
|
|
78
154
|
}
|
|
79
155
|
|
|
80
156
|
/** Render a full DatabaseIR into a single schema.ts source string. */
|