uql-orm 0.25.1 → 0.26.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 (55) hide show
  1. package/README.md +9 -8
  2. package/dist/browser/uql-browser.min.js.map +1 -1
  3. package/dist/cockroachdb/cockroachDialect.js +1 -1
  4. package/dist/dialect/indexSqlDialect.d.ts +3 -2
  5. package/dist/dialect/indexSqlDialect.js +10 -8
  6. package/dist/dialect/mysqlLikeSqlDialect.d.ts +0 -2
  7. package/dist/dialect/mysqlLikeSqlDialect.js +0 -7
  8. package/dist/dialect/pgLikeSqlDialect.js +1 -0
  9. package/dist/maria/mariaDialect.js +1 -1
  10. package/dist/migrate/builder/migrationBuilder.js +1 -1
  11. package/dist/migrate/builder/tableBuilder.js +2 -2
  12. package/dist/migrate/cli.js +4 -6
  13. package/dist/migrate/codegen/entityCodeGenerator.d.ts +5 -0
  14. package/dist/migrate/codegen/entityCodeGenerator.js +22 -25
  15. package/dist/migrate/codegen/fieldOptionsSource.d.ts +1 -1
  16. package/dist/migrate/codegen/fieldOptionsSource.js +6 -1
  17. package/dist/migrate/codegen/indexDecoratorSource.d.ts +14 -0
  18. package/dist/migrate/codegen/indexDecoratorSource.js +105 -0
  19. package/dist/migrate/drift/driftDetector.d.ts +5 -50
  20. package/dist/migrate/drift/driftDetector.js +215 -224
  21. package/dist/migrate/drift/index.d.ts +1 -1
  22. package/dist/migrate/drift/index.js +1 -1
  23. package/dist/migrate/generator/definitionToNode.d.ts +9 -0
  24. package/dist/migrate/generator/definitionToNode.js +79 -0
  25. package/dist/migrate/generator/indexNodeToSchema.js +4 -2
  26. package/dist/migrate/generator/mongoSchemaGenerator.js +3 -3
  27. package/dist/migrate/introspection/baseSqlIntrospector.d.ts +3 -0
  28. package/dist/migrate/introspection/baseSqlIntrospector.js +13 -5
  29. package/dist/migrate/introspection/mongoIntrospector.d.ts +3 -0
  30. package/dist/migrate/introspection/mongoIntrospector.js +5 -10
  31. package/dist/migrate/introspection/mysqlIntrospector.js +1 -1
  32. package/dist/migrate/introspection/postgresIntrospector.d.ts +57 -5
  33. package/dist/migrate/introspection/postgresIntrospector.js +93 -10
  34. package/dist/migrate/introspection/sqliteIntrospector.js +1 -1
  35. package/dist/migrate/migrator.js +3 -2
  36. package/dist/migrate/schemaGenerator.d.ts +26 -3
  37. package/dist/migrate/schemaGenerator.js +53 -92
  38. package/dist/schema/index.d.ts +3 -3
  39. package/dist/schema/index.js +2 -2
  40. package/dist/schema/indexColumns.d.ts +10 -0
  41. package/dist/schema/indexColumns.js +11 -0
  42. package/dist/schema/indexDifferences.d.ts +22 -0
  43. package/dist/schema/indexDifferences.js +49 -0
  44. package/dist/schema/schemaAST.js +2 -8
  45. package/dist/schema/schemaASTBuilder.d.ts +6 -59
  46. package/dist/schema/schemaASTBuilder.js +208 -236
  47. package/dist/schema/schemaASTDiffer.d.ts +9 -56
  48. package/dist/schema/schemaASTDiffer.js +229 -393
  49. package/dist/schema/types.d.ts +5 -12
  50. package/dist/schema/types.js +15 -0
  51. package/dist/type/dialect.d.ts +7 -2
  52. package/dist/type/dialect.js +1 -0
  53. package/dist/type/migration.d.ts +14 -1
  54. package/dist/util/string.util.js +6 -1
  55. package/package.json +1 -1
package/README.md CHANGED
@@ -9,7 +9,7 @@
9
9
 
10
10
  <h3>The smartest TypeScript ORM</h3>
11
11
 
12
- <p>Type-safe to the leaf, serializable queries, no codegen, <a href="https://uql-orm.dev/benchmark">extremely fast</a>, and one API across every SQL database, MongoDB, and every runtime.</p>
12
+ <p>Type-safe to the leaf, serializable queries, no codegen, and one API across every SQL database, MongoDB, and every runtime. And the <a href="https://uql-orm.dev/benchmark">fastest</a>.</p>
13
13
 
14
14
  <p>
15
15
  <a href="https://uql-orm.dev"><b>Website</b></a> ·
@@ -50,14 +50,15 @@ from the browser to the server. The same object runs on every supported database
50
50
 
51
51
  ## Why UQL?
52
52
 
