uql-orm 0.83.1 → 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 +30 -9
- 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/browser/uql-browser.min.js +2 -2
- package/dist/browser/uql-browser.min.js.map +4 -4
- package/dist/context/context.browser.d.ts +0 -1
- package/dist/context/context.browser.js +0 -1
- package/dist/context/context.d.ts +0 -1
- package/dist/context/context.js +0 -1
- package/dist/d1/d1SqliteDialect.js +1 -1
- package/dist/dialect/mysqlLikeSqlDialect.js +1 -0
- package/dist/dialect/pgLikeSqlDialect.js +1 -0
- package/dist/http/fetchHandler.js +2 -1
- package/dist/http/handler.d.ts +7 -3
- package/dist/http/handler.js +101 -105
- package/dist/http/query.d.ts +8 -1
- package/dist/http/query.js +8 -7
- package/dist/mongo/mongoDialect.js +1 -0
- package/dist/mssql/mssqlDialect.js +1 -0
- package/dist/querier/abstractQuerier.d.ts +11 -3
- package/dist/querier/abstractQuerier.js +55 -8
- package/dist/querier/queryError.js +2 -2
- package/dist/sqlite/sqliteDialect.js +1 -0
- package/dist/type/dialect.d.ts +5 -0
- package/dist/util/dialect.util.d.ts +13 -2
- package/dist/util/dialect.util.js +59 -40
- package/dist/util/uqlError.d.ts +19 -8
- package/dist/util/uqlError.js +15 -6
- package/package.json +10 -3
- package/skills/uql-orm/SKILL.md +12 -3
- package/dist/context/securityError.d.ts +0 -4
- package/dist/context/securityError.js +0 -4
package/dist/http/query.js
CHANGED
|
@@ -7,10 +7,12 @@ import { RAW_VALUE } from '../type/queryRaw.js';
|
|
|
7
7
|
import { getKeys, isRecord, isWhereMap } from '../util/object.util.js';
|
|
8
8
|
// the error class alone, from its own leaf module: `queryError.ts` carries every driver's code map
|
|
9
9
|
import { UqlUsageError } from '../util/uqlError.js';
|
|
10
|
+
/** The flags a request carries beside its query: `hardDelete` on a delete, `count` on a `findMany`. */
|
|
11
|
+
const WIRE_FLAGS = ['hardDelete', 'count'];
|
|
10
12
|
/**
|
|
11
|
-
* Keys accepted from the wire - query structure ({@link Query}) plus the
|
|
12
|
-
*
|
|
13
|
-
*
|
|
13
|
+
* Keys accepted from the wire - query structure ({@link Query}) plus the {@link WIRE_FLAGS}. Anything else
|
|
14
|
+
* (e.g. `filters`, `context`, `$entity`) is dropped so a remote client can't bypass a security filter or
|
|
15
|
+
* inject ambient context - those are server-only. The `satisfies` ties
|
|
14
16
|
* every entry to a real query/option key, so a typo or a renamed option fails to compile.
|
|
15
17
|
*/
|
|
16
18
|
const ALLOWED_QUERY_KEYS = new Set([
|
|
@@ -19,8 +21,7 @@ const ALLOWED_QUERY_KEYS = new Set([
|
|
|
19
21
|
...QUERY_NUMBER_CLAUSES,
|
|
20
22
|
...QUERY_ROOT_NUMBER_CLAUSES,
|
|
21
23
|
...QUERY_BOOLEAN_CLAUSES,
|
|
22
|
-
|
|
23
|
-
'count',
|
|
24
|
+
...WIRE_FLAGS,
|
|
24
25
|
]);
|
|
25
26
|
/**
|
|
26
27
|
* Keys that mean something locally but that this transport can never honor, so they are rejected
|
|
@@ -53,7 +54,7 @@ export function parseQueryParams(params = {}) {
|
|
|
53
54
|
query[key] = JSON.parse(value);
|
|
54
55
|
}
|
|
55
56
|
catch {
|
|
56
|
-
throw
|
|
57
|
+
throw new UqlUsageError(`invalid JSON in '${key}'`);
|
|
57
58
|
}
|
|
58
59
|
}
|
|
59
60
|
}
|
|
@@ -69,7 +70,7 @@ export function parseQueryParams(params = {}) {
|
|
|
69
70
|
query[key] = Number(query[key]);
|
|
70
71
|
}
|
|
71
72
|
}
|
|
72
|
-
for (const key of QUERY_BOOLEAN_CLAUSES) {
|
|
73
|
+
for (const key of [...QUERY_BOOLEAN_CLAUSES, ...WIRE_FLAGS]) {
|
|
73
74
|
if (query[key] !== undefined) {
|
|
74
75
|
query[key] = query[key] === true || query[key] === 'true';
|
|
75
76
|
}
|
|
@@ -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;
|
|
@@ -88,6 +88,8 @@ export declare abstract class AbstractQuerier implements Querier {
|
|
|
88
88
|
* same rows the statement wrote.
|
|
89
89
|
*/
|
|
90
90
|
insertMany<E extends object>(entity: Type<E>, payload: readonly EntityWrite<E>[]): Promise<(WrittenId<E> | undefined)[]>;
|
|
91
|
+
/** Fills and guards `rows`, then writes them: an insert, and the insert half of a guarded upsert. */
|
|
92
|
+
private insertRows;
|
|
91
93
|
/** Writes `rows`, and onto each one the key the database generated for it, where it can tell. */
|
|
92
94
|
protected abstract internalInsertMany<E extends object>(entity: Type<E>, rows: EntityData<E>[]): Promise<void>;
|
|
93
95
|
updateOneById<E extends object>(entity: Type<E>, id: EntityId<E>, payload: UpdateWrite<E>, opts?: QueryOptions): Promise<number>;
|
|
@@ -126,6 +128,12 @@ export declare abstract class AbstractQuerier implements Querier {
|
|
|
126
128
|
/** Fires `beforeUpsert`/`afterUpsert`: which branch a row takes is the database's to decide, so neither the insert's nor the update's pair fits. */
|
|
127
129
|
upsertOne<E extends object>(entity: Type<E>, conflictPaths: QueryConflictPaths<E>, payload: EntityWrite<E>): Promise<QueryUpsertOneResult<E>>;
|
|
128
130
|
upsertMany<E extends object>(entity: Type<E>, conflictPaths: QueryConflictPaths<E>, payload: readonly EntityWrite<E>[]): Promise<QueryUpsertManyResult<E>>;
|
|
131
|
+
/**
|
|
132
|
+
* An upsert on an entity a `security` filter guards. `ON CONFLICT` updates the conflicting row whoever
|
|
133
|
+
* it belongs to, so the rows the conflict names are read through the filter: those found update, the
|
|
134
|
+
* rest insert, where another tenant's row fails on its key. See architecture/security-filter-writes.md.
|
|
135
|
+
*/
|
|
136
|
+
private guardedUpsert;
|
|
129
137
|
protected abstract internalUpsertOne<E extends object>(entity: Type<E>, conflictPaths: QueryConflictPaths<E>, payload: EntityData<E>): Promise<QueryUpdateResult>;
|
|
130
138
|
protected abstract internalUpsertMany<E extends object>(entity: Type<E>, conflictPaths: QueryConflictPaths<E>, payload: EntityData<E>[]): Promise<QueryUpdateResult>;
|
|
131
139
|
deleteOneById<E extends object>(entity: Type<E>, id: EntityId<E>, opts?: QueryOptions): Promise<number>;
|
|
@@ -171,9 +179,9 @@ export declare abstract class AbstractQuerier implements Querier {
|
|
|
171
179
|
*/
|
|
172
180
|
private emitLoaded;
|
|
173
181
|
/**
|
|
174
|
-
* The ids of `rows`, read
|
|
175
|
-
* not report them in payload order
|
|
176
|
-
* `undefined`: a missing id is honest where a guessed one is not.
|
|
182
|
+
* The ids of `rows`, read through the filters by the columns an upsert matches them on: after a
|
|
183
|
+
* statement that could not report them in payload order, or before a guarded upsert. A row that no
|
|
184
|
+
* read row matches, or that two do, keeps `undefined`: a missing id is honest where a guessed one is not.
|
|
177
185
|
*/
|
|
178
186
|
protected idsByConflict<E extends object>(entity: Type<E>, conflictPaths: QueryConflictPaths<E>, rows: EntityData<E>[]): Promise<(PrimaryKey | undefined)[]>;
|
|
179
187
|
/**
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { assertSoleId, getMeta, idOf, namesKey, relationOf } from '../entity/index.js';
|
|
2
2
|
import { namesRows } from '../dialect/operators.js';
|
|
3
3
|
import { parseQueryLock } from '../type/index.js';
|
|
4
|
-
import { cascadesOnDelete, childrenOf, clone, entityName, fillOnFields, filterFieldKeys, filterPersistableRelationKeys, forEachRequestedRelation, getKeys, getRelationRequestSummary, idOnlyQuery, keySet, isPagedQuery, isScalarId, LoggerWrapper, parentJoins, queryLoggerFor, parseRelationAtKey, parseRelationQueryValue, rowKey, runHooks, someKey, targetKeyColumns, whereAnyOf, whereEach, whereIds, whereWith, withoutSoftDeleteFilter, } from '../util/index.js';
|
|
4
|
+
import { cascadesOnDelete, childrenOf, clone, entityName, fillOnFields, filterFieldKeys, filterPersistableRelationKeys, forEachRequestedRelation, getKeys, getRelationRequestSummary, guardWrite, idOnlyQuery, keySet, isPagedQuery, isScalarId, LoggerWrapper, parentJoins, queryLoggerFor, parseRelationAtKey, parseRelationQueryValue, rowKey, runHooks, securityConditions, someKey, targetKeyColumns, whereAnyOf, whereEach, whereIds, whereWith, withoutSoftDeleteFilter, } from '../util/index.js';
|
|
5
5
|
import { UqlOptimisticLockError, UqlUsageError } from '../util/uqlError.js';
|
|
6
6
|
import { enrichError } from './queryError.js';
|
|
7
7
|
/**
|
|
@@ -240,11 +240,17 @@ export class AbstractQuerier {
|
|
|
240
240
|
}
|
|
241
241
|
const meta = getMeta(entity);
|
|
242
242
|
return this.hooked(entity, 'Insert', payload, async (rows) => {
|
|
243
|
-
|
|
244
|
-
await this.internalInsertMany(entity, rows);
|
|
243
|
+
await this.insertRows(entity, rows);
|
|
245
244
|
return writtenIds(meta, rows);
|
|
246
245
|
});
|
|
247
246
|
}
|
|
247
|
+
/** Fills and guards `rows`, then writes them: an insert, and the insert half of a guarded upsert. */
|
|
248
|
+
async insertRows(entity, rows) {
|
|
249
|
+
const meta = getMeta(entity);
|
|
250
|
+
fillOnFields(meta, rows, 'onInsert');
|
|
251
|
+
guardWrite(meta, rows, 'insert');
|
|
252
|
+
await this.internalInsertMany(entity, rows);
|
|
253
|
+
}
|
|
248
254
|
async updateOneById(entity, id, payload, opts) {
|
|
249
255
|
assertIdValue(entity, id);
|
|
250
256
|
return this.updateMany(entity, { $where: whereIds(getMeta(entity), id) }, payload, opts);
|
|
@@ -261,6 +267,7 @@ export class AbstractQuerier {
|
|
|
261
267
|
async updateRows(entity, q, row, opts, lockKey) {
|
|
262
268
|
const meta = getMeta(entity);
|
|
263
269
|
fillOnFields(meta, [row], 'onUpdate');
|
|
270
|
+
guardWrite(meta, [row], 'update');
|
|
264
271
|
const relKeys = filterPersistableRelationKeys(meta, row, 'persist');
|
|
265
272
|
const settles = !!relKeys.length || this.settlesWrite(entity, q);
|
|
266
273
|
if (lockKey) {
|
|
@@ -345,7 +352,9 @@ export class AbstractQuerier {
|
|
|
345
352
|
const meta = getMeta(entity);
|
|
346
353
|
assertUnversioned(meta, "'upsertOne'");
|
|
347
354
|
return this.hooked(entity, 'Upsert', [payload], async (rows) => {
|
|
348
|
-
const { ids, changes, created } =
|
|
355
|
+
const { ids, changes, created } = securityConditions(meta).length
|
|
356
|
+
? await this.guardedUpsert(entity, conflictPaths, rows)
|
|
357
|
+
: await this.internalUpsertOne(entity, conflictPaths, rows[0]);
|
|
349
358
|
adoptReportedIds(meta, rows, ids);
|
|
350
359
|
const [id] = writtenIds(meta, rows);
|
|
351
360
|
return { id, changes, created };
|
|
@@ -355,11 +364,49 @@ export class AbstractQuerier {
|
|
|
355
364
|
const meta = getMeta(entity);
|
|
356
365
|
assertUnversioned(meta, "'upsertMany'");
|
|
357
366
|
return this.hooked(entity, 'Upsert', payload, async (rows) => {
|
|
358
|
-
const { ids, changes } =
|
|
367
|
+
const { ids, changes } = securityConditions(meta).length
|
|
368
|
+
? await this.guardedUpsert(entity, conflictPaths, rows)
|
|
369
|
+
: await this.internalUpsertMany(entity, conflictPaths, rows);
|
|
359
370
|
adoptReportedIds(meta, rows, ids);
|
|
360
371
|
return { ids: writtenIds(meta, rows), changes };
|
|
361
372
|
});
|
|
362
373
|
}
|
|
374
|
+
/**
|
|
375
|
+
* An upsert on an entity a `security` filter guards. `ON CONFLICT` updates the conflicting row whoever
|
|
376
|
+
* it belongs to, so the rows the conflict names are read through the filter: those found update, the
|
|
377
|
+
* rest insert, where another tenant's row fails on its key. See architecture/security-filter-writes.md.
|
|
378
|
+
*/
|
|
379
|
+
async guardedUpsert(entity, conflictPaths, rows) {
|
|
380
|
+
guardWrite(getMeta(entity), rows, 'insert');
|
|
381
|
+
if (!rows.length) {
|
|
382
|
+
return { changes: 0 };
|
|
383
|
+
}
|
|
384
|
+
const keys = getKeys(conflictPaths);
|
|
385
|
+
const write = async () => {
|
|
386
|
+
const ids = await this.idsByConflict(entity, conflictPaths, rows);
|
|
387
|
+
let changes = 0;
|
|
388
|
+
for (const [index, row] of rows.entries()) {
|
|
389
|
+
if (ids[index] !== undefined) {
|
|
390
|
+
// What `ON CONFLICT` would assign: the row, less the columns it matched on.
|
|
391
|
+
const payload = { ...row };
|
|
392
|
+
for (const key of keys) {
|
|
393
|
+
delete payload[key];
|
|
394
|
+
}
|
|
395
|
+
changes += await this.updateColumns(entity, { $where: whereEach(keys, (key) => row[key]) }, payload, undefined, 0);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
const inserts = rows.filter((_, index) => ids[index] === undefined);
|
|
399
|
+
if (inserts.length) {
|
|
400
|
+
await this.insertRows(entity, inserts);
|
|
401
|
+
changes += inserts.length;
|
|
402
|
+
}
|
|
403
|
+
const created = rows.length === 1 ? ids[0] === undefined : undefined;
|
|
404
|
+
// A found row's key is adopted by the caller; an inserted one carries the key its insert wrote.
|
|
405
|
+
return { changes, created, ids };
|
|
406
|
+
};
|
|
407
|
+
// One row is one statement after the read, so it needs no transaction of its own.
|
|
408
|
+
return rows.length === 1 ? write() : this.transaction(write);
|
|
409
|
+
}
|
|
363
410
|
async deleteOneById(entity, id, opts) {
|
|
364
411
|
assertIdValue(entity, id);
|
|
365
412
|
return this.deleteMany(entity, { $where: whereIds(getMeta(entity), id) }, opts);
|
|
@@ -571,9 +618,9 @@ export class AbstractQuerier {
|
|
|
571
618
|
await this.emitHook(entity, 'afterLoad', rows);
|
|
572
619
|
}
|
|
573
620
|
/**
|
|
574
|
-
* The ids of `rows`, read
|
|
575
|
-
* not report them in payload order
|
|
576
|
-
* `undefined`: a missing id is honest where a guessed one is not.
|
|
621
|
+
* The ids of `rows`, read through the filters by the columns an upsert matches them on: after a
|
|
622
|
+
* statement that could not report them in payload order, or before a guarded upsert. A row that no
|
|
623
|
+
* read row matches, or that two do, keeps `undefined`: a missing id is honest where a guessed one is not.
|
|
577
624
|
*/
|
|
578
625
|
async idsByConflict(entity, conflictPaths, rows) {
|
|
579
626
|
const meta = getMeta(entity);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { UqlError } from '../util/uqlError.js';
|
|
2
2
|
/** Postgres, CockroachDB, PGlite and Neon in `code`; Bun SQL in `errno`. */
|
|
3
3
|
const SQLSTATE_KINDS = new Map([
|
|
4
4
|
['23505', 'uniqueViolation'],
|
|
@@ -53,7 +53,7 @@ export function queryErrorKind(err) {
|
|
|
53
53
|
if (typeof err !== 'object' || err === null) {
|
|
54
54
|
return undefined;
|
|
55
55
|
}
|
|
56
|
-
if (err instanceof
|
|
56
|
+
if (err instanceof UqlError) {
|
|
57
57
|
return err.kind;
|
|
58
58
|
}
|
|
59
59
|
const { code, errno, number, errorLabels, message } = err;
|
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 {
|
|
@@ -119,10 +119,21 @@ export declare function assertWhere<E>(meta: EntityMeta<E>, where: unknown): voi
|
|
|
119
119
|
export declare function withoutSoftDeleteFilter(filters: QueryOptions['filters']): QueryOptions['filters'];
|
|
120
120
|
/**
|
|
121
121
|
* `$where` with the entity's active filters merged in, against the ambient {@link UqlContext}. A convenience
|
|
122
|
-
* filter yields to a `$where` on its key; a `security` one is always ANDed,
|
|
123
|
-
* resolves to nothing, unless `onMissing: 'skip'`.
|
|
122
|
+
* filter yields to a `$where` on its key; a `security` one is always ANDed, from {@link securityConditions}.
|
|
124
123
|
*/
|
|
125
124
|
export declare function applyFilters<E>(meta: EntityMeta<E>, whereMap: QueryWhere<E>, opts?: QueryOptions): QueryWhere<E>;
|
|
125
|
+
/**
|
|
126
|
+
* Each `security` filter's condition, by name, resolved against the ambient context. One resolving to
|
|
127
|
+
* `{}`, a trusted context's "no restriction", is left out. Reads AND these in; writes are held to them.
|
|
128
|
+
*/
|
|
129
|
+
export declare function securityConditions<E>(meta: EntityMeta<E>): [name: string, condition: QueryWhere<E>][];
|
|
130
|
+
/**
|
|
131
|
+
* Holds written rows to the `security` filters, as {@link applyFilters} holds reads: an inserted row gets
|
|
132
|
+
* each field a condition names and it leaves out, and a row naming one must carry the condition's value.
|
|
133
|
+
* A condition other than field equalities refuses the write, having nothing a row can be checked against.
|
|
134
|
+
* See architecture/security-filter-writes.md.
|
|
135
|
+
*/
|
|
136
|
+
export declare function guardWrite<E, R extends EntityData<E> | UpdatePayload<E>>(meta: EntityMeta<E>, rows: readonly R[], write: 'insert' | 'update'): void;
|
|
126
137
|
/**
|
|
127
138
|
* Parsed entry from a `$group` map - either a raw group key or an aggregate function call.
|
|
128
139
|
*/
|
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import { getContext
|
|
1
|
+
import { getContext } from '../context/context.js';
|
|
2
2
|
import { soleIdOf } from '../entity/metadata/definition.js';
|
|
3
3
|
import { QueryRaw, resolveAggregateOp, SOFT_DELETE_FILTER, } from '../type/index.js';
|
|
4
4
|
import { DEFAULT_VECTOR_DISTANCE, VECTOR_INDEX_TYPES } from '../type/vector.js';
|
|
5
5
|
import { defaultReadKeys, fieldKeys, isDatabaseWritten } from './field.util.js';
|
|
6
6
|
import { entityName, getKeys, hasKeys, isOperatorObject, isScalarId, isRecord, isWhereMap, someKey, } from './object.util.js';
|
|
7
|
-
import { UqlUsageError } from './uqlError.js';
|
|
7
|
+
import { UqlSecurityError, UqlUsageError } from './uqlError.js';
|
|
8
8
|
/** The keys of `payload` a write persists as columns. */
|
|
9
9
|
export function filterFieldKeys(meta, payload, callbackKey) {
|
|
10
10
|
return getKeys(payload).filter((key) => {
|
|
@@ -295,59 +295,78 @@ export function withoutSoftDeleteFilter(filters) {
|
|
|
295
295
|
}
|
|
296
296
|
/**
|
|
297
297
|
* `$where` with the entity's active filters merged in, against the ambient {@link UqlContext}. A convenience
|
|
298
|
-
* filter yields to a `$where` on its key; a `security` one is always ANDed,
|
|
299
|
-
* resolves to nothing, unless `onMissing: 'skip'`.
|
|
298
|
+
* filter yields to a `$where` on its key; a `security` one is always ANDed, from {@link securityConditions}.
|
|
300
299
|
*/
|
|
301
300
|
export function applyFilters(meta, whereMap, opts) {
|
|
302
301
|
if (!meta.filters) {
|
|
303
302
|
return whereMap;
|
|
304
303
|
}
|
|
305
|
-
const context = getContext();
|
|
306
304
|
const result = { ...whereMap };
|
|
307
|
-
const
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
let active;
|
|
311
|
-
if (filter.security) {
|
|
312
|
-
active = true;
|
|
313
|
-
}
|
|
314
|
-
else if (opts?.filters === false) {
|
|
315
|
-
active = false;
|
|
316
|
-
}
|
|
317
|
-
else {
|
|
318
|
-
active = opts?.filters?.[name] ?? filter.default !== false;
|
|
319
|
-
}
|
|
320
|
-
if (!active) {
|
|
305
|
+
for (const [name, filter] of Object.entries(meta.filters)) {
|
|
306
|
+
const active = opts?.filters !== false && (opts?.filters?.[name] ?? filter.default !== false);
|
|
307
|
+
if (filter.security || !active) {
|
|
321
308
|
continue;
|
|
322
309
|
}
|
|
323
|
-
const condition =
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
throw new UqlSecurityError(`filter '${name}' on '${entityName(meta)}' could not resolve (missing context)`);
|
|
310
|
+
const condition = resolveFilter(meta, name, filter) ?? {};
|
|
311
|
+
for (const key of getKeys(condition)) {
|
|
312
|
+
if (result[key] === undefined) {
|
|
313
|
+
result[key] = condition[key];
|
|
328
314
|
}
|
|
329
|
-
continue;
|
|
330
|
-
}
|
|
331
|
-
const conditionMap = condition;
|
|
332
|
-
if (!hasKeys(conditionMap)) {
|
|
333
|
-
continue; // resolved to "no restriction" (e.g. a trusted system context) - nothing to merge
|
|
334
315
|
}
|
|
335
|
-
|
|
336
|
-
|
|
316
|
+
}
|
|
317
|
+
const security = securityConditions(meta).map(([, condition]) => condition);
|
|
318
|
+
if (security.length) {
|
|
319
|
+
const existing = result['$and'];
|
|
320
|
+
result['$and'] = existing ? [...existing, ...security] : security;
|
|
321
|
+
}
|
|
322
|
+
return result;
|
|
323
|
+
}
|
|
324
|
+
/**
|
|
325
|
+
* Each `security` filter's condition, by name, resolved against the ambient context. One resolving to
|
|
326
|
+
* `{}`, a trusted context's "no restriction", is left out. Reads AND these in; writes are held to them.
|
|
327
|
+
*/
|
|
328
|
+
export function securityConditions(meta) {
|
|
329
|
+
return Object.entries(meta.filters ?? {}).flatMap(([name, filter]) => {
|
|
330
|
+
const condition = filter.security ? resolveFilter(meta, name, filter) : undefined;
|
|
331
|
+
return condition && hasKeys(condition) ? [[name, condition]] : [];
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
/** A filter's condition, `undefined` where it resolves to nothing and may skip; throws where it may not. */
|
|
335
|
+
function resolveFilter(meta, name, filter) {
|
|
336
|
+
const condition = typeof filter.where === 'function' ? filter.where(getContext()) : filter.where;
|
|
337
|
+
const onMissing = filter.onMissing ?? (filter.security ? 'throw' : 'skip');
|
|
338
|
+
if (condition === undefined && onMissing === 'throw') {
|
|
339
|
+
throw new UqlSecurityError(`filter '${name}' on '${entityName(meta)}' could not resolve (missing context)`);
|
|
340
|
+
}
|
|
341
|
+
return condition;
|
|
342
|
+
}
|
|
343
|
+
/**
|
|
344
|
+
* Holds written rows to the `security` filters, as {@link applyFilters} holds reads: an inserted row gets
|
|
345
|
+
* each field a condition names and it leaves out, and a row naming one must carry the condition's value.
|
|
346
|
+
* A condition other than field equalities refuses the write, having nothing a row can be checked against.
|
|
347
|
+
* See architecture/security-filter-writes.md.
|
|
348
|
+
*/
|
|
349
|
+
export function guardWrite(meta, rows, write) {
|
|
350
|
+
for (const [name, condition] of securityConditions(meta)) {
|
|
351
|
+
const values = condition;
|
|
352
|
+
const keys = fieldKeys(meta, () => true).filter((key) => Object.hasOwn(values, key));
|
|
353
|
+
const equalities = keys.length === getKeys(values).length;
|
|
354
|
+
if (!equalities || keys.some((key) => Array.isArray(values[key]) || isOperatorObject(values[key]))) {
|
|
355
|
+
throw new UqlSecurityError(`'${entityName(meta)}' security filter '${name}' is not field equalities, so no write can be checked against it`);
|
|
337
356
|
}
|
|
338
|
-
|
|
339
|
-
for (const
|
|
340
|
-
if (
|
|
341
|
-
|
|
357
|
+
for (const key of keys) {
|
|
358
|
+
for (const row of rows) {
|
|
359
|
+
if (row[key] === undefined) {
|
|
360
|
+
if (write === 'insert') {
|
|
361
|
+
row[key] = values[key];
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
else if (row[key] !== values[key]) {
|
|
365
|
+
throw new UqlSecurityError(`'${entityName(meta)}' row sets '${key}' outside security filter '${name}'`);
|
|
342
366
|
}
|
|
343
367
|
}
|
|
344
368
|
}
|
|
345
369
|
}
|
|
346
|
-
if (securityConditions.length) {
|
|
347
|
-
const existing = result['$and'];
|
|
348
|
-
result['$and'] = existing ? [...existing, ...securityConditions] : securityConditions;
|
|
349
|
-
}
|
|
350
|
-
return result;
|
|
351
370
|
}
|
|
352
371
|
/**
|
|
353
372
|
* The `$size` of a relation condition, `{ comments: { $size: { $gte: 2 } } }`, or `undefined` where it
|
package/dist/util/uqlError.d.ts
CHANGED
|
@@ -2,22 +2,33 @@
|
|
|
2
2
|
* What a failed query ran into, named the same on every engine - what {@link queryErrorKind} answers
|
|
3
3
|
* with, whether a driver raised the error or UQL did. `retryable` is a deadlock, a serialization
|
|
4
4
|
* failure, a lock timeout or a busy database: the transaction can simply run again. `usage` is the
|
|
5
|
-
* caller's own mistake, which running it again will not fix.
|
|
5
|
+
* caller's own mistake, which running it again will not fix. `security` is a `security` filter refusing.
|
|
6
6
|
*/
|
|
7
|
-
export type QueryErrorKind = 'uniqueViolation' | 'foreignKeyViolation' | 'notNullViolation' | 'checkViolation' | 'optimisticLock' | 'retryable' | 'usage';
|
|
7
|
+
export type QueryErrorKind = 'uniqueViolation' | 'foreignKeyViolation' | 'notNullViolation' | 'checkViolation' | 'optimisticLock' | 'retryable' | 'usage' | 'security';
|
|
8
|
+
/** Every error UQL raises of its own: the kind `queryErrorKind` answers, and the status HTTP answers with. */
|
|
9
|
+
export declare abstract class UqlError extends Error {
|
|
10
|
+
abstract readonly kind: QueryErrorKind;
|
|
11
|
+
abstract readonly status: number;
|
|
12
|
+
}
|
|
8
13
|
/**
|
|
9
14
|
* Thrown where the caller used the API in a way no statement can carry out: an update payload with no
|
|
10
|
-
* version, a `$lock` outside a transaction, a method with no version to match. A `
|
|
11
|
-
* since the call itself is wrong, but one carrying the `status` an HTTP transport answers with - the
|
|
15
|
+
* version, a `$lock` outside a transaction, a method with no version to match. A `400` over HTTP: the
|
|
12
16
|
* request is malformed, not the server's failure, and an untyped client is exactly who reaches this.
|
|
13
17
|
*/
|
|
14
|
-
export declare class UqlUsageError extends
|
|
18
|
+
export declare class UqlUsageError extends UqlError {
|
|
15
19
|
name: string;
|
|
16
|
-
/** What `queryErrorKind` answers, so a caller branches on it rather than on the class. */
|
|
17
20
|
readonly kind = "usage";
|
|
18
|
-
/** What an HTTP transport answers with. */
|
|
19
21
|
readonly status = 400;
|
|
20
22
|
}
|
|
23
|
+
/**
|
|
24
|
+
* Thrown where a `security` filter refuses: its context is missing, or a write would leave a row outside it.
|
|
25
|
+
* Fails the statement closed.
|
|
26
|
+
*/
|
|
27
|
+
export declare class UqlSecurityError extends UqlError {
|
|
28
|
+
name: string;
|
|
29
|
+
readonly kind = "security";
|
|
30
|
+
readonly status = 403;
|
|
31
|
+
}
|
|
21
32
|
/** What a value is, for a refusal naming what `/http` handed over instead of what the types require. */
|
|
22
33
|
export declare function kindOf(value: unknown): string;
|
|
23
34
|
/**
|
|
@@ -31,7 +42,7 @@ export type UqlLockUsageError = UqlUsageError;
|
|
|
31
42
|
* or it is gone. `expected` is what the payload carried, `actual` what the row holds now, `undefined`
|
|
32
43
|
* where there is no row left.
|
|
33
44
|
*/
|
|
34
|
-
export declare class UqlOptimisticLockError extends
|
|
45
|
+
export declare class UqlOptimisticLockError extends UqlError {
|
|
35
46
|
readonly expected: unknown;
|
|
36
47
|
readonly actual: unknown;
|
|
37
48
|
name: string;
|
package/dist/util/uqlError.js
CHANGED
|
@@ -1,16 +1,25 @@
|
|
|
1
|
+
/** Every error UQL raises of its own: the kind `queryErrorKind` answers, and the status HTTP answers with. */
|
|
2
|
+
export class UqlError extends Error {
|
|
3
|
+
}
|
|
1
4
|
/**
|
|
2
5
|
* Thrown where the caller used the API in a way no statement can carry out: an update payload with no
|
|
3
|
-
* version, a `$lock` outside a transaction, a method with no version to match. A `
|
|
4
|
-
* since the call itself is wrong, but one carrying the `status` an HTTP transport answers with - the
|
|
6
|
+
* version, a `$lock` outside a transaction, a method with no version to match. A `400` over HTTP: the
|
|
5
7
|
* request is malformed, not the server's failure, and an untyped client is exactly who reaches this.
|
|
6
8
|
*/
|
|
7
|
-
export class UqlUsageError extends
|
|
9
|
+
export class UqlUsageError extends UqlError {
|
|
8
10
|
name = 'UqlUsageError';
|
|
9
|
-
/** What `queryErrorKind` answers, so a caller branches on it rather than on the class. */
|
|
10
11
|
kind = 'usage';
|
|
11
|
-
/** What an HTTP transport answers with. */
|
|
12
12
|
status = 400;
|
|
13
13
|
}
|
|
14
|
+
/**
|
|
15
|
+
* Thrown where a `security` filter refuses: its context is missing, or a write would leave a row outside it.
|
|
16
|
+
* Fails the statement closed.
|
|
17
|
+
*/
|
|
18
|
+
export class UqlSecurityError extends UqlError {
|
|
19
|
+
name = 'UqlSecurityError';
|
|
20
|
+
kind = 'security';
|
|
21
|
+
status = 403;
|
|
22
|
+
}
|
|
14
23
|
/** What a value is, for a refusal naming what `/http` handed over instead of what the types require. */
|
|
15
24
|
export function kindOf(value) {
|
|
16
25
|
return value === null ? 'null' : Array.isArray(value) ? 'array' : typeof value;
|
|
@@ -25,7 +34,7 @@ export const UqlLockUsageError = UqlUsageError;
|
|
|
25
34
|
* or it is gone. `expected` is what the payload carried, `actual` what the row holds now, `undefined`
|
|
26
35
|
* where there is no row left.
|
|
27
36
|
*/
|
|
28
|
-
export class UqlOptimisticLockError extends
|
|
37
|
+
export class UqlOptimisticLockError extends UqlError {
|
|
29
38
|
expected;
|
|
30
39
|
actual;
|
|
31
40
|
name = 'UqlOptimisticLockError';
|
package/package.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "uql-orm",
|
|
3
3
|
"homepage": "https://uql-orm.dev",
|
|
4
|
-
"description": "
|
|
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",
|
|
@@ -56,7 +57,7 @@
|
|
|
56
57
|
"README.md"
|
|
57
58
|
],
|
|
58
59
|
"scripts": {
|
|
59
|
-
"prepack": "bun run build &&
|
|
60
|
+
"prepack": "bun run build && cp ../../README.md . && cp -R ../../skills .",
|
|
60
61
|
"postpack": "rm -r README.md skills && npm pkg delete gitHead",
|
|
61
62
|
"compile.browser": "bun build src/browser/index.ts --minify --sourcemap=linked --format=esm --target=browser --outdir=dist/browser --entry-naming 'uql-browser.min.[ext]'",
|
|
62
63
|
"build": "bun run clean && tsc -b tsconfig.build.json && bun run compile.browser && bun run verify-dist.ts",
|
|
@@ -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.
|
|
@@ -131,8 +132,8 @@ const users = await pool.findMany(User, {
|
|
|
131
132
|
someone else holds) and needs an open transaction; SQLite, libSQL, Turso, D1 and MongoDB have no row lock and
|
|
132
133
|
refuse it.
|
|
133
134
|
- `queryErrorKind(err)` names any failure the same on every engine - `uniqueViolation`, `foreignKeyViolation`,
|
|
134
|
-
`notNullViolation`, `checkViolation`, `optimisticLock`, `retryable`, `usage` - so catch by kind
|
|
135
|
-
a driver's code or an `instanceof`.
|
|
135
|
+
`notNullViolation`, `checkViolation`, `optimisticLock`, `retryable`, `usage`, `security` - so catch by kind
|
|
136
|
+
rather than by a driver's code or an `instanceof`. Every error UQL raises itself is a `UqlError`.
|
|
136
137
|
- `raw()` embeds SQL anywhere a value or field goes; `pool.all(sql, values)` runs a raw `SELECT`. A field read off `refs(Entity)` carries its type: on its own as a value it fits only a field of that type.
|
|
137
138
|
|
|
138
139
|
## Connections and transactions
|
|
@@ -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
|