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.
Files changed (36) hide show
  1. package/README.md +30 -9
  2. package/dist/betterAuth/authEntities.d.ts +7 -0
  3. package/dist/betterAuth/authEntities.js +154 -0
  4. package/dist/betterAuth/index.d.ts +2 -0
  5. package/dist/betterAuth/index.js +2 -0
  6. package/dist/betterAuth/uqlAdapter.d.ts +16 -0
  7. package/dist/betterAuth/uqlAdapter.js +155 -0
  8. package/dist/browser/uql-browser.min.js +2 -2
  9. package/dist/browser/uql-browser.min.js.map +4 -4
  10. package/dist/context/context.browser.d.ts +0 -1
  11. package/dist/context/context.browser.js +0 -1
  12. package/dist/context/context.d.ts +0 -1
  13. package/dist/context/context.js +0 -1
  14. package/dist/d1/d1SqliteDialect.js +1 -1
  15. package/dist/dialect/mysqlLikeSqlDialect.js +1 -0
  16. package/dist/dialect/pgLikeSqlDialect.js +1 -0
  17. package/dist/http/fetchHandler.js +2 -1
  18. package/dist/http/handler.d.ts +7 -3
  19. package/dist/http/handler.js +101 -105
  20. package/dist/http/query.d.ts +8 -1
  21. package/dist/http/query.js +8 -7
  22. package/dist/mongo/mongoDialect.js +1 -0
  23. package/dist/mssql/mssqlDialect.js +1 -0
  24. package/dist/querier/abstractQuerier.d.ts +11 -3
  25. package/dist/querier/abstractQuerier.js +55 -8
  26. package/dist/querier/queryError.js +2 -2
  27. package/dist/sqlite/sqliteDialect.js +1 -0
  28. package/dist/type/dialect.d.ts +5 -0
  29. package/dist/util/dialect.util.d.ts +13 -2
  30. package/dist/util/dialect.util.js +59 -40
  31. package/dist/util/uqlError.d.ts +19 -8
  32. package/dist/util/uqlError.js +15 -6
  33. package/package.json +10 -3
  34. package/skills/uql-orm/SKILL.md +12 -3
  35. package/dist/context/securityError.d.ts +0 -4
  36. package/dist/context/securityError.js +0 -4
@@ -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 `hardDelete`/`count` scalar
12
- * flags. Anything else (e.g. `filters`, `context`, `$entity`) is dropped so a remote client can't
13
- * bypass a security filter or inject ambient context - those are server-only. The `satisfies` ties
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
- 'hardDelete',
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 Object.assign(new SyntaxError(`invalid JSON in '${key}'`), { status: 400 });
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;
@@ -31,6 +31,7 @@ const MSSQL_FEATURES = {
31
31
  serverSideCursors: false,
32
32
  correlatedWrites: true,
33
33
  rowLocks: { of: true, withWindow: true, placement: 'tableHint' },
34
+ transactions: true,
34
35
  nullsOrdering: 'case',
35
36
  textScoreIndexes: false,
36
37
  orderedUpsertReturning: false,
@@ -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 back by the columns an upsert matched them on, for a statement that could
175
- * not report them in payload order. A row that no read row matches, or that two do, keeps
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
- fillOnFields(meta, rows, 'onInsert');
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 } = await this.internalUpsertOne(entity, conflictPaths, rows[0]);
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 } = await this.internalUpsertMany(entity, conflictPaths, rows);
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 back by the columns an upsert matched them on, for a statement that could
575
- * not report them in payload order. A row that no read row matches, or that two do, keeps
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 { UqlOptimisticLockError, UqlUsageError } from '../util/uqlError.js';
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 UqlOptimisticLockError || err instanceof UqlUsageError) {
56
+ if (err instanceof UqlError) {
57
57
  return err.kind;
58
58
  }
59
59
  const { code, errno, number, errorLabels, message } = err;
@@ -34,6 +34,7 @@ export const SQLITE_FEATURES = {
34
34
  serverSideCursors: false,
35
35
  correlatedWrites: true,
36
36
  rowLocks: false,
37
+ transactions: true,
37
38
  nullsOrdering: 'clause',
38
39
  textScoreIndexes: false,
39
40
  orderedUpsertReturning: true,
@@ -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, and throws where its condition
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, UqlSecurityError } from '../context/context.js';
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, and throws where its condition
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 securityConditions = [];
308
- for (const name of getKeys(meta.filters)) {
309
- const filter = meta.filters[name];
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 = typeof filter.where === 'function' ? filter.where(context) : filter.where;
324
- if (condition === undefined) {
325
- const onMissing = filter.onMissing ?? (filter.security ? 'throw' : 'skip');
326
- if (onMissing === 'throw') {
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
- if (filter.security) {
336
- securityConditions.push(conditionMap);
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
- else {
339
- for (const key of getKeys(conditionMap)) {
340
- if (result[key] === undefined) {
341
- result[key] = conditionMap[key];
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
@@ -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 `TypeError` still,
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 TypeError {
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 Error {
45
+ export declare class UqlOptimisticLockError extends UqlError {
35
46
  readonly expected: unknown;
36
47
  readonly actual: unknown;
37
48
  name: string;
@@ -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 `TypeError` still,
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 TypeError {
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 Error {
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": "The 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.",
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.83.1",
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 && bun run verify-dist.ts && cp ../../README.md . && cp -R ../../skills .",
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",
@@ -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 rather than by
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
@@ -1,4 +0,0 @@
1
- /** Thrown when a `security` filter can't resolve its condition - fails the query closed. */
2
- export declare class UqlSecurityError extends Error {
3
- name: string;
4
- }
@@ -1,4 +0,0 @@
1
- /** Thrown when a `security` filter can't resolve its condition - fails the query closed. */
2
- export class UqlSecurityError extends Error {
3
- name = 'UqlSecurityError';
4
- }