uql-orm 0.85.0 → 0.87.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 +1 -1
- package/dist/d1/d1SqliteDialect.js +1 -1
- package/dist/dialect/mysqlLikeSqlDialect.js +0 -1
- package/dist/dialect/pgLikeSqlDialect.js +0 -1
- package/dist/migrate/ddl/tableDdl.js +4 -2
- package/dist/migrate/introspection/baseSqlIntrospector.d.ts +4 -2
- package/dist/migrate/introspection/baseSqlIntrospector.js +25 -3
- package/dist/migrate/migrator.d.ts +13 -2
- package/dist/migrate/migrator.js +62 -14
- package/dist/migrate/schemaChange.js +1 -0
- package/dist/migrate/schemaGenerator.d.ts +3 -5
- package/dist/migrate/schemaGenerator.js +8 -8
- package/dist/mongo/mongoDialect.js +0 -1
- package/dist/mssql/mssqlDialect.js +0 -1
- package/dist/schema/matchByKey.d.ts +9 -0
- package/dist/schema/matchByKey.js +18 -0
- package/dist/schema/schemaASTDiffer.d.ts +13 -0
- package/dist/schema/schemaASTDiffer.js +35 -4
- package/dist/sqlite/sqliteDialect.js +0 -1
- package/dist/type/dialect.d.ts +0 -5
- package/dist/type/migration.d.ts +24 -4
- package/package.json +1 -8
- package/skills/uql-orm/SKILL.md +1 -9
- package/dist/betterAuth/authEntities.d.ts +0 -7
- package/dist/betterAuth/authEntities.js +0 -154
- package/dist/betterAuth/index.d.ts +0 -2
- package/dist/betterAuth/index.js +0 -2
- package/dist/betterAuth/uqlAdapter.d.ts +0 -16
- package/dist/betterAuth/uqlAdapter.js +0 -155
package/README.md
CHANGED
|
@@ -185,7 +185,7 @@ Only the entities in `include` are served: a `$populate: { author: true }` here
|
|
|
185
185
|
- **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. No `.prisma` file, no generated client.
|
|
186
186
|
- **Relations without N+1.** [`$populate`](https://uql-orm.dev/querying/relations) reads a to-many inside the parent's statement, so a read is one round trip. Nothing is lazy, so nothing fires behind your back in a serializer.
|
|
187
187
|
- **Light.** Zero runtime dependencies and every dialect in one package, yet `uql-orm/postgres` is about 27 kB gzipped. See [what we deleted to get there](https://uql-orm.dev/blog/zero-dependencies).
|
|
188
|
-
- **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), [drift checks](https://uql-orm.dev/migrations) that catch a database that no longer matches
|
|
188
|
+
- **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 [drift checks](https://uql-orm.dev/migrations) that catch a database that no longer matches.
|
|
189
189
|
- **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), on Bun, Node and Deno alike. The same benchmark [scores the types](https://github.com/rogerpadilla/ts-orm-benchmark#type-safety) by compiling ordinary mistakes in each ORM's API: UQL is the only one that catches them all.
|
|
190
190
|
|
|
191
191
|
## Get started
|
|
@@ -7,7 +7,7 @@ import { UqlUsageError } from '../util/uqlError.js';
|
|
|
7
7
|
*/
|
|
8
8
|
export class D1SqliteDialect extends SqliteDialect {
|
|
9
9
|
/** A vector stays text: D1 answers a BLOB as an array of its byte values, which reads like a vector. */
|
|
10
|
-
features = { ...SQLITE_FEATURES, vectorBytes: false
|
|
10
|
+
features = { ...SQLITE_FEATURES, vectorBytes: false };
|
|
11
11
|
// Cloudflare D1 caps bound parameters at 100 per query.
|
|
12
12
|
maxBindValues = 100;
|
|
13
13
|
// And a function call at 32 arguments.
|
|
@@ -34,7 +34,6 @@ export const PG_FEATURES = {
|
|
|
34
34
|
serverSideCursors: true,
|
|
35
35
|
correlatedWrites: true,
|
|
36
36
|
rowLocks: { of: true, withWindow: false, placement: 'suffix' },
|
|
37
|
-
transactions: true,
|
|
38
37
|
nullsOrdering: 'clause',
|
|
39
38
|
textScoreIndexes: false,
|
|
40
39
|
orderedUpsertReturning: true,
|
|
@@ -47,9 +47,11 @@ export class TableDdl {
|
|
|
47
47
|
if (this.dialect.alterColumnStrategy !== 'separate-clauses') {
|
|
48
48
|
return [`ALTER TABLE ${target} ${this.dialect.alterColumnSyntax} ${definition};`];
|
|
49
49
|
}
|
|
50
|
-
const
|
|
50
|
+
const name = this.dialect.escapeId(column.name);
|
|
51
|
+
const alter = `ALTER TABLE ${target} ALTER COLUMN ${name}`;
|
|
51
52
|
return [
|
|
52
|
-
|
|
53
|
+
// Cast, since the engine converts only between types it deems compatible: text to integer needs saying.
|
|
54
|
+
(!from || from.type !== column.type) && `${alter} TYPE ${column.type} USING ${name}::${column.type};`,
|
|
53
55
|
(!from || from.nullable !== column.nullable) && `${alter} ${column.nullable ? 'DROP NOT NULL' : 'SET NOT NULL'};`,
|
|
54
56
|
(!from || !sameDefault(column.defaultValue, from.defaultValue, this.dialect)) &&
|
|
55
57
|
(column.defaultValue === undefined ? `${alter} DROP DEFAULT;` : `${alter} SET${this.defaultClause(column)};`),
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { AbstractSqlDialect } from '../../dialect/index.js';
|
|
2
2
|
import type { IndexFacet } from '../../schema/indexDifferences.js';
|
|
3
3
|
import { SchemaAST } from '../../schema/schemaAST.js';
|
|
4
|
-
import type { TableSchema } from '../../type/migration.js';
|
|
4
|
+
import type { ColumnRenames, TableSchema } from '../../type/migration.js';
|
|
5
5
|
/**
|
|
6
6
|
* Base class for SQL introspectors with shared AST building logic.
|
|
7
7
|
*/
|
|
@@ -22,7 +22,9 @@ export declare abstract class BaseSqlIntrospector {
|
|
|
22
22
|
* rather than raised: the point of naming them is to read a database other things are still
|
|
23
23
|
* changing, where scanning every table is both wasted work and a relation that can vanish mid-scan.
|
|
24
24
|
*/
|
|
25
|
-
introspect(tables?: readonly string[]): Promise<SchemaAST>;
|
|
25
|
+
introspect(tables?: readonly string[], renames?: ColumnRenames): Promise<SchemaAST>;
|
|
26
|
+
/** `table` with each column `renames` names under its new name, wherever the table names it. */
|
|
27
|
+
private renamed;
|
|
26
28
|
abstract getTableNames(): Promise<string[]>;
|
|
27
29
|
abstract getTableSchema(tableName: string): Promise<TableSchema | undefined>;
|
|
28
30
|
/**
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { canonicalColumnType } from '../../schema/canonicalType.js';
|
|
2
2
|
import { createTableNode, keyOfColumns, SchemaAST } from '../../schema/schemaAST.js';
|
|
3
3
|
import { escapeSqlId } from '../../util/index.js';
|
|
4
|
-
import { derivedForeignKeyName } from '../../util/sql.util.js';
|
|
4
|
+
import { derivedForeignKeyName, qualifyName } from '../../util/sql.util.js';
|
|
5
5
|
/**
|
|
6
6
|
* Base class for SQL introspectors with shared AST building logic.
|
|
7
7
|
*/
|
|
@@ -27,17 +27,39 @@ export class BaseSqlIntrospector {
|
|
|
27
27
|
* rather than raised: the point of naming them is to read a database other things are still
|
|
28
28
|
* changing, where scanning every table is both wasted work and a relation that can vanish mid-scan.
|
|
29
29
|
*/
|
|
30
|
-
async introspect(tables) {
|
|
30
|
+
async introspect(tables, renames) {
|
|
31
31
|
const tableNames = tables ?? (await this.getTableNames());
|
|
32
32
|
const tableSchemas = [];
|
|
33
33
|
for (const tableName of tableNames) {
|
|
34
34
|
const schema = await this.getTableSchema(tableName);
|
|
35
35
|
if (schema) {
|
|
36
|
-
tableSchemas.push(schema);
|
|
36
|
+
tableSchemas.push(renames ? this.renamed(schema, renames) : schema);
|
|
37
37
|
}
|
|
38
38
|
}
|
|
39
39
|
return this.buildAST(tableSchemas);
|
|
40
40
|
}
|
|
41
|
+
/** `table` with each column `renames` names under its new name, wherever the table names it. */
|
|
42
|
+
renamed(table, renames) {
|
|
43
|
+
const nameIn = (tableName) => (column) => renames.get(qualifyName(tableName, this.schema))?.find((rename) => rename.from === column)?.to ?? column;
|
|
44
|
+
const own = nameIn(table.name);
|
|
45
|
+
return {
|
|
46
|
+
...table,
|
|
47
|
+
columns: table.columns.map((column) => ({ ...column, name: own(column.name) })),
|
|
48
|
+
primaryKey: table.primaryKey && { ...table.primaryKey, columns: table.primaryKey.columns.map(own) },
|
|
49
|
+
indexes: table.indexes?.map((index) => ({
|
|
50
|
+
...index,
|
|
51
|
+
entries: index.entries.map((entry) => (entry.expression ? entry : { ...entry, column: own(entry.column) })),
|
|
52
|
+
})),
|
|
53
|
+
foreignKeys: table.foreignKeys?.map((foreignKey) => ({
|
|
54
|
+
...foreignKey,
|
|
55
|
+
columns: foreignKey.columns.map(own),
|
|
56
|
+
references: {
|
|
57
|
+
...foreignKey.references,
|
|
58
|
+
columns: foreignKey.references.columns.map(nameIn(foreignKey.references.table)),
|
|
59
|
+
},
|
|
60
|
+
})),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
41
63
|
/**
|
|
42
64
|
* Build SchemaAST from table schemas.
|
|
43
65
|
*/
|
|
@@ -86,14 +86,25 @@ export declare class Migrator {
|
|
|
86
86
|
* it. Read off the catalogue rather than recorded by uql, and exactly right for restoring one.
|
|
87
87
|
*/
|
|
88
88
|
private revertedTriggers;
|
|
89
|
+
/**
|
|
90
|
+
* What a generated migration does that its reader must not miss: each column it drops or retypes, which
|
|
91
|
+
* can lose data, and each table it creates empty while the database holds one no entity names with the
|
|
92
|
+
* same columns, which may be the table renamed. That one is never renamed here: it may be another's.
|
|
93
|
+
*/
|
|
94
|
+
private noteChanges;
|
|
95
|
+
/** The tables the database holds that no entity names, each paired with a new one it is identical to. */
|
|
96
|
+
private renamedTables;
|
|
89
97
|
/** The entities whose tables are among `created`. */
|
|
90
98
|
private createdEntities;
|
|
91
99
|
/** The table `entity` maps to, as a diff names it. */
|
|
92
100
|
private tableOf;
|
|
93
101
|
/**
|
|
94
|
-
*
|
|
102
|
+
* The differences between the entities and the database. With `renames`, a column identical to one the
|
|
103
|
+
* entity no longer names is renamed in place rather than dropped and added, as a generated migration wants.
|
|
95
104
|
*/
|
|
96
|
-
getDiffs(
|
|
105
|
+
getDiffs(options?: {
|
|
106
|
+
renames?: boolean;
|
|
107
|
+
}): Promise<SchemaDiff[]>;
|
|
97
108
|
/**
|
|
98
109
|
* The tables `entities` name, read a schema at a time so each is keyed as its entity spells it. Those
|
|
99
110
|
* alone: nothing else is diffed, and another table can be dropped mid-scan by whatever else is running.
|
package/dist/migrate/migrator.js
CHANGED
|
@@ -3,6 +3,7 @@ import { basename, extname, join } from 'node:path';
|
|
|
3
3
|
import { pathToFileURL } from 'node:url';
|
|
4
4
|
import { getEntities, getMeta } from '../entity/index.js';
|
|
5
5
|
import { SchemaAST } from '../schema/index.js';
|
|
6
|
+
import { columnRenames, tableRenameCandidates } from '../schema/schemaASTDiffer.js';
|
|
6
7
|
import { hasTriggers } from '../util/field.util.js';
|
|
7
8
|
import { LoggerWrapper } from '../util/index.js';
|
|
8
9
|
import { UqlUsageError } from '../util/uqlError.js';
|
|
@@ -183,8 +184,9 @@ export class Migrator {
|
|
|
183
184
|
*/
|
|
184
185
|
async generateFromEntities(name) {
|
|
185
186
|
const generator = await this.getSchemaGenerator();
|
|
186
|
-
const { created, altered } = await this.pendingChanges();
|
|
187
|
+
const { created, altered } = await this.pendingChanges({ renames: true });
|
|
187
188
|
const plan = this.alterPlan(generator, altered, await this.installedTriggers(created));
|
|
189
|
+
await this.noteChanges(generator, created, altered);
|
|
188
190
|
const up = [...this.createSchema(generator, created), ...plan.up];
|
|
189
191
|
if (up.length === 0) {
|
|
190
192
|
this.logger.logInfo('No schema changes detected.');
|
|
@@ -234,18 +236,23 @@ export class Migrator {
|
|
|
234
236
|
* its entity declares goes back on after. `down` is lazy: SQLite cannot express every alter's inverse.
|
|
235
237
|
*/
|
|
236
238
|
alterPlan(generator, altered, state) {
|
|
237
|
-
const changing = new Set(altered
|
|
239
|
+
const changing = new Set(altered
|
|
240
|
+
.filter((diff) => sides(diff.columns, 'from').length || diff.renamedColumns?.length)
|
|
241
|
+
.map((diff) => diff.tableName));
|
|
238
242
|
const cleared = state.filter(({ entity }) => changing.has(this.tableOf(entity)));
|
|
239
243
|
const after = state.map((it) => (cleared.includes(it) ? { entity: it.entity, installed: new Map() } : it));
|
|
240
244
|
return {
|
|
241
245
|
up: [
|
|
242
246
|
...cleared.flatMap(({ entity, installed }) => generator.generateTriggerDrops(entity, [...installed.keys()])),
|
|
243
|
-
...altered.flatMap((diff) => generator.generateAlterTable(diff)),
|
|
247
|
+
...altered.flatMap((diff) => [...renameStatements(generator, diff), ...generator.generateAlterTable(diff)]),
|
|
244
248
|
...this.reconcileTriggers(generator, after),
|
|
245
249
|
],
|
|
246
250
|
down: () => [
|
|
247
251
|
...this.revertedTriggers(generator, after),
|
|
248
|
-
...altered.toReversed().flatMap((diff) =>
|
|
252
|
+
...altered.toReversed().flatMap((diff) => {
|
|
253
|
+
const reversed = reverseDiff(diff);
|
|
254
|
+
return [...generator.generateAlterTable(reversed), ...renameStatements(generator, reversed)];
|
|
255
|
+
}),
|
|
249
256
|
...cleared.flatMap(({ installed }) => [...installed.values()].flat().map((sql) => `${sql};`)),
|
|
250
257
|
],
|
|
251
258
|
};
|
|
@@ -265,6 +272,36 @@ export class Migrator {
|
|
|
265
272
|
revertedTriggers(generator, state) {
|
|
266
273
|
return state.flatMap(({ entity, installed }) => generator.generateTriggersDown(entity, installed));
|
|
267
274
|
}
|
|
275
|
+
/**
|
|
276
|
+
* What a generated migration does that its reader must not miss: each column it drops or retypes, which
|
|
277
|
+
* can lose data, and each table it creates empty while the database holds one no entity names with the
|
|
278
|
+
* same columns, which may be the table renamed. That one is never renamed here: it may be another's.
|
|
279
|
+
*/
|
|
280
|
+
async noteChanges(generator, created, altered) {
|
|
281
|
+
for (const { tableName, columns = [] } of altered) {
|
|
282
|
+
for (const { from, to } of columns.filter((change) => change.isBreaking)) {
|
|
283
|
+
this.logger.logWarn(to
|
|
284
|
+
? `Retypes "${tableName}"."${to.name}" from ${from?.type} to ${to.type}: a value that does not fit is lost or refused.`
|
|
285
|
+
: `Drops "${tableName}"."${from?.name}", losing what it holds.`);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
for (const { from, to } of await this.renamedTables(generator, created)) {
|
|
289
|
+
this.logger.logWarn(`Creates "${to}" empty, while "${from}", which no entity names, holds the same columns. If it was ` +
|
|
290
|
+
`renamed, replace its creation in this migration with \`renameTable('${from}', '${to}')\`.`);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
/** The tables the database holds that no entity names, each paired with a new one it is identical to. */
|
|
294
|
+
async renamedTables(generator, created) {
|
|
295
|
+
const createdEntities = this.createdEntities(created);
|
|
296
|
+
const diffOptions = generator.diffOptions?.();
|
|
297
|
+
if (!createdEntities.length || !generator.buildAST || !diffOptions) {
|
|
298
|
+
return [];
|
|
299
|
+
}
|
|
300
|
+
const owned = new Set(this.entities.map((entity) => this.tableOf(entity)));
|
|
301
|
+
const unowned = (await this.schemaIntrospector.getTableNames()).filter((table) => !owned.has(table));
|
|
302
|
+
const current = await this.schemaIntrospector.introspect(unowned);
|
|
303
|
+
return tableRenameCandidates(generator.buildAST(createdEntities), current, diffOptions);
|
|
304
|
+
}
|
|
268
305
|
/** The entities whose tables are among `created`. */
|
|
269
306
|
createdEntities(created) {
|
|
270
307
|
const fresh = new Set(created);
|
|
@@ -275,18 +312,25 @@ export class Migrator {
|
|
|
275
312
|
return this.pool.dialect.resolveTableName(getMeta(entity));
|
|
276
313
|
}
|
|
277
314
|
/**
|
|
278
|
-
*
|
|
315
|
+
* The differences between the entities and the database. With `renames`, a column identical to one the
|
|
316
|
+
* entity no longer names is renamed in place rather than dropped and added, as a generated migration wants.
|
|
279
317
|
*/
|
|
280
|
-
async getDiffs() {
|
|
318
|
+
async getDiffs(options = {}) {
|
|
281
319
|
const generator = await this.getSchemaGenerator();
|
|
282
|
-
|
|
283
|
-
// Both sides built once: the database's above, the entities' here. Left to `diffSchema`, each
|
|
320
|
+
// Both sides built once: the database's here, the entities' below. Left to `diffSchema`, each
|
|
284
321
|
// entity would rebuild the whole AST, which is quadratic in the number of entities. Absent on a
|
|
285
322
|
// generator that compares no schema of its own - MongoDB, which reads only indexes.
|
|
286
323
|
const desiredAst = generator.buildAST?.(this.entities);
|
|
324
|
+
let ast = await this.introspectEntities(this.entities);
|
|
325
|
+
const diffOptions = generator.diffOptions?.();
|
|
326
|
+
const renames = options.renames && desiredAst && diffOptions ? columnRenames(desiredAst, ast, diffOptions) : new Map();
|
|
327
|
+
if (renames.size) {
|
|
328
|
+
// Read again under the names the entities give them, so the rest compares as the columns they become.
|
|
329
|
+
ast = await this.introspectEntities(this.entities, renames);
|
|
330
|
+
}
|
|
287
331
|
return this.entities.flatMap((entity) => {
|
|
288
|
-
const
|
|
289
|
-
const diff = generator.diffSchema(entity,
|
|
332
|
+
const tableName = generator.resolveTableName(getMeta(entity));
|
|
333
|
+
const diff = generator.diffSchema(entity, ast.getTable(tableName), desiredAst, renames.get(tableName));
|
|
290
334
|
return diff ? [diff] : [];
|
|
291
335
|
});
|
|
292
336
|
}
|
|
@@ -294,13 +338,13 @@ export class Migrator {
|
|
|
294
338
|
* The tables `entities` name, read a schema at a time so each is keyed as its entity spells it. Those
|
|
295
339
|
* alone: nothing else is diffed, and another table can be dropped mid-scan by whatever else is running.
|
|
296
340
|
*/
|
|
297
|
-
async introspectEntities(entities) {
|
|
341
|
+
async introspectEntities(entities, renames) {
|
|
298
342
|
const { dialect } = this.pool;
|
|
299
343
|
const bySchema = Map.groupBy(new Set(entities), (entity) => dialect.resolveSchema(getMeta(entity)));
|
|
300
344
|
const merged = new SchemaAST();
|
|
301
345
|
for (const [schema, members] of bySchema) {
|
|
302
346
|
const tables = members.map((entity) => dialect.resolveTableAlias(getMeta(entity)));
|
|
303
|
-
for (const table of (await this.schemaIntrospectorFor(schema).introspect(tables)).getTables()) {
|
|
347
|
+
for (const table of (await this.schemaIntrospectorFor(schema).introspect(tables, renames)).getTables()) {
|
|
304
348
|
merged.addTable(table);
|
|
305
349
|
}
|
|
306
350
|
}
|
|
@@ -388,8 +432,8 @@ export class Migrator {
|
|
|
388
432
|
* alter. What to emit for each stays with the caller: a sync narrows an alter to what it allows and
|
|
389
433
|
* never asks for the rollback, which on SQLite cannot even be expressed (no `ALTER COLUMN`).
|
|
390
434
|
*/
|
|
391
|
-
async pendingChanges() {
|
|
392
|
-
const diffs = await this.getDiffs();
|
|
435
|
+
async pendingChanges(options = {}) {
|
|
436
|
+
const diffs = await this.getDiffs(options);
|
|
393
437
|
return {
|
|
394
438
|
created: diffs.filter((diff) => diff.type === 'create').map((diff) => diff.tableName),
|
|
395
439
|
altered: diffs.filter((diff) => diff.type === 'alter'),
|
|
@@ -554,3 +598,7 @@ function referencedEntities(meta) {
|
|
|
554
598
|
const relations = Object.values(meta.relations).flatMap((relation) => relation?.entity?.() ?? []);
|
|
555
599
|
return [...fields, ...relations];
|
|
556
600
|
}
|
|
601
|
+
/** A diff's column renames, through the builder operation every SQL generator already renders. */
|
|
602
|
+
function renameStatements(generator, { tableName, renamedColumns = [] }) {
|
|
603
|
+
return renamedColumns.flatMap(({ from, to }) => generator.generateOperation({ type: 'renameColumn', tableName, oldName: from, newName: to }));
|
|
604
|
+
}
|
|
@@ -3,7 +3,7 @@ import type { SchemaAST } from '../schema/schemaAST.js';
|
|
|
3
3
|
import { type BuildSchemaASTOptions } from '../schema/schemaASTBuilder.js';
|
|
4
4
|
import { type DiffOptions } from '../schema/schemaASTDiffer.js';
|
|
5
5
|
import type { CanonicalType, ColumnNode, ForeignKeyAction, IndexNode, TableNode } from '../schema/types.js';
|
|
6
|
-
import type { ColumnSchema, CreateSchemaOptions, DialectFeatures, DropSchemaOptions, EntityMeta, InstalledTriggers, EntityWhereMeta, FieldMeta, FieldOptions, ForeignKeySchema, IndexSchema, NamingStrategy, SchemaDiff, SchemaGenerator, Type } from '../type/index.js';
|
|
6
|
+
import type { ColumnSchema, CreateSchemaOptions, DialectFeatures, DropSchemaOptions, EntityMeta, InstalledTriggers, EntityWhereMeta, FieldMeta, FieldOptions, ForeignKeySchema, IndexSchema, Rename, NamingStrategy, SchemaDiff, SchemaGenerator, Type } from '../type/index.js';
|
|
7
7
|
import type { AnyMigrationOperation, FullColumnDefinition, IndexDefinition, TableDefinition } from './builder/types.js';
|
|
8
8
|
import { type IndexDdl, type TableDdl } from './ddl/index.js';
|
|
9
9
|
/**
|
|
@@ -106,8 +106,6 @@ export declare class SqlSchemaGenerator implements SchemaGenerator {
|
|
|
106
106
|
* one column of a composite key, which its table never makes serial.
|
|
107
107
|
*/
|
|
108
108
|
getSqlType(field: FieldMeta): string;
|
|
109
|
-
/** The statements that alter `column` in place, as this dialect spells them. */
|
|
110
|
-
generateAlterColumnStatements(tableName: string, column: ColumnSchema, newDefinition: string): string[];
|
|
111
109
|
/** The inline ` COMMENT '...'` a column declaration carries, where the engine takes one there. */
|
|
112
110
|
generateColumnComment(comment: string): string;
|
|
113
111
|
/** The `COMMENT ON` statements a table and its columns need, after the `CREATE TABLE`, where the engine uses them. */
|
|
@@ -121,8 +119,8 @@ export declare class SqlSchemaGenerator implements SchemaGenerator {
|
|
|
121
119
|
* How the entity differs from the table the database reported, compared by {@link diffTable}, the one
|
|
122
120
|
* drift detection runs, with types normalized as the engine stores them.
|
|
123
121
|
*/
|
|
124
|
-
diffSchema(entity: Type<object>, currentTable: TableNode | undefined, desiredAst?: SchemaAST): SchemaDiff | undefined;
|
|
125
|
-
|
|
122
|
+
diffSchema(entity: Type<object>, currentTable: TableNode | undefined, desiredAst?: SchemaAST, renamedColumns?: readonly Rename[]): SchemaDiff | undefined;
|
|
123
|
+
diffOptions(): DiffOptions;
|
|
126
124
|
/** Spread, not copied field by field, so a field the node gains cannot go missing here. */
|
|
127
125
|
private columnNodeToSchema;
|
|
128
126
|
/** Whether a column's stored default is the one the entity declares, as this engine reprints it. */
|
|
@@ -270,10 +270,6 @@ export class SqlSchemaGenerator {
|
|
|
270
270
|
? this.serialType(canonical)
|
|
271
271
|
: this.canonicalTypeToSql(canonical);
|
|
272
272
|
}
|
|
273
|
-
/** The statements that alter `column` in place, as this dialect spells them. */
|
|
274
|
-
generateAlterColumnStatements(tableName, column, newDefinition) {
|
|
275
|
-
return this.tableDdl.alterColumn(tableName, column, newDefinition);
|
|
276
|
-
}
|
|
277
273
|
/** The inline ` COMMENT '...'` a column declaration carries, where the engine takes one there. */
|
|
278
274
|
generateColumnComment(comment) {
|
|
279
275
|
return this.features.commentSyntax === 'inline' ? ` COMMENT ${this.dialect.escape(comment)}` : '';
|
|
@@ -302,7 +298,7 @@ export class SqlSchemaGenerator {
|
|
|
302
298
|
* How the entity differs from the table the database reported, compared by {@link diffTable}, the one
|
|
303
299
|
* drift detection runs, with types normalized as the engine stores them.
|
|
304
300
|
*/
|
|
305
|
-
diffSchema(entity, currentTable, desiredAst) {
|
|
301
|
+
diffSchema(entity, currentTable, desiredAst, renamedColumns) {
|
|
306
302
|
const meta = getMeta(entity);
|
|
307
303
|
const tableName = this.resolveTableName(meta);
|
|
308
304
|
const schema = this.resolveSchema(meta);
|
|
@@ -326,11 +322,12 @@ export class SqlSchemaGenerator {
|
|
|
326
322
|
return { to: this.columnNodeToSchema(it.expected) };
|
|
327
323
|
}
|
|
328
324
|
if (it.type === 'drop') {
|
|
329
|
-
return { from: this.columnNodeToSchema(it.actual) };
|
|
325
|
+
return { from: this.columnNodeToSchema(it.actual), isBreaking: true };
|
|
330
326
|
}
|
|
331
327
|
return {
|
|
332
328
|
from: this.columnNodeToSchema(it.actual),
|
|
333
329
|
to: { ...this.columnNodeToSchema(it.expected), enum: undefined },
|
|
330
|
+
isBreaking: it.isBreaking,
|
|
334
331
|
};
|
|
335
332
|
});
|
|
336
333
|
const keyDiff = tableDiff?.primaryKeyDiff;
|
|
@@ -363,8 +360,11 @@ export class SqlSchemaGenerator {
|
|
|
363
360
|
...indexes.toAlter.map(({ from, to }) => ({ from: indexNodeToSchema(from), to: indexNodeToSchema(to) })),
|
|
364
361
|
]),
|
|
365
362
|
foreignKeys: nonEmpty(foreignKeys),
|
|
363
|
+
renamedColumns: nonEmpty(renamedColumns ?? []),
|
|
366
364
|
};
|
|
367
|
-
return alter.primaryKey || alter.columns || alter.indexes || alter.foreignKeys
|
|
365
|
+
return alter.primaryKey || alter.columns || alter.indexes || alter.foreignKeys || alter.renamedColumns
|
|
366
|
+
? alter
|
|
367
|
+
: undefined;
|
|
368
368
|
}
|
|
369
369
|
diffOptions() {
|
|
370
370
|
return {
|
|
@@ -513,7 +513,7 @@ export class SqlSchemaGenerator {
|
|
|
513
513
|
}
|
|
514
514
|
generateAlterColumnSql(tableName, columnName, column) {
|
|
515
515
|
const node = fullColumnDefinitionToNode(column, tableName);
|
|
516
|
-
return this.
|
|
516
|
+
return this.tableDdl.alterColumn(tableName, { ...this.columnNodeToSchema(node), name: columnName }, this.generateColumnFromNode(node));
|
|
517
517
|
}
|
|
518
518
|
generateDropColumnSql(tableName, columnName) {
|
|
519
519
|
return this.tableDdl.dropColumn(tableName, columnName);
|
|
@@ -32,7 +32,6 @@ export const mongoDialectFeatures = {
|
|
|
32
32
|
serverSideCursors: false,
|
|
33
33
|
correlatedWrites: false,
|
|
34
34
|
rowLocks: false, // its concurrency control is the transaction plus atomic document updates
|
|
35
|
-
transactions: true,
|
|
36
35
|
};
|
|
37
36
|
/** What `toWireId` converts: the hex spelling of an `ObjectId`, and nothing looser. */
|
|
38
37
|
const HEX_24 = /^[0-9a-f]{24}$/i;
|
|
@@ -8,3 +8,12 @@ export declare function matchByKey<S, T>(source: Iterable<S>, target: Iterable<T
|
|
|
8
8
|
dropped: T[];
|
|
9
9
|
matched: (readonly [S, T])[];
|
|
10
10
|
};
|
|
11
|
+
/**
|
|
12
|
+
* What {@link matchByKey} left unpaired, paired where `same` finds exactly one counterpart on each side:
|
|
13
|
+
* an item two others could be is ambiguous, so it stays created or dropped.
|
|
14
|
+
*/
|
|
15
|
+
export declare function pairUnique<S, T>(created: readonly S[], dropped: readonly T[], same: (source: S, target: T) => boolean): {
|
|
16
|
+
created: S[];
|
|
17
|
+
dropped: T[];
|
|
18
|
+
matched: (readonly [S, T & ({} | null)])[];
|
|
19
|
+
};
|
|
@@ -16,3 +16,21 @@ export function matchByKey(source, target, key) {
|
|
|
16
16
|
}
|
|
17
17
|
return { created, dropped: [...unpaired.values()].flat(), matched };
|
|
18
18
|
}
|
|
19
|
+
/**
|
|
20
|
+
* What {@link matchByKey} left unpaired, paired where `same` finds exactly one counterpart on each side:
|
|
21
|
+
* an item two others could be is ambiguous, so it stays created or dropped.
|
|
22
|
+
*/
|
|
23
|
+
export function pairUnique(created, dropped, same) {
|
|
24
|
+
const matched = created.flatMap((source) => {
|
|
25
|
+
const [target, ...others] = dropped.filter((candidate) => same(source, candidate));
|
|
26
|
+
return target !== undefined && !others.length && created.filter((other) => same(other, target)).length === 1
|
|
27
|
+
? [[source, target]]
|
|
28
|
+
: [];
|
|
29
|
+
});
|
|
30
|
+
const paired = new Set(matched.flat());
|
|
31
|
+
return {
|
|
32
|
+
created: created.filter((item) => !paired.has(item)),
|
|
33
|
+
dropped: dropped.filter((item) => !paired.has(item)),
|
|
34
|
+
matched,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { ColumnRenames, Rename } from '../type/migration.js';
|
|
1
2
|
import type { SchemaAST } from './schemaAST.js';
|
|
2
3
|
import type { CanonicalType } from './types.js';
|
|
3
4
|
import type { ColumnDiff, ForeignKeyAction, IndexDiff, RelationshipDiff, RelationshipNode, SchemaDiffResult, TableDiff, TableNode } from './types.js';
|
|
@@ -28,6 +29,18 @@ export declare function diffTable(source: TableNode, target: TableNode, options?
|
|
|
28
29
|
readonly columnDiffs: ColumnDiff[];
|
|
29
30
|
readonly indexDiffs: IndexDiff[];
|
|
30
31
|
}) | undefined;
|
|
32
|
+
/**
|
|
33
|
+
* The columns renamed in the tables both sides name, by qualified table:
|
|
34
|
+
* a new column identical to exactly one the entity no longer names, and to no other. The dropped side is
|
|
35
|
+
* always the entity's own table, so a wrong guess renames, keeping the data, and never drops it.
|
|
36
|
+
*/
|
|
37
|
+
export declare function columnRenames(desired: SchemaAST, actual: SchemaAST, options?: DiffOptions): ColumnRenames;
|
|
38
|
+
/**
|
|
39
|
+
* Tables the database holds that a new one is identical to but for its name, each only where it is the one
|
|
40
|
+
* match on both sides. Suggested, never applied: the database's side is a table no entity names, which may
|
|
41
|
+
* be another application's rather than one this schema renamed.
|
|
42
|
+
*/
|
|
43
|
+
export declare function tableRenameCandidates(desired: SchemaAST, actual: SchemaAST, options?: DiffOptions): Rename[];
|
|
31
44
|
/** The differences between two lists of foreign keys, matched by their columns and never by the name the engine gave them. */
|
|
32
45
|
export declare function diffRelationshipNodes(source: readonly RelationshipNode[], target: readonly RelationshipNode[], opts?: DiffOptions): RelationshipDiff[];
|
|
33
46
|
/** A relationship's `ON DELETE` and `ON UPDATE`, an unstated one read as the action the database applies. */
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
import { qualifyName } from '../util/sql.util.js';
|
|
1
2
|
import { areTypesEqual, isBreakingTypeChange } from './canonicalType.js';
|
|
2
3
|
import { describeIndexDifferences, pairIndexes } from './indexDifferences.js';
|
|
3
|
-
import { matchByKey } from './matchByKey.js';
|
|
4
|
+
import { matchByKey, pairUnique } from './matchByKey.js';
|
|
4
5
|
import { DEFAULT_FOREIGN_KEY_ACTION } from './types.js';
|
|
5
6
|
/**
|
|
6
7
|
* Default diff options.
|
|
@@ -23,9 +24,7 @@ function relationEnds(relation) {
|
|
|
23
24
|
/** The differences between the expected schema (the entities) and the actual one (the database). */
|
|
24
25
|
export function diffSchemas(source, target, options = {}) {
|
|
25
26
|
const opts = { ...DEFAULT_OPTIONS, ...options };
|
|
26
|
-
const
|
|
27
|
-
const included = (tables) => [...tables].filter((table) => !opts.excludeTables.includes(table.name));
|
|
28
|
-
const { created: tablesToCreate, dropped: tablesToDrop, matched, } = matchByKey(included(source.tables.values()), included(target.tables.values()), (table) => normalizeName(table.name));
|
|
27
|
+
const { created: tablesToCreate, dropped: tablesToDrop, matched } = matchTables(source, target, opts);
|
|
29
28
|
const tablesToAlter = matched
|
|
30
29
|
.map(([sourceTable, targetTable]) => diffTable(sourceTable, targetTable, opts))
|
|
31
30
|
.filter((tableDiff) => tableDiff !== undefined);
|
|
@@ -104,6 +103,38 @@ function diffTableColumns(source, target, opts) {
|
|
|
104
103
|
.filter((diff) => diff !== undefined),
|
|
105
104
|
];
|
|
106
105
|
}
|
|
106
|
+
/**
|
|
107
|
+
* The columns renamed in the tables both sides name, by qualified table:
|
|
108
|
+
* a new column identical to exactly one the entity no longer names, and to no other. The dropped side is
|
|
109
|
+
* always the entity's own table, so a wrong guess renames, keeping the data, and never drops it.
|
|
110
|
+
*/
|
|
111
|
+
export function columnRenames(desired, actual, options = {}) {
|
|
112
|
+
const opts = { ...DEFAULT_OPTIONS, ...options };
|
|
113
|
+
const normalizeName = nameNormalizer(opts);
|
|
114
|
+
return new Map(matchTables(desired, actual, opts).matched.flatMap(([expected, current]) => {
|
|
115
|
+
const { created, dropped } = matchByKey(expected.columns.values(), current.columns.values(), (column) => normalizeName(column.name));
|
|
116
|
+
const { matched } = pairUnique(created, dropped, (to, from) => !diffColumn(expected.name, to, from, opts));
|
|
117
|
+
const table = qualifyName(current.name, current.schema);
|
|
118
|
+
return matched.length ? [[table, matched.map(([to, from]) => ({ from: from.name, to: to.name }))]] : [];
|
|
119
|
+
}));
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Tables the database holds that a new one is identical to but for its name, each only where it is the one
|
|
123
|
+
* match on both sides. Suggested, never applied: the database's side is a table no entity names, which may
|
|
124
|
+
* be another application's rather than one this schema renamed.
|
|
125
|
+
*/
|
|
126
|
+
export function tableRenameCandidates(desired, actual, options = {}) {
|
|
127
|
+
const opts = { ...DEFAULT_OPTIONS, ...options };
|
|
128
|
+
const { created, dropped } = matchTables(desired, actual, opts);
|
|
129
|
+
const same = (to, from) => to.schema === from.schema && !diffTable(to, from, { ...opts, compareIndexes: false });
|
|
130
|
+
return pairUnique(created, dropped, same).matched.map(([to, from]) => ({ from: from.name, to: to.name }));
|
|
131
|
+
}
|
|
132
|
+
/** The two sides' tables paired by name, those `excludeTables` names left out of both. */
|
|
133
|
+
function matchTables(desired, actual, opts) {
|
|
134
|
+
const normalizeName = nameNormalizer(opts);
|
|
135
|
+
const included = (tables) => [...tables].filter((table) => !opts.excludeTables.includes(table.name));
|
|
136
|
+
return matchByKey(included(desired.tables.values()), included(actual.tables.values()), (table) => normalizeName(table.name));
|
|
137
|
+
}
|
|
107
138
|
/** Compare indexes between two tables, paired by {@link pairIndexes}, in what the target's reader reports. */
|
|
108
139
|
function diffTableIndexes(source, target, opts) {
|
|
109
140
|
const { created, dropped, matched } = pairIndexes(source.indexes, target.indexes, nameNormalizer(opts));
|
package/dist/type/dialect.d.ts
CHANGED
|
@@ -141,11 +141,6 @@ export interface DialectFeatures {
|
|
|
141
141
|
* value rather than a flag each, since the details mean nothing without a lock.
|
|
142
142
|
*/
|
|
143
143
|
readonly rowLocks: RowLockFeatures | false;
|
|
144
|
-
/**
|
|
145
|
-
* Whether the engine runs a transaction across statements: false on D1, which refuses one, so what
|
|
146
|
-
* would open one can run its steps in order instead. MongoDB has them as a replica set alone.
|
|
147
|
-
*/
|
|
148
|
-
readonly transactions: boolean;
|
|
149
144
|
}
|
|
150
145
|
/** How a dialect spells a row lock, once {@link DialectFeatures.rowLocks} says it has one. */
|
|
151
146
|
export interface RowLockFeatures {
|
package/dist/type/migration.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { AnyMigrationOperation } from '../migrate/builder/types.js';
|
|
2
2
|
import type { IndexFacet } from '../schema/indexDifferences.js';
|
|
3
3
|
import type { SchemaAST } from '../schema/schemaAST.js';
|
|
4
|
+
import type { DiffOptions } from '../schema/schemaASTDiffer.js';
|
|
4
5
|
import type { ColumnNode, ForeignKeyAction, IndexType, TableNode } from '../schema/types.js';
|
|
5
6
|
import type { EntityMeta, EntityWhereMeta, FieldOptions, IndexColumnSchema, IndexedVectorField, LoggingOptions, Querier, SqlQuerier, Type, VectorIndexOptions } from './index.js';
|
|
6
7
|
/**
|
|
@@ -173,6 +174,17 @@ export interface ForeignKeySchema {
|
|
|
173
174
|
* change is undone by swapping its ends. No engine alters an index, a key or a foreign key in place, so
|
|
174
175
|
* an alter of one is its drop and its add, which safe mode holds back together.
|
|
175
176
|
*/
|
|
177
|
+
/** A column's change, and whether it can lose what the column holds: a drop, or a retype that narrows it. */
|
|
178
|
+
export type ColumnChange = Change<ColumnSchema> & {
|
|
179
|
+
readonly isBreaking?: boolean;
|
|
180
|
+
};
|
|
181
|
+
/** A name changed, `from` the database's `to` the entity's. */
|
|
182
|
+
export type Rename = {
|
|
183
|
+
readonly from: string;
|
|
184
|
+
readonly to: string;
|
|
185
|
+
};
|
|
186
|
+
/** Renamed columns by qualified table name. */
|
|
187
|
+
export type ColumnRenames = ReadonlyMap<string, readonly Rename[]>;
|
|
176
188
|
export interface Change<T> {
|
|
177
189
|
readonly from?: T;
|
|
178
190
|
readonly to?: T;
|
|
@@ -199,9 +211,11 @@ export interface SchemaDiff {
|
|
|
199
211
|
readonly schema?: string;
|
|
200
212
|
readonly type: 'create' | 'alter' | 'drop';
|
|
201
213
|
readonly primaryKey?: Change<PrimaryKeySchema>;
|
|
202
|
-
readonly columns?: readonly
|
|
214
|
+
readonly columns?: readonly ColumnChange[];
|
|
203
215
|
readonly indexes?: readonly Change<IndexSchema>[];
|
|
204
216
|
readonly foreignKeys?: readonly Change<ForeignKeySchema>[];
|
|
217
|
+
/** Columns renamed in place, `from` the database's name `to` the entity's, which the other changes already use. */
|
|
218
|
+
readonly renamedColumns?: readonly Rename[];
|
|
205
219
|
}
|
|
206
220
|
/**
|
|
207
221
|
* What every sync entry point takes: `safe` keeps it additive, `drop` lets it remove a column, and
|
|
@@ -288,10 +302,13 @@ export interface SchemaGenerator {
|
|
|
288
302
|
/**
|
|
289
303
|
* An entity's differences from its table. `desiredAst`, from {@link buildAST}, has to span every entity
|
|
290
304
|
* a foreign key here points at, or those keys read as matching.
|
|
305
|
+
* `renamedColumns` are columns `currentTable` already holds under their new names.
|
|
291
306
|
*/
|
|
292
|
-
diffSchema(entity: Type<object>, currentTable: TableNode | undefined, desiredAst?: SchemaAST): SchemaDiff | undefined;
|
|
307
|
+
diffSchema(entity: Type<object>, currentTable: TableNode | undefined, desiredAst?: SchemaAST, renamedColumns?: readonly Rename[]): SchemaDiff | undefined;
|
|
293
308
|
/** The entities as one AST, built once per run for every {@link diffSchema}. Absent on MongoDB, which diffs only indexes. */
|
|
294
309
|
buildAST?(entities: readonly Type<object>[]): SchemaAST;
|
|
310
|
+
/** How this engine's diff compares types and defaults. Absent where {@link buildAST} is. */
|
|
311
|
+
diffOptions?(): DiffOptions;
|
|
295
312
|
/**
|
|
296
313
|
* The table's key: {@link resolveTableAlias} behind {@link resolveSchema}, which is how a
|
|
297
314
|
* `SchemaAST` stores it and how a diff finds it again.
|
|
@@ -326,8 +343,11 @@ export interface SchemaIntrospector {
|
|
|
326
343
|
* the database side never reports it, and no migration can close the gap.
|
|
327
344
|
*/
|
|
328
345
|
readonly indexFacets: ReadonlySet<IndexFacet>;
|
|
329
|
-
/**
|
|
330
|
-
|
|
346
|
+
/**
|
|
347
|
+
* The whole database, or just the tables named. Names nothing matches are left out. `renames` reads
|
|
348
|
+
* each column under the name it is being renamed to, so a diff compares it as the column it becomes.
|
|
349
|
+
*/
|
|
350
|
+
introspect(tables?: readonly string[], renames?: ColumnRenames): Promise<SchemaAST>;
|
|
331
351
|
/**
|
|
332
352
|
* Get all table names in the database
|
|
333
353
|
*/
|
package/package.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"homepage": "https://uql-orm.dev",
|
|
4
4
|
"description": "JSON-native TypeScript ORM for Bun, Browsers, Edge, Deno, Node, Workers. Supports PostgreSQL, PGlite, MySQL, MariaDB, SQLite, CockroachDB, SQL Server, Turso, Neon, Cloudflare D1 and MongoDB. Queries are plain JSON, typed to the leaf.",
|
|
5
5
|
"license": "MIT",
|
|
6
|
-
"version": "0.
|
|
6
|
+
"version": "0.87.0",
|
|
7
7
|
"type": "module",
|
|
8
8
|
"engines": {
|
|
9
9
|
"node": ">=24"
|
|
@@ -35,7 +35,6 @@
|
|
|
35
35
|
"./http": "./dist/http/index.js",
|
|
36
36
|
"./express": "./dist/express/index.js",
|
|
37
37
|
"./nestjs": "./dist/nestjs/index.js",
|
|
38
|
-
"./betterAuth": "./dist/betterAuth/index.js",
|
|
39
38
|
"./browser": {
|
|
40
39
|
"types": "./dist/browser/index.d.ts",
|
|
41
40
|
"import": "./dist/browser/index.js",
|
|
@@ -72,7 +71,6 @@
|
|
|
72
71
|
"@nestjs/core": ">=10.0.0",
|
|
73
72
|
"@tursodatabase/database": ">=0.7.0",
|
|
74
73
|
"@tursodatabase/serverless": ">=1.3.0",
|
|
75
|
-
"better-auth": ">=1.7.0",
|
|
76
74
|
"better-sqlite3": ">=9.0.0",
|
|
77
75
|
"express": ">=5.0.0",
|
|
78
76
|
"mariadb": ">=3.0.0",
|
|
@@ -105,9 +103,6 @@
|
|
|
105
103
|
"@tursodatabase/serverless": {
|
|
106
104
|
"optional": true
|
|
107
105
|
},
|
|
108
|
-
"better-auth": {
|
|
109
|
-
"optional": true
|
|
110
|
-
},
|
|
111
106
|
"better-sqlite3": {
|
|
112
107
|
"optional": true
|
|
113
108
|
},
|
|
@@ -137,7 +132,6 @@
|
|
|
137
132
|
}
|
|
138
133
|
},
|
|
139
134
|
"devDependencies": {
|
|
140
|
-
"@better-auth/test-utils": "^1.7.6",
|
|
141
135
|
"@electric-sql/pglite": "0.5.8",
|
|
142
136
|
"@electric-sql/pglite-pgvector": "0.0.9",
|
|
143
137
|
"@libsql/client": "^0.18.0",
|
|
@@ -152,7 +146,6 @@
|
|
|
152
146
|
"@types/mssql": "^12.3.0",
|
|
153
147
|
"@types/pg": "^8.23.1",
|
|
154
148
|
"@types/ws": "^8.18.1",
|
|
155
|
-
"better-auth": "^1.7.6",
|
|
156
149
|
"better-sqlite3": "^13.0.3",
|
|
157
150
|
"express": "^5.2.1",
|
|
158
151
|
"mariadb": "^3.5.4",
|
package/skills/uql-orm/SKILL.md
CHANGED
|
@@ -154,17 +154,10 @@ transaction. A querier from `pool.getQuerier()` is yours to release: bind it wit
|
|
|
154
154
|
## Migrations
|
|
155
155
|
|
|
156
156
|
`npx uql-migrate` reads `uql.config.ts`. `sync` creates what the entities imply (development only);
|
|
157
|
-
`generate:entities` writes the diff as a migration file to review; `up` applies migrations; `generate:from-db`
|
|
157
|
+
`generate:entities` writes the diff as a migration file to review, renaming a column its field was renamed from and printing `renameTable` for a table that may have been; `up` applies migrations; `generate:from-db`
|
|
158
158
|
writes entity classes from an existing database; `drift:check` fails when the database no longer matches.
|
|
159
159
|
Triggers are part of the diff: uql installs its own under `_uql_`-prefixed names and never touches another.
|
|
160
160
|
|
|
161
|
-
## Better Auth
|
|
162
|
-
|
|
163
|
-
`betterAuth({ ...authOptions, database: uqlAdapter(pool) })`, from `uql-orm/betterAuth`, runs Better Auth on any
|
|
164
|
-
pool; `...authEntities(authOptions)` in the config's `entities` has `uql-migrate` create its tables. Keep
|
|
165
|
-
`authOptions` (plugins, table and field names, `rateLimit.storage`) in a module of its own, since the config imports
|
|
166
|
-
it, and never put those entities in an HTTP handler's `include`: a session row holds its token.
|
|
167
|
-
|
|
168
161
|
## Where to read more
|
|
169
162
|
|
|
170
163
|
- Operators, per-dialect SQL: https://uql-orm.dev/querying/comparison-operators.md
|
|
@@ -173,5 +166,4 @@ it, and never put those entities in an HTTP handler's `include`: a session row h
|
|
|
173
166
|
- Triggers: https://uql-orm.dev/entities/triggers.md
|
|
174
167
|
- Every method's signature: https://uql-orm.dev/querying/methods.md
|
|
175
168
|
- Coming from Prisma, Drizzle, TypeORM or MikroORM: https://uql-orm.dev/switching-to-uql.md
|
|
176
|
-
- Better Auth: https://uql-orm.dev/better-auth.md
|
|
177
169
|
- Breaking changes by version: https://uql-orm.dev/upgrade-guide.md
|
|
@@ -1,7 +0,0 @@
|
|
|
1
|
-
import type { BetterAuthOptions } from 'better-auth';
|
|
2
|
-
import type { Type } from '../type/index.js';
|
|
3
|
-
/**
|
|
4
|
-
* The entities Better Auth's tables are for these options, its core tables and every plugin's: list them
|
|
5
|
-
* in `uql.config.ts` so `uql-migrate` creates and migrates them with the rest.
|
|
6
|
-
*/
|
|
7
|
-
export declare function authEntities(options: BetterAuthOptions): Type<object>[];
|
|
@@ -1,154 +0,0 @@
|
|
|
1
|
-
import { getAuthTables } from 'better-auth/db';
|
|
2
|
-
import { defineEntity, defineField, defineId, defineIndex, defineRelation } from '../entity/index.js';
|
|
3
|
-
import { UqlUsageError } from '../util/uqlError.js';
|
|
4
|
-
/**
|
|
5
|
-
* The entities Better Auth's tables are for these options, its core tables and every plugin's: list them
|
|
6
|
-
* in `uql.config.ts` so `uql-migrate` creates and migrates them with the rest.
|
|
7
|
-
*/
|
|
8
|
-
export function authEntities(options) {
|
|
9
|
-
const shapes = shapesOf(options);
|
|
10
|
-
// A field type that is a constructor keyed by its name, which JSON would drop.
|
|
11
|
-
const key = JSON.stringify(shapes, (_, value) => (typeof value === 'function' ? value.name : value));
|
|
12
|
-
let entities = defined.get(key);
|
|
13
|
-
if (!entities) {
|
|
14
|
-
entities = defineTables(shapes);
|
|
15
|
-
defined.set(key, entities);
|
|
16
|
-
}
|
|
17
|
-
return [...entities];
|
|
18
|
-
}
|
|
19
|
-
/**
|
|
20
|
-
* Each set of tables defined so far, by its shapes: kept, since an adapter built on it may still be
|
|
21
|
-
* reading, and a table defined twice for one shape would be two entities over one table.
|
|
22
|
-
*/
|
|
23
|
-
const defined = new Map();
|
|
24
|
-
/** Defines every table at once, since each one's foreign keys point at the others. */
|
|
25
|
-
function defineTables(shapes) {
|
|
26
|
-
const byName = Object.fromEntries(shapes.map(({ name }) => [
|
|
27
|
-
name,
|
|
28
|
-
{
|
|
29
|
-
[name]: class {
|
|
30
|
-
},
|
|
31
|
-
}[name],
|
|
32
|
-
]));
|
|
33
|
-
for (const { name, id, fields, indexes } of shapes) {
|
|
34
|
-
const entity = byName[name];
|
|
35
|
-
defineId(entity, 'id', id);
|
|
36
|
-
for (const field of fields) {
|
|
37
|
-
defineField(entity, field.name, field.options);
|
|
38
|
-
const { references } = field;
|
|
39
|
-
if (references) {
|
|
40
|
-
defineRelation(entity, `${field.name}Ref`, {
|
|
41
|
-
entity: () => byName[references.table],
|
|
42
|
-
cardinality: 'm1',
|
|
43
|
-
references: (local, foreign) => [{ local: local[field.name], foreign: foreign[references.column] }],
|
|
44
|
-
onDelete: references.onDelete,
|
|
45
|
-
});
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
for (const index of indexes) {
|
|
49
|
-
defineIndex(entity, { ...index, columns: (refs) => index.columns.map((column) => refs[column]) });
|
|
50
|
-
}
|
|
51
|
-
defineEntity(entity, { name });
|
|
52
|
-
}
|
|
53
|
-
return Object.values(byName);
|
|
54
|
-
}
|
|
55
|
-
/** Better Auth's schema as the tables UQL defines, every reference resolved to the column it points at. */
|
|
56
|
-
function shapesOf(options) {
|
|
57
|
-
const schema = getAuthTables(options);
|
|
58
|
-
const id = keyOf(options);
|
|
59
|
-
const columnOf = (fields, key) => fields[key]?.fieldName ?? key;
|
|
60
|
-
// Refused rather than left for the schema build, which drops a foreign key whose column it cannot find.
|
|
61
|
-
const referenceOf = ({ model, field, onDelete = 'cascade' }) => {
|
|
62
|
-
const table = schema[model] ?? Object.values(schema).find(({ modelName }) => modelName === model);
|
|
63
|
-
const column = table?.fields[field];
|
|
64
|
-
if (!table || (field !== 'id' && !column)) {
|
|
65
|
-
throw new UqlUsageError(`a Better Auth field references '${model}.${field}', which its schema does not have`);
|
|
66
|
-
}
|
|
67
|
-
return {
|
|
68
|
-
// Every foreign key is indexed, so it takes the type an indexed copy of the column it points at would.
|
|
69
|
-
type: column ? typeOf({ ...column, index: true }) : id.type,
|
|
70
|
-
references: {
|
|
71
|
-
table: table.modelName,
|
|
72
|
-
column: column ? columnOf(table.fields, field) : 'id',
|
|
73
|
-
onDelete: ON_DELETE[onDelete],
|
|
74
|
-
},
|
|
75
|
-
};
|
|
76
|
-
};
|
|
77
|
-
return Object.values(schema).map((table) => ({
|
|
78
|
-
name: table.modelName,
|
|
79
|
-
id,
|
|
80
|
-
fields: Object.entries(table.fields).map(([key, field]) => {
|
|
81
|
-
const name = columnOf(table.fields, key);
|
|
82
|
-
const reference = field.references && referenceOf(field.references);
|
|
83
|
-
return {
|
|
84
|
-
name,
|
|
85
|
-
options: {
|
|
86
|
-
name,
|
|
87
|
-
type: reference?.type ?? typeOf(field),
|
|
88
|
-
nullable: field.required === false,
|
|
89
|
-
unique: field.unique,
|
|
90
|
-
index: field.index,
|
|
91
|
-
defaultValue: staticDefault(field),
|
|
92
|
-
},
|
|
93
|
-
references: reference?.references,
|
|
94
|
-
};
|
|
95
|
-
}),
|
|
96
|
-
indexes: (table.indexes ?? []).map(({ fields, unique, name }) => ({
|
|
97
|
-
columns: fields.map((key) => columnOf(table.fields, key)),
|
|
98
|
-
unique,
|
|
99
|
-
name,
|
|
100
|
-
})),
|
|
101
|
-
}));
|
|
102
|
-
}
|
|
103
|
-
/** The key: Better Auth's own string, its UUID, or the database's number; a key left to the database otherwise is refused. */
|
|
104
|
-
function keyOf(options) {
|
|
105
|
-
const generateId = options.advanced?.database?.generateId;
|
|
106
|
-
if (generateId === false) {
|
|
107
|
-
throw new UqlUsageError("Better Auth's 'generateId: false' leaves the key to the database, which it can generate in more than one " +
|
|
108
|
-
"way: set 'serial' for a number or 'uuid' for a UUID");
|
|
109
|
-
}
|
|
110
|
-
if (generateId === 'serial') {
|
|
111
|
-
return { type: Number, autoIncrement: true };
|
|
112
|
-
}
|
|
113
|
-
return { type: generateId === 'uuid' ? 'uuid' : String };
|
|
114
|
-
}
|
|
115
|
-
/**
|
|
116
|
-
* A field's column type. Text is `text` unless something indexes it, which a column of unbounded length
|
|
117
|
-
* cannot be on MySQL; a list of allowed values is text too, since Better Auth checks them itself and a
|
|
118
|
-
* database check on them would need a migration each time one is added.
|
|
119
|
-
*/
|
|
120
|
-
function typeOf(field) {
|
|
121
|
-
const { type } = field;
|
|
122
|
-
if (type === 'string' || Array.isArray(type)) {
|
|
123
|
-
return field.unique || field.index || field.sortable ? String : 'text';
|
|
124
|
-
}
|
|
125
|
-
switch (type) {
|
|
126
|
-
case 'number':
|
|
127
|
-
return field.bigint ? BigInt : Number;
|
|
128
|
-
case 'boolean':
|
|
129
|
-
return Boolean;
|
|
130
|
-
case 'date':
|
|
131
|
-
return Date;
|
|
132
|
-
case 'json':
|
|
133
|
-
case 'string[]':
|
|
134
|
-
case 'number[]':
|
|
135
|
-
return 'json';
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
/**
|
|
139
|
-
* The default a column takes in the database, as Better Auth's own migrator gives one: a plain value on a
|
|
140
|
-
* text, number or boolean field, so a required column added to a populated table has one to backfill. A
|
|
141
|
-
* nullable unique column gets none, `NULL` being its only backfill two rows can share.
|
|
142
|
-
*/
|
|
143
|
-
function staticDefault({ type, defaultValue, unique, required }) {
|
|
144
|
-
const plain = typeof defaultValue === 'string' || typeof defaultValue === 'number' || typeof defaultValue === 'boolean';
|
|
145
|
-
const typed = type === 'string' || type === 'number' || type === 'boolean';
|
|
146
|
-
return plain && typed && !(unique && required === false) ? defaultValue : undefined;
|
|
147
|
-
}
|
|
148
|
-
const ON_DELETE = {
|
|
149
|
-
cascade: 'CASCADE',
|
|
150
|
-
'no action': 'NO ACTION',
|
|
151
|
-
restrict: 'RESTRICT',
|
|
152
|
-
'set null': 'SET NULL',
|
|
153
|
-
'set default': 'SET DEFAULT',
|
|
154
|
-
};
|
package/dist/betterAuth/index.js
DELETED
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
import type { BetterAuthOptions } from 'better-auth';
|
|
2
|
-
import { type DBAdapter, type DBAdapterDebugLogOption } from 'better-auth/adapters';
|
|
3
|
-
import type { QuerierPool } from '../type/index.js';
|
|
4
|
-
export type UqlAdapterOptions = {
|
|
5
|
-
readonly debugLogs?: DBAdapterDebugLogOption;
|
|
6
|
-
/**
|
|
7
|
-
* Whether Better Auth runs its multi-step writes in one transaction: on wherever the engine has them,
|
|
8
|
-
* so off on D1. A standalone MongoDB, which has them only as a replica set, needs `false`.
|
|
9
|
-
*/
|
|
10
|
-
readonly transaction?: boolean;
|
|
11
|
-
};
|
|
12
|
-
/**
|
|
13
|
-
* Better Auth on a UQL pool, on every engine UQL runs on: `betterAuth({ database: uqlAdapter(pool) })`.
|
|
14
|
-
* Its tables are the entities {@link authEntities} returns, which `uql-migrate` creates like any other.
|
|
15
|
-
*/
|
|
16
|
-
export declare function uqlAdapter(pool: QuerierPool, opts?: UqlAdapterOptions): (options: BetterAuthOptions) => DBAdapter<BetterAuthOptions>;
|
|
@@ -1,155 +0,0 @@
|
|
|
1
|
-
import { createAdapterFactory, } from 'better-auth/adapters';
|
|
2
|
-
import { likeLiteral } from '../dialect/operators.js';
|
|
3
|
-
import { getMeta, idOf } from '../entity/index.js';
|
|
4
|
-
import { whereIds } from '../util/dialect.util.js';
|
|
5
|
-
import { entityName } from '../util/object.util.js';
|
|
6
|
-
import { UqlUsageError } from '../util/uqlError.js';
|
|
7
|
-
import { authEntities } from './authEntities.js';
|
|
8
|
-
/**
|
|
9
|
-
* Better Auth on a UQL pool, on every engine UQL runs on: `betterAuth({ database: uqlAdapter(pool) })`.
|
|
10
|
-
* Its tables are the entities {@link authEntities} returns, which `uql-migrate` creates like any other.
|
|
11
|
-
*/
|
|
12
|
-
export function uqlAdapter(pool, opts = {}) {
|
|
13
|
-
const transactions = opts.transaction ?? pool.dialect.features.transactions;
|
|
14
|
-
return (options) => {
|
|
15
|
-
const entities = Object.fromEntries(authEntities(options).map((entity) => [entityName(getMeta(entity)), entity]));
|
|
16
|
-
const on = (querier, inTransaction) => createAdapterFactory({
|
|
17
|
-
config: {
|
|
18
|
-
adapterId: 'uql',
|
|
19
|
-
adapterName: 'UQL',
|
|
20
|
-
debugLogs: opts.debugLogs,
|
|
21
|
-
supportsJSON: true,
|
|
22
|
-
supportsDates: true,
|
|
23
|
-
supportsBooleans: true,
|
|
24
|
-
supportsNumericIds: true,
|
|
25
|
-
supportsArrays: true,
|
|
26
|
-
// The same adapter over the transaction's querier, as Better Auth's own adapters rebuild theirs.
|
|
27
|
-
transaction: transactions && !inTransaction ? (callback) => pool.transaction((trx) => callback(on(trx, true))) : false,
|
|
28
|
-
},
|
|
29
|
-
adapter: ({ getFieldName }) => methodsOf(querier, entities, getFieldName),
|
|
30
|
-
})(options);
|
|
31
|
-
return on(pool, false);
|
|
32
|
-
};
|
|
33
|
-
}
|
|
34
|
-
/**
|
|
35
|
-
* Better Auth's database methods over `querier`, its tables by the names its factory checked each `model`
|
|
36
|
-
* against. Only `where` arrives in database names; the rest are mapped here.
|
|
37
|
-
*/
|
|
38
|
-
function methodsOf(querier, entities, getFieldName) {
|
|
39
|
-
const select = (model, fields) => fields?.length ? Object.fromEntries(fields.map((field) => [getFieldName({ model, field }), true])) : undefined;
|
|
40
|
-
const filter = (where) => ({ $where: whereOf(where) });
|
|
41
|
-
/**
|
|
42
|
-
* Writes `payload` to the first row `where` finds, pinned to that row while it still matches `where`,
|
|
43
|
-
* and reads it back: `null` where none matched, or a concurrent write moved the row past the guard first.
|
|
44
|
-
*/
|
|
45
|
-
const updateOne = async (model, where, payload) => {
|
|
46
|
-
const entity = entities[model];
|
|
47
|
-
const row = await querier.findOne(entity, filter(where));
|
|
48
|
-
if (!row) {
|
|
49
|
-
return null;
|
|
50
|
-
}
|
|
51
|
-
const meta = getMeta(entity);
|
|
52
|
-
const id = idOf(meta, row);
|
|
53
|
-
const pinned = { $where: { $and: [whereOf(where), whereIds(meta, id)] } };
|
|
54
|
-
const changed = await querier.updateMany(entity, pinned, payload);
|
|
55
|
-
return changed ? asRow(await querier.findOneById(entity, id)) : null;
|
|
56
|
-
};
|
|
57
|
-
return {
|
|
58
|
-
async create({ model, data }) {
|
|
59
|
-
const entity = entities[model];
|
|
60
|
-
const id = await querier.insertOne(entity, data);
|
|
61
|
-
// The row as stored, which Better Auth's SQL adapters return too: a column left out reads its default.
|
|
62
|
-
const row = id === undefined ? undefined : await querier.findOneById(entity, id);
|
|
63
|
-
if (!row) {
|
|
64
|
-
throw new UqlUsageError(`Better Auth inserted a '${model}' row it cannot read back: does a filter hide it?`);
|
|
65
|
-
}
|
|
66
|
-
return asRow(row);
|
|
67
|
-
},
|
|
68
|
-
async findOne({ model, modelKey = model, where, select: fields }) {
|
|
69
|
-
return asRow(await querier.findOne(entities[model], { ...filter(where), $select: select(modelKey, fields) }));
|
|
70
|
-
},
|
|
71
|
-
async findMany({ model, modelKey = model, where = [], select: fields, sortBy, offset, limit }) {
|
|
72
|
-
const rows = await querier.findMany(entities[model], {
|
|
73
|
-
...filter(where),
|
|
74
|
-
$select: select(modelKey, fields),
|
|
75
|
-
$sort: sortBy && { [getFieldName({ model: modelKey, field: sortBy.field })]: sortBy.direction },
|
|
76
|
-
$skip: offset,
|
|
77
|
-
$limit: limit,
|
|
78
|
-
});
|
|
79
|
-
return rows.map((row) => asRow(row));
|
|
80
|
-
},
|
|
81
|
-
count({ model, where = [] }) {
|
|
82
|
-
return querier.count(entities[model], filter(where));
|
|
83
|
-
},
|
|
84
|
-
update({ model, where, update }) {
|
|
85
|
-
return updateOne(model, where, payloadOf(update));
|
|
86
|
-
},
|
|
87
|
-
// `$inc` adds in the statement, so racing increments all land, where a read-then-write would retry.
|
|
88
|
-
incrementOne({ model, where, increment, set }) {
|
|
89
|
-
const steps = Object.fromEntries(Object.entries(increment).map(([field, $inc]) => [field, { $inc }]));
|
|
90
|
-
return updateOne(model, where, { ...set, ...steps });
|
|
91
|
-
},
|
|
92
|
-
updateMany({ model, where, update }) {
|
|
93
|
-
return querier.updateMany(entities[model], filter(where), update, { unfiltered: !where.length });
|
|
94
|
-
},
|
|
95
|
-
async delete({ model, where }) {
|
|
96
|
-
// Unlike `deleteMany`, never unfiltered: UQL refuses one naming no row.
|
|
97
|
-
await querier.deleteMany(entities[model], filter(where));
|
|
98
|
-
},
|
|
99
|
-
deleteMany({ model, where }) {
|
|
100
|
-
return querier.deleteMany(entities[model], filter(where), { unfiltered: !where.length });
|
|
101
|
-
},
|
|
102
|
-
};
|
|
103
|
-
}
|
|
104
|
-
/**
|
|
105
|
-
* A row as the type Better Auth asks for, `null` for none: each method lets its caller name the shape of
|
|
106
|
-
* its own table, which no query can check, so this is the one place UQL takes that on trust.
|
|
107
|
-
*/
|
|
108
|
-
function asRow(row) {
|
|
109
|
-
return (row ?? null);
|
|
110
|
-
}
|
|
111
|
-
/** An update's payload, which Better Auth types as anything and always sends as a row of fields. */
|
|
112
|
-
function payloadOf(update) {
|
|
113
|
-
if (typeof update !== 'object' || update === null) {
|
|
114
|
-
throw new UqlUsageError('Better Auth sent an update that is not a row of fields');
|
|
115
|
-
}
|
|
116
|
-
return update;
|
|
117
|
-
}
|
|
118
|
-
/**
|
|
119
|
-
* Better Auth's clauses as one `$where`: all of those joined by `AND`, and any of those joined by `OR`.
|
|
120
|
-
* Each clause is an entry of its own, since two on one field would overwrite each other in one map.
|
|
121
|
-
*/
|
|
122
|
-
function whereOf(where) {
|
|
123
|
-
const all = where.filter((clause) => clause.connector === 'AND').map(clauseOf);
|
|
124
|
-
const any = where.filter((clause) => clause.connector === 'OR').map(clauseOf);
|
|
125
|
-
const $where = {};
|
|
126
|
-
if (all.length)
|
|
127
|
-
$where['$and'] = all;
|
|
128
|
-
if (any.length)
|
|
129
|
-
$where['$or'] = any;
|
|
130
|
-
return $where;
|
|
131
|
-
}
|
|
132
|
-
/** One clause; ignoring case applies where every value is text, as Better Auth's own adapters hold. */
|
|
133
|
-
function clauseOf({ field, operator, value, mode }) {
|
|
134
|
-
const values = [value].flat();
|
|
135
|
-
const texts = mode === 'insensitive' && values.length && values.every((it) => typeof it === 'string') ? values : undefined;
|
|
136
|
-
return CLAUSES[operator](field, value, texts);
|
|
137
|
-
}
|
|
138
|
-
/** A literal case-insensitive match, which reads the same on every engine. */
|
|
139
|
-
const ilike = (field, text) => ({ [field]: { $ilike: likeLiteral(text) } });
|
|
140
|
-
/** An `in` list without `null`, which no `IN` matches and which makes every `NOT IN` match nothing. */
|
|
141
|
-
const listOf = (value) => [value].flat().filter((it) => it !== null);
|
|
142
|
-
/** Every Better Auth operator as the clause it is; `satisfies` fails the build on one it adds. */
|
|
143
|
-
const CLAUSES = {
|
|
144
|
-
eq: (field, value, texts) => (texts ? ilike(field, texts[0]) : { [field]: { $eq: value } }),
|
|
145
|
-
ne: (field, value, texts) => (texts ? { $not: [ilike(field, texts[0])] } : { [field]: { $ne: value } }),
|
|
146
|
-
lt: (field, value) => ({ [field]: { $lt: value } }),
|
|
147
|
-
lte: (field, value) => ({ [field]: { $lte: value } }),
|
|
148
|
-
gt: (field, value) => ({ [field]: { $gt: value } }),
|
|
149
|
-
gte: (field, value) => ({ [field]: { $gte: value } }),
|
|
150
|
-
in: (field, value, texts) => texts ? { $or: texts.map((text) => ilike(field, text)) } : { [field]: { $in: listOf(value) } },
|
|
151
|
-
not_in: (field, value, texts) => texts ? { $nor: texts.map((text) => ilike(field, text)) } : { [field]: { $nin: listOf(value) } },
|
|
152
|
-
contains: (field, value, texts) => ({ [field]: { [texts ? '$iincludes' : '$includes']: value } }),
|
|
153
|
-
starts_with: (field, value, texts) => ({ [field]: { [texts ? '$istartsWith' : '$startsWith']: value } }),
|
|
154
|
-
ends_with: (field, value, texts) => ({ [field]: { [texts ? '$iendsWith' : '$endsWith']: value } }),
|
|
155
|
-
};
|