uql-orm 0.42.1 → 0.44.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.
Files changed (54) hide show
  1. package/dist/browser/querier/httpQuerier.d.ts +6 -0
  2. package/dist/browser/querier/httpQuerier.js +1 -1
  3. package/dist/browser/uql-browser.min.js +2 -2
  4. package/dist/browser/uql-browser.min.js.map +5 -5
  5. package/dist/dialect/abstractDialect.js +5 -6
  6. package/dist/dialect/abstractSqlDialect.d.ts +10 -12
  7. package/dist/dialect/abstractSqlDialect.js +34 -34
  8. package/dist/dialect/jsonSql.d.ts +3 -2
  9. package/dist/dialect/jsonSql.js +7 -5
  10. package/dist/dialect/vectorCast.d.ts +0 -6
  11. package/dist/dialect/vectorCast.js +0 -8
  12. package/dist/entity/decorator/bag.d.ts +3 -0
  13. package/dist/entity/decorator/members.d.ts +5 -2
  14. package/dist/entity/index.d.ts +1 -1
  15. package/dist/entity/index.js +1 -1
  16. package/dist/entity/metadata/definition.d.ts +15 -13
  17. package/dist/entity/metadata/definition.js +73 -25
  18. package/dist/http/handler.d.ts +8 -0
  19. package/dist/http/handler.js +5 -5
  20. package/dist/maria/mariaDialect.js +2 -2
  21. package/dist/migrate/cli.d.ts +5 -0
  22. package/dist/migrate/cli.js +33 -16
  23. package/dist/migrate/codegen/entityTypes.d.ts +7 -0
  24. package/dist/migrate/codegen/entityTypes.js +69 -0
  25. package/dist/migrate/codegen/index.d.ts +1 -0
  26. package/dist/migrate/codegen/index.js +1 -0
  27. package/dist/migrate/drift/driftDetector.js +5 -1
  28. package/dist/migrate/generator/mongoSchemaGenerator.d.ts +1 -1
  29. package/dist/migrate/generator/mongoSchemaGenerator.js +1 -1
  30. package/dist/migrate/index.d.ts +1 -1
  31. package/dist/migrate/migrator.d.ts +34 -20
  32. package/dist/migrate/migrator.js +77 -34
  33. package/dist/migrate/schemaGenerator.d.ts +1 -5
  34. package/dist/migrate/schemaGenerator.js +11 -15
  35. package/dist/schema/canonicalType.d.ts +19 -4
  36. package/dist/schema/canonicalType.js +114 -164
  37. package/dist/schema/schemaASTBuilder.d.ts +23 -2
  38. package/dist/schema/schemaASTBuilder.js +2 -2
  39. package/dist/schema/schemaASTDiffer.js +6 -2
  40. package/dist/type/entity.d.ts +36 -6
  41. package/dist/type/migration.d.ts +14 -1
  42. package/dist/type/query.d.ts +2 -6
  43. package/dist/type/queryWhere.d.ts +13 -3
  44. package/dist/util/field.util.d.ts +19 -8
  45. package/dist/util/field.util.js +47 -51
  46. package/dist/util/fieldOption.util.d.ts +79 -0
  47. package/dist/util/fieldOption.util.js +84 -0
  48. package/dist/util/index.d.ts +1 -0
  49. package/dist/util/index.js +1 -0
  50. package/dist/util/object.util.d.ts +3 -3
  51. package/dist/util/object.util.js +3 -3
  52. package/dist/util/sql.util.d.ts +4 -0
  53. package/dist/util/sql.util.js +4 -0
  54. package/package.json +3 -3
@@ -1,4 +1,5 @@
1
1
  import type { EntityGetter, FieldOptions, FieldType, IdValue, RelationManyToManyOptions, RelationManyToOneOptions, RelationOneToManyOptions, RelationOneToOneOptions, TsTypeOf } from '../../type/index.js';
2
+ import type { RejectIncompatible } from '../../util/index.js';
2
3
  /** A member decorator that also constrains the property it may be applied to. */
3
4
  type MemberDecorator<V> = (value: undefined, context: ClassFieldDecoratorContext<unknown, V>) => void;
4
5
  /**
@@ -51,7 +52,7 @@ export declare function Field<O extends FieldOptions<DeclaredValue<O>> & ({
51
52
  type: FieldType;
52
53
  } | {
53
54
  references: EntityGetter;
54
- }) & RejectUnknown<O, FieldOptions>>(opts: O): MemberDecorator<DeclaredValue<O> | undefined>;
55
+ }) & RejectUnknown<O, FieldOptions> & RejectIncompatible<O>>(opts: O): MemberDecorator<DeclaredValue<O> | undefined>;
55
56
  /**
56
57
  * Declares the primary key, checked the same way as `@Field`.
57
58
  *
@@ -60,7 +61,9 @@ export declare function Field<O extends FieldOptions<DeclaredValue<O>> & ({
60
61
  */
