agnes-cli 0.0.6 → 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 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:
@@ -143,6 +159,14 @@ bun agnes generate # uses config.output
143
159
  bun agnes generate --output src/db.js # override; JS output
144
160
  ```
145
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
+
146
170
  ## Options
147
171
 
148
172
  | Flag | Applies to | Meaning |
@@ -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.6",
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.6"
26
+ "agnes-library": "0.0.7"
27
27
  },
28
28
  "devDependencies": {
29
29
  "@types/bun": "latest"
@@ -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)}`);
@@ -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
@@ -14,6 +14,7 @@ export async function openDb(config: AgnesConfig): Promise<CliDb> {
14
14
  driver: config.driver,
15
15
  url: config.url,
16
16
  maxConnections: config.maxConnections,
17
+ stripTimezone: config.stripTimezone,
17
18
  },
18
19
  config.schema as never,
19
20
  );
package/src/ir.ts CHANGED
@@ -88,7 +88,8 @@ interface DslTableEntry {
88
88
  tableName: string;
89
89
  }
90
90
 
91
- export type DslSchema = Record<string, DslTableEntry>;
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>>;
92
93
 
93
94
  function fkName(table: string, column: string): string {
94
95
  return `fk_${table}_${column}`;
@@ -100,16 +101,47 @@ function colName(def: Record<string, DslField>, key: string): string | undefined
100
101
  return f && f._kind === "column" ? f.name : undefined;
101
102
  }
102
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
+
103
130
  /** Convert the user's schema DSL into the canonical DatabaseIR. */
104
131
  export function schemaToIR(schema: DslSchema): DatabaseIR {
105
- // Map DSL table key → physical name for relation resolution.
106
- const physicalName = new Map<string, string>();
107
- for (const key in schema) physicalName.set(key, schema[key]!.tableName);
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
+ }
108
141
 
109
142
  const ir: DatabaseIR = {};
110
143
 
111
- for (const key in schema) {
112
- const entry = schema[key]!;
144
+ for (const { entry } of entries) {
113
145
  const def = entry.def;
114
146
  const columns: ColumnIR[] = [];
115
147
  const indexes: IndexIR[] = [];
@@ -137,9 +169,9 @@ export function schemaToIR(schema: DslSchema): DatabaseIR {
137
169
  }
138
170
  } else if (field._kind === "one") {
139
171
  const localCol = colName(def, field.localKey);
140
- const targetDef = schema[field.target]?.def;
141
- const refCol = targetDef ? colName(targetDef, field.targetKey) : undefined;
142
- const refTable = physicalName.get(field.target);
172
+ const targetEntry = byRef.get(field.target);
173
+ const refCol = targetEntry ? colName(targetEntry.def, field.targetKey) : undefined;
174
+ const refTable = targetEntry?.tableName;
143
175
  if (localCol && refCol && refTable) {
144
176
  foreignKeys.push({
145
177
  name: fkName(entry.tableName, localCol),
package/src/print.ts CHANGED
@@ -68,11 +68,24 @@ function idKey(name: string): string {
68
68
  return name.replace(/[^A-Za-z0-9_]/g, "_");
69
69
  }
70
70
 
71
- function tableSource(t: TableIR): string {
71
+ /** Render the `table({...}, "physical")` expression. `pad` indents column lines. */
72
+ function tableBody(t: TableIR, pad: string): string {
72
73
  const physical = qualifiedName(t.name, t.schema);
73
74
  const fkByColumn = new Map(t.foreignKeys.map((fk) => [fk.column, fk]));
74
75
  const lines: string[] = [];
75
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
+
76
89
  for (const col of t.columns) {
77
90
  let expr = `${HELPER[col.type]}(${JSON.stringify(col.name)})`;
78
91
  if (col.primary) expr += ".primary()";
@@ -90,28 +103,54 @@ function tableSource(t: TableIR): string {
90
103
  : `.index(${JSON.stringify(idx.name)})`;
91
104
  }
92
105
  }
93
- lines.push(` ${col.name}: ${expr},`);
106
+ lines.push(`${pad} ${col.name}: ${expr},`);
94
107
  }
95
108
 
96
109
  for (const fk of fkByColumn.values()) {
97
- // Relation TS key: derive from referenced table (best effort).
98
- const relKey = idKey(fk.refTable);
110
+ const relKey = relKeyFor(fk);
99
111
  lines.push(
100
- ` ${relKey}: one(${JSON.stringify(fk.refTable)}, ${JSON.stringify(fk.column)}, ` +
112
+ `${pad} ${relKey}: one(${JSON.stringify(fk.refTable)}, ${JSON.stringify(fk.column)}, ` +
101
113
  `${JSON.stringify(fk.refColumn)}, ${onAction(fk.onUpdate)}, ${onAction(fk.onDelete)}),`,
102
114
  );
103
115
  }
104
116
 
105
- return ` ${idKey(physical)}: table({\n${lines.join("\n")}\n }, ${JSON.stringify(physical)}),`;
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`;
106
150
  }
107
151
 
108
152
  function schemaBlock(tables: TableIR[]): string {
109
- const body = tables
110
- .slice()
111
- .sort((a, b) => qualifiedName(a.name, a.schema).localeCompare(qualifiedName(b.name, b.schema)))
112
- .map(tableSource)
113
- .join("\n");
114
- return `export const schema = {\n${body}\n};\n`;
153
+ return schemaObject(tables);
115
154
  }
116
155
 
117
156
  /** Render a full DatabaseIR into a single schema.ts source string. */