53
- - **The fastest.** Wins [all 8 categories](https://uql-orm.dev/benchmark) of our open-source [benchmark](https://github.com/rogerpadilla/ts-orm-benchmark), beating even query builders like Knex and Kysely: ~2. faster than the runner-up on average, reaching over 4.6M ops/s on simple SELECTs.
53
+ - **Type-safe to the leaf, nothing to generate.** Every key is checked against your entity, down into populated relations and [JSON/JSONB](https://uql-orm.dev/querying/json) dot-paths, so `$like` on a numeric column is a compile error. Entities are plain classes: no `.prisma` file, no generated client, no build step.
54
+ - **Queries are data (JSON), not method chains.** Plain JSON in, typed rows out. No DSL to learn.
55
+ - **One API, everywhere it runs.** PostgreSQL, CockroachDB, MySQL, MariaDB, SQLite, Turso, libSQL, Neon, Cloudflare D1, Bun's native SQL, and even MongoDB. The same code on Node 24+, Bun, Deno, [Cloudflare Workers](https://uql-orm.dev/cloudflare-d1), [AWS Lambda and Vercel](https://uql-orm.dev/serverless), and [the browser](https://uql-orm.dev/browser), with no native binaries on the `fetch`-based drivers.
56
+ - **Relations without N+1.** [`$populate`](https://uql-orm.dev/querying/relations) loads a to-many with one query for all parents, not one per parent. Nothing is lazy, so nothing fires behind your back in a serializer.
57
+ - **Migrations you read before they run.** Edit an entity, run `uql-migrate generate:entities`, review the SQL in the PR like any other file. [`drift:check`](https://uql-orm.dev/migrations) catches a database that no longer matches.
58
+ - **Raw SQL when you want it.** [`raw()`](https://uql-orm.dev/querying/raw-sql) fits anywhere in a query, [virtual fields](https://uql-orm.dev/entities/virtual-fields) are sub-queries you can filter on, and a migration can be plain SQL.
54
59
  - **Light.** Zero runtime dependencies, 305 kB on the wire, every dialect included. See [what we deleted to get there](https://uql-orm.dev/blog/zero-dependencies).
55
- - **Queries are data (JSON), not method chains.** Plain JSON in, typed rows out. There's no DSL to learn and nothing to compile.
56
- - **Type-safe to the leaf.** Every key is autocompleted and checked against your entity, down to the fields of a populated relation. Operators are gated per field type, and [JSON/JSONB](https://uql-orm.dev/querying/json) dot-paths resolve each path's value type, so `$like` on a numeric column, or a typo'd path, is a compile error instead of a runtime surprise.
57
- - **No codegen, no build step.** Entities are TypeScript classes, so your code *is* the schema. There's no `.prisma` file to regenerate and no generated client to keep in sync.
58
- - **One API everywhere.** PostgreSQL, CockroachDB, MySQL, MariaDB, SQLite, Turso, libSQL, Neon, Cloudflare D1, Bun's native SQL, and even MongoDB!
59
- - **Runs on every runtime.** Node 24+, Bun, Deno, [Cloudflare Workers](https://uql-orm.dev/cloudflare-d1), [AWS Lambda and Vercel](https://uql-orm.dev/serverless), and [the browser](https://uql-orm.dev/browser). ESM-only with no native binaries on the `fetch`-based drivers, so an edge bundle needs no special build.
60
- - **The hard things are built in.** [Semantic and vector search](https://uql-orm.dev/ai-semantic-search), [non-bypassable multi-tenant filters](https://uql-orm.dev/multi-tenancy), [entity-first migrations](https://uql-orm.dev/migrations), [soft-delete with restore](https://uql-orm.dev/entities/soft-delete), [streaming](https://uql-orm.dev/querying/streaming), and [a REST API from your entities](https://uql-orm.dev/http).
60
+ - **The hard things are built in.** [Semantic and vector search](https://uql-orm.dev/ai-semantic-search), [multi-tenant filters you cannot bypass by accident](https://uql-orm.dev/multi-tenancy), [soft-delete with restore](https://uql-orm.dev/entities/soft-delete), [streaming](https://uql-orm.dev/querying/streaming), and [a REST API from your entities](https://uql-orm.dev/http).
61
+ - **The fastest ORM.** On a full PostgreSQL round trip it adds the least over hand-written driver code of any ORM in our open-source [benchmark](https://github.com/rogerpadilla/ts-orm-benchmark): 278µs, against 621µs for the next closest and 1,889µs for the slowest.
61
62
 
62
63
  ## Get started
63
64
 
@@ -5,7 +5,7 @@
5
5
  "import type { RequestCallback, RequestNotification } from '../type/index.js';\n\nconst subscriptors: RequestCallback[] = [];\n\nexport function notify(notification: RequestNotification): void {\n for (const subscriptor of subscriptors) {\n subscriptor(notification);\n }\n}\n\nexport function on(cb: RequestCallback): () => void {\n subscriptors.push(cb);\n const index = subscriptors.length - 1;\n return (): void => {\n subscriptors.splice(index, 1);\n };\n}\n",
6
6
  "import type { RequestErrorResponse, RequestSuccessResponse } from '../../http/contract.js';\nimport type { RequestOptions } from '../type/index.js';\nimport { notify } from './bus.js';\n\n/**\n * Error thrown for non-2xx responses. Carries the HTTP status so callers can key\n * behavior on it (401 redirects, 402 payment flows, error-boundary routing).\n */\nexport class RequestError extends Error {\n constructor(\n message: string,\n readonly status: number,\n ) {\n super(message);\n this.name = 'RequestError';\n }\n}\n\nexport function get<T>(url: string, opts?: RequestOptions) {\n return request<T>(url, { method: 'get' }, opts);\n}\n\nexport function post<T>(url: string, payload: unknown, opts?: RequestOptions) {\n const body = JSON.stringify(payload);\n return request<T>(url, { method: 'post', body }, opts);\n}\n\nexport function patch<T>(url: string, payload: unknown, opts?: RequestOptions) {\n const body = JSON.stringify(payload);\n return request<T>(url, { method: 'patch', body }, opts);\n}\n\nexport function put<T>(url: string, payload: unknown, opts?: RequestOptions) {\n const body = JSON.stringify(payload);\n return request<T>(url, { method: 'put', body }, opts);\n}\n\nexport function remove<T>(url: string, opts?: RequestOptions) {\n return request<T>(url, { method: 'delete' }, opts);\n}\n\n/**\n * HTTP QUERY (RFC 10008): a safe, idempotent read whose JSON query travels in the\n * request body, avoiding URL-length limits. Method name must stay uppercase\n * (fetch only normalizes the classic verbs).\n */\nexport function query<T>(url: string, payload: unknown, opts?: RequestOptions) {\n const body = JSON.stringify(payload);\n return request<T>(url, { method: 'QUERY', body }, opts);\n}\n\nfunction request<T>(url: string, init: RequestInit, opts?: RequestOptions) {\n notify({ phase: 'start', opts });\n\n init.headers = {\n accept: 'application/json',\n 'content-type': 'application/json',\n ...opts?.headers,\n };\n if (opts?.signal) {\n init.signal = opts.signal;\n }\n\n return fetch(url, init)\n .then((rawResp) =>\n rawResp.json().then((resp: unknown) => {\n const isSuccess = rawResp.status >= 200 && rawResp.status < 300;\n if (isSuccess) {\n notify({ phase: 'success', opts });\n return resp as RequestSuccessResponse<T>;\n }\n const errorResp = resp as Partial<RequestErrorResponse> | undefined;\n const error = {\n message: errorResp?.error?.message ?? rawResp.statusText,\n code: errorResp?.error?.code ?? rawResp.status,\n };\n notify({ phase: 'error', error, opts });\n throw new RequestError(error.message, error.code);\n }),\n )\n .finally(() => {\n notify({ phase: 'complete', opts });\n });\n}\n",
7
7
  "import type { FieldKey, FieldOptions } from '../type/index.js';\n\nexport function throwPendingTransaction(): never {\n throw TypeError('pending transaction');\n}\n\nexport function throwNoPendingTransaction(): never {\n throw TypeError('not a pending transaction');\n}\n\nexport function clone<T>(value: T): T {\n if (typeof value !== 'object' || value === null) {\n return value;\n }\n if (Array.isArray(value)) {\n return value.map((it) => clone(it)) as T;\n }\n return { ...value };\n}\n\n/** Whether `obj` has at least one enumerable key. Narrows away `undefined`/`null` for callers. */\nexport function hasKeys<T>(obj: T): obj is NonNullable<T> {\n if (typeof obj !== 'object' || obj === null) return false;\n for (const _ in obj) return true;\n return false;\n}\n\n/** Whether `obj` has at least two enumerable keys. */\nexport function hasMultipleKeys(obj: object): boolean {\n let count = 0;\n for (const _ in obj) {\n if (++count > 1) return true;\n }\n return false;\n}\n\n/**\n * Whether any enumerable key of `obj` satisfies `pred`, short-circuiting on the first match\n * without materializing a key array (unlike `Object.keys(obj).some(pred)`).\n */\nexport function someKey(obj: object, pred: (key: string) => boolean): boolean {\n for (const key in obj) {\n if (pred(key)) return true;\n }\n return false;\n}\n\n/** Whether any enumerable value of `obj` satisfies `pred`, short-circuiting like {@link someKey}. */\nexport function someValue(obj: object, pred: (value: unknown) => boolean): boolean {\n return someKey(obj, (key) => pred((obj as Record<string, unknown>)[key]));\n}\n\nconst isOperatorKey = (key: string) => key.startsWith('$');\n\n/**\n * Whether `value` is a non-empty object whose keys are query/update operators (`$eq`, `$push`, ...).\n * The single source of this test: the SQL dialects, the MongoDB dialect and the `$elemMatch` walker\n * all classify operator objects with it, and they used to disagree about `{}`.\n */\nexport function isOperatorObject(value: unknown): value is Record<string, unknown> {\n return hasKeys(value) && !Array.isArray(value) && someKey(value, isOperatorKey);\n}\n\n/** Whether every key of the non-empty object `value` is an operator (no plain field names mixed in). */\nexport function isOperatorOnlyObject(value: unknown): value is Record<string, unknown> {\n return hasKeys(value) && !Array.isArray(value) && !someKey(value, (key) => !isOperatorKey(key));\n}\n\nexport function getKeys<T extends object>(obj: T): (keyof T & string)[] {\n return obj ? (Object.keys(obj) as (keyof T & string)[]) : [];\n}\n\nexport function getFieldKeys<E>(\n fields: {\n [K in FieldKey<E>]?: FieldOptions;\n },\n): FieldKey<E>[] {\n return getKeys(fields).filter((field) => fields[field]!.eager ?? true);\n}\n",
8
- "export function kebabCase(val: string): string {\n let resp = val.charAt(0).toLowerCase();\n for (let i = 1; i < val.length; ++i) {\n resp += val[i] === val[i].toUpperCase() ? '-' + val[i].toLowerCase() : val[i];\n }\n return resp;\n}\n\nexport function upperFirst(text: string): string {\n if (!text) return text;\n return text[0].toUpperCase() + text.slice(1);\n}\n\nexport function lowerFirst(text: string): string {\n if (!text) return text;\n return text[0].toLowerCase() + text.slice(1);\n}\n\nexport function snakeCase(val: string): string {\n if (val === null || val === undefined) return val as string;\n if (!val) return '';\n let resp = val.charAt(0).toLowerCase();\n for (let i = 1; i < val.length; ++i) {\n const char = val[i];\n const charLower = char.toLowerCase();\n if (char !== charLower && char === char.toUpperCase()) {\n resp += '_' + charLower;\n } else {\n resp += char;\n }\n }\n return resp;\n}\n\n/**\n * Convert a string to PascalCase (UpperCamelCase).\n * @example 'user_profile' -> 'UserProfile'\n * @example 'some-text' -> 'SomeText'\n */\nexport function pascalCase(str: string): string {\n if (!str) return '';\n return str\n .split(/[_\\s-]+/)\n .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())\n .join('');\n}\n\n/**\n * Convert a string to camelCase.\n * @example 'user_profile' -> 'userProfile'\n * @example 'SomeText' -> 'someText'\n */\nexport function camelCase(str: string): string {\n const pascal = pascalCase(str);\n return pascal.charAt(0).toLowerCase() + pascal.slice(1);\n}\n\n/**\n * Simple singularize function for English words.\n * @example 'users' -> 'user'\n * @example 'categories' -> 'category'\n */\nexport function singularize(name: string): string {\n if (!name) return '';\n if (name.endsWith('ies')) {\n return name.slice(0, -3) + 'y';\n }\n if (name.endsWith('ses') || name.endsWith('xes') || name.endsWith('zes')) {\n return name.slice(0, -2);\n }\n if (name.endsWith('s') && !name.endsWith('ss')) {\n return name.slice(0, -1);\n }\n return name;\n}\n\n/**\n * Simple pluralize function for English words.\n * @example 'user' -> 'users'\n * @example 'category' -> 'categories'\n */\nexport function pluralize(name: string): string {\n if (!name) return '';\n if (name.endsWith('y') && name.length > 1 && !/[aeiou]/.test(name[name.length - 2])) {\n return name.slice(0, -1) + 'ies';\n }\n if (name.endsWith('s') || name.endsWith('x') || name.endsWith('z') || name.endsWith('ch') || name.endsWith('sh')) {\n return name + 'es';\n }\n return name + 's';\n}\n",
8
+ "export function kebabCase(val: string): string {\n let resp = val.charAt(0).toLowerCase();\n for (let i = 1; i < val.length; ++i) {\n resp += val[i] === val[i].toUpperCase() ? '-' + val[i].toLowerCase() : val[i];\n }\n return resp;\n}\n\nexport function upperFirst(text: string): string {\n if (!text) return text;\n return text[0].toUpperCase() + text.slice(1);\n}\n\nexport function lowerFirst(text: string): string {\n if (!text) return text;\n return text[0].toLowerCase() + text.slice(1);\n}\n\nexport function snakeCase(val: string): string {\n if (val === null || val === undefined) return val as string;\n if (!val) return '';\n let resp = val.charAt(0).toLowerCase();\n for (let i = 1; i < val.length; ++i) {\n const char = val[i];\n const charLower = char.toLowerCase();\n if (char !== charLower && char === char.toUpperCase()) {\n resp += '_' + charLower;\n } else {\n resp += char;\n }\n }\n return resp;\n}\n\n/**\n * Convert a string to PascalCase (UpperCamelCase).\n * @example 'user_profile' -> 'UserProfile'\n * @example 'some-text' -> 'SomeText'\n */\nexport function pascalCase(str: string): string {\n if (!str) return '';\n return str\n .split(/[_\\s-]+/)\n .map((word) => {\n // Lower-casing the rest is only right for a word that carries no case of its own: it turns\n // `USER_ID` into `UserId`, but it also turns `tenantId` into `Tenantid`.\n const rest = word === word.toUpperCase() ? word.slice(1).toLowerCase() : word.slice(1);\n return word.charAt(0).toUpperCase() + rest;\n })\n .join('');\n}\n\n/**\n * Convert a string to camelCase.\n * @example 'user_profile' -> 'userProfile'\n * @example 'SomeText' -> 'someText'\n */\nexport function camelCase(str: string): string {\n const pascal = pascalCase(str);\n return pascal.charAt(0).toLowerCase() + pascal.slice(1);\n}\n\n/**\n * Simple singularize function for English words.\n * @example 'users' -> 'user'\n * @example 'categories' -> 'category'\n */\nexport function singularize(name: string): string {\n if (!name) return '';\n if (name.endsWith('ies')) {\n return name.slice(0, -3) + 'y';\n }\n if (name.endsWith('ses') || name.endsWith('xes') || name.endsWith('zes')) {\n return name.slice(0, -2);\n }\n if (name.endsWith('s') && !name.endsWith('ss')) {\n return name.slice(0, -1);\n }\n return name;\n}\n\n/**\n * Simple pluralize function for English words.\n * @example 'user' -> 'users'\n * @example 'category' -> 'categories'\n */\nexport function pluralize(name: string): string {\n if (!name) return '';\n if (name.endsWith('y') && name.length > 1 && !/[aeiou]/.test(name[name.length - 2])) {\n return name.slice(0, -1) + 'ies';\n }\n if (name.endsWith('s') || name.endsWith('x') || name.endsWith('z') || name.endsWith('ch') || name.endsWith('sh')) {\n return name + 'es';\n }\n return name + 's';\n}\n",
9
9
  "import type { Type, UniversalQuerier } from '../type/index.js';\n// the specific util modules, not the barrel, so the browser bundle does not pull in entity metadata\nimport { getKeys } from '../util/object.util.js';\nimport { kebabCase } from '../util/string.util.js';\n\ntype RouteShape = {\n readonly method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';\n readonly path: '' | `/${string}`;\n};\n\n/**\n * Single source of truth for the CRUD-over-HTTP surface, shared by server adapters and the browser client.\n * Keys are constrained to {@link UniversalQuerier} method names, so renaming a querier method\n * (or routing a non-existent one) is a compile error.\n */\nexport const CRUD_ROUTES = {\n findMany: { method: 'GET', path: '' },\n findOne: { method: 'GET', path: '/one' },\n count: { method: 'GET', path: '/count' },\n findOneById: { method: 'GET', path: '/:id' },\n insertOne: { method: 'POST', path: '' },\n insertMany: { method: 'POST', path: '/many' },\n saveOne: { method: 'PUT', path: '' },\n saveMany: { method: 'PUT', path: '/many' },\n updateMany: { method: 'PATCH', path: '' },\n updateOneById: { method: 'PATCH', path: '/:id' },\n deleteOneById: { method: 'DELETE', path: '/:id' },\n deleteMany: { method: 'DELETE', path: '' },\n} as const satisfies Partial<Record<keyof UniversalQuerier, RouteShape>>;\n\nexport type CrudOperation = keyof typeof CRUD_ROUTES;\n\nexport type CrudRoute = (typeof CRUD_ROUTES)[CrudOperation];\n\n/**\n * `QUERY` (RFC 10008) is an alternate transport for the read operations: same semantics as the\n * GET routes, but the JSON query travels in the request body instead of the query string,\n * avoiding URL-length limits for large queries.\n */\nexport type HttpMethod = CrudRoute['method'] | 'QUERY';\n\nconst CRUD_OPS = getKeys(CRUD_ROUTES);\n\n// derived from CRUD_ROUTES (the literal-path GET routes) so the sub-paths live in exactly one place\nconst QUERY_READ_OPS: ReadonlyMap<string, CrudOperation> = new Map(\n CRUD_OPS.filter((op) => CRUD_ROUTES[op].method === 'GET' && CRUD_ROUTES[op].path !== '/:id').map((op) => [\n CRUD_ROUTES[op].path,\n op,\n ]),\n);\n\n/**\n * URL segment for an entity, e.g. `entityPath(UserProfile) === 'user-profile'`.\n */\nexport function entityPath<E>(entity: Type<E>): string {\n return kebabCase(entity.name);\n}\n\nexport type RouteMatch = {\n readonly op: CrudOperation;\n /**\n * the resolved transport method - differs from the op's canonical route method for QUERY.\n */\n readonly method: HttpMethod;\n readonly id?: string;\n};\n\n/**\n * Resolve a (method, sub-path) pair to a CRUD operation. Literal sub-paths win over `:id`.\n */\nexport function matchRoute(method: string, subPath: string | undefined): RouteMatch | undefined {\n const raw = method.toUpperCase();\n const literal = subPath === undefined ? '' : `/${subPath}`;\n if (raw === 'QUERY') {\n const op = QUERY_READ_OPS.get(literal);\n return op ? { op, method: 'QUERY' } : undefined;\n }\n // HEAD reads like GET per HTTP semantics; the server runtime omits the response body\n const verb = raw === 'HEAD' ? 'GET' : raw;\n let idOp: CrudOperation | undefined;\n for (const op of CRUD_OPS) {\n const route = CRUD_ROUTES[op];\n if (route.method !== verb) {\n continue;\n }\n if (route.path === literal) {\n return { op, method: route.method };\n }\n if (route.path === '/:id') {\n idOp = op;\n }\n }\n return idOp && subPath !== undefined ? { op: idOp, method: CRUD_ROUTES[idOp].method, id: subPath } : undefined;\n}\n\nexport type RequestSuccessResponse<E> = {\n data: E;\n count?: number;\n};\n\nexport type RequestCountedSuccessResponse<E> = RequestSuccessResponse<E> & {\n count: number;\n};\n\nexport type RequestErrorResponse = {\n readonly error: {\n readonly message: string;\n readonly code: number;\n };\n};\n\n/**\n * Map a thrown error to the wire error envelope. Honors a numeric `status` on the error\n * (e.g. hooks throwing 403), defaults to 500; `code` mirrors the HTTP status.\n */\nexport function toErrorResponse(err: unknown): { status: number; body: RequestErrorResponse } {\n const status = err instanceof Error && 'status' in err && typeof err.status === 'number' ? err.status : 500;\n const message = err instanceof Error ? err.message : 'Internal Server Error';\n return { status, body: { error: { message, code: status } } };\n}\n",
10
10
  "import type { Query, QueryOptions } from '../type/index.js';\n// the specific util module, not the barrel, so the browser bundle does not pull in entity metadata\nimport { getKeys } from '../util/object.util.js';\n\nconst JSON_QUERY_KEYS = [\n '$select',\n '$populate',\n '$exclude',\n '$where',\n '$sort',\n] as const satisfies readonly (keyof Query<unknown>)[];\n\n/**\n * Keys accepted from the wire - query structure ({@link Query}) plus the `hardDelete`/`count` scalar\n * flags. Anything else (e.g. `filters`, `context`, `$entity`) is dropped so a remote client can't\n * bypass a security filter or inject ambient context - those are server-only. The `satisfies` ties\n * every entry to a real query/option key, so a typo or a renamed option fails to compile.\n */\nconst ALLOWED_QUERY_KEYS = new Set<string>([...JSON_QUERY_KEYS, '$skip', '$limit', 'hardDelete', 'count'] satisfies (\n | keyof Query<unknown>\n | keyof Pick<QueryOptions, 'hardDelete'>\n | 'count'\n)[]);\n\n/**\n * Parse raw query-string entries (with JSON-stringified values) into a UQL query object.\n * Symmetric counterpart of {@link stringifyQuery}. Only {@link ALLOWED_QUERY_KEYS} are honored.\n */\nexport function parseQueryParams(params: Record<string, unknown> = {}): Query<unknown> {\n const query: Record<string, unknown> = {};\n for (const key of getKeys(params)) {\n if (ALLOWED_QUERY_KEYS.has(key)) {\n query[key] = params[key];\n }\n }\n\n for (const key of JSON_QUERY_KEYS) {\n const value = query[key];\n if (typeof value === 'string') {\n try {\n query[key] = JSON.parse(value);\n } catch {\n throw Object.assign(new SyntaxError(`invalid JSON in '${key}'`), { status: 400 });\n }\n }\n }\n\n query['$where'] ??= {};\n\n if (query['$skip']) {\n query['$skip'] = Number(query['$skip']);\n }\n if (query['$limit']) {\n query['$limit'] = Number(query['$limit']);\n }\n\n return query as Query<unknown>;\n}\n\n/**\n * Serialize a UQL query object into a percent-encoded query string where object values\n * are JSON-stringified. Symmetric counterpart of {@link parseQueryParams}.\n */\nexport function stringifyQuery(query?: Record<string, unknown>): string {\n if (!query) {\n return '';\n }\n const params = new URLSearchParams();\n for (const key of getKeys(query)) {\n const value = query[key];\n if (value === undefined) {\n continue;\n }\n params.append(key, typeof value === 'object' && value !== null ? JSON.stringify(value) : String(value));\n }\n const qs = params.toString();\n return qs ? `?${qs}` : '';\n}\n",
11
11
  "import {\n CRUD_ROUTES,\n entityPath,\n type HttpMethod,\n type RequestCountedSuccessResponse,\n type RequestSuccessResponse,\n} from '../../http/contract.js';\nimport { stringifyQuery } from '../../http/query.js';\nimport type { IdValue, Query, QueryOne, QueryOptions, QuerySearch, Type, UpdatePayload } from '../../type/index.js';\nimport { get, query as httpQuery, patch, post, put, remove } from '../http/index.js';\nimport type { ClientQuerier, RequestFindOptions, RequestOptions } from '../type/index.js';\n\nexport type HttpQuerierDefaults = {\n /**\n * headers sent with every request from this instance, merged under per-call headers.\n * Create one instance per request (e.g. during SSR) to scope auth headers safely.\n */\n readonly headers?: Record<string, string>;\n /**\n * transport for read queries (findOne, findMany, count). 'QUERY' (RFC 10008) sends the\n * JSON query in the request body, avoiding URL-length limits for large queries; requires\n * infrastructure (proxies, CDNs) that forwards the QUERY method. Defaults to 'GET'.\n */\n readonly readMethod?: Extract<HttpMethod, 'GET' | 'QUERY'>;\n};\n\nexport class HttpQuerier implements ClientQuerier {\n constructor(\n readonly basePath: string,\n readonly defaults: HttpQuerierDefaults = {},\n ) {}\n\n findOneById<E extends object>(\n entity: Type<E>,\n id: IdValue<E>,\n q?: QueryOne<E>,\n opts?: RequestOptions,\n ): Promise<RequestSuccessResponse<E | undefined>> {\n const basePath = this.getBasePath(entity);\n const qs = stringifyQuery(q);\n return get<E | undefined>(`${basePath}/${id}${qs}`, this.buildOptions(opts));\n }\n\n findOne<E extends object>(\n entity: Type<E>,\n q: QueryOne<E>,\n opts?: RequestOptions,\n ): Promise<RequestSuccessResponse<E | undefined>> {\n return this.read<E | undefined>(`${this.getBasePath(entity)}${CRUD_ROUTES.findOne.path}`, q, opts);\n }\n\n findMany<E extends object>(\n entity: Type<E>,\n q: Query<E>,\n opts?: RequestFindOptions,\n ): Promise<RequestSuccessResponse<E[]>> {\n const data: Query<E> & { count?: boolean } = { ...q };\n if (opts?.count) {\n data.count = true;\n }\n return this.read<E[]>(this.getBasePath(entity), data, opts);\n }\n\n async findManyAndCount<E extends object>(\n entity: Type<E>,\n q: Query<E>,\n opts?: RequestFindOptions,\n ): Promise<RequestCountedSuccessResponse<E[]>> {\n const response = await this.findMany(entity, q, { ...opts, count: true });\n if (typeof response.count !== 'number') {\n throw new TypeError('findManyAndCount response has an invalid count');\n }\n return { ...response, count: response.count };\n }\n\n count<E extends object>(entity: Type<E>, q?: QuerySearch<E>, opts?: RequestOptions) {\n return this.read<number>(`${this.getBasePath(entity)}${CRUD_ROUTES.count.path}`, q, opts);\n }\n\n insertOne<E extends object>(entity: Type<E>, payload: E, opts?: RequestOptions) {\n const basePath = this.getBasePath(entity);\n return post<IdValue<E> | undefined>(basePath, payload, this.buildOptions(opts));\n }\n\n insertMany<E extends object>(entity: Type<E>, payload: E[], opts?: RequestOptions) {\n const basePath = this.getBasePath(entity);\n return post<IdValue<E>[]>(`${basePath}${CRUD_ROUTES.insertMany.path}`, payload, this.buildOptions(opts));\n }\n\n updateOneById<E extends object>(entity: Type<E>, id: IdValue<E>, payload: UpdatePayload<E>, opts?: RequestOptions) {\n const basePath = this.getBasePath(entity);\n return patch<number>(`${basePath}/${id}`, payload, this.buildOptions(opts));\n }\n\n updateMany<E extends object>(entity: Type<E>, q: QuerySearch<E>, payload: UpdatePayload<E>, opts?: RequestOptions) {\n const basePath = this.getBasePath(entity);\n const qs = stringifyQuery(q);\n return patch<number>(`${basePath}${qs}`, payload, this.buildOptions(opts));\n }\n\n saveOne<E extends object>(entity: Type<E>, payload: E, opts?: RequestOptions) {\n const basePath = this.getBasePath(entity);\n return put<IdValue<E>>(basePath, payload, this.buildOptions(opts));\n }\n\n saveMany<E extends object>(entity: Type<E>, payload: E[], opts?: RequestOptions) {\n const basePath = this.getBasePath(entity);\n return put<IdValue<E>[]>(`${basePath}${CRUD_ROUTES.saveMany.path}`, payload, this.buildOptions(opts));\n }\n\n deleteOneById<E extends object>(entity: Type<E>, id: IdValue<E>, opts: QueryOptions & RequestOptions = {}) {\n const basePath = this.getBasePath(entity);\n const qs = opts.hardDelete ? stringifyQuery({ hardDelete: opts.hardDelete }) : '';\n return remove<number>(`${basePath}/${id}${qs}`, this.buildOptions(opts));\n }\n\n deleteMany<E extends object>(entity: Type<E>, q: QuerySearch<E>, opts: QueryOptions & RequestOptions = {}) {\n const basePath = this.getBasePath(entity);\n const qs = stringifyQuery(opts.hardDelete ? { ...q, hardDelete: opts.hardDelete } : q);\n return remove<number>(`${basePath}${qs}`, this.buildOptions(opts));\n }\n\n getBasePath<E>(entity: Type<E>) {\n return `${this.basePath}/${entityPath(entity)}`;\n }\n\n protected read<T>(path: string, q: Record<string, unknown> | undefined, opts?: RequestOptions) {\n if (this.defaults.readMethod === 'QUERY') {\n return httpQuery<T>(path, q ?? {}, this.buildOptions(opts));\n }\n return get<T>(`${path}${stringifyQuery(q)}`, this.buildOptions(opts));\n }\n\n protected buildOptions(opts?: RequestOptions): RequestOptions | undefined {\n if (!this.defaults.headers && !opts?.headers) {\n return opts;\n }\n return { ...opts, headers: { ...this.defaults.headers, ...opts?.headers } };\n }\n}\n",
@@ -26,7 +26,7 @@ export class CockroachDialect extends PgLikeSqlDialect {
26
26
  * `NULLS FIRST/LAST` answers "unimplemented: this syntax" and `jsonb_path_ops` "operator class is
27
27
  * not supported" (both verified on v26.2), so neither is offered here.
28
28
  */
29
- indexFeatures = new Set(['expression', 'include']);
29
+ indexFeatures = new Set(['expression', 'partial', 'include']);
30
30
  /**
31
31
  * CockroachDB's vector index is native and has its own syntax: `CREATE VECTOR INDEX ... ("col"
32
32
  * vector_cosine_ops)`, with no access-method keyword, and tuning knobs of its own names that UQL
@@ -50,8 +50,9 @@ export declare abstract class IndexSqlDialect extends VectorSqlDialect {
50
50
  /** pgvector's ` WITH (m = ..., ef_construction = ..., lists = ...)`. */
51
51
  protected indexTuning(_index: IndexSchema): string;
52
52
  /**
53
- * The partial-index predicate. Dialects without partial indexes throw instead of dropping it:
54
- * silently widening a partial unique index changes which rows the database accepts.
53
+ * The partial-index predicate. Engines without one reject the index in {@link assertIndexFeatures}
54
+ * rather than reaching here: silently widening a partial unique index changes which rows the
55
+ * database accepts.
55
56
  */
56
57
  protected indexPredicate(index: IndexSchema): string;
57
58
  }
@@ -18,7 +18,7 @@ export class IndexSqlDialect extends VectorSqlDialect {
18
18
  this.assertIndexFeatures(index);
19
19
  const unique = index.unique ? 'UNIQUE ' : '';
20
20
  const ifNotExists = (opts.ifNotExists ?? this.features.indexIfNotExists) ? 'IF NOT EXISTS ' : '';
21
- const columns = index.columns.map((entry) => this.indexColumn(entry, index)).join(', ');
21
+ const columns = index.entries.map((entry) => this.indexColumn(entry, index)).join(', ');
22
22
  return (`CREATE ${unique}${this.indexKeyword(index)} ${ifNotExists}${this.escapeId(index.name)} ` +
23
23
  `ON ${this.escapeId(tableName)}${this.indexAccessMethod(index)} (${columns})` +
24
24
  `${this.indexInclude(index)}${this.indexTuning(index)}${this.indexPredicate(index)};`);
@@ -28,13 +28,14 @@ export class IndexSqlDialect extends VectorSqlDialect {
28
28
  * refused by at least one other, so an index asking for a missing one is rejected rather than
29
29
  * emitted: each of them is a hard error at the server, not a slower plan.
30
30
  */
31
- indexFeatures = new Set(['expression']);
31
+ indexFeatures = new Set(['expression', 'partial']);
32
32
  assertIndexFeatures(index) {
33
33
  const requested = [
34
- ['expression', index.columns.some((entry) => entry.expression)],
35
- ['prefixLength', index.columns.some((entry) => entry.length !== undefined)],
36
- ['nullsOrder', index.columns.some((entry) => entry.nulls !== undefined)],
37
- ['opsClass', index.columns.some((entry) => entry.opsClass !== undefined)],
34
+ ['expression', index.entries.some((entry) => entry.expression)],
35
+ ['partial', index.where !== undefined],
36
+ ['prefixLength', index.entries.some((entry) => entry.length !== undefined)],
37
+ ['nullsOrder', index.entries.some((entry) => entry.nulls !== undefined)],
38
+ ['opsClass', index.entries.some((entry) => entry.opsClass !== undefined)],
38
39
  ['include', Boolean(index.include?.length)],
39
40
  ];
40
41
  for (const [feature, needed] of requested) {
@@ -92,8 +93,9 @@ export class IndexSqlDialect extends VectorSqlDialect {
92
93
  return '';
93
94
  }
94
95
  /**
95
- * The partial-index predicate. Dialects without partial indexes throw instead of dropping it:
96
- * silently widening a partial unique index changes which rows the database accepts.
96
+ * The partial-index predicate. Engines without one reject the index in {@link assertIndexFeatures}
97
+ * rather than reaching here: silently widening a partial unique index changes which rows the
98
+ * database accepts.
97
99
  */
98
100
  indexPredicate(index) {
99
101
  return index.where ? ` WHERE ${index.where}` : '';
@@ -56,8 +56,6 @@ export declare abstract class MysqlLikeSqlDialect extends AbstractSqlDialect {
56
56
  protected indexKeyword(index: IndexSchema): string;
57
57
  protected readonly indexFeatures: Set<IndexFeature>;
58
58
  protected indexAccessMethod(index: IndexSchema): string;
59
- /** Neither MySQL nor MariaDB has partial indexes, and quietly widening one changes which rows it rejects. */
60
- protected indexPredicate(index: IndexSchema): string;
61
59
  protected numericCast(expr: string): string;
62
60
  protected ilikeExpr(f: string, ph: string): string;
63
61
  protected neExpr(field: string, ph: string): string;
@@ -105,13 +105,6 @@ export class MysqlLikeSqlDialect extends AbstractSqlDialect {
105
105
  indexAccessMethod(index) {
106
106
  return index.type && index.type !== 'fulltext' ? ` USING ${index.type}` : '';
107
107
  }
108
- /** Neither MySQL nor MariaDB has partial indexes, and quietly widening one changes which rows it rejects. */
109
- indexPredicate(index) {
110
- if (index.where) {
111
- throw new TypeError(`${this.dialectName} does not support partial indexes (index "${index.name}" declares a "where" condition)`);
112
- }
113
- return '';
114
- }
115
108
  numericCast(expr) {
116
109
  return `CAST(${expr} AS DECIMAL)`;
117
110
  }
@@ -70,6 +70,7 @@ export class PgLikeSqlDialect extends AbstractSqlDialect {
70
70
  }
71
71
  indexFeatures = new Set([
72
72
  'expression',
73
+ 'partial',
73
74
  'nullsOrder',
74
75
  'opsClass',
75
76
  'include',
@@ -72,7 +72,7 @@ export class MariaDialect extends MysqlLikeSqlDialect {
72
72
  * being dropped, which would silently build the index on cosine instead.
73
73
  */
74
74
  getInlineVectorIndexDeclaration(index) {
75
- const columns = index.columns.map((entry) => this.indexColumnTarget(entry)).join(', ');
75
+ const columns = index.entries.map((entry) => this.indexColumnTarget(entry)).join(', ');
76
76
  let clause = `VECTOR INDEX (${columns})`;
77
77
  if (index.m !== undefined) {
78
78
  clause += ` M=${index.m}`;
@@ -27,7 +27,7 @@ function createIndexOperation(tableName, columns, options = {}) {
27
27
  index: {
28
28
  ...index,
29
29
  name: name ?? `idx_${tableName}_${entries.map((entry) => entry.column).join('_')}`,
30
- columns: entries,
30
+ entries,
31
31
  unique: unique ?? false,
32
32
  },
33
33
  };
@@ -167,7 +167,7 @@ export class TableBuilder {
167
167
  this._indexes.push({
168
168
  ...rest,
169
169
  name: name ?? `${prefix}_${this._name}_${entries.map((entry) => entry.column).join('_')}`,
170
- columns: entries,
170
+ entries,
171
171
  unique,
172
172
  });
173
173
  return this;
@@ -195,7 +195,7 @@ export class TableBuilder {
195
195
  if (!this._indexes.some((idx) => idx.name === indexName)) {
196
196
  this._indexes.push({
197
197
  name: indexName,
198
- columns: [{ column: col.name }],
198
+ entries: [{ column: col.name }],
199
199
  unique: col.unique,
200
200
  });
201
201
  }
@@ -1,13 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
  import * as fs from 'node:fs';
3
3
  import * as path from 'node:path';
4
- import { SchemaASTBuilder } from '../schema/index.js';
5
4
  import { assertCliConfig } from './assertCliConfig.js';
6
5
  import { loadConfig } from './cli-config.js';
7
6
  import { createEntityCodeGenerator } from './codegen/entityCodeGenerator.js';
8
7
  import { detectDrift } from './drift/driftDetector.js';
9
8
  import { Migrator } from './migrator.js';
10
- import { createSchemaGenerator } from './schemaGenerator.js';
9
+ import { buildEntityAST, createSchemaGenerator } from './schemaGenerator.js';
11
10
  import { createSchemaGeneratorAsync } from './schemaGeneratorAsync.js';
12
11
  import { DEFAULT_MIGRATIONS_TABLE } from './storage/databaseStorage.js';
13
12
  /** Sync helper for SQL dialects only; returns `undefined` for MongoDB - use {@link createSchemaGeneratorAsync}. */
@@ -253,21 +252,20 @@ export async function runDriftCheck(migrator, config) {
253
252
  console.error('No entities configured. Add entities to your uql config.');
254
253
  process.exit(1);
255
254
  }
256
- else if (!migrator.schemaIntrospector) {
255
+ else if (!migrator.schemaIntrospector || !migrator.schemaGenerator) {
257
256
  console.error('No introspector available. Check your pool configuration.');
258
257
  process.exit(1);
259
258
  }
260
259
  else {
261
260
  console.log('\nChecking for schema drift...');
262
- // Build expected schema from entities
263
- const builder = new SchemaASTBuilder();
264
- const expectedAST = builder.fromEntities(config.entities);
261
+ const expectedAST = buildEntityAST(migrator.schemaGenerator, config.entities);
265
262
  // Build actual schema from database
266
263
  const actualAST = await migrator.schemaIntrospector.introspect();
267
264
  // Detect drift. The dialect renders canonical types as SQL - without it every type formats as
268
265
  // `unknown` and type drift compares equal, silently reporting a mismatched column as in sync.
269
266
  const report = detectDrift(expectedAST, actualAST, {
270
267
  dialect: config.pool?.dialect,
268
+ indexFacets: migrator.schemaIntrospector.indexFacets,
271
269
  excludeTables: [config.tableName ?? DEFAULT_MIGRATIONS_TABLE],
272
270
  });
273
271
  printDriftReport(report);
@@ -85,6 +85,11 @@ export declare class EntityCodeGenerator {
85
85
  * Build Field decorator options.
86
86
  */
87
87
  private buildFieldOptions;
88
+ /**
89
+ * The indexes this table needs an `@Index` for, which is every one a `@Field({ index })` cannot
90
+ * carry on its own.
91
+ */
92
+ private declaredIndexes;
88
93
  /**
89
94
  * Build relation definitions.
90
95
  */
@@ -13,6 +13,7 @@ import { canonicalToTypeScript } from '../../schema/canonicalType.js';
13
13
  import { DEFAULT_FOREIGN_KEY_ACTION, } from '../../schema/types.js';
14
14
  import { camelCase, pascalCase, singularize } from '../../util/string.util.js';
15
15
  import { buildFieldOptionsSource } from './fieldOptionsSource.js';
16
+ import { buildIndexDecoratorSource, indexNeedsRaw, isPlainFieldIndex } from './indexDecoratorSource.js';
16
17
  /**
17
18
  * Generates TypeScript entity code from SchemaAST.
18
19
  */
@@ -91,12 +92,14 @@ export class EntityCodeGenerator {
91
92
  }
92
93
  }
93
94
  }
94
- // Check for composite index decorators
95
95
  if (this.options.includeIndexes) {
96
- const compositeIndexes = this.ast.getTableIndexes(table.name).filter((idx) => idx.columns.length > 1);
97
- if (compositeIndexes.length > 0) {
96
+ const declared = this.declaredIndexes(table);
97
+ if (declared.length > 0) {
98
98
  uqlImports.add('Index');
99
99
  }
100
+ if (declared.some(indexNeedsRaw)) {
101
+ uqlImports.add('raw');
102
+ }
100
103
  }
101
104
  let code = `import { ${Array.from(uqlImports).sort().join(', ')} } from '${this.options.uqlImportPath}';\n`;
102
105
  // Add related entity imports
@@ -110,18 +113,9 @@ export class EntityCodeGenerator {
110
113
  */
111
114
  buildEntityDecorators(table) {
112
115
  const lines = [];
113
- // Add composite index decorators
114
116
  if (this.options.includeIndexes) {
115
- const compositeIndexes = this.ast.getTableIndexes(table.name).filter((idx) => idx.columns.length > 1);
116
- for (const idx of compositeIndexes) {
117
- const propNames = idx.columns.map((c) => `'${this.options.propertyNameTransformer(c.name)}'`).join(', ');
118
- const options = [];
119
- if (idx.name)
120
- options.push(`name: '${idx.name}'`);
121
- if (idx.unique)
122
- options.push('unique: true');
123
- const optStr = options.length > 0 ? `, { ${options.join(', ')} }` : '';
124
- lines.push(`@Index([${propNames}]${optStr})`);
117
+ for (const index of this.declaredIndexes(table)) {
118
+ lines.push(buildIndexDecoratorSource(index, this.options.propertyNameTransformer));
125
119
  }
126
120
  }
127
121
  // Entity decorator
@@ -158,11 +152,11 @@ export class EntityCodeGenerator {
158
152
  }
159
153
  // Decorator
160
154
  if (col.isPrimaryKey) {
161
- const idOptions = this.buildIdOptions(col);
155
+ const idOptions = this.buildIdOptions(col, propertyName);
162
156
  lines.push(` @Id(${idOptions})`);
163
157
  }
164
158
  else {
165
- const fieldOptions = this.buildFieldOptions(col);
159
+ const fieldOptions = this.buildFieldOptions(col, propertyName);
166
160
  lines.push(` @Field(${fieldOptions})`);
167
161
  }
168
162
  // Property
@@ -173,20 +167,23 @@ export class EntityCodeGenerator {
173
167
  /**
174
168
  * Build Id decorator options.
175
169
  */
176
- buildIdOptions(col) {
177
- const options = [];
178
- if (col.name !== 'id') {
179
- options.push(`name: '${col.name}'`);
180
- }
181
- return options.length > 0 ? `{ ${options.join(', ')} }` : '';
170
+ buildIdOptions(col, propertyName) {
171
+ return propertyName === col.name ? '' : `{ name: '${col.name}' }`;
182
172
  }
183
173
  /**
184
174
  * Build Field decorator options.
185
175
  */
186
- buildFieldOptions(col) {
176
+ buildFieldOptions(col, propertyName) {
187
177
  const indexes = this.options.includeIndexes ? this.ast.getTableIndexes(col.table.name) : [];
188
- const singleColIndex = indexes.find((idx) => idx.columns.length === 1 && idx.columns[0].name === col.name);
189
- return buildFieldOptionsSource(col, singleColIndex?.name);
178
+ const fieldIndex = indexes.find((idx) => isPlainFieldIndex(idx) && idx.entries[0]?.column === col.name);
179
+ return buildFieldOptionsSource(col, propertyName, fieldIndex?.name);
180
+ }
181
+ /**
182
+ * The indexes this table needs an `@Index` for, which is every one a `@Field({ index })` cannot
183
+ * carry on its own.
184
+ */
185
+ declaredIndexes(table) {
186
+ return this.ast.getTableIndexes(table.name).filter((index) => !isPlainFieldIndex(index));
190
187
  }
191
188
  /**
192
189
  * Build relation definitions.
@@ -7,4 +7,4 @@ import type { ColumnNode } from '../../schema/types.js';
7
7
  * merging a column into an existing entity file quietly produced a weaker field than generating the
8
8
  * file from scratch.
9
9
  */
10
- export declare function buildFieldOptionsSource(col: ColumnNode, indexName?: string): string;
10
+ export declare function buildFieldOptionsSource(col: ColumnNode, propertyName: string, indexName?: string): string;
@@ -7,8 +7,13 @@ import { canonicalToColumnType } from '../../schema/canonicalType.js';
7
7
  * merging a column into an existing entity file quietly produced a weaker field than generating the
8
8
  * file from scratch.
9
9
  */
10
- export function buildFieldOptionsSource(col, indexName) {
10
+ export function buildFieldOptionsSource(col, propertyName, indexName) {
11
11
  const options = [];
12
+ // Without this the entity maps to a column named after the property, which for anything the
13
+ // transformer rewrote - every `user_id` - is a column the database does not have.
14
+ if (propertyName !== col.name) {
15
+ options.push(`name: '${col.name}'`);
16
+ }
12
17
  const columnType = canonicalToColumnType(col.type);
13
18
  if (columnType) {
14
19
  options.push(`columnType: '${columnType}'`);
@@ -0,0 +1,14 @@
1
+ import type { IndexNode } from '../../schema/types.js';
2
+ /**
3
+ * Whether `@Field({ index })` can carry the whole index. It says only "this column is indexed under
4
+ * this name", so anything else the index declares - an expression, a predicate, uniqueness, an access
5
+ * method, stored columns, a stored order - has to be written out as `@Index([...])` instead.
6
+ */
7
+ export declare function isPlainFieldIndex(index: IndexNode): boolean;
8
+ /**
9
+ * One `@Index([...])` as source, for an index no `@Field` can express. Emits `raw(...)` for an
10
+ * expression entry, so callers import `raw` when {@link indexNeedsRaw} holds.
11
+ */
12
+ export declare function buildIndexDecoratorSource(index: IndexNode, propertyName: (column: string) => string): string;
13
+ /** Whether emitting this index needs `raw` imported alongside `Index`. */
14
+ export declare function indexNeedsRaw(index: IndexNode): boolean;
@@ -0,0 +1,105 @@
1
+ /**
2
+ * A vector index carries its metric in the operator class pgvector names after it
3
+ * (`vector_cosine_ops`), which is the only place introspection can recover it from. `@Index` requires
4
+ * a `distance` beside a vector `type`, so emitting the type without one would not compile.
5
+ */
6
+ const DISTANCE_BY_OPS_SUFFIX = new Map([
7
+ ['cosine', 'cosine'],
8
+ ['l2', 'l2'],
9
+ ['ip', 'inner'],
10
+ ['l1', 'l1'],
11
+ ]);
12
+ function vectorDistance(index) {
13
+ if (index.distance) {
14
+ return index.distance;
15
+ }
16
+ const opsClass = index.entries.map((entry) => entry.opsClass).find(Boolean);
17
+ const suffix = opsClass?.match(/_(\w+)_ops$/)?.[1];
18
+ return suffix === undefined ? undefined : DISTANCE_BY_OPS_SUFFIX.get(suffix);
19
+ }
20
+ /**
21
+ * The per-entry modifiers worth writing into an entity, which is not everything introspection reports.
22
+ * Postgres states an entry in full - a plain column comes back `order: 'asc', nulls: 'last'` - and
23
+ * emitting that would bake one engine's defaults into source that is meant to run on any of them.
24
+ * `nulls` never survives for the same reason: it is Postgres-only and always reported.
25
+ */
26
+ function significantModifiers(entry) {
27
+ const parts = [];
28
+ if (entry.order === 'desc')
29
+ parts.push(`order: 'desc'`);
30
+ if (entry.opsClass)
31
+ parts.push(`opsClass: '${entry.opsClass}'`);
32
+ if (entry.length !== undefined)
33
+ parts.push(`length: ${entry.length}`);
34
+ return parts;
35
+ }
36
+ /**
37
+ * Whether `@Field({ index })` can carry the whole index. It says only "this column is indexed under
38
+ * this name", so anything else the index declares - an expression, a predicate, uniqueness, an access
39
+ * method, stored columns, a stored order - has to be written out as `@Index([...])` instead.
40
+ */
41
+ export function isPlainFieldIndex(index) {
42
+ const entries = index.entries;
43
+ const [entry] = entries;
44
+ return (entries.length === 1 &&
45
+ entry !== undefined &&
46
+ !entry.expression &&
47
+ !index.unique &&
48
+ index.where === undefined &&
49
+ // Postgres names an access method on every index, so the default one still counts as plain.
50
+ (index.type === undefined || index.type === 'btree') &&
51
+ !index.include?.length &&
52
+ significantModifiers(entry).length === 0);
53
+ }
54
+ /**
55
+ * One `@Index([...])` as source, for an index no `@Field` can express. Emits `raw(...)` for an
56
+ * expression entry, so callers import `raw` when {@link indexNeedsRaw} holds.
57
+ */
58
+ export function buildIndexDecoratorSource(index, propertyName) {
59
+ const entries = index.entries.map((entry) => indexEntrySource(entry, propertyName)).join(', ');
60
+ const isVector = index.type === 'hnsw' || index.type === 'ivfflat';
61
+ const distance = isVector ? vectorDistance(index) : undefined;
62
+ const options = [];
63
+ if (index.name)
64
+ options.push(`name: '${index.name}'`);
65
+ if (index.unique)
66
+ options.push('unique: true');
67
+ // `btree` is every engine's default and is reported on every index, so writing it out would put it
68
+ // in every generated entity. A vector type whose metric could not be recovered is left off too,
69
+ // rather than written out in a form that does not compile.
70
+ const writesType = index.type !== undefined && index.type !== 'btree' && (distance !== undefined || !isVector);
71
+ if (writesType) {
72
+ options.push(`type: '${index.type}'`);
73
+ }
74
+ if (distance)
75
+ options.push(`distance: '${distance}'`);
76
+ if (index.where)
77
+ options.push(`where: ${quote(index.where)}`);
78
+ if (index.include?.length) {
79
+ options.push(`include: [${index.include.map((column) => `'${propertyName(column)}'`).join(', ')}]`);
80
+ }
81
+ return `@Index([${entries}]${options.length > 0 ? `, { ${options.join(', ')} }` : ''})`;
82
+ }
83
+ /** Whether emitting this index needs `raw` imported alongside `Index`. */
84
+ export function indexNeedsRaw(index) {
85
+ return index.entries.some((entry) => entry.expression);
86
+ }
87
+ function indexEntrySource(entry, propertyName) {
88
+ if (entry.expression) {
89
+ return `raw(${quote(entry.column)})`;
90
+ }
91
+ const modifiers = significantModifiers(entry);
92
+ if (modifiers.length === 0) {
93
+ return `'${propertyName(entry.column)}'`;
94
+ }
95
+ return `{ column: '${propertyName(entry.column)}', ${modifiers.join(', ')} }`;
96
+ }
97
+ /**
98
+ * SQL as a TypeScript string literal. `JSON.stringify` rather than hand-rolled quoting: a reprinted
99
+ * expression is arbitrary text, and it arrives multi-line, carrying quotes of both kinds and
100
+ * backslashes (`name ~ '\\d+'`), each of which a naive wrapper turns into source that does not
101
+ * compile or, worse, compiles to a different index.
102
+ */
103
+ function quote(sql) {
104
+ return JSON.stringify(sql);
105
+ }
@@ -5,6 +5,7 @@
5
5
  * actual database schema.
6
6
  */
7
7
  import type { AbstractDialect } from '../../dialect/abstractDialect.js';
8
+ import type { IndexFacet } from '../../schema/indexDifferences.js';
8
9
  import type { SchemaAST } from '../../schema/schemaAST.js';
9
10
  import type { DriftReport } from '../../schema/types.js';
10
11
  /**
@@ -17,6 +18,8 @@ export interface DriftDetectorOptions {
17
18
  checkNullable?: boolean;
18
19
  /** Include index differences */
19
20
  checkIndexes?: boolean;
21
+ /** `indexFacets` of the introspector that produced the actual schema; anything else goes uncompared. */
22
+ indexFacets?: ReadonlySet<IndexFacet>;
20
23
  /** Include foreign key differences */
21
24
  checkForeignKeys?: boolean;
22
25
  /**
@@ -34,55 +37,7 @@ export interface DriftDetectorOptions {
34
37
  dialect?: AbstractDialect;
35
38
  }
36
39
  /**
37
- * Detects drift between expected and actual database schemas.
38
- */
39
- export declare class DriftDetector {
40
- private readonly expectedAST;
41
- private readonly actualAST;
42
- private readonly options;
43
- constructor(expectedAST: SchemaAST, actualAST: SchemaAST, options?: DriftDetectorOptions);
44
- /**
45
- * Detect all schema drift.
46
- */
47
- detect(): DriftReport;
48
- /**
49
- * Detect table-level drifts (missing/unexpected tables).
50
- */
51
- private detectTableDrifts;
52
- /**
53
- * Detect column-level drifts.
54
- */
55
- private detectColumnDrifts;
56
- /**
57
- * Add drifts for column alterations (type/nullable mismatches).
58
- */
59
- private addAlterColumnDrifts;
60
- /**
61
- * Detect index drifts.
62
- */
63
- private detectIndexDrifts;
64
- /**
65
- * Detect relationship/FK drifts.
66
- */
67
- private detectRelationshipDrifts;
68
- /**
69
- * Calculate overall status based on drifts.
70
- */
71
- private calculateStatus;
72
- /**
73
- * Create a summary of drifts by severity.
74
- */
75
- private createSummary;
76
- /**
77
- * Format type for display.
78
- */
79
- private formatType;
80
- }
81
- /**
82
- * Create a DriftDetector for comparing expected vs actual schemas.
83
- */
84
- export declare function createDriftDetector(expectedAST: SchemaAST, actualAST: SchemaAST, options?: DriftDetectorOptions): DriftDetector;
85
- /**
86
- * Quick check for schema drift.
40
+ * Compare an expected schema (from entities) with an actual one (from the database) and report every
41
+ * way they have drifted apart.
87
42
  */
88
43
  export declare function detectDrift(expectedAST: SchemaAST, actualAST: SchemaAST, options?: DriftDetectorOptions): DriftReport;