61
62
  export declare function Id<O extends FieldOptions<DeclaredValue<O>> & {
62
63
  type: FieldType;
63
- } & RejectUnknown<O, FieldOptions>>(opts: O): MemberDecorator<DeclaredValue<O> | undefined>;
64
+ } & RejectUnknown<O, FieldOptions> & RejectIncompatible<O> & {
65
+ readonly nullable?: false;
66
+ }>(opts: O): MemberDecorator<DeclaredValue<O> | undefined>;
64
67
  /**
65
68
  * `E` comes from the mandatory `entity` getter, so the context can insist the property really holds that
66
69
  * entity: `@ManyToOne({ entity: () => Other })` on a `Company` field stops compiling, and a to-many
@@ -1,3 +1,3 @@
1
1
  export * from './decorator/entity.js';
2
2
  export * from './decorator/members.js';
3
- export { defineEntity, defineField, defineFilter, defineHook, defineId, defineIndex, defineRelation, getEntities, getMeta, assertSoleId, idOf, soleIdOf, } from './metadata/definition.js';
3
+ export { defineEntity, defineField, defineFilter, defineHook, defineId, defineIndex, defineRelation, getEntities, getMeta, removeEntity, assertSoleId, idOf, soleIdOf, } from './metadata/definition.js';
@@ -1,3 +1,3 @@
1
1
  export * from './decorator/entity.js';
2
2
  export * from './decorator/members.js';
3
- export { defineEntity, defineField, defineFilter, defineHook, defineId, defineIndex, defineRelation, getEntities, getMeta, assertSoleId, idOf, soleIdOf, } from './metadata/definition.js';
3
+ export { defineEntity, defineField, defineFilter, defineHook, defineId, defineIndex, defineRelation, getEntities, getMeta, removeEntity, assertSoleId, idOf, soleIdOf, } from './metadata/definition.js';
@@ -1,4 +1,4 @@
1
- import type { EntityId, EntityIndexInput, EntityMeta, EntityOptions, FieldKey, FieldOptions, FilterOptions, HookEvent, IdKey, RelationOptions, Type } from '../../type/index.js';
1
+ import type { EntityId, EntityIndexInput, EntityMembers, EntityMeta, EntityOptions, FieldKey, FieldOptions, FilterOptions, HookEvent, IdKey, RelationOptions, Type } from '../../type/index.js';
2
2
  export declare function defineField<E>(entity: Type<E>, key: string, opts?: FieldOptions): EntityMeta<E>;
3
3
  export declare function defineId<E>(entity: Type<E>, key: string, opts: FieldOptions): EntityMeta<E>;
4
4
  export declare function defineRelation<E>(entity: Type<E>, key: string, opts: RelationOptions): EntityMeta<E>;
@@ -9,21 +9,17 @@ export declare function defineHook<E>(entity: Type<E>, methodName: string, event
9
9
  */
10
10
  export declare function defineIndex<E>(entity: Type<E>, index: EntityIndexInput<FieldKey<E>, E>): EntityMeta<E>;
11
11
  export declare function defineFilter<E>(entity: Type<E>, name: string, opts: FilterOptions<E>): EntityMeta<E>;
12
- /**
13
- * What a decorator bag and {@link EntityOptions} have in common at registration time. The keyed mapped
14
- * types in `EntityOptions<E>` are what check the imperative call; a member decorator has no class to key
15
- * against, so by the time either reaches the primitives the keys are plain strings.
16
- */
17
- type MemberSpecs = {
18
- readonly fields?: Readonly<Record<string, FieldOptions | undefined>>;
19
- readonly relations?: Readonly<Record<string, RelationOptions | undefined>>;
20
- readonly hooks?: Readonly<Partial<Record<HookEvent, readonly string[]>>>;
21
- };
22
12
  /**
23
13
  * Feeds fields, relations and hooks into the `define*` primitives, so the decorators and the imperative
24
14
  * API converge on one registration path before anything is finalized.
25
15
  */
26
- export declare function applyMembers<E>(entity: Type<E>, specs: MemberSpecs | undefined): void;
16
+ export declare function applyMembers<E>(entity: Type<E>, specs: EntityMembers | undefined): void;
17
+ /**
18
+ * Registers an entity described by data alone, minting the class the registry keys it by. The row
19
+ * type follows from the spec - see {@link SpecRow} - so a definition written out is checked column by
20
+ * column, and one assembled at runtime is the column bag it is. Pass `Row` to name a shape the spec
21
+ * cannot describe, such as the interface `uql-migrate types` generated for it.
22
+ */
27
23
  export declare function defineEntity<E>(entity: Type<E>, opts?: EntityOptions<E>): EntityMeta<E>;
28
24
  /**
29
25
  * Refuses an entity whose primary key is not one column, naming the path that cannot express it.
@@ -42,6 +38,12 @@ export declare function soleIdOf<E>(meta: EntityMeta<E>, what: string): IdKey<E>
42
38
  * one of its columns would address every row agreeing on that one.
43
39
  */
44
40
  export declare function idOf<E>(meta: EntityMeta<E>, row: E): EntityId<E>;
