@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/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 { AnyColumn, IdOf, Insertable, MoneyInput, MoneyValue, RowOf } from './types';
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
+ ];