turbine-orm 0.33.0 → 0.34.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.
@@ -4527,7 +4527,18 @@ class QueryInterface {
4527
4527
  const extract = this.dialect.buildJsonPathExtract(`${prefix}${this.q(col)}`, this.p(params.length));
4528
4528
  const lhs = spec.type === 'numeric' ? this.castJsonNumeric(extract) : extract;
4529
4529
  const dir = spec.direction?.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
4530
- return `${lhs} ${dir}${this.nullsSuffix(spec.nulls)}`;
4530
+ // Rows whose document lacks the path extract to NULL. Without a nulls
4531
+ // clause, Postgres DESC defaults to NULLS FIRST, which both diverges from
4532
+ // pick-row ordering (NULLS LAST both directions since 0.33) and from
4533
+ // engines whose path ordering is nulls-last in both directions. Default to
4534
+ // NULLS LAST in BOTH directions unless the caller set `nulls` explicitly;
4535
+ // the grammar gate matches nullsSuffix.
4536
+ const nullsSql = spec.nulls
4537
+ ? this.nullsSuffix(spec.nulls)
4538
+ : this.dialect.name === 'postgresql' || this.dialect.name === 'sqlite'
4539
+ ? ' NULLS LAST'
4540
+ : '';
4541
+ return `${lhs} ${dir}${nullsSql}`;
4531
4542
  }
4532
4543
  /**
4533
4544
  * Compile a relation ordering term. For a to-many relation the only allowed
@@ -25,6 +25,7 @@
25
25
  */
26
26
  Object.defineProperty(exports, "__esModule", { value: true });
27
27
  exports.camelToSnake = exports.column = exports.ColumnBuilder = void 0;
28
+ exports.isDocFieldIndexDef = isDocFieldIndexDef;
28
29
  exports.defineSchema = defineSchema;
29
30
  exports.table = table;
30
31
  exports.applyManyToManyRelations = applyManyToManyRelations;
@@ -102,6 +103,10 @@ function resolveColumn(def) {
102
103
  check: def.check ?? null,
103
104
  };
104
105
  }
106
+ /** Type guard: is this index declaration a doc-field expression index? */
107
+ function isDocFieldIndexDef(idx) {
108
+ return 'docField' in idx && typeof idx.docField === 'string';
109
+ }
105
110
  /** Check if a value is a TableDef (from legacy table() builder) */
