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.
- package/README.md +2 -2
- package/dist/cjs/index-advisor.js +0 -0
- package/dist/cjs/index.js +2 -1
- package/dist/cjs/optional-peer-import.cjs +28 -0
- package/dist/cjs/powdb-introspect.js +222 -0
- package/dist/cjs/powdb.js +443 -55
- package/dist/cjs/powql.js +566 -111
- package/dist/cjs/query/builder.js +12 -1
- package/dist/cjs/schema-builder.js +16 -0
- package/dist/cjs/schema-metadata.js +81 -10
- package/dist/index-advisor.d.ts +15 -1
- package/dist/index-advisor.js +0 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/optional-peer-import.cjs +28 -0
- package/dist/optional-peer-import.d.cts +19 -0
- package/dist/powdb-introspect.d.ts +84 -0
- package/dist/powdb-introspect.js +219 -0
- package/dist/powdb.d.ts +249 -13
- package/dist/powdb.js +435 -54
- package/dist/powql.d.ts +113 -6
- package/dist/powql.js +568 -113
- package/dist/query/builder.js +12 -1
- package/dist/query/types.d.ts +39 -6
- package/dist/schema-builder.d.ts +46 -1
- package/dist/schema-builder.js +15 -0
- package/dist/schema-metadata.d.ts +13 -7
- package/dist/schema-metadata.js +82 -11
- package/dist/schema.d.ts +25 -0
- package/package.json +3 -3
|
@@ -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
|
-
|
|
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`
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
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
|
-
//
|
|
306
|
-
//
|
|
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 = {};
|
package/dist/index-advisor.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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)
|
package/dist/index-advisor.js
CHANGED
|
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
|
+
}
|