@ultimat3/entity 22.15.0 → 24.0.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/CLAUDE.md +72 -51
- package/README.md +145 -21
- package/package.json +6 -5
- package/src/aggregate.ts +31 -22
- package/src/bulk-write.ts +4 -13
- package/src/column.ts +36 -1
- package/src/columns-data.ts +24 -3
- package/src/columns-scalar.ts +13 -6
- package/src/columns.ts +8 -1
- package/src/containment.ts +15 -2
- package/src/database.ts +9 -2
- package/src/entity-error.ts +45 -20
- package/src/entity.ts +81 -18
- package/src/errors.ts +16 -42
- package/src/expr.ts +66 -20
- package/src/index.ts +16 -1
- package/src/instant.ts +84 -5
- package/src/invariants.ts +3 -3
- package/src/judged-write.ts +28 -0
- package/src/like.ts +85 -0
- package/src/memory-match.ts +27 -82
- package/src/memory-repo.ts +16 -2
- package/src/memory-unique.ts +7 -6
- package/src/numeric-compare.ts +57 -0
- package/src/pg-driver.ts +38 -29
- package/src/pg-row.ts +4 -1
- package/src/pg-sql.ts +5 -1
- package/src/plan.ts +9 -3
- package/src/preload-ceiling.ts +75 -0
- package/src/preload.ts +23 -8
- package/src/query.ts +67 -34
- package/src/record-projection.ts +17 -2
- package/src/row-observer.ts +17 -2
- package/src/rows-of.ts +13 -1
- package/src/sealed-column.ts +123 -0
- package/src/sealed-declaration.ts +64 -0
- package/src/sealed-errors.ts +112 -0
- package/src/sealed-repo.ts +235 -0
- package/src/sealed.ts +196 -0
- package/src/seed.ts +97 -73
- package/src/transition.ts +3 -0
- package/src/type-pins.ts +109 -3
- package/src/types.ts +106 -0
- package/src/unique-constraints.ts +44 -0
package/src/type-pins.ts
CHANGED
|
@@ -5,15 +5,25 @@
|
|
|
5
5
|
// enforcement this repo counts (axiom 3).
|
|
6
6
|
|
|
7
7
|
import type { Id } from '@ultimat3/core';
|
|
8
|
-
import type { MoneyValue as SchemaMoneyValue } from '@ultimat3/schema';
|
|
8
|
+
import type { Schema, MoneyValue as SchemaMoneyValue } from '@ultimat3/schema';
|
|
9
9
|
import type { uuid } from './columns';
|
|
10
10
|
import type { EntitySet } from './database';
|
|
11
11
|
import type { Entity, EntityCore, EntityInit } from './entity';
|
|
12
12
|
import type { ColumnExpr, InvariantColumns } from './expr';
|
|
13
13
|
import type { Invariant, InvariantDef } from './invariants';
|
|
14
|
-
import type { Table } from './query';
|
|
14
|
+
import type { ReadBuilder, Table } from './query';
|
|
15
15
|
import type { Repo } from './repo';
|
|
16
|
-
import type {
|
|
16
|
+
import type {
|
|
17
|
+
AnyColumn,
|
|
18
|
+
IdOf,
|
|
19
|
+
Insertable,
|
|
20
|
+
MoneyInput,
|
|
21
|
+
MoneyValue,
|
|
22
|
+
RowOf,
|
|
23
|
+
SealedColumn,
|
|
24
|
+
TextColumn,
|
|
25
|
+
WireRow,
|
|
26
|
+
} from './types';
|
|
17
27
|
|
|
18
28
|
/** Fails to compile when `T` is anything but `true`. The whole mechanism. */
|
|
19
29
|
type Assert<T extends true> = T;
|
|
@@ -345,3 +355,99 @@ type _RepoBatchWritesTakeABigIntMinor = Assert<
|
|
|
345
355
|
type _RepoAnswersTheValueType = Assert<
|
|
346
356
|
[Awaited<ReturnType<Repo<PinMoneyRow>['insert']>>] extends [PinMoneyRow] ? true : false
|
|
347
357
|
>;
|
|
358
|
+
|
|
359
|
+
// --- Sealed columns (plan 101, slice 03) ---------------------------------------------------------
|
|
360
|
+
// The row keeps `string`; the wire schema loses the column; a predicate naming it does not compile.
|
|
361
|
+
|
|
362
|
+
declare const sealedPin: Entity<RowOf<SealedPinColumns>, SealedPinColumns>;
|
|
363
|
+
type SealedPinColumns = {
|
|
364
|
+
readonly id: ReturnType<typeof uuid>;
|
|
365
|
+
readonly label: TextColumn;
|
|
366
|
+
readonly password: SealedColumn<string, false, false>;
|
|
367
|
+
readonly email: SealedColumn<string, false, true>;
|
|
368
|
+
};
|
|
369
|
+
type SealedPinRow = RowOf<SealedPinColumns>;
|
|
370
|
+
declare const sealedTable: Table<SealedPinRow, SealedPinColumns>;
|
|
371
|
+
|
|
372
|
+
/** The server row is the whole row, plaintext included. */
|
|
373
|
+
export type _SealedRowKeepsTheColumn = Assert<
|
|
374
|
+
SealedPinRow['password'] extends string ? true : false
|
|
375
|
+
>;
|
|
376
|
+
|
|
377
|
+
/** `$schema` parses to the row WITHOUT its sealed columns — what an `output` sends. */
|
|
378
|
+
type SealedWire = WireRow<SealedPinRow, SealedPinColumns>;
|
|
379
|
+
export type _SealedWireDropsBoth = Assert<
|
|
380
|
+
'password' extends keyof SealedWire ? false : 'email' extends keyof SealedWire ? false : true
|
|
381
|
+
>;
|
|
382
|
+
export type _SealedWireKeepsTheRest = Assert<'label' extends keyof SealedWire ? true : false>;
|
|
383
|
+
export type _SealedSchemaIsTheWireRow = Assert<
|
|
384
|
+
(typeof sealedPin)['$schema'] extends Schema<unknown, SealedWire> ? true : false
|
|
385
|
+
>;
|
|
386
|
+
/** …and an entity with no sealed column keeps `Row` itself, not a mapped copy. */
|
|
387
|
+
export type _UnsealedWireIsTheRow = Assert<
|
|
388
|
+
WireRow<PinRow, PinColumns> extends PinRow
|
|
389
|
+
? PinRow extends WireRow<PinRow, PinColumns>
|
|
390
|
+
? true
|
|
391
|
+
: false
|
|
392
|
+
: false
|
|
393
|
+
>;
|
|
394
|
+
|
|
395
|
+
/** A sealed entity is still an `EntityCore`, so `database()` and every driver take it. */
|
|
396
|
+
export type _SealedEntityIsACore = Assert<
|
|
397
|
+
typeof sealedPin extends EntityCore<SealedPinRow, SealedPinColumns> ? true : false
|
|
398
|
+
>;
|
|
399
|
+
export type _SealedEntityIsInASet = Assert<
|
|
400
|
+
{ readonly x: typeof sealedPin } extends EntitySet ? true : false
|
|
401
|
+
>;
|
|
402
|
+
|
|
403
|
+
type WhereFilter = Parameters<(typeof sealedTable)['where']>[0];
|
|
404
|
+
/** An opaque column is absent from the filter; a lookup column and a plain one are present. */
|
|
405
|
+
export type _OpaqueIsNotFilterable = Assert<'password' extends keyof WhereFilter ? false : true>;
|
|
406
|
+
export type _LookupIsFilterable = Assert<'email' extends keyof WhereFilter ? true : false>;
|
|
407
|
+
export type _PlainIsFilterable = Assert<'label' extends keyof WhereFilter ? true : false>;
|
|
408
|
+
|
|
409
|
+
type AndWhereColumn = Parameters<(typeof sealedTable)['andWhere']>[0];
|
|
410
|
+
export type _OpaqueIsNotAPredicate = Assert<'password' extends AndWhereColumn ? false : true>;
|
|
411
|
+
export type _LookupIsAPredicate = Assert<'email' extends AndWhereColumn ? true : false>;
|
|
412
|
+
/** …but a NULL test names any column: presence is not sealed. The overload is the first one. */
|
|
413
|
+
declare const nullTested: ReturnType<typeof nullTest>;
|
|
414
|
+
declare function nullTest(): ReturnType<(typeof sealedTable)['andWhere']>;
|
|
415
|
+
export type _NullTestTakesAnOpaqueColumn = Assert<
|
|
416
|
+
(typeof sealedTable)['andWhere'] extends (column: 'password', op: 'is-null') => typeof nullTested
|
|
417
|
+
? true
|
|
418
|
+
: false
|
|
419
|
+
>;
|
|
420
|
+
|
|
421
|
+
type OrderColumn = Parameters<(typeof sealedTable)['orderBy']>[0];
|
|
422
|
+
/** Neither kind orders: ciphertext has no order worth asking for. */
|
|
423
|
+
export type _SealedDoesNotOrder = Assert<
|
|
424
|
+
'password' extends OrderColumn ? false : 'email' extends OrderColumn ? false : true
|
|
425
|
+
>;
|
|
426
|
+
export type _PlainOrders = Assert<'label' extends OrderColumn ? true : false>;
|
|
427
|
+
|
|
428
|
+
/** `.sealed()` is `text()`'s: no other builder has it, so a sealed number does not compile. */
|
|
429
|
+
export type _OnlyTextSeals = Assert<
|
|
430
|
+
'sealed' extends keyof TextColumn
|
|
431
|
+
? 'sealed' extends keyof ReturnType<typeof uuid>
|
|
432
|
+
? false
|
|
433
|
+
: true
|
|
434
|
+
: false
|
|
435
|
+
>;
|
|
436
|
+
/** It survives the links that may precede it, and keeps its marker across the ones that follow. */
|
|
437
|
+
export type _SealedAfterNullable = Assert<
|
|
438
|
+
ReturnType<TextColumn['nullable']> extends { sealed: unknown } ? true : false
|
|
439
|
+
>;
|
|
440
|
+
export type _MarkerSurvivesNullable = Assert<
|
|
441
|
+
ReturnType<SealedColumn<string, false, true>['nullable']>['$sealed'] extends 'lookup'
|
|
442
|
+
? true
|
|
443
|
+
: false
|
|
444
|
+
>;
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* A sealed table is still a `ReadBuilder<Row>`: `backfill({ source })`, `preload` and every other
|
|
448
|
+
* consumer that names the chain without the columns takes it. The refusal is in the PARAMETERS,
|
|
449
|
+
* which a method compares bivariantly, so the narrower chain is assignable to the wider one.
|
|
450
|
+
*/
|
|
451
|
+
export type _SealedTableIsAReadBuilder = Assert<
|
|
452
|
+
typeof sealedTable extends ReadBuilder<SealedPinRow> ? true : false
|
|
453
|
+
>;
|
package/src/types.ts
CHANGED
|
@@ -155,6 +155,33 @@ export interface ColumnMeta {
|
|
|
155
155
|
* indexes by a data key.
|
|
156
156
|
*/
|
|
157
157
|
readonly machine?: StateMachine;
|
|
158
|
+
/**
|
|
159
|
+
* Presence is what makes this column SEALED: stored as `@ultimat3/core`'s `seal()` output, opened
|
|
160
|
+
* on read, absent from every schema that leaves the server. `sealed-repo.ts` is the one reader
|
|
161
|
+
* that seals and opens; everything else reads it to refuse (`sealed-declaration.ts`).
|
|
162
|
+
*/
|
|
163
|
+
readonly sealed?: SealedMeta;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** What `.sealed(options)` declared, resolved. */
|
|
167
|
+
export interface SealedMeta {
|
|
168
|
+
/** Deterministic seal: equality in `where` and `.unique()` are allowed, and nothing else. */
|
|
169
|
+
readonly lookup: boolean;
|
|
170
|
+
/**
|
|
171
|
+
* The parser the column had BEFORE it was sealed — the one that judges a plaintext, `max`
|
|
172
|
+
* included. The column's own `$parse` passes any string shaped like a sealed value (a driver
|
|
173
|
+
* hands it one), so a value about to be sealed is judged by this instead.
|
|
174
|
+
*/
|
|
175
|
+
readonly plaintext: (value: unknown) => unknown;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export interface SealedOptions<Lookup extends boolean = boolean> {
|
|
179
|
+
/**
|
|
180
|
+
* Seal deterministically so the column can be matched by equality (`where`, `in`, `.unique()`).
|
|
181
|
+
* It REVEALS EQUALITY: two rows holding the same value store the same string. Never for a
|
|
182
|
+
* low-entropy value — a boolean, a status, a PIN.
|
|
183
|
+
*/
|
|
184
|
+
readonly lookup?: Lookup;
|
|
158
185
|
}
|
|
159
186
|
|
|
160
187
|
/**
|
|
@@ -230,6 +257,85 @@ export interface TimestampColumn<Optional extends boolean = false> extends Colum
|
|
|
230
257
|
column(name: string): TimestampColumn<Optional>;
|
|
231
258
|
}
|
|
232
259
|
|
|
260
|
+
/**
|
|
261
|
+
* `text()`'s own column: the one that may be sealed, because a sealed value is a string and no
|
|
262
|
+
* other kind has a use for it. `nullable()`, `unique()`, `default()` and `column()` keep the
|
|
263
|
+
* builder, so `.sealed()` reads the same wherever it sits among them.
|
|
264
|
+
*/
|
|
265
|
+
export interface TextColumn<T extends string | null = string, Optional extends boolean = false>
|
|
266
|
+
extends Column<T, Optional> {
|
|
267
|
+
nullable(): TextColumn<T | null, Optional>;
|
|
268
|
+
unique(): TextColumn<T, Optional>;
|
|
269
|
+
default(value: T): TextColumn<T, true>;
|
|
270
|
+
column(name: string): TextColumn<T, Optional>;
|
|
271
|
+
/**
|
|
272
|
+
* Seal this column under the app's master key: the row still reads `string`, the database holds
|
|
273
|
+
* `x1.<keyId>.<iv>.<ciphertext>`, and no view, `$schema`, record or predicate carries it.
|
|
274
|
+
* `.sealed({ lookup: true })` allows equality at the cost of revealing it.
|
|
275
|
+
*/
|
|
276
|
+
sealed<const Lookup extends boolean = false>(
|
|
277
|
+
options?: SealedOptions<Lookup>,
|
|
278
|
+
): SealedColumn<T, Optional, Lookup>;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* A sealed column. `$sealed` is the phantom the typed handle reads: `'opaque'` is refused in every
|
|
283
|
+
* predicate, `'lookup'` in everything but equality. It is a real string at runtime too.
|
|
284
|
+
*/
|
|
285
|
+
export interface SealedColumn<
|
|
286
|
+
T extends string | null = string,
|
|
287
|
+
Optional extends boolean = false,
|
|
288
|
+
Lookup extends boolean = false,
|
|
289
|
+
> extends Column<T, Optional> {
|
|
290
|
+
readonly $sealed: Lookup extends true ? 'lookup' : 'opaque';
|
|
291
|
+
nullable(): SealedColumn<T | null, Optional, Lookup>;
|
|
292
|
+
/** `lookup` only: uniqueness over ciphertext needs equal values to seal equal. */
|
|
293
|
+
unique(): SealedColumn<T, Optional, Lookup>;
|
|
294
|
+
column(name: string): SealedColumn<T, Optional, Lookup>;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** The columns no predicate may name, and the ones only equality may. Read by `query.ts`. */
|
|
298
|
+
export type OpaqueKeys<C extends ColumnMap> = {
|
|
299
|
+
[K in keyof C]-?: C[K] extends { readonly $sealed: 'opaque' } ? K : never;
|
|
300
|
+
}[keyof C];
|
|
301
|
+
export type SealedKeys<C extends ColumnMap> = {
|
|
302
|
+
[K in keyof C]-?: C[K] extends { readonly $sealed: string } ? K : never;
|
|
303
|
+
}[keyof C];
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Which keys the typed chain refuses: `opaque` in every predicate, `sealed` (opaque and lookup
|
|
307
|
+
* both) in an order or a group. A pair rather than the column map itself, so `ReadBuilder` stays
|
|
308
|
+
* row-shaped and a `select()` or a `preload()` carries it along unchanged.
|
|
309
|
+
*/
|
|
310
|
+
export interface SealedKeySet {
|
|
311
|
+
readonly opaque: PropertyKey;
|
|
312
|
+
readonly sealed: PropertyKey;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** The default: an entity with no sealed column, and every row-agnostic consumer. */
|
|
316
|
+
export interface NoSealedKeys {
|
|
317
|
+
readonly opaque: never;
|
|
318
|
+
readonly sealed: never;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
export interface SealedKeysOf<C extends ColumnMap> {
|
|
322
|
+
readonly opaque: OpaqueKeys<C>;
|
|
323
|
+
readonly sealed: SealedKeys<C>;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** A filter's shape: the row without the columns no predicate may name. */
|
|
327
|
+
export type Filterable<Row, S extends SealedKeySet> = [S['opaque']] extends [never]
|
|
328
|
+
? Row
|
|
329
|
+
: Omit<Row, S['opaque']>;
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* The row as it leaves the server: without its sealed columns. `[…] extends [never]` so an entity
|
|
333
|
+
* with none — and every row-agnostic `EntityCore` — keeps `Row` itself, not a mapped copy of it.
|
|
334
|
+
*/
|
|
335
|
+
export type WireRow<Row, C extends ColumnMap> = [SealedKeys<C>] extends [never]
|
|
336
|
+
? Row
|
|
337
|
+
: Omit<Row, SealedKeys<C>>;
|
|
338
|
+
|
|
233
339
|
export type AnyColumn = Column<unknown, boolean>;
|
|
234
340
|
|
|
235
341
|
export type ColumnMap = Readonly<Record<string, AnyColumn>>;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// Single responsibility: the non-partial unique constraints an entity declares, beside its primary
|
|
2
|
+
// key — ONE list, read by the upsert's conflict-target check (`bulk-write.ts`) and by the in-memory
|
|
3
|
+
// driver's own enforcement (`memory-unique.ts`), so a unique the one accepts as a target is a
|
|
4
|
+
// unique the other refuses a duplicate under.
|
|
5
|
+
|
|
6
|
+
import { constraintNameFor } from '@ultimat3/db';
|
|
7
|
+
import type { EntityCore } from './entity';
|
|
8
|
+
|
|
9
|
+
export interface UniqueConstraint {
|
|
10
|
+
/** Physical column names, in declaration order. */
|
|
11
|
+
readonly columns: readonly string[];
|
|
12
|
+
/** The constraint's name in the database — asked for only once a collision is being reported. */
|
|
13
|
+
name(): string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Two sources, because this framework has two ways to declare a unique index beside the key and a
|
|
18
|
+
* rule honoured for one of them only sends its author to declare the constraint a second time:
|
|
19
|
+
* `unique()` / `indexes: [{ unique: true }]` (both land in `$indexes`), and
|
|
20
|
+
* `invariant(name, c.unique([…]))`, which emits its `create unique index` out of `$invariants`.
|
|
21
|
+
*
|
|
22
|
+
* A PARTIAL unique is excluded from both: its predicate is SQL the memory driver cannot evaluate
|
|
23
|
+
* and an `on conflict` clause here does not spell. `bindInvariant` stamps that `where` on a
|
|
24
|
+
* soft-deleting entity, so the one rule covers both lists.
|
|
25
|
+
*/
|
|
26
|
+
export const uniqueConstraints = <Row>(entity: EntityCore<Row>): readonly UniqueConstraint[] => [
|
|
27
|
+
...entity.$indexes
|
|
28
|
+
.filter((index) => index.unique && index.where === undefined)
|
|
29
|
+
.map((index) => ({ columns: index.columns, name: () => index.name })),
|
|
30
|
+
...entity.$invariants
|
|
31
|
+
.filter((rule) => rule.kind === 'unique' && rule.where === undefined)
|
|
32
|
+
.map((rule) => ({
|
|
33
|
+
columns: rule.columns,
|
|
34
|
+
// `@ultimat3/db` names it, as it does in the DDL: `<table>_<name>_key`.
|
|
35
|
+
name: () =>
|
|
36
|
+
constraintNameFor(entity.$table, {
|
|
37
|
+
name: rule.name,
|
|
38
|
+
kind: 'unique',
|
|
39
|
+
message: rule.message,
|
|
40
|
+
sql: rule.sql,
|
|
41
|
+
where: null,
|
|
42
|
+
}),
|
|
43
|
+
})),
|
|
44
|
+
];
|