106
111
  function isTableDef(v) {
107
112
  return typeof v === 'object' && v !== null && 'columns' in v && 'name' in v;
@@ -145,7 +150,17 @@ function defineSchema(input, options) {
145
150
  let pk;
146
151
  let m2m;
147
152
  let checks;
153
+ let indexes;
148
154
  for (const [fieldName, def] of Object.entries(raw)) {
155
+ if (fieldName === 'indexes') {
156
+ if (def !== undefined) {
157
+ if (!Array.isArray(def)) {
158
+ throw new Error(`Table "${accessor}": "indexes" must be an array of index declarations`);
159
+ }
160
+ indexes = def;
161
+ }
162
+ continue;
163
+ }
149
164
  if (fieldName === 'manyToMany') {
150
165
  if (def !== undefined) {
151
166
  if (!Array.isArray(def)) {
@@ -205,6 +220,7 @@ function defineSchema(input, options) {
205
220
  ...(pk && pk.length > 0 ? { primaryKey: pk } : {}),
206
221
  ...(m2m && m2m.length > 0 ? { manyToMany: m2m } : {}),
207
222
  ...(checks && checks.length > 0 ? { checks } : {}),
223
+ ...(indexes && indexes.length > 0 ? { indexes } : {}),
208
224
  };
209
225
  }
210
226
  }
@@ -25,10 +25,12 @@
25
25
  * the same conservative auto-`manyToMany` treatment as introspection.
26
26
  * - Explicit `manyToMany` declarations on the SchemaDef are merged via
27
27
  * {@link applyManyToManyRelations} (additive, never clobbering).
28
- * - `indexes` is always `[]` SchemaDef cannot express indexes, and an
29
- * empty list keeps `schemaHasIndexInfo()` false so the index advisor
30
- * and the dev-mode missing-index warning stay silent instead of
31
- * producing blanket false positives.
28
+ * - `indexes` carries any declared `TableDef.indexes` (plain column and/or
29
+ * PowDB doc-field expression indexes); a table with none declared gets
30
+ * `[]`, which keeps `schemaHasIndexInfo()` false so the index advisor and
31
+ * the dev-mode missing-index warning stay silent instead of producing
32
+ * blanket false positives. Doc-field (docPath) indexes are ignored by the
33
+ * advisor, so a doc-only index set never flips `schemaHasIndexInfo()`.
32
34
  *
33
35
  * @example
34
36
  * ```ts
@@ -45,6 +47,7 @@
45
47
  */
46
48
  Object.defineProperty(exports, "__esModule", { value: true });
47
49
  exports.schemaDefToMetadata = schemaDefToMetadata;
50
+ const errors_js_1 = require("./errors.js");
48
51
  const introspect_js_1 = require("./introspect.js");
49
52
  const schema_js_1 = require("./schema.js");
50
53
  const schema_builder_js_1 = require("./schema-builder.js");
@@ -97,6 +100,67 @@ function resolveColumnName(raw, target) {
97
100
  return (0, schema_js_1.camelToSnake)(raw);
98
101
  }
99
102
  // ---------------------------------------------------------------------------
103
+ // Index declarations → IndexMetadata
104
+ // ---------------------------------------------------------------------------
105
+ /**
106
+ * Render a doc-field JSON path into an illustrative PowQL fragment for the
107
+ * `IndexMetadata.definition` field (debuggability only; the authoritative,
108
+ * lexer-exact emission lives in `powqlSchemaDDL`). String segments are shown
109
+ * double-quoted, integer array indexes bare.
110
+ */
111
+ function docPathFragment(column, path) {
112
+ const segs = path.map((s) => (typeof s === 'number' ? `->${s}` : `->"${s}"`)).join('');
113
+ return `(.${column}${segs})`;
114
+ }
115
+ /**
116
+ * Convert a table's {@link SchemaIndexDef} list into {@link IndexMetadata}.
117
+ * A doc-field index carries `docPath` and `columns: [<json column>]`; a plain
118
+ * column index carries its snake_case column list and no `docPath`. Names are
119
+ * auto-derived (`<table>_<cols>_idx`) when not supplied.
120
+ */
121
+ function mapIndexes(tableDef, declared) {
122
+ if (!declared || declared.length === 0)
123
+ return [];
124
+ const out = [];
125
+ for (const idx of declared) {
126
+ if ((0, schema_builder_js_1.isDocFieldIndexDef)(idx)) {
127
+ const column = (0, schema_js_1.camelToSnake)(idx.docField);
128
+ const segPart = idx.path.map((s) => (typeof s === 'number' ? String(s) : s)).join('_');
129
+ const name = idx.name ?? `${tableDef.name}_${column}_${segPart}_idx`;
130
+ // Validate numeric (array-index) segments up front: PowDB rejects a
131
+ // negative / fractional / NaN JSON-path index at migration time with an
132
+ // opaque parse error, so fail here with a typed ValidationError naming the
133
+ // index instead of emitting malformed PowQL later.
134
+ for (const seg of idx.path) {
135
+ if (typeof seg === 'number' && (!Number.isInteger(seg) || seg < 0)) {
136
+ throw new errors_js_1.ValidationError(`[turbine] Doc-field index "${name}" on "${tableDef.name}": array-index path segment ${seg} must be a ` +
137
+ 'non-negative integer (a JSON array index). Use a string for an object key.');
138
+ }
139
+ }
140
+ out.push({
141
+ name,
142
+ columns: [column],
143
+ unique: idx.unique ?? false,
144
+ definition: `${idx.unique ? 'unique ' : 'index '}${docPathFragment(column, idx.path)}`,
145
+ docPath: [...idx.path],
146
+ declared: true,
147
+ });
148
+ }
149
+ else {
150
+ const columns = idx.columns.map(schema_js_1.camelToSnake);
151
+ const name = idx.name ?? `${tableDef.name}_${columns.join('_')}_idx`;
152
+ out.push({
153
+ name,
154
+ columns,
155
+ unique: idx.unique ?? false,
156
+ definition: `${idx.unique ? 'unique ' : 'index '}(${columns.join(', ')})`,
157
+ declared: true,
158
+ });
159
+ }
160
+ }
161
+ return out;
162
+ }
163
+ // ---------------------------------------------------------------------------
100
164
  // The converter
101
165
  // ---------------------------------------------------------------------------
102
166
  /**
@@ -122,10 +186,14 @@ function resolveColumnName(raw, target) {
122
186
  * - Explicit `manyToMany` declarations → merged additively.
123
187
  * - Schema-level `enums`.
124
188
  *
189
+ * What maps (continued):
190
+ * - `indexes` → declared `TableDef.indexes` become `IndexMetadata` (plain
191
+ * column indexes and PowDB doc-field expression indexes, the latter
192
+ * carrying `docPath`). A table with no declared indexes gets `[]`, keeping
193
+ * `schemaHasIndexInfo()` false so index-advisor consumers produce no false
194
+ * positives on index-less code-first metadata.
195
+ *
125
196
  * What SchemaDef cannot express (and how it degrades):
126
- * - Indexes → every table gets `indexes: []`, which keeps
127
- * `schemaHasIndexInfo()` false so index-advisor consumers produce no
128
- * false positives on code-first metadata.
129
197
  * - Views → never marked (`isView` is introspection-only).
130
198
  * - Composite foreign keys → `references:` is single-column by design.
131
199
  */
@@ -302,9 +370,12 @@ function schemaDefToMetadata(def) {
302
370
  primaryKey: pk,
303
371
  uniqueColumns,
304
372
  relations: relationsByTable.get(tableDef.name) ?? {},
305
- // SchemaDef cannot express indexes. An empty list keeps
306
- // schemaHasIndexInfo() false no index-advisor false positives.
307
- indexes: [],
373
+ // Declared `indexes` (plain column + doc-field expression) carry through;
374
+ // an undeclared table gets `[]`, which keeps schemaHasIndexInfo() false so
375
+ // the index advisor stays silent. Doc-field (docPath) indexes are ignored
376
+ // by the advisor entirely (see index-advisor.ts), so a doc-only index set
377
+ // never flips schemaHasIndexInfo() and never produces FK false positives.
378
+ indexes: mapIndexes(tableDef, tableDef.indexes),
308
379
  };
309
380
  }
310
381
  const enums = {};
@@ -58,7 +58,21 @@ export declare function isProbeIndexed(meta: TableMetadata, columns: string[]):
58
58
  * should gate on {@link schemaHasIndexInfo} to avoid blanket false positives.
59
59
  */
60
60
  export declare function findMissingRelationIndexes(schema: SchemaMetadata): MissingRelationIndex[];
61
- /** True when at least one table in the schema carries index metadata. */
61
+ /**
62
+ * True when at least one table in the schema carries real, DB-backed index
63
+ * metadata (i.e. from introspection).
64
+ *
65
+ * Excluded (so they never flip the flag, keeping the schema "index-info
66
+ * unknown"):
67
+ * - doc-field expression indexes (`docPath`): they carry no FK-coverage info;
68
+ * - code-first DECLARED indexes (`declared`): the SQL DDL generators do NOT
69
+ * emit `TableDef.indexes` yet, so a declared index does not reflect a real
70
+ * index on the SQL engines. Counting one would arm blanket FK false
71
+ * positives (the FK auto-index the push path DID create is then reported
72
+ * "missing") and, inversely, suppress warnings for indexes never created.
73
+ * A pure code-first schema therefore stays silent exactly as it did before
74
+ * `TableDef.indexes` existed.
75
+ */
62
76
  export declare function schemaHasIndexInfo(schema: SchemaMetadata): boolean;
63
77
  /**
64
78
  * Single-relation check for the dev-mode runtime warning: the (table, columns)
Binary file
package/dist/index.d.ts CHANGED
@@ -47,7 +47,7 @@ export { type AggregateArgs, type AggregateResult, type ArrayFilter, type Column
47
47
  export { type ActiveSubscription, type NotificationHandler, type Subscription, validateChannel } from './realtime.js';
48
48
  export type { CheckMetadata, ColumnMetadata, IndexMetadata, ReferentialAction, RelationDef, SchemaMetadata, TableMetadata, } from './schema.js';
49
49
  export { camelToSnake, isDateType, normalizeKeyColumns, pgArrayType, pgTypeToTs, singularize, snakeToCamel, snakeToPascal, } from './schema.js';
50
- export { applyManyToManyRelations, type CheckDef, ColumnBuilder, type ColumnConfig, type ColumnDef, type ColumnType, type ColumnTypeName, column, type DefineSchemaOptions, defineSchema, type ManyToManyDef, type ReferenceDef, type SchemaDef, type TableDef, table, } from './schema-builder.js';
50
+ export { applyManyToManyRelations, type CheckDef, ColumnBuilder, type ColumnConfig, type ColumnDef, type ColumnIndexDef, type ColumnType, type ColumnTypeName, column, type DefineSchemaOptions, type DocFieldIndexDef, defineSchema, isDocFieldIndexDef, type ManyToManyDef, type ReferenceDef, type SchemaDef, type SchemaIndexDef, type TableDef, table, } from './schema-builder.js';
51
51
  export { schemaDefToMetadata } from './schema-metadata.js';
52
52
  export { type AlterColumnDef, type AlterDef, type DiffResult, type PushResult, type SchemaSqlOptions, schemaDiff, schemaPush, schemaToSQL, schemaToSQLString, } from './schema-sql.js';
53
53
  export { type DefinedSeed, defineSeed, type SeedFunction } from './seed.js';
package/dist/index.js CHANGED
@@ -53,7 +53,7 @@ export { validateChannel } from './realtime.js';
53
53
  // Schema utilities
54
54
  export { camelToSnake, isDateType, normalizeKeyColumns, pgArrayType, pgTypeToTs, singularize, snakeToCamel, snakeToPascal, } from './schema.js';
55
55
  // Schema builder — define schemas in TypeScript
56
- export { applyManyToManyRelations, ColumnBuilder, column, defineSchema,
56
+ export { applyManyToManyRelations, ColumnBuilder, column, defineSchema, isDocFieldIndexDef,
57
57
  // Legacy compat (deprecated — use object format with defineSchema)
58
58
  table, } from './schema-builder.js';
59
59
  // Schema metadata bridge — defineSchema() → SchemaMetadata without a live DB
@@ -86,4 +86,32 @@ async function importOptionalPeer(specifier, allowEsmFallback = true) {
86
86
  return esmCapableCopy(specifier, false);
87
87
  }
88
88
  }
89
+ /**
90
+ * Merged namespace so callers can reach {@link peerPackageVersion} off the same
91
+ * default import (`importOptionalPeer.peerPackageVersion(...)`). Lives in this
92
+ * `.cts` file for the same reason the dynamic import does: a `.cts` compiles to
93
+ * CommonJS in BOTH build passes, so `require` / `require.resolve` are natively
94
+ * available and `import.meta` is never emitted (which would break the CJS build
95
+ * and crash CJS consumers, see `resolveEmbeddedVersion` in powdb.ts).
96
+ */
97
+ (function (importOptionalPeer) {
98
+ /**
99
+ * Resolve an optional peer's declared `package.json` version WITHOUT loading
100
+ * the package itself (so an ESM-only peer never trips `require`). `require` is
101
+ * anchored on THIS module's location (inside the published `dist/`), so bare
102
+ * resolution walks up `node_modules` and finds the peer exactly where
103
+ * `import.meta.url` used to point, but it compiles under `module: CommonJS`
104
+ * too. Returns `null` when the peer / its package.json cannot be resolved.
105
+ */
106
+ function peerPackageVersion(specifier) {
107
+ try {
108
+ const pkg = require(`${specifier}/package.json`);
109
+ return typeof pkg.version === 'string' ? pkg.version : null;
110
+ }
111
+ catch {
112
+ return null;
113
+ }
114
+ }
115
+ importOptionalPeer.peerPackageVersion = peerPackageVersion;
116
+ })(importOptionalPeer || (importOptionalPeer = {}));
89
117
  module.exports = importOptionalPeer;
@@ -50,4 +50,23 @@
50
50
  * `false` so a failure in the sibling copy can never bounce back.
51
51
  */
52
52
  declare function importOptionalPeer(specifier: string, allowEsmFallback?: boolean): Promise<unknown>;
53
+ /**
54
+ * Merged namespace so callers can reach {@link peerPackageVersion} off the same
55
+ * default import (`importOptionalPeer.peerPackageVersion(...)`). Lives in this
56
+ * `.cts` file for the same reason the dynamic import does: a `.cts` compiles to
57
+ * CommonJS in BOTH build passes, so `require` / `require.resolve` are natively
58
+ * available and `import.meta` is never emitted (which would break the CJS build
59
+ * and crash CJS consumers, see `resolveEmbeddedVersion` in powdb.ts).
60
+ */
61
+ declare namespace importOptionalPeer {
62
+ /**
63
+ * Resolve an optional peer's declared `package.json` version WITHOUT loading
64
+ * the package itself (so an ESM-only peer never trips `require`). `require` is
65
+ * anchored on THIS module's location (inside the published `dist/`), so bare
66
+ * resolution walks up `node_modules` and finds the peer exactly where
67
+ * `import.meta.url` used to point, but it compiles under `module: CommonJS`
68
+ * too. Returns `null` when the peer / its package.json cannot be resolved.
69
+ */
70
+ function peerPackageVersion(specifier: string): string | null;
71
+ }
53
72
  export = importOptionalPeer;
@@ -0,0 +1,84 @@
1
+ /**
2
+ * turbine-orm/powdb — `describe`-based introspection.
3
+ *
4
+ * PowDB exposes its catalog through two ordinary rows-returning statements
5
+ * (keywords since engine 0.10):
6
+ * - `schema` → one row per type: `{ name, columns }` (columns = a count).
7
+ * - `describe <T>` / `schema <T>` → one row per column:
8
+ * `{ column, type, nullable, index }` where `type` is a PowQL type name
9
+ * (`str`/`int`/`float`/`bool`/`json`/`datetime`/`uuid`/`bytes`), `nullable`
10
+ * is `"true"`/`"false"`, and `index` is `"unique"` / `"index"` / `""`.
11
+ *
12
+ * {@link introspectPowdbDatabase} turns those into the same {@link SchemaMetadata}
13
+ * shape the SQL introspectors produce, so a code-first PowDB database can be
14
+ * introspected for bootstrap/verification. It is transport-agnostic: the caller
15
+ * supplies an `exec(powql)` that returns row objects **keyed by column name**.
16
+ * - Embedded / owned pool: `exec = async (q) => ({ rows: await db.raw([q]) })`
17
+ * using a live `turbinePowDB` client's `raw` tagged template.
18
+ * - Networked: the raw `@zvndev/powdb-client` returns POSITIONAL rows
19
+ * (`{ columns: string[], rows: string[][] }`), so zip them into records.
20
+ * A bare `(await client.query(q)).rows` would hand this function `string[][]`
21
+ * whose `.name` cell is `undefined` and every table would silently drop out:
22
+ * ```ts
23
+ * const exec = async (q) => {
24
+ * const r = await client.query(q);
25
+ * return { rows: r.rows.map((row) => Object.fromEntries(r.columns.map((c, i) => [c, row[i]]))) };
26
+ * };
27
+ * ```
28
+ * (A mis-shaped exec is now caught: if `schema` returns rows but none carry
29
+ * a `name`, {@link introspectPowdbDatabase} throws instead of returning an
30
+ * empty schema.)
31
+ *
32
+ * IMPORTANT LIMITATIONS (all documented, none silent):
33
+ * - Relations are ALWAYS `{}`: PowDB has no declared foreign keys, so
34
+ * `describe` cannot report them. The recommended flow for relation-aware
35
+ * metadata is code-first `defineSchema` + `schemaDefToMetadata`; use
36
+ * introspection to bootstrap or verify column shape.
37
+ * - Primary key is a HEURISTIC (`describe` has no PK concept): PowDB marks a
38
+ * PK column as `required unique`, so the first non-nullable `unique` column
39
+ * is chosen (a column named `id` wins ties). A table with no such column
40
+ * yields `primaryKey: []` and a warning; single-row ops on it fail loudly.
41
+ * - `isGenerated` is always `false`: `describe` does not expose PowDB's `auto`
42
+ * modifier, so an introspected int PK is treated as client-supplied unless
43
+ * the caller hand-edits the metadata.
44
+ * - Doc-field expression indexes are INVISIBLE to `describe`, so they never
45
+ * round-trip; only plain `unique`/`index` columns appear in `indexes`.
46
+ * - `datetime` / `uuid` / `bytes` columns map to read-oriented TS types
47
+ * (`Date` / `string` / `Uint8Array`). Turbine never emits those PowQL types
48
+ * on write, so writing to such a column may not round-trip.
49
+ *
50
+ * v1 is a PROGRAMMATIC API (exported from `turbine-orm/powdb`); the CLI's
51
+ * `turbine generate` still defaults to Postgres. Routing a `powdb://` URL
52
+ * through the CLI would additionally need: a `powdbDialect.introspector`
53
+ * wired to a networked `exec`, and `cli/config.ts` teaching the generate
54
+ * funnel to construct a PowDB client instead of a `pg` client for `powdb://`.
55
+ */
56
+ import { type PowdbCapabilities } from './powdb.js';
57
+ import type { SchemaMetadata } from './schema.js';
58
+ /** A minimal rows-returning executor over a PowDB connection (embedded or networked). */
59
+ export type PowdbExec = (powql: string) => Promise<{
60
+ rows: Record<string, unknown>[];
61
+ }>;
62
+ /** Options controlling which tables {@link introspectPowdbDatabase} reads. */
63
+ export interface PowdbIntrospectOptions {
64
+ /** Only introspect these table names (snake_case, as PowDB reports them). */
65
+ include?: string[];
66
+ /** Skip these table names. */
67
+ exclude?: string[];
68
+ /**
69
+ * Bound connection capabilities. When supplied AND `introspection` (engine
70
+ * >= 0.10) is false, this throws a version-hinting {@link UnsupportedFeatureError}
71
+ * (E017) up front instead of letting a pre-0.10 engine reject the `schema` /
72
+ * `describe` keywords with an opaque parse error. Omit it (the bare-exec path)
73
+ * to run ungated; the pool paths that know the version pass it through.
74
+ */
75
+ capabilities?: PowdbCapabilities;
76
+ }
77
+ /**
78
+ * Read a live PowDB database into {@link SchemaMetadata} via `schema` +
79
+ * `describe <T>` statements run through the supplied {@link PowdbExec}.
80
+ *
81
+ * @param exec Rows-returning executor (embedded `db.raw` or networked `client.query`).
82
+ * @param options `include`/`exclude` table filters.
83
+ */
84
+ export declare function introspectPowdbDatabase(exec: PowdbExec, options?: PowdbIntrospectOptions): Promise<SchemaMetadata>;
@@ -0,0 +1,219 @@
1
+ /**
2
+ * turbine-orm/powdb — `describe`-based introspection.
3
+ *
4
+ * PowDB exposes its catalog through two ordinary rows-returning statements
5
+ * (keywords since engine 0.10):
6
+ * - `schema` → one row per type: `{ name, columns }` (columns = a count).
7
+ * - `describe <T>` / `schema <T>` → one row per column:
8
+ * `{ column, type, nullable, index }` where `type` is a PowQL type name
9
+ * (`str`/`int`/`float`/`bool`/`json`/`datetime`/`uuid`/`bytes`), `nullable`
10
+ * is `"true"`/`"false"`, and `index` is `"unique"` / `"index"` / `""`.
11
+ *
12
+ * {@link introspectPowdbDatabase} turns those into the same {@link SchemaMetadata}
13
+ * shape the SQL introspectors produce, so a code-first PowDB database can be
14
+ * introspected for bootstrap/verification. It is transport-agnostic: the caller
15
+ * supplies an `exec(powql)` that returns row objects **keyed by column name**.
16
+ * - Embedded / owned pool: `exec = async (q) => ({ rows: await db.raw([q]) })`
17
+ * using a live `turbinePowDB` client's `raw` tagged template.
18
+ * - Networked: the raw `@zvndev/powdb-client` returns POSITIONAL rows
19
+ * (`{ columns: string[], rows: string[][] }`), so zip them into records.
20
+ * A bare `(await client.query(q)).rows` would hand this function `string[][]`
21
+ * whose `.name` cell is `undefined` and every table would silently drop out:
22
+ * ```ts
23
+ * const exec = async (q) => {
24
+ * const r = await client.query(q);
25
+ * return { rows: r.rows.map((row) => Object.fromEntries(r.columns.map((c, i) => [c, row[i]]))) };
26
+ * };
27
+ * ```
28
+ * (A mis-shaped exec is now caught: if `schema` returns rows but none carry
29
+ * a `name`, {@link introspectPowdbDatabase} throws instead of returning an
30
+ * empty schema.)
31
+ *
32
+ * IMPORTANT LIMITATIONS (all documented, none silent):
33
+ * - Relations are ALWAYS `{}`: PowDB has no declared foreign keys, so
34
+ * `describe` cannot report them. The recommended flow for relation-aware
35
+ * metadata is code-first `defineSchema` + `schemaDefToMetadata`; use
36
+ * introspection to bootstrap or verify column shape.
37
+ * - Primary key is a HEURISTIC (`describe` has no PK concept): PowDB marks a
38
+ * PK column as `required unique`, so the first non-nullable `unique` column
39
+ * is chosen (a column named `id` wins ties). A table with no such column
40
+ * yields `primaryKey: []` and a warning; single-row ops on it fail loudly.
41
+ * - `isGenerated` is always `false`: `describe` does not expose PowDB's `auto`
42
+ * modifier, so an introspected int PK is treated as client-supplied unless
43
+ * the caller hand-edits the metadata.
44
+ * - Doc-field expression indexes are INVISIBLE to `describe`, so they never
45
+ * round-trip; only plain `unique`/`index` columns appear in `indexes`.
46
+ * - `datetime` / `uuid` / `bytes` columns map to read-oriented TS types
47
+ * (`Date` / `string` / `Uint8Array`). Turbine never emits those PowQL types
48
+ * on write, so writing to such a column may not round-trip.
49
+ *
50
+ * v1 is a PROGRAMMATIC API (exported from `turbine-orm/powdb`); the CLI's
51
+ * `turbine generate` still defaults to Postgres. Routing a `powdb://` URL
52
+ * through the CLI would additionally need: a `powdbDialect.introspector`
53
+ * wired to a networked `exec`, and `cli/config.ts` teaching the generate
54
+ * funnel to construct a PowDB client instead of a `pg` client for `powdb://`.
55
+ */
56
+ import { ValidationError } from './errors.js';
57
+ import { quotePowqlIdent, requireCapability } from './powdb.js';
58
+ import { snakeToCamel } from './schema.js';
59
+ /** Coerce a wire cell to string (legacy wire cells are strings; native cells may be typed). */
60
+ function asString(v) {
61
+ return v === null || v === undefined ? '' : String(v);
62
+ }
63
+ /** Coerce a `describe` `nullable` cell (`"true"`/`"false"` or a native boolean) to a JS boolean. */
64
+ function asBool(v) {
65
+ return v === true || asString(v).toLowerCase() === 'true';
66
+ }
67
+ /**
68
+ * Map a PowQL type name to the {@link ColumnMetadata} TS/dialect types. The
69
+ * `tsType` drives read coercion (`coerceValue`) and write typing
70
+ * (`powqlColumnType`); `dialectType`/`pgType` carry the PowQL type name so
71
+ * `isFloatColumn` / `isJsonColumn` classify correctly.
72
+ */
73
+ function mapPowqlType(powqlType) {
74
+ switch (powqlType) {
75
+ case 'int':
76
+ return { tsType: 'number', dialectType: 'int' };
77
+ case 'float':
78
+ return { tsType: 'number', dialectType: 'float' };
79
+ case 'bool':
80
+ return { tsType: 'boolean', dialectType: 'bool' };
81
+ case 'json':
82
+ return { tsType: 'unknown', dialectType: 'json' };
83
+ case 'datetime':
84
+ return { tsType: 'Date', dialectType: 'datetime' };
85
+ case 'uuid':
86
+ return { tsType: 'string', dialectType: 'uuid' };
87
+ case 'bytes':
88
+ return { tsType: 'Uint8Array', dialectType: 'bytes' };
89
+ default:
90
+ // `str` and any unknown future scalar fall back to string.
91
+ return { tsType: 'string', dialectType: 'str' };
92
+ }
93
+ }
94
+ /**
95
+ * Read a live PowDB database into {@link SchemaMetadata} via `schema` +
96
+ * `describe <T>` statements run through the supplied {@link PowdbExec}.
97
+ *
98
+ * @param exec Rows-returning executor (embedded `db.raw` or networked `client.query`).
99
+ * @param options `include`/`exclude` table filters.
100
+ */
101
+ export async function introspectPowdbDatabase(exec, options = {}) {
102
+ // Gate on the engine's introspection capability (>= 0.10) when the caller
103
+ // knows the version, so a pre-0.10 engine gets a typed E017 hint instead of
104
+ // an opaque `unexpected token schema` parse error.
105
+ if (options.capabilities) {
106
+ requireCapability(options.capabilities, 'introspection', 'PowDB `describe` introspection');
107
+ }
108
+ // ----- Types (one row per table, columns `name`, `columns`) -----
109
+ const schemaRows = (await exec('schema')).rows;
110
+ let tableNames = schemaRows.map((r) => asString(r.name)).filter((n) => n.length > 0);
111
+ // A mis-shaped `exec` (e.g. the raw client's positional `string[][]` rows
112
+ // passed straight through) yields rows whose `name` cell is `undefined`, so
113
+ // every table filters out and the schema comes back silently empty. Refuse
114
+ // that instead of losing data: real rows must carry a `name`.
115
+ if (schemaRows.length > 0 && tableNames.length === 0) {
116
+ throw new ValidationError(`[turbine] PowDB introspection: the \`schema\` statement returned ${schemaRows.length} row(s) but none carried a ` +
117
+ '`name` cell. The `exec` you supplied likely returns POSITIONAL rows (string[][]) rather than records keyed by ' +
118
+ 'column name; zip `columns` with each row (see introspectPowdbDatabase docs).');
119
+ }
120
+ if (options.include?.length) {
121
+ const inc = new Set(options.include);
122
+ tableNames = tableNames.filter((t) => inc.has(t));
123
+ }
124
+ if (options.exclude?.length) {
125
+ const exc = new Set(options.exclude);
126
+ tableNames = tableNames.filter((t) => !exc.has(t));
127
+ }
128
+ const tables = {};
129
+ for (const tableName of tableNames) {
130
+ // `describe` needs the table name in bare-identifier position → quote it so
131
+ // a reserved-word / non-bare table name (`order`) does not become a parse
132
+ // error.
133
+ const describeRows = (await exec(`describe ${quotePowqlIdent(tableName)}`)).rows.map((r) => ({
134
+ column: asString(r.column),
135
+ type: asString(r.type),
136
+ nullable: asBool(r.nullable),
137
+ index: asString(r.index),
138
+ }));
139
+ const columns = [];
140
+ const columnMap = {};
141
+ const reverseColumnMap = {};
142
+ const dateColumns = new Set();
143
+ const dialectTypes = {};
144
+ const pgTypes = {};
145
+ const allColumns = [];
146
+ const uniqueColumns = [];
147
+ const indexes = [];
148
+ // PK heuristic candidates: non-nullable `unique` columns.
149
+ const pkCandidates = [];
150
+ for (const row of describeRows) {
151
+ const name = row.column;
152
+ const field = snakeToCamel(name);
153
+ const { tsType, dialectType } = mapPowqlType(row.type);
154
+ const nullable = row.nullable;
155
+ const finalTs = nullable ? `${tsType} | null` : tsType;
156
+ const col = {
157
+ name,
158
+ field,
159
+ dialectType,
160
+ pgType: dialectType,
161
+ tsType: finalTs,
162
+ nullable,
163
+ // `describe` reports neither defaults nor the `auto` modifier.
164
+ hasDefault: false,
165
+ isGenerated: false,
166
+ isArray: false,
167
+ arrayType: undefined,
168
+ pgArrayType: 'text[]',
169
+ };
170
+ columns.push(col);
171
+ columnMap[field] = name;
172
+ reverseColumnMap[name] = field;
173
+ allColumns.push(name);
174
+ dialectTypes[name] = dialectType;
175
+ pgTypes[name] = dialectType;
176
+ if (dialectType === 'datetime')
177
+ dateColumns.add(name);
178
+ if (row.index === 'unique') {
179
+ uniqueColumns.push([name]);
180
+ if (!nullable)
181
+ pkCandidates.push(name);
182
+ }
183
+ if (row.index === 'unique' || row.index === 'index') {
184
+ indexes.push({
185
+ name: `${tableName}_${name}_idx`,
186
+ columns: [name],
187
+ unique: row.index === 'unique',
188
+ definition: `${row.index === 'unique' ? 'unique ' : ''}index on ${tableName}(${name})`,
189
+ });
190
+ }
191
+ }
192
+ // Primary key: first non-nullable unique column, preferring one named `id`.
193
+ let primaryKey = [];
194
+ if (pkCandidates.length > 0) {
195
+ primaryKey = [pkCandidates.includes('id') ? 'id' : pkCandidates[0]];
196
+ }
197
+ else {
198
+ console.warn(`[turbine] PowDB introspection: table "${tableName}" has no non-nullable unique column; ` +
199
+ 'primaryKey is [] (single-row operations will fail). Supply a primary key via code-first ' +
200
+ '`defineSchema` metadata if this table needs findUnique/update/delete by id.');
201
+ }
202
+ tables[tableName] = {
203
+ name: tableName,
204
+ columns,
205
+ columnMap,
206
+ reverseColumnMap,
207
+ dateColumns,
208
+ dialectTypes,
209
+ pgTypes,
210
+ allColumns,
211
+ primaryKey,
212
+ uniqueColumns,
213
+ // PowDB has no declared foreign keys → no relations from introspection.
214
+ relations: {},
215
+ indexes,
216
+ };
217
+ }
218
+ return { tables, enums: {} };
219
+ }