41
+ /**
42
+ * Forgets an entity, and reports whether there was one - for a registry that grows at runtime, where a
43
+ * deleted content type would otherwise keep its metadata for the life of the process. Nothing rewrites
44
+ * what pointed at it, and a decorated class does not come back (its decorators drained at first
45
+ * registration). See the Runtime Schemas guide.
46
+ */
47
+ export declare function removeEntity<E>(entity: Type<E>): boolean;
45
48
  export declare function getEntities(): Type<unknown>[];
46
49
  export declare function getMeta<E>(entity: Type<E>): EntityMeta<E>;
47
- export {};
@@ -1,20 +1,29 @@
1
1
  import { SOFT_DELETE_FILTER } from '../../type/index.js';
2
- import { getKeys, ddlText, hasKeys, isToManyRelation, lowerFirst, normalizeIndexColumn, upperFirst, } from '../../util/index.js';
2
+ import { entityName, fieldOptionConflict, getKeys, ddlText, hasKeys, isToManyRelation, lowerFirst, normalizeIndexColumn, upperFirst, } from '../../util/index.js';
3
3
  import { ownRegistrations } from '../decorator/bag.js';
4
- // Held on `globalThis` via the global symbol registry so a single metadata map survives multiple
5
- // evaluations of this module (HMR, duplicated/federated bundles, ESM+CJS dual-loading). Version-suffixed
6
- // because v1 changed the `FieldOptions` shape: a tree holding both majors gets two maps rather than one
7
- // map with entries the other major cannot read.
8
- const holder = globalThis;
9
- const metaKey = Symbol.for('uql-orm/entity/metadata/v1');
10
- const metas = holder[metaKey] ?? new Map();
11
- holder[metaKey] = metas;
4
+ /**
5
+ * A map held on `globalThis` through the global symbol registry, so a single one survives multiple
6
+ * evaluations of this module (HMR, duplicated/federated bundles, ESM+CJS dual-loading). The keys are
7
+ * version-suffixed because v1 changed the `FieldOptions` shape: a tree holding both majors gets two
8
+ * maps rather than one map with entries the other major cannot read.
9
+ */
10
+ function globalMap(key) {
11
+ const holder = globalThis;
12
+ const symbol = Symbol.for(key);
13
+ holder[symbol] ??= new Map();
14
+ return holder[symbol];
15
+ }
16
+ const metas = globalMap('uql-orm/entity/metadata/v1');
12
17
  export function defineField(entity, key, opts = {}) {
13
- const meta = ensureMeta(entity);
18
+ const meta = ensureWritableMeta(entity);
14
19
  if (!opts.type && !opts.references && !opts.virtual) {
15
20
  throw new TypeError(`'${entity.name}.${key}' needs a 'type'. Declare it - '@Field({ type: String })' - or point the field ` +
16
21
  "at another entity with 'references', which resolves the column type from its primary key.");
17
22
  }
23
+ const conflict = fieldOptionConflict(opts);
24
+ if (conflict) {
25
+ throw new TypeError(`'${entity.name}.${key}' ${conflict}.`);
26
+ }
18
27
  const fieldKey = key;
19
28
  // Flagged when the author gave `references` but no `type`, so schema generation knows to resolve the
20
29
  // column from the referenced primary key (picking up its `columnType`, length and chained keys)
@@ -32,7 +41,7 @@ export function defineRelation(entity, key, opts) {
32
41
  if (!opts.entity) {
33
42
  throw new TypeError(`'${entity.name}.${key}' needs an 'entity' getter, e.g. '@ManyToOne({ entity: () => Company })'.`);
34
43
  }
35
- const meta = ensureMeta(entity);
44
+ const meta = ensureWritableMeta(entity);
36
45
  // Registration writes the authored shape into a map declared as resolved: `getMeta` runs
37
46
  // `fillRelations`, which settles `entity`, `references` and `mappedBy` or throws. Bridging the two
38
47
  // shapes here is what lets every consumer read `RelationMeta` without asserting.
@@ -41,7 +50,7 @@ export function defineRelation(entity, key, opts) {
41
50
  return meta;
42
51
  }
43
52
  export function defineHook(entity, methodName, event) {
44
- const meta = ensureMeta(entity);
53
+ const meta = ensureWritableMeta(entity);
45
54
  if (!meta.hooks)
46
55
  meta.hooks = {};
47
56
  if (!meta.hooks[event])
@@ -54,7 +63,7 @@ export function defineHook(entity, methodName, event) {
54
63
  * lets the dialects render one shape instead of re-parsing it.
55
64
  */
56
65
  export function defineIndex(entity, index) {
57
- const meta = ensureMeta(entity);
66
+ const meta = ensureWritableMeta(entity);
58
67
  if (!meta.indexes)
59
68
  meta.indexes = [];
60
69
  meta.indexes.push({
@@ -66,7 +75,7 @@ export function defineIndex(entity, index) {
66
75
  return meta;
67
76
  }
68
77
  export function defineFilter(entity, name, opts) {
69
- const meta = ensureMeta(entity);
78
+ const meta = ensureWritableMeta(entity);
70
79
  if (name === SOFT_DELETE_FILTER) {
71
80
  throw TypeError(`'${entity.name}' filter name '${SOFT_DELETE_FILTER}' is reserved; it is auto-registered from @Field({ softDelete })`);
72
81
  }
@@ -103,6 +112,12 @@ export function applyMembers(entity, specs) {
103
112
  }
104
113
  }
105
114
  }
115
+ /**
116
+ * Registers an entity described by data alone, minting the class the registry keys it by. The row
117
+ * type follows from the spec - see {@link SpecRow} - so a definition written out is checked column by
118
+ * column, and one assembled at runtime is the column bag it is. Pass `Row` to name a shape the spec
119
+ * cannot describe, such as the interface `uql-migrate types` generated for it.
120
+ */
106
121
  export function defineEntity(entity, opts = {}) {
107
122
  // Ahead of any registration, so a rejected definition leaves nothing half-written in the registry.
108
123
  // A dotted name reads like a schema and is not one: it escapes as a single identifier, so the
@@ -112,7 +127,7 @@ export function defineEntity(entity, opts = {}) {
112
127
  throw new TypeError(`'${entity.name}' has a dotted name '${opts.name}'. Name the schema separately as ` +
113
128
  `{ schema: '${schema}', name: '${rest.join('.')}' }.`);
114
129
  }
115
- const meta = ensureMeta(entity);
130
+ const meta = ensureWritableMeta(entity);
116
131
  // Covers `defineEntity(Decorated)` called on a class whose members carry decorators. `@Entity()`
117
132
  // drains `context.metadata` itself, because TypeScript only attaches `Symbol.metadata` to the class
118
133
  // after class decorators return; draining empties the bag, so whichever runs second is a no-op.
@@ -125,15 +140,27 @@ export function defineEntity(entity, opts = {}) {
125
140
  for (const index of opts.indexes ?? []) {
126
141
  defineIndex(entity, index);
127
142
  }
128
- for (const [name, spec] of Object.entries(opts.filters ?? {})) {
129
- if (spec)
130
- defineFilter(entity, name, spec);
143
+ for (const [name, filter] of Object.entries(opts.filters ?? {})) {
144
+ if (filter)
145
+ defineFilter(entity, name, filter);
131
146
  }
132
147
  if (!hasKeys(meta.fields)) {
133
148
  throw TypeError(`'${entity.name}' must have fields`);
134
149
  }
135
- meta.name = opts.name ?? entity.name;
136
- meta.schema = opts.schema;
150
+ // A later call composes onto the entity, so saying nothing about the table retracts nothing - which
151
+ // is why `derivedName` is only ever *set*, never recomputed from what a previous call left.
152
+ // It records that the class name stood in, telling a naming strategy there is something to derive;
153
+ // comparing the two cannot, since an entity may name its table exactly what its class is called and
154
+ // a spec's minted class is named after its table.
155
+ if (opts.name !== undefined) {
156
+ meta.name = opts.name;
157
+ meta.derivedName = false;
158
+ }
159
+ else if (meta.name === undefined) {
160
+ meta.name = entity.name;
161
+ meta.derivedName = true;
162
+ }
163
+ meta.schema = opts.schema ?? meta.schema;
137
164
  let proto = Object.getPrototypeOf(entity.prototype);
138
165
  while (proto.constructor !== Object) {
139
166
  const parent = proto.constructor;
@@ -203,20 +230,38 @@ export function idOf(meta, row) {
203
230
  }
204
231
  return Object.fromEntries(ids.map((key) => [key, row[key]]));
205
232
  }
233
+ /**
234
+ * Forgets an entity, and reports whether there was one - for a registry that grows at runtime, where a
235
+ * deleted content type would otherwise keep its metadata for the life of the process. Nothing rewrites
236
+ * what pointed at it, and a decorated class does not come back (its decorators drained at first
237
+ * registration). See the Runtime Schemas guide.
238
+ */
239
+ export function removeEntity(entity) {
240
+ return metas.delete(entity);
241
+ }
206
242
  export function getEntities() {
207
- return [...metas.entries()].reduce((acc, [key, val]) => {
243
+ return metas.entries().reduce((acc, [key, val]) => {
208
244
  if (val.ids.length) {
209
245
  acc.push(key);
210
246
  }
211
247
  return acc;
212
248
  }, []);
213
249
  }
250
+ /**
251
+ * The metadata of `entity`, marked as changed. Every `define*` goes through this, and nothing outside
252
+ * this file writes to a meta, so it is the one place a derived cache can be told it has gone stale.
253
+ */
254
+ function ensureWritableMeta(entity) {
255
+ const meta = ensureMeta(entity);
256
+ meta.revision++;
257
+ return meta;
258
+ }
214
259
  function ensureMeta(entity) {
215
260
  let meta = metas.get(entity);
216
261
  if (meta) {
217
262
  return meta;
218
263
  }
219
- meta = { entity, ids: [], fields: {}, relations: {} };
264
+ meta = { entity, ids: [], fields: {}, relations: {}, revision: 0 };
220
265
  metas.set(entity, meta);
221
266
  return meta;
222
267
  }
@@ -225,10 +270,13 @@ export function getMeta(entity) {
225
270
  if (!meta) {
226
271
  throw TypeError(`'${entity.name}' is not an entity`);
227
272
  }
228
- if (meta.processed) {
273
+ if (meta.processedAt === meta.revision) {
229
274
  return meta;
230
275
  }
231
- meta.processed = true;
276
+ // Stamped before finalizing, not after: `fillInverseSide` reads the other side through `getMeta`,
277
+ // and with each side mapped by the other that recursion has to find this half-filled meta rather
278
+ // than run again. Finalizing twice is harmless anyway - every step of it skips what it settled.
279
+ meta.processedAt = meta.revision;
232
280
  return fillRelations(meta);
233
281
  }
234
282
  function fillRelations(meta) {
@@ -374,7 +422,7 @@ function fillForeignKeyRelations(meta) {
374
422
  }
375
423
  /** `<entityName><IdColumn>`, not the `<relationKey>Id` an owning to-one derives: a junction row has no relation key to borrow from. */
376
424
  function junctionColumn(meta, idKey) {
377
- return lowerFirst(meta.name ?? '') + upperFirst(meta.fields[idKey]?.name ?? idKey);
425
+ return lowerFirst(entityName(meta)) + upperFirst(meta.fields[idKey]?.name ?? idKey);
378
426
  }
379
427
  /** A callback only reads one property off the key map, and that property is the key, so one serves every entity. */
380
428
  const RELATION_KEY_MAP = new Proxy({}, { get: (_, key) => key });
@@ -51,6 +51,14 @@ export type ResponseHook<Ctx = unknown> = <E extends object>(ctx: HookContext<E,
51
51
  export type RequestHandlerOptions<Ctx = unknown> = {
52
52
  include?: Type<any>[];
53
53
  exclude?: Type<any>[];
54
+ /**
55
+ * The URL segment an entity is addressed by, defaulting to its kebab-cased class name.
56
+ *
57
+ * State it where the default cannot serve: a build that minifies class names renames every route,
58
+ * and two entities mapping one table in different schemas collide on one. The browser client takes
59
+ * the same option, so both ends can read one map.
60
+ */
61
+ entityPath?: (entity: Type<unknown>) => string;
54
62
  /**
55
63
  * Allow augment any kind of request before it runs. Hooks may be async
56
64
  * and abort the request by throwing (a numeric `status` on the error is honored).
@@ -9,6 +9,7 @@ function tableOf(entity) {
9
9
  }
10
10
  export function createRequestHandler(opts) {
11
11
  const { include, exclude, pre, preSave, preFilter, post, getContext, pool } = opts;
12
+ const pathOf = opts.entityPath ?? entityPath;
12
13
  let entities = include ?? getEntities();
13
14
  if (exclude) {
14
15
  entities = entities.filter((entity) => !exclude.includes(entity));
@@ -16,15 +17,14 @@ export function createRequestHandler(opts) {
16
17
  if (!entities.length) {
17
18
  throw new TypeError('no entities for the uql middleware');
18
19
  }
19
- // The route is the class name, so two entities mapping one table in different schemas collide here
20
- // even though nothing else about them does. All of them at once, so fixing the first collision
21
- // does not just reveal the next.
22
- const byPath = Map.groupBy(entities, entityPath);
20
+ // All of them at once, so fixing the first collision does not just reveal the next.
21
+ const byPath = Map.groupBy(entities, pathOf);
23
22
  const collisions = [...byPath].filter(([, clashing]) => clashing.length > 1);
24
23
  if (collisions.length) {
25
24
  const lines = collisions.map(([path, clashing]) => ` /${path} <- ${clashing.map(tableOf).join(', ')}`);
26
25
  throw new TypeError(`every entity below shares a route with another, so all but the first are unreachable:\n${lines.join('\n')}\n` +
27
- "A route is the kebab-cased class name. Rename a class, or pass only one of them in 'include'.");
26
+ "A route is the kebab-cased class name unless 'entityPath' says otherwise. Name them apart, " +
27
+ "pass an 'entityPath', or pass only one of them in 'include'.");
28
28
  }
29
29
  // oxlint-disable-next-line typescript/no-explicit-any -- heterogeneous entity map
30
30
  const entityByPath = new Map([...byPath].map(([path, [entity]]) => [path, entity]));
@@ -1,7 +1,7 @@
1
1
  import { jsonPath } from '../dialect/jsonSql.js';
2
2
  import { MysqlLikeSqlDialect } from '../dialect/mysqlLikeSqlDialect.js';
3
- import { isVectorFieldType } from '../dialect/vectorCast.js';
4
3
  import { getMeta } from '../entity/index.js';
4
+ import { columnFamily } from '../util/field.util.js';
5
5
  import { MARIA_VECTOR_METRICS } from './mariaVectorMetrics.js';
6
6
  export class MariaDialect extends MysqlLikeSqlDialect {
7
7
  dialectName = 'mariadb';
@@ -76,6 +76,6 @@ export class MariaDialect extends MysqlLikeSqlDialect {
76
76
  }
77
77
  /** The reverse: selecting a `VECTOR` column raw yields that blob, so it is read back as text. */
78
78
  selectFieldExpr(escapedColumn, field) {
79
- return isVectorFieldType(field.type) ? `VEC_ToText(${escapedColumn})` : escapedColumn;
79
+ return columnFamily(field.type) === 'vector' ? `VEC_ToText(${escapedColumn})` : escapedColumn;
80
80
  }
81
81
  }
@@ -14,6 +14,11 @@ export declare function runStatus(migrator: Migrator): Promise<void>;
14
14
  export declare function runPending(migrator: Migrator): Promise<void>;
15
15
  export declare function runGenerate(migrator: Migrator, args: string[]): Promise<void>;
16
16
  export declare function runGenerateFromEntities(migrator: Migrator, args: string[]): Promise<void>;
17
+ /**
18
+ * Writes a `.d.ts` for the registered entities. The point is a schema defined at runtime: the same
19
+ * registration that made the tables is what the compiler then checks queries against.
20
+ */
21
+ export declare function runTypes(migrator: Migrator, args: string[]): void;
17
22
  export declare function runSync(migrator: Migrator, args: string[], config: Partial<Config>): Promise<void>;
18
23
  export declare function runGenerateFromDb(migrator: Migrator, args: string[], config: Partial<Config>): Promise<void>;
19
24
  export declare function runDriftCheck(migrator: Migrator, config: Partial<Config>): Promise<void>;
@@ -4,6 +4,7 @@ import * as path from 'node:path';
4
4
  import { assertCliConfig } from './assertCliConfig.js';
5
5
  import { loadConfig } from './cli-config.js';
6
6
  import { createEntityCodeGenerator } from './codegen/entityCodeGenerator.js';
7
+ import { entityTypesSource } from './codegen/entityTypes.js';
7
8
  import { detectDrift } from './drift/driftDetector.js';
8
9
  import { Migrator } from './migrator.js';
9
10
  import { buildEntityAST, createSchemaGenerator } from './schemaGenerator.js';
@@ -74,6 +75,9 @@ export async function main(args = process.argv.slice(2)) {
74
75
  case 'sync':
75
76
  await runSync(migrator, filteredArgs.slice(1), config);
76
77
  break;
78
+ case 'types':
79
+ runTypes(migrator, filteredArgs.slice(1));
80
+ break;
77
81
  case 'pending':
78
82
  await runPending(migrator);
79
83
  break;
@@ -189,35 +193,45 @@ export async function runGenerateFromEntities(migrator, args) {
189
193
  const filePath = await migrator.generateFromEntities(name);
190
194
  console.log(`\nCreated migration from entities: ${filePath}`);
191
195
  }
196
+ /**
197
+ * Writes a `.d.ts` for the registered entities. The point is a schema defined at runtime: the same
198
+ * registration that made the tables is what the compiler then checks queries against.
199
+ */
200
+ export function runTypes(migrator, args) {
201
+ const output = readOutput(args) ?? './uql-entities.d.ts';
202
+ fs.mkdirSync(path.dirname(output), { recursive: true });
203
+ fs.writeFileSync(output, entityTypesSource(migrator.entities), 'utf-8');
204
+ console.log(`Wrote ${migrator.entities.length} entities to ${output}`);
205
+ }
206
+ /** `--output`/`-o`, wherever a command takes one. */
207
+ function readOutput(args) {
208
+ const at = args.findIndex((arg) => arg === '--output' || arg === '-o');
209
+ return at === -1 ? undefined : args[at + 1];
210
+ }
192
211
  export async function runSync(migrator, args, config) {
193
- if (args.includes('--force')) {
194
- console.log('\n⚠️ WARNING: This will drop and recreate all tables!');
195
- console.log(' All data will be lost. This should only be used in development.\n');
196
- await migrator.sync({ force: true });
197
- console.log('\nSchema sync completed.');
198
- return;
199
- }
200
212
  // Pulling the database into entity files is what `generate:from-db` does; one implementation.
201
213
  if (args.includes('--pull')) {
202
214
  return runGenerateFromDb(migrator, args, config);
203
215
  }
216
+ const force = args.includes('--force');
204
217
  const safe = !args.includes('--unsafe');
218
+ const options = { force, safe, drop: !safe };
219
+ // Ahead of the warning as well as of the run: `--dry-run` means the same thing whatever else was
220
+ // asked for, and it used to be ignored beside `--force`.
205
221
  if (args.includes('--dry-run')) {
206
- const statements = await migrator.planSync({ safe, drop: !safe });
222
+ const statements = await migrator.planSync(options);
207
223
  console.log(statements.length ? `\n${statements.join('\n')}` : '\nSchema is already in sync.');
208
224
  return;
209
225
  }
210
- await migrator.autoSync({ safe, drop: !safe, logging: true });
226
+ if (force) {
227
+ console.log('\n⚠️ WARNING: This will drop and recreate all tables!');
228
+ console.log(' All data will be lost. This should only be used in development.\n');
229
+ }
230
+ await migrator.sync({ ...options, logging: true });
211
231
  console.log('\nSchema sync completed.');
212
232
  }
213
233
  export async function runGenerateFromDb(migrator, args, config) {
214
- // Parse output directory
215
- let outputDir = './src/entities';
216
- for (let i = 0; i < args.length; i++) {
217
- if ((args[i] === '--output' || args[i] === '-o') && args[i + 1]) {
218
- outputDir = args[++i];
219
- }
220
- }
234
+ const outputDir = readOutput(args) ?? './src/entities';
221
235
  if (!migrator.schemaIntrospector) {
222
236
  console.error('No introspector available. Check your pool configuration.');
223
237
  process.exit(1);
@@ -342,6 +356,9 @@ Commands:
342
356
  --pull Go the other way: generate entities from the database
343
357
  --force Drop and recreate all tables (dangerous!)
344
358
 
359
+ types Write a .d.ts for the registered entities
360
+ --output, -o <file> Output path (default: ./uql-entities.d.ts)
361
+
345
362
  drift:check Check for schema drift between entities and database
346
363
 
347
364
  Configuration:
@@ -0,0 +1,7 @@
1
+ import type { Type } from '../../type/index.js';
2
+ /**
3
+ * A `.d.ts` for the entities as registered, so a shape that only exists at runtime reaches the
4
+ * compiler too. Interfaces, not the entity classes `generate:from-db` writes: those are the source of
5
+ * a schema, these describe one already defined elsewhere.
6
+ */
7
+ export declare function entityTypesSource(entities: readonly Type<unknown>[]): string;
@@ -0,0 +1,69 @@
1
+ import { getMeta } from '../../entity/index.js';
2
+ import { canonicalToTypeScript } from '../../schema/canonicalType.js';
3
+ import { resolveColumnCanonicalType } from '../../schema/schemaASTBuilder.js';
4
+ import { isToManyRelation, upperFirst } from '../../util/index.js';
5
+ /**
6
+ * A `.d.ts` for the entities as registered, so a shape that only exists at runtime reaches the
7
+ * compiler too. Interfaces, not the entity classes `generate:from-db` writes: those are the source of
8
+ * a schema, these describe one already defined elsewhere.
9
+ */
10
+ export function entityTypesSource(entities) {
11
+ const metas = [...entities]
12
+ .map((entity) => getMeta(entity))
13
+ .sort((a, b) => a.entity.name.localeCompare(b.entity.name));
14
+ const names = interfaceNames(metas);
15
+ const interfaces = metas.map((meta) => {
16
+ const members = [
17
+ ...Object.entries(meta.fields).map(([key, field]) => member(key, fieldType(field))),
18
+ ...Object.entries(meta.relations).map(([key, rel]) => member(key, relationType(rel, names))),
19
+ ];
20
+ return `export interface ${names.get(meta.entity)} {\n${members.join('\n')}\n}`;
21
+ });
22
+ return ['// Generated by `uql-migrate types`. Do not edit.', '', ...interfaces, ''].join('\n');
23
+ }
24
+ /**
25
+ * One interface name per entity, settled before anything is written so a relation and its target
26
+ * agree. A class minted at runtime is named after the content type it came from, which nothing stops
27
+ * from carrying a dash or colliding with another once the dashes are gone - and either would emit a
28
+ * file that does not compile.
29
+ */
30
+ function interfaceNames(metas) {
31
+ const taken = new Set();
32
+ return new Map(metas.map((meta) => {
33
+ const base = identifier(upperFirst(meta.entity.name));
34
+ let name = base;
35
+ for (let n = 2; taken.has(name); n++) {
36
+ name = `${base}${n}`;
37
+ }
38
+ taken.add(name);
39
+ return [meta.entity, name];
40
+ }));
41
+ }
42
+ /** `text` as an identifier: what cannot be in one is dropped, and what cannot start one is prefixed. */
43
+ function identifier(text) {
44
+ const stripped = text.replace(/[^A-Za-z0-9_$]/g, '');
45
+ return /^[A-Za-z_$]/.test(stripped) ? stripped : `Entity${stripped}`;
46
+ }
47
+ /** A column name a property cannot hold - `hero-image` - is quoted rather than dropped. */
48
+ function member(key, type) {
49
+ const name = key === identifier(key) ? key : JSON.stringify(key);
50
+ return ` ${name}?: ${type};`;
51
+ }
52
+ /**
53
+ * The property type a column reads back as, resolved the way the DDL resolves it - so a foreign key
54
+ * reports the type of the key it points at rather than the fallback its own options carry.
55
+ */
56
+ function fieldType(field) {
57
+ return field ? canonicalToTypeScript(resolveColumnCanonicalType(field)) : 'unknown';
58
+ }
59
+ /**
60
+ * A relation is the related interface, a list where the cardinality says so - and `unknown` where the
61
+ * target is outside the set, since naming an interface the file does not declare would not compile.
62
+ */
63
+ function relationType(relation, names) {
64
+ const target = relation && names.get(relation.entity());
65
+ if (!target) {
66
+ return 'unknown';
67
+ }
68
+ return isToManyRelation(relation) ? `${target}[]` : target;
69
+ }
@@ -4,4 +4,5 @@
4
4
  * Generates TypeScript entity code from database schemas.
5
5
  */
6
6
  export { createEntityCodeGenerator, EntityCodeGenerator, type EntityCodeGeneratorOptions, type GeneratedEntity, } from './entityCodeGenerator.js';
7
+ export { entityTypesSource } from './entityTypes.js';
7
8
  export { buildSqlQuerierMigrationModule, EMPTY_MANUAL_MIGRATION_DOWN_INNER, EMPTY_MANUAL_MIGRATION_UP_INNER, emitSqlRunCall, emitSqlRunCalls, type SqlMigrationModuleOptions, } from './migrationFile.js';
@@ -5,4 +5,5 @@
5
5
  */
6
6
  // Entity code generator
7
7
  export { createEntityCodeGenerator, EntityCodeGenerator, } from './entityCodeGenerator.js';
8
+ export { entityTypesSource } from './entityTypes.js';
8
9
  export { buildSqlQuerierMigrationModule, EMPTY_MANUAL_MIGRATION_DOWN_INNER, EMPTY_MANUAL_MIGRATION_UP_INNER, emitSqlRunCall, emitSqlRunCalls, } from './migrationFile.js';
@@ -4,7 +4,7 @@
4
4
  * Detects schema drift between expected schema (from entities) and
5
5
  * actual database schema.
6
6
  */
7
- import { canonicalToSql } from '../../schema/canonicalType.js';
7
+ import { canonicalToSql, engineType } from '../../schema/canonicalType.js';
8
8
  import { diffSchemas } from '../../schema/schemaASTDiffer.js';
9
9
  function resolveOptions(options) {
10
10
  return {
@@ -24,11 +24,15 @@ function resolveOptions(options) {
24
24
  */
25
25
  export function detectDrift(expectedAST, actualAST, options = {}) {
26
26
  const opts = resolveOptions(options);
27
+ const { dialect } = opts;
27
28
  const diff = diffSchemas(expectedAST, actualAST, {
28
29
  compareIndexes: opts.checkIndexes,
29
30
  indexFacets: opts.indexFacets,
30
31
  compareRelationships: opts.checkForeignKeys,
31
32
  excludeTables: opts.excludeTables,
33
+ // Without a dialect there is no engine to compare through, and `formatType` below then reports no
34
+ // type drift at all.
35
+ ...(dialect && { normalizeType: engineType(dialect) }),
32
36
  });
33
37
  const drifts = [
34
38
  ...detectTableDrifts(diff),
@@ -39,7 +39,7 @@ export declare class MongoSchemaGenerator extends AbstractDialect implements Sch
39
39
  */
40
40
  generateCreateIndex(tableName: string, index: IndexSchema): string;
41
41
  generateDropIndex(tableName: string, indexName: string): string;
42
- getSqlType(fieldOptions: FieldOptions, fieldType?: unknown): string;
42
+ getSqlType(fieldOptions: FieldOptions): string;
43
43
  generateCreateTableFromNode(table: TableNode, _options?: {
44
44
  ifNotExists?: boolean;
45
45
  }): string[];
@@ -120,7 +120,7 @@ export class MongoSchemaGenerator extends AbstractDialect {
120
120
  name: indexName,
121
121
  });
122
122
  }
123
- getSqlType(fieldOptions, fieldType) {
123
+ getSqlType(fieldOptions) {
124
124
  return '';
125
125
  }
126
126
  generateCreateTableFromNode(table, _options) {
@@ -1,4 +1,4 @@
1
- export type { ColumnSchema, DialectName, ForeignKeySchema, IndexSchema, Migration, MigrationDefinition, MigrationResult, MigrationStorage, MigratorOptions, MongoQuerier, SchemaDiff, SchemaGenerator, SchemaIntrospector, SqlDialectName, SqlQuerier, SqlQueryDialect, TableSchema, } from '../type/index.js';
1
+ export type { ColumnSchema, DialectName, ForeignKeySchema, IndexSchema, Migration, MigrationDefinition, MigrationResult, MigrationStorage, MigratorOptions, MongoQuerier, SchemaDiff, SchemaGenerator, SchemaIntrospector, SqlDialectName, SqlQuerier, SqlQueryDialect, SyncOptions, TableSchema, } from '../type/index.js';
2
2
  export { type Config, isSqlQuerier } from '../type/index.js';
3
3
  export { acquireQuerierForMigrations } from './acquireQuerierForMigrations.js';
4
4
  export { assertCliConfig } from './assertCliConfig.js';