uql-orm 0.84.0 → 0.85.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/betterAuth/authEntities.d.ts +7 -0
- package/dist/betterAuth/authEntities.js +154 -0
- package/dist/betterAuth/index.d.ts +2 -0
- package/dist/betterAuth/index.js +2 -0
- package/dist/betterAuth/uqlAdapter.d.ts +16 -0
- package/dist/betterAuth/uqlAdapter.js +155 -0
- package/dist/d1/d1SqliteDialect.js +1 -1
- package/dist/dialect/mysqlLikeSqlDialect.js +1 -0
- package/dist/dialect/pgLikeSqlDialect.js +1 -0
- package/dist/http/handler.d.ts +5 -2
- package/dist/http/handler.js +5 -8
- package/dist/mongo/mongoDialect.js +1 -0
- package/dist/mssql/mssqlDialect.js +1 -0
- package/dist/sqlite/sqliteDialect.js +1 -0
- package/dist/type/dialect.d.ts +5 -0
- package/package.json +8 -1
- package/skills/uql-orm/SKILL.md +10 -1
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),
|
|
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, and [Better Auth](https://uql-orm.dev/better-auth) on every engine.
|
|
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
|
|
@@ -0,0 +1,7 @@
|
|
|
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>[];
|
|
@@ -0,0 +1,154 @@
|
|
|
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
|
+
};
|
|
@@ -0,0 +1,16 @@
|
|
|
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>;
|
|
@@ -0,0 +1,155 @@
|
|
|
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
|
+
};
|
|
@@ -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, transactions: 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,6 +34,7 @@ export const PG_FEATURES = {
|
|
|
34
34
|
serverSideCursors: true,
|
|
35
35
|
correlatedWrites: true,
|
|
36
36
|
rowLocks: { of: true, withWindow: false, placement: 'suffix' },
|
|
37
|
+
transactions: true,
|
|
37
38
|
nullsOrdering: 'clause',
|
|
38
39
|
textScoreIndexes: false,
|
|
39
40
|
orderedUpsertReturning: true,
|
package/dist/http/handler.d.ts
CHANGED
|
@@ -44,8 +44,11 @@ export type HookContext<E extends object, Ctx = unknown> = {
|
|
|
44
44
|
export type Hook<Ctx = unknown> = <E extends object>(ctx: HookContext<E, Ctx>) => void | Promise<void>;
|
|
45
45
|
export type ResponseHook<Ctx = unknown> = <E extends object>(ctx: HookContext<E, Ctx>, envelope: RequestSuccessResponse<unknown>) => void | Promise<void>;
|
|
46
46
|
export type RequestHandlerOptions<Ctx = unknown> = {
|
|
47
|
-
|
|
48
|
-
|
|
47
|
+
/**
|
|
48
|
+
* The entities served, and the only ones a request reaches, through a relation too: nothing is served
|
|
49
|
+
* by being defined, so an entity holding secrets (a session's token) stays off the wire unless named.
|
|
50
|
+
*/
|
|
51
|
+
include: readonly Type<object>[];
|
|
49
52
|
/** The URL segment an entity is addressed by, its kebab-cased class name by default; the browser client takes the same option. */
|
|
50
53
|
entityPath?: (entity: Type<unknown>) => string;
|
|
51
54
|
/**
|
package/dist/http/handler.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { withContext } from '../context/context.js';
|
|
2
|
-
import {
|
|
2
|
+
import { getMeta, soleIdOf } from '../entity/index.js';
|
|
3
3
|
import { whereIds, whereWith } from '../util/dialect.util.js';
|
|
4
4
|
import { UqlUsageError } from '../util/uqlError.js';
|
|
5
5
|
import { CRUD_ROUTES, entityPath, matchRoute, } from './contract.js';
|
|
@@ -10,14 +10,11 @@ function tableOf(entity) {
|
|
|
10
10
|
return `${entity.name} (${meta.schema ? `${meta.schema}.${meta.name}` : meta.name})`;
|
|
11
11
|
}
|
|
12
12
|
export function createRequestHandler(opts) {
|
|
13
|
-
const { include
|
|
13
|
+
const { include: entities, pre, preSave, preFilter, post, getContext, pool } = opts;
|
|
14
14
|
const pathOf = opts.entityPath ?? entityPath;
|
|
15
|
-
|
|
16
|
-
if (
|
|
17
|
-
|
|
18
|
-
}
|
|
19
|
-
if (!entities.length) {
|
|
20
|
-
throw new UqlUsageError('no entities for the uql middleware');
|
|
15
|
+
// Refused rather than defaulted to every entity defined, which would serve whatever one registers.
|
|
16
|
+
if (!entities?.length) {
|
|
17
|
+
throw new UqlUsageError("name the entities the handler serves in 'include': it serves those alone");
|
|
21
18
|
}
|
|
22
19
|
// All of them at once, so fixing the first collision does not just reveal the next.
|
|
23
20
|
const byPath = Map.groupBy(entities, pathOf);
|
|
@@ -32,6 +32,7 @@ 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,
|
|
35
36
|
};
|
|
36
37
|
/** What `toWireId` converts: the hex spelling of an `ObjectId`, and nothing looser. */
|
|
37
38
|
const HEX_24 = /^[0-9a-f]{24}$/i;
|
package/dist/type/dialect.d.ts
CHANGED
|
@@ -141,6 +141,11 @@ 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;
|
|
144
149
|
}
|
|
145
150
|
/** How a dialect spells a row lock, once {@link DialectFeatures.rowLocks} says it has one. */
|
|
146
151
|
export interface RowLockFeatures {
|
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.85.0",
|
|
7
7
|
"type": "module",
|
|
8
8
|
"engines": {
|
|
9
9
|
"node": ">=24"
|
|
@@ -35,6 +35,7 @@
|
|
|
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",
|
|
38
39
|
"./browser": {
|
|
39
40
|
"types": "./dist/browser/index.d.ts",
|
|
40
41
|
"import": "./dist/browser/index.js",
|
|
@@ -71,6 +72,7 @@
|
|
|
71
72
|
"@nestjs/core": ">=10.0.0",
|
|
72
73
|
"@tursodatabase/database": ">=0.7.0",
|
|
73
74
|
"@tursodatabase/serverless": ">=1.3.0",
|
|
75
|
+
"better-auth": ">=1.7.0",
|
|
74
76
|
"better-sqlite3": ">=9.0.0",
|
|
75
77
|
"express": ">=5.0.0",
|
|
76
78
|
"mariadb": ">=3.0.0",
|
|
@@ -103,6 +105,9 @@
|
|
|
103
105
|
"@tursodatabase/serverless": {
|
|
104
106
|
"optional": true
|
|
105
107
|
},
|
|
108
|
+
"better-auth": {
|
|
109
|
+
"optional": true
|
|
110
|
+
},
|
|
106
111
|
"better-sqlite3": {
|
|
107
112
|
"optional": true
|
|
108
113
|
},
|
|
@@ -132,6 +137,7 @@
|
|
|
132
137
|
}
|
|
133
138
|
},
|
|
134
139
|
"devDependencies": {
|
|
140
|
+
"@better-auth/test-utils": "^1.7.6",
|
|
135
141
|
"@electric-sql/pglite": "0.5.8",
|
|
136
142
|
"@electric-sql/pglite-pgvector": "0.0.9",
|
|
137
143
|
"@libsql/client": "^0.18.0",
|
|
@@ -146,6 +152,7 @@
|
|
|
146
152
|
"@types/mssql": "^12.3.0",
|
|
147
153
|
"@types/pg": "^8.23.1",
|
|
148
154
|
"@types/ws": "^8.18.1",
|
|
155
|
+
"better-auth": "^1.7.6",
|
|
149
156
|
"better-sqlite3": "^13.0.3",
|
|
150
157
|
"express": "^5.2.1",
|
|
151
158
|
"mariadb": "^3.5.4",
|
package/skills/uql-orm/SKILL.md
CHANGED
|
@@ -119,7 +119,8 @@ const users = await pool.findMany(User, {
|
|
|
119
119
|
- A result is narrowed to what the query selected and populated: reading an unselected field is a compile error.
|
|
120
120
|
Name that shape with `QueryFindResult<User, 'id' | 'email'>` rather than widening the query.
|
|
121
121
|
- `$populate` loads relations in the same statement. Nothing is lazy: a relation not populated is not there.
|
|
122
|
-
- A query is plain data, so it can be built dynamically, stored, or sent from a browser to `uql-orm/http
|
|
122
|
+
- A query is plain data, so it can be built dynamically, stored, or sent from a browser to `uql-orm/http`,
|
|
123
|
+
whose handler serves only the entities its required `include` names.
|
|
123
124
|
- Methods: `findMany`, `findOne`, `findOneById`, `findManyAndCount`, `findManyStream`, `count`, `exists`,
|
|
124
125
|
`aggregate`, `insertOne`, `insertMany`, `updateOneById`, `updateMany`, `saveOne`, `saveMany`, `upsertOne`,
|
|
125
126
|
`upsertMany`, `deleteOneById`, `deleteMany`. Each takes the entity class first.
|
|
@@ -157,6 +158,13 @@ transaction. A querier from `pool.getQuerier()` is yours to release: bind it wit
|
|
|
157
158
|
writes entity classes from an existing database; `drift:check` fails when the database no longer matches.
|
|
158
159
|
Triggers are part of the diff: uql installs its own under `_uql_`-prefixed names and never touches another.
|
|
159
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
|
+
|
|
160
168
|
## Where to read more
|
|
161
169
|
|
|
162
170
|
- Operators, per-dialect SQL: https://uql-orm.dev/querying/comparison-operators.md
|
|
@@ -165,4 +173,5 @@ Triggers are part of the diff: uql installs its own under `_uql_`-prefixed names
|
|
|
165
173
|
- Triggers: https://uql-orm.dev/entities/triggers.md
|
|
166
174
|
- Every method's signature: https://uql-orm.dev/querying/methods.md
|
|
167
175
|
- 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
|
|
168
177
|
- Breaking changes by version: https://uql-orm.dev/upgrade-guide.md
|