@ultimat3/entity 22.14.0 → 23.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 +36 -31
- package/README.md +113 -2
- package/package.json +6 -5
- package/src/columns-scalar.ts +13 -6
- package/src/entity-error.ts +21 -1
- package/src/entity.ts +78 -12
- package/src/index.ts +10 -0
- package/src/instant.ts +84 -5
- package/src/memory-match.ts +7 -0
- package/src/memory-repo.ts +16 -2
- package/src/pg-driver.ts +4 -1
- package/src/pg-row.ts +4 -1
- package/src/preload.ts +3 -1
- package/src/query.ts +54 -21
- 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 +119 -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 +194 -0
- package/src/type-pins.ts +109 -3
- package/src/types.ts +100 -0
package/CLAUDE.md
CHANGED
|
@@ -87,37 +87,13 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
|
|
|
87
87
|
(`close()` = `AsyncGenerator.return()`), `.cursor` is advanced before the yield, no empty batch, and
|
|
88
88
|
three refusals on the chain (size, `limit()` alongside, an order no cursor can carry — judged by
|
|
89
89
|
exported `totalOrder`).
|
|
90
|
-
- **A grouped count means one thing in both drivers,
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
no caller can look up again and the result would be a map that only ever answers `undefined`. The
|
|
98
|
-
refusal is `X_INVARIANT_VIOLATED` naming a column of *this* entity that is groupable, never
|
|
99
|
-
`x entity explain`: what repairs it is one edit to the call, and the entity is the only place the
|
|
100
|
-
replacement column lives. **The bound is a refusal, not a truncation.** The statement asks for
|
|
101
|
-
`MAX_GROUPS + 1` groups — the trick a page already uses when it reads one row past its limit — and
|
|
102
|
-
that extra group is what says the answer was never going to fit, so `countsFrom` throws with the
|
|
103
|
-
`andWhere(…, 'in', <values>)` that bounds it. Truncating would hand back a map that reads exactly
|
|
104
|
-
like a complete one, and a caller recounting from it would write the wrong number to every row it
|
|
105
|
-
missed. **Absent is not `0`**: a value nothing matched has no entry, because that is what
|
|
106
|
-
`group by` returns and it is the only way a caller can tell "none" from "never asked" — the
|
|
107
|
-
`?? 0` is theirs to write, and inventing it here would answer for keys the table has never seen.
|
|
108
|
-
**NULL is one group**, keyed `null`: the memory driver reads the property as `?? null` so it lands
|
|
109
|
-
where Postgres puts its NULL rows, while `0`, `''` and `false` stay the values they are. **The
|
|
110
|
-
order is applied after the rows are in, never in SQL** — a hash aggregate returns groups in
|
|
111
|
-
whatever order it built them and a `Map` filled row by row returns insertion order, so an
|
|
112
|
-
`order by` in the statement would let the two drivers disagree about a result they agree on;
|
|
113
|
-
sorting groups (never rows) costs nothing at this size and is what puts the largest bucket at the
|
|
114
|
-
front. **Both output names are fixed aliases** — `group_value` and `group_count` in
|
|
115
|
-
`countByStatement` (`pg-sql.ts`) — because an entity is free to declare a column called `count`,
|
|
116
|
-
and the un-aliased form would then return two outputs of one name; the grouped value is re-parsed
|
|
117
|
-
by the column that declared it, since `int8` arrives as a string and would otherwise key the map
|
|
118
|
-
by text where memory keys it by a `bigint`. **Nothing new to declare**: no `groupBy()` builder and
|
|
119
|
-
no error code of its own — it is a terminal on the chain that already exists, over exactly the
|
|
120
|
-
rows `count()` counts.
|
|
90
|
+
- **A grouped count means one thing in both drivers, written in `count-by.ts`**: both call
|
|
91
|
+
`groupColumnOf` before the statement and `countsFrom` after the rows. Groupable kinds are a closed
|
|
92
|
+
set (`uuid`, `text`, `char`, `boolean`, `integer`, `bigint`); the bound is a REFUSAL at
|
|
93
|
+
`MAX_GROUPS + 1`, never a truncation; absent is not `0`; NULL is one group keyed `null`; the order
|
|
94
|
+
is applied after the rows are in, never in SQL; `group_value` / `group_count` are fixed aliases.
|
|
95
|
+
The refusal names a groupable column of THIS entity, never `x entity explain`.
|
|
96
|
+
The argument for each: [`docs/history/entity.md`](../../docs/history/entity.md).
|
|
121
97
|
- **The codec is `@ultimat3/core`'s**, reached through `cursorFor(entity, plan, row, id)` and
|
|
122
98
|
`seekFrom(entity, plan)`; **`assertSeekable` runs in `planFor`**, before a statement exists. A
|
|
123
99
|
`timestamptz` sort key is refused when its `<column>$US` alias would pass 63 bytes. A cursor is bound
|
|
@@ -248,6 +224,35 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
|
|
|
248
224
|
after the refusal from a tenant-scoped `findById`; no DDL beyond `enumerated()`'s CHECK; a machine
|
|
249
225
|
column may not be nullable; `whyNot` asks unknown → terminal → legal list.
|
|
250
226
|
|
|
227
|
+
## Do not regress — sealed columns
|
|
228
|
+
|
|
229
|
+
- **`sealed-repo.ts` is the ONE seam**: `memoryRepo()` and `postgresRepo()` both return through
|
|
230
|
+
`sealedRepo(entity, repo)`, which seals rows and patches going in, opens rows coming out and
|
|
231
|
+
rewrites or refuses every predicate. A driver never seals for itself; an entity with no sealed
|
|
232
|
+
column gets its repository back untouched. The cipher is core's (`scripts/seal-calls.ts`).
|
|
233
|
+
- **A sealed column's `$parse` passes a sealed string and judges a plaintext**; `decodeRow` parses
|
|
234
|
+
none. `sealValue` runs the plaintext parser BEFORE sealing — nothing after can.
|
|
235
|
+
- **No reading of an unsealed value.** Not sealed is `X_SEAL_INVALID`; existing plaintext is
|
|
236
|
+
expand → `backfill()` → contract (README). No `legacy` option.
|
|
237
|
+
- **Purpose is `entity:<table>.<column>`, physical names** (`sealed.ts`). **Sealing is not on
|
|
238
|
+
`$describe()`**: it changes no DDL, so it must not move the drift hash — the manifest and
|
|
239
|
+
`x doctor` read `sealedFields(entity)`. Only `check`/`length` leave the description.
|
|
240
|
+
- **A sealed property on a repository row is SERVER-ONLY** — own, readable, writable, NOT
|
|
241
|
+
enumerable (`serverOnly`, `sealed.ts`; set by `openRow`). Every generic serialiser omits it, which
|
|
242
|
+
is what holds a `query`'s rows, a loose `output`, a log line, an island prop, a job payload and a
|
|
243
|
+
cache tier — none of them parses a row. So ABOVE the seam a row is copied with `copyRow` /
|
|
244
|
+
`pickRow` (descriptors), never `{ ...row }`, `Object.assign({}, row)` or `out[k] = row[k]`:
|
|
245
|
+
`preload.ts`, `select()` and `touch()` in `query.ts`. `sealRow` reads each sealed property BY NAME,
|
|
246
|
+
so a row passed back whole is written whole. A required one missing on insert is `sealedMissing`
|
|
247
|
+
(`X_INVARIANT_VIOLATED`, from `$parse` and from the seam), naming the spread. `sealed.test.ts`.
|
|
248
|
+
- **Nothing sealed leaves**: `$schema` (node and parsed value; typed `WireRow` on `Entity`,
|
|
249
|
+
`unknown` on `EntityCore`), `$view` (`X_ENTITY_SEALED_IN_VIEW`), `rowsOf`, the row observer, and
|
|
250
|
+
the repository row itself (above). `sealed-egress.test.ts` here and one per consuming package.
|
|
251
|
+
- **Opaque: NULL tests only. `lookup`: also `eq` / `in`, `.unique()`, an unordered index.**
|
|
252
|
+
Refused at the type (`SealedKeysOf<C>` on `ReadBuilder`, `type-pins.ts`) AND at runtime
|
|
253
|
+
(`X_ENTITY_SEALED_PREDICATE`). A filtered write's FILTER refuses both kinds. Declaration rules
|
|
254
|
+
are `sealed-column.ts` (the chain) and `sealed-declaration.ts` (the entity).
|
|
255
|
+
|
|
251
256
|
## Do not regress — records and tests
|
|
252
257
|
|
|
253
258
|
- **An entity row on the client is a RECORD, derived** (`row-schema.ts`, `ENTITY_BRAND` =
|
package/README.md
CHANGED
|
@@ -113,10 +113,121 @@ never runs, while the subpath retains the projection, the key and the registry a
|
|
|
113
113
|
| `text({ max })`, `integer()`, `boolean()`, `url()` | `text`/`integer`/`boolean` + CHECK | format is enforced by the database too |
|
|
114
114
|
|
|
115
115
|
Chain: `.primaryKey()` · `.nullable()` · `.unique()` · `.default(v)` · `.defaultNow()` ·
|
|
116
|
-
`.onUpdateNow()` · `.references(() => other.id, { onDelete })` · `.tenant()` · `.column(name)
|
|
116
|
+
`.onUpdateNow()` · `.references(() => other.id, { onDelete })` · `.tenant()` · `.column(name)` ·
|
|
117
|
+
`.sealed()` (`text()` only).
|
|
117
118
|
Physical names are derived from the property key (`orgId` → `org_id`); a name is written once, or
|
|
118
119
|
never — `.column()` is the exception, and it exists for tables this framework did not create.
|
|
119
120
|
|
|
121
|
+
## A sealed column
|
|
122
|
+
|
|
123
|
+
A credential column is declared `.sealed()`. The row type stays `string`; the database never sees
|
|
124
|
+
the plaintext.
|
|
125
|
+
|
|
126
|
+
```ts
|
|
127
|
+
import { database, entity, text, uuid } from '@ultimat3/entity';
|
|
128
|
+
|
|
129
|
+
export const connections = entity('connections', {
|
|
130
|
+
columns: {
|
|
131
|
+
id: uuid().primaryKey(),
|
|
132
|
+
label: text({ max: 80 }),
|
|
133
|
+
password: text({ max: 200 }).sealed(), // opaque: read and written, never compared
|
|
134
|
+
token: text().nullable().sealed(),
|
|
135
|
+
email: text().sealed({ lookup: true }).unique(), // deterministic: equality only
|
|
136
|
+
},
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
const db = database({ connections });
|
|
140
|
+
|
|
141
|
+
export async function passwordFor(email: string): Promise<string | null> {
|
|
142
|
+
const row = await db.connections.where({ email }).one();
|
|
143
|
+
return row?.password ?? null; // the plaintext — on the server, and nowhere else
|
|
144
|
+
}
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
| Fact | |
|
|
148
|
+
|---|---|
|
|
149
|
+
| Builder | `text()` only — `text().sealed()`, in any order with `.nullable()`, `.unique()`, `.column()`. A sealed number or date does not compile |
|
|
150
|
+
| Stored | `x1.<keyId>.<iv>.<ciphertext+tag>` from `@ultimat3/core`'s `seal()`, in a plain `text` column. No new SQL type; a `text({ max })` bound is checked against the plaintext before sealing and emits no CHECK |
|
|
151
|
+
| Key | the master key `x secrets` manages. No key is `X_SEAL_KEY_MISSING` — the plaintext is never stored instead |
|
|
152
|
+
| Purpose | derived: `entity:<table>.<column>`, physical names. Never stated, so two columns cannot share one; a value copied between sealed columns does not open. Renaming the table or the column is a re-seal — pin the name with `.column()` |
|
|
153
|
+
| Both drivers | `memoryDriver()` seals too; `memoryRepo(entity, seed)` refuses a seed for a sealed entity (`insertAll` instead) |
|
|
154
|
+
| The seam | `sealedRepo(entity, repo)` — both driver factories return through it, so `database()`'s handle and a hand-held `postgresRepo(entity)` agree. A driver written outside this package returns through it too |
|
|
155
|
+
| Cost | the key ring is resolved ONCE per repository call, however many values it seals or opens — a 500-row page is one resolution, not a thousand |
|
|
156
|
+
| Retry | `X_ENTITY_SEALED_PREDICATE` and `X_ENTITY_SEALED_IN_VIEW` are registered `terminal` (`ENTITY_ERROR_RETRY`): a job stops on its first attempt |
|
|
157
|
+
|
|
158
|
+
**What leaves the server: nothing.**
|
|
159
|
+
|
|
160
|
+
| Path | Sealed column |
|
|
161
|
+
|---|---|
|
|
162
|
+
| `entity.$schema` (an action's or query's `output`) | absent from the node and from the parsed value; the type is `WireRow` — the row without it |
|
|
163
|
+
| `entity.$view([...])` | `X_ENTITY_SEALED_IN_VIEW` |
|
|
164
|
+
| a record (`rowsOf`, `recordProjection`, the `persist: true` browser store) | dropped from every collected row; `projection.sealed` lists them |
|
|
165
|
+
| a change reported to `setRowObserver` | dropped from `before` and `after` |
|
|
166
|
+
| `decodeRow` on a write-ahead-log row | the stored string, never the plaintext (`@ultimat3/realtime` drops the property before a change leaves) |
|
|
167
|
+
| a repository row serialised whole — a `query`'s rows, a loose `output`, a log line, an error's `meta`, an island prop, a job payload, a cache tier | absent: the property is **server-only** (below) |
|
|
168
|
+
| `{ token: row.token }` — the secret named by hand | leaves. Naming it is the one read there is |
|
|
169
|
+
| `$parse`, `$row`, the repository | the whole row, plaintext included — server code only |
|
|
170
|
+
|
|
171
|
+
**A sealed property is server-only**, `As of 2026-10`: own, readable and writable, **not
|
|
172
|
+
enumerable**. `row.password` is the plaintext; nothing that walks the row sees it.
|
|
173
|
+
|
|
174
|
+
| | |
|
|
175
|
+
|---|---|
|
|
176
|
+
| Reads it | `row.password`, `const { password } = row`, `update(id, row)` and `insert(row)` with the row passed whole |
|
|
177
|
+
| Does not | `JSON.stringify(row)`, `{ ...row }`, `Object.keys` / `entries` / `assign`, `structuredClone(row)` |
|
|
178
|
+
| Prints it | `console.log(row)` / `Bun.inspect(row)` — log through `logger` |
|
|
179
|
+
| A spread patch | `update(id, { ...row, label })` leaves the stored secret alone |
|
|
180
|
+
| A spread insert, required column | `X_INVARIANT_VIOLATED` naming the column and the rewrite: `insert({ ...row, password: row.password })` |
|
|
181
|
+
| A spread insert, **nullable** column | stores NULL — silent; an absent nullable column is a legal insert. Name it: `{ ...row, token: row.token }` |
|
|
182
|
+
| `select({ password: true })`, `preload()` | the row keeps it, still server-only |
|
|
183
|
+
|
|
184
|
+
**Testing a sealed row.** `toEqual` walks enumerable properties, so it cannot see the secret:
|
|
185
|
+
`expect(row).toEqual({ …, password })` fails with an empty diff, and `expect(a).toEqual(b)` passes
|
|
186
|
+
over a wrong one. Write one of:
|
|
187
|
+
|
|
188
|
+
| Assertion | Compares |
|
|
189
|
+
|---|---|
|
|
190
|
+
| `expect(row).toEqualRow({ id, label: 'primary', password: 'hunter2' })` | every own property (`@ultimat3/testing`) |
|
|
191
|
+
| `expect(row.password).toBe('hunter2')` | the one property, by name |
|
|
192
|
+
| `expect(row).toMatchObject({ password: 'hunter2' })` | the named subset |
|
|
193
|
+
|
|
194
|
+
`toEqualRow` compares every own property and names what differs — a server-only one by name,
|
|
195
|
+
never by value. No key is needed in a test: `@ultimat3/testing/preload` installs a throwaway one
|
|
196
|
+
when the app supplied none.
|
|
197
|
+
|
|
198
|
+
**What the database may compare: nothing, or equality.**
|
|
199
|
+
|
|
200
|
+
| Use | `.sealed()` | `.sealed({ lookup: true })` |
|
|
201
|
+
|---|---|---|
|
|
202
|
+
| `where` / `andWhere` `eq`, `in` | compile error; `X_ENTITY_SEALED_PREDICATE` at runtime | allowed — rewritten to the stored string |
|
|
203
|
+
| `andWhere(column, 'is-null')` / `'is-not-null'` | allowed — `null` is stored as NULL, never sealed, so presence is not a secret | allowed |
|
|
204
|
+
| any other operator, `orderBy`, `countBy`, an aggregate, a cursor | refused | refused |
|
|
205
|
+
| `deleteWhere` / `updateWhere` **filter** | refused | refused — find the row, then write by id |
|
|
206
|
+
| `.unique()`, a unique index, an upsert conflict target | refused | allowed |
|
|
207
|
+
| a plain index | refused | allowed, unordered |
|
|
208
|
+
| an invariant other than `unique` | refused | refused — a CHECK would read ciphertext |
|
|
209
|
+
| `.default()`, `.primaryKey()`, `.tenant()`, `.references()`, `.searchable()` | refused on the chain | refused on the chain |
|
|
210
|
+
|
|
211
|
+
`lookup` **reveals equality**: two rows holding one value store one string. Never for a low-entropy
|
|
212
|
+
value — a boolean, a status, a PIN.
|
|
213
|
+
|
|
214
|
+
**During a key rotation** a lookup value has one stored form per declared key, so equality reads
|
|
215
|
+
through `in (…)` over `sealAll()` and `.unique()` holds per key. `x doctor` reports
|
|
216
|
+
`X_SEAL_RESEAL_PENDING` until every row is rewritten (`update(id, { column: row.column })` seals
|
|
217
|
+
under the current key) and the retired key is dropped (`x secrets rotate --drop <keyId>`).
|
|
218
|
+
|
|
219
|
+
**A column that already holds plaintext is migrated, never tolerated.** A stored value that is not
|
|
220
|
+
a sealed value is `X_SEAL_INVALID`, always. Expand, backfill, contract:
|
|
221
|
+
|
|
222
|
+
| # | Step | Edit |
|
|
223
|
+
|---|---|---|
|
|
224
|
+
| 1 | expand | add `passwordSealed: text().nullable().sealed()` beside `password`; `x db gen "add sealed password"`; `x db migrate` |
|
|
225
|
+
| 2 | backfill | a `backfill()` over `db.connections.andWhere('passwordSealed', 'is-null')` — only the rows still to seal — whose `handle` runs `update(row.id, { passwordSealed: row.password })` per row. Idempotent, so a replayed page is harmless |
|
|
226
|
+
| 3 | switch | readers and writers use `passwordSealed` |
|
|
227
|
+
| 4 | contract | delete `password`; declare `password: text().sealed().column('password_sealed')` — the purpose follows the physical name, so nothing is re-sealed; `x db gen "drop plaintext password"` |
|
|
228
|
+
|
|
229
|
+
The full recipe with the `backfill()` declaration: [Entities and migrations](../../wiki/Entities-And-Migrations.md#sealing-a-column-that-already-holds-data).
|
|
230
|
+
|
|
120
231
|
## Wide columns
|
|
121
232
|
|
|
122
233
|
The vocabulary an EXISTING schema needs. The blessed set above is a decision the framework made
|
|
@@ -888,7 +999,7 @@ database from its boot code has decided to, and a library that overruled that wo
|
|
|
888
999
|
`X_TENANCY_ACTOR_MISMATCH` · `X_TENANCY_ACTOR_ORG_REQUIRED` · `X_TENANCY_CROSS_DENIED` ·
|
|
889
1000
|
`X_DB_DRIFT` · `X_NOT_FOUND` · `X_WRITE_UNFILTERED` · `X_PATCH_EMPTY` ·
|
|
890
1001
|
`X_PRELOAD_UNKNOWN_RELATION` · `X_N_PLUS_ONE_QUERY` · `X_N_PLUS_ONE_WRITE` ·
|
|
891
|
-
`X_RECORD_KEY_MISSING`
|
|
1002
|
+
`X_RECORD_KEY_MISSING` · `X_ENTITY_SEALED_PREDICATE` · `X_ENTITY_SEALED_IN_VIEW`
|
|
892
1003
|
|
|
893
1004
|
### Error classes
|
|
894
1005
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/entity",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "23.0.0",
|
|
4
4
|
"description": "A table + its domain type + invariants the database also enforces",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
"files": [
|
|
21
21
|
"src",
|
|
22
22
|
"!src/**/*.test.ts",
|
|
23
|
+
"!src/**/*-fixture.ts",
|
|
23
24
|
"CLAUDE.md",
|
|
24
25
|
"README.md",
|
|
25
26
|
"LICENSE"
|
|
@@ -32,9 +33,9 @@
|
|
|
32
33
|
"test": "bun test"
|
|
33
34
|
},
|
|
34
35
|
"dependencies": {
|
|
35
|
-
"@ultimat3/core": "
|
|
36
|
-
"@ultimat3/db": "
|
|
37
|
-
"@ultimat3/schema": "
|
|
38
|
-
"@ultimat3/time": "
|
|
36
|
+
"@ultimat3/core": "23.0.0",
|
|
37
|
+
"@ultimat3/db": "23.0.0",
|
|
38
|
+
"@ultimat3/schema": "23.0.0",
|
|
39
|
+
"@ultimat3/time": "23.0.0"
|
|
39
40
|
}
|
|
40
41
|
}
|
package/src/columns-scalar.ts
CHANGED
|
@@ -3,17 +3,18 @@
|
|
|
3
3
|
// store a value production answers 23514, 22003 or 22021 (a NUL in text) for. Split from `columns.ts` at its ceiling.
|
|
4
4
|
|
|
5
5
|
import { charCount } from '@ultimat3/schema';
|
|
6
|
-
import { column } from './column';
|
|
6
|
+
import { BARE, column } from './column';
|
|
7
7
|
import { got, refuseNul } from './column-values';
|
|
8
8
|
import { refuseColumn } from './refuse';
|
|
9
|
-
import
|
|
9
|
+
import { makeTextColumn } from './sealed-column';
|
|
10
|
+
import type { Column, TextColumn } from './types';
|
|
10
11
|
|
|
11
12
|
export interface TextOptions {
|
|
12
13
|
/** Emits `char_length(<column>) <= max`, so Postgres refuses an over-long string too. */
|
|
13
14
|
readonly max?: number;
|
|
14
15
|
}
|
|
15
16
|
|
|
16
|
-
export const text = (options: TextOptions = {}):
|
|
17
|
+
export const text = (options: TextOptions = {}): TextColumn => {
|
|
17
18
|
const { max } = options;
|
|
18
19
|
// Refused where it is declared: a `NaN` or fractional max emitted `char_length(x) <= NaN` into
|
|
19
20
|
// the DDL, which Postgres refuses one migration later, far from the line that wrote it.
|
|
@@ -24,8 +25,14 @@ export const text = (options: TextOptions = {}): Column<string> => {
|
|
|
24
25
|
'text({ max: 200 }) — a whole count of characters, or text() for no bound',
|
|
25
26
|
);
|
|
26
27
|
}
|
|
27
|
-
return
|
|
28
|
-
|
|
28
|
+
return makeTextColumn<string, false>(
|
|
29
|
+
{
|
|
30
|
+
...BARE,
|
|
31
|
+
kind: 'text',
|
|
32
|
+
...(max === undefined
|
|
33
|
+
? {}
|
|
34
|
+
: { length: max, check: (name) => `char_length(${name}) <= ${max}` }),
|
|
35
|
+
},
|
|
29
36
|
(value) => {
|
|
30
37
|
if (typeof value !== 'string') {
|
|
31
38
|
return refuseColumn(
|
|
@@ -45,7 +52,7 @@ export const text = (options: TextOptions = {}): Column<string> => {
|
|
|
45
52
|
}
|
|
46
53
|
return refuseNul(value);
|
|
47
54
|
},
|
|
48
|
-
|
|
55
|
+
false,
|
|
49
56
|
);
|
|
50
57
|
};
|
|
51
58
|
|
package/src/entity-error.ts
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
// raises — split from `errors.ts` so a module the BROWSER loads (the record key, the projection,
|
|
3
3
|
// the registry) can raise one without importing `@ultimat3/db`, which `errors.ts` needs for
|
|
4
4
|
// `dbDrift`'s shell-inert fix line.
|
|
5
|
-
import {
|
|
5
|
+
import type { ErrorRetry } from '@ultimat3/core';
|
|
6
|
+
import { registerErrorCodes, registerErrorRetry, UltimateError } from '@ultimat3/core';
|
|
6
7
|
|
|
7
8
|
/** Codes this package declares and owns. */
|
|
8
9
|
export const ENTITY_OWNED_ERROR_CODES = [
|
|
@@ -28,6 +29,8 @@ export const ENTITY_OWNED_ERROR_CODES = [
|
|
|
28
29
|
'X_STATE_TRANSITION_ILLEGAL',
|
|
29
30
|
'X_STATE_CONFLICT',
|
|
30
31
|
'X_RECORD_KEY_MISSING',
|
|
32
|
+
'X_ENTITY_SEALED_PREDICATE',
|
|
33
|
+
'X_ENTITY_SEALED_IN_VIEW',
|
|
31
34
|
] as const;
|
|
32
35
|
|
|
33
36
|
/**
|
|
@@ -71,6 +74,9 @@ export const ENTITY_ERROR_TITLES: Readonly<Record<EntityOwnedErrorCode, string>>
|
|
|
71
74
|
X_STATE_TRANSITION_ILLEGAL: 'the machine has no such transition',
|
|
72
75
|
X_STATE_CONFLICT: 'the row is no longer in the state this transition named',
|
|
73
76
|
X_RECORD_KEY_MISSING: 'a row reached its record key without a primary-key value',
|
|
77
|
+
X_ENTITY_SEALED_PREDICATE:
|
|
78
|
+
'a sealed column is read by a predicate, an order or a rule the database evaluates',
|
|
79
|
+
X_ENTITY_SEALED_IN_VIEW: 'a sealed column is named in a view',
|
|
74
80
|
};
|
|
75
81
|
|
|
76
82
|
// Registered at module load, unconditionally, in one call. Without this the registry humanises the
|
|
@@ -80,6 +86,20 @@ registerErrorCodes(
|
|
|
80
86
|
Object.fromEntries(Object.entries(ENTITY_ERROR_TITLES).map(([code, title]) => [code, { title }])),
|
|
81
87
|
);
|
|
82
88
|
|
|
89
|
+
/**
|
|
90
|
+
* How the codes here that are worth classifying are retried. Only the two sealed-column refusals:
|
|
91
|
+
* both are the app's own declaration or call, so attempt five answers what attempt one did — and
|
|
92
|
+
* left unclassified, `classifyThrown` reads them as undecided and a job spends its whole retry
|
|
93
|
+
* policy re-proving it. Listed although `terminal` is the default, for the reason core's own table
|
|
94
|
+
* gives for `X_NOT_IMPLEMENTED`. The rest of this package's codes are unclassified, as they were.
|
|
95
|
+
*/
|
|
96
|
+
export const ENTITY_ERROR_RETRY = {
|
|
97
|
+
X_ENTITY_SEALED_PREDICATE: 'terminal',
|
|
98
|
+
X_ENTITY_SEALED_IN_VIEW: 'terminal',
|
|
99
|
+
} as const satisfies Readonly<Partial<Record<EntityOwnedErrorCode, ErrorRetry>>>;
|
|
100
|
+
|
|
101
|
+
registerErrorRetry(ENTITY_ERROR_RETRY);
|
|
102
|
+
|
|
83
103
|
/**
|
|
84
104
|
* Base for every error this package throws. No `docs:` — `UltimateError` fills it from
|
|
85
105
|
* `describeErrorCode(code).docs`, which is `@ultimat3/core`'s `ERROR_DOCS_URL`: one page for every
|
package/src/entity.ts
CHANGED
|
@@ -19,10 +19,13 @@ import { recordProjection } from './record-projection';
|
|
|
19
19
|
import type { EntityDescription, ReferenceDescription } from './registry';
|
|
20
20
|
import { registerEntity } from './registry';
|
|
21
21
|
import { rowSchema } from './row-schema';
|
|
22
|
+
import { sealedFields } from './sealed';
|
|
23
|
+
import { assertSealedDeclaration } from './sealed-declaration';
|
|
24
|
+
import { sealedInView, sealedMissing } from './sealed-errors';
|
|
22
25
|
import type { SearchInit, SearchSource, SearchVector } from './search';
|
|
23
26
|
import { searchVectorOf } from './search';
|
|
24
27
|
import { resolveTenantColumn } from './tenancy';
|
|
25
|
-
import type { AnyColumn, ColumnMap, ColumnMeta, IndexDef, RowOf } from './types';
|
|
28
|
+
import type { AnyColumn, ColumnMap, ColumnMeta, IndexDef, RowOf, WireRow } from './types';
|
|
26
29
|
import type { EntityView } from './view';
|
|
27
30
|
import { viewFor } from './view';
|
|
28
31
|
|
|
@@ -123,8 +126,13 @@ export interface EntityCore<Row = unknown, C extends ColumnMap = ColumnMap> {
|
|
|
123
126
|
* (bare or wrapped: `t.array(posts.$schema)`) is a row the client store adopts, because its node
|
|
124
127
|
* carries the `recordProjection` brand. A `$view` or a `.pick()` carries none: a partial row is
|
|
125
128
|
* never a record.
|
|
129
|
+
*
|
|
130
|
+
* Typed `unknown` HERE and precisely on `Entity`: the parsed value is the row WITHOUT its sealed
|
|
131
|
+
* columns (`WireRow`), which is not `Row`, and a conditional over `C` in this interface would
|
|
132
|
+
* stop every `EntityCore<Row, C>` being an `EntityCore<Row>`. A row-agnostic consumer never read
|
|
133
|
+
* the output type; the one that does holds the `entity()` result.
|
|
126
134
|
*/
|
|
127
|
-
readonly $schema: Schema<unknown,
|
|
135
|
+
readonly $schema: Schema<unknown, unknown>;
|
|
128
136
|
/** `entity:<name>:<id>` — row-level invalidation for live queries. */
|
|
129
137
|
$tagFor(id: string): string;
|
|
130
138
|
/** Fills declared defaults, then validates every column. Throws on a bad value. */
|
|
@@ -149,7 +157,14 @@ export interface EntityCore<Row = unknown, C extends ColumnMap = ColumnMap> {
|
|
|
149
157
|
$references(): readonly ReferenceDescription[];
|
|
150
158
|
}
|
|
151
159
|
|
|
152
|
-
|
|
160
|
+
/**
|
|
161
|
+
* What `entity()` returns. `$schema` is restated with its real output: the row as it leaves the
|
|
162
|
+
* server, without the sealed columns — so `output: users.$schema` types a client that cannot read
|
|
163
|
+
* `password`, which is the value it will not be sent. `$parse` and `$row` are the whole row.
|
|
164
|
+
*/
|
|
165
|
+
export type Entity<Row, C extends ColumnMap = ColumnMap> = Omit<EntityCore<Row, C>, '$schema'> & {
|
|
166
|
+
readonly $schema: Schema<unknown, WireRow<Row, C>>;
|
|
167
|
+
} & C;
|
|
153
168
|
|
|
154
169
|
const MONEY_PARTS = new Set(['minor', 'currency']);
|
|
155
170
|
|
|
@@ -341,6 +356,19 @@ export const entity = <const C extends ColumnMap>(
|
|
|
341
356
|
declared.findIndex((other) => identity(other) === identity(index)) === position,
|
|
342
357
|
);
|
|
343
358
|
|
|
359
|
+
const sealed = sealedFields({ $name: name, $table: table, $columns: init.columns });
|
|
360
|
+
assertSealedDeclaration({
|
|
361
|
+
name,
|
|
362
|
+
fields: sealed,
|
|
363
|
+
primaryKey,
|
|
364
|
+
tenantColumn,
|
|
365
|
+
indexes,
|
|
366
|
+
invariants,
|
|
367
|
+
});
|
|
368
|
+
const sealedKeys = new Set(sealed.map((field) => field.property));
|
|
369
|
+
/** The columns that leave the server: every one that is not sealed. */
|
|
370
|
+
const wireEntries = entries.filter(([property]) => !sealedKeys.has(property));
|
|
371
|
+
|
|
344
372
|
const tags = [cacheTag, ...(init.tags ?? [])];
|
|
345
373
|
const describe = (): EntityDescription =>
|
|
346
374
|
describeEntity({
|
|
@@ -377,7 +405,11 @@ export const entity = <const C extends ColumnMap>(
|
|
|
377
405
|
const given = raw === undefined ? defaultValue(column.$meta) : raw;
|
|
378
406
|
if (given === undefined || given === null) {
|
|
379
407
|
if (column.$meta.notNull) {
|
|
380
|
-
|
|
408
|
+
// A required sealed column ABSENT from a row is nearly always a spread of a repository
|
|
409
|
+
// row, which does not enumerate it — so the refusal names that, and the rewrite.
|
|
410
|
+
throw given === undefined && sealedKeys.has(property)
|
|
411
|
+
? sealedMissing(name, property)
|
|
412
|
+
: invariantViolated(name, property, 'is required and has no default');
|
|
381
413
|
}
|
|
382
414
|
row[property] = null;
|
|
383
415
|
continue;
|
|
@@ -388,6 +420,39 @@ export const entity = <const C extends ColumnMap>(
|
|
|
388
420
|
return row as Row;
|
|
389
421
|
};
|
|
390
422
|
|
|
423
|
+
/**
|
|
424
|
+
* The row as `$schema` validates it: the sealed columns are neither required nor returned, so a
|
|
425
|
+
* handler's row — which holds their plaintext — parses to a value that does not.
|
|
426
|
+
*/
|
|
427
|
+
const parseWire = (value: unknown): WireRow<Row, C> => {
|
|
428
|
+
if (sealedKeys.size === 0) return parse(value) as WireRow<Row, C>;
|
|
429
|
+
if (typeof value !== 'object' || value === null) {
|
|
430
|
+
throw invariantViolated(name, 'row', `expected an object, got ${describeValue(value)}`);
|
|
431
|
+
}
|
|
432
|
+
const input = value as Readonly<Record<string, unknown>>;
|
|
433
|
+
const row: Record<string, unknown> = {};
|
|
434
|
+
for (const [property, column] of wireEntries) {
|
|
435
|
+
const raw = input[property];
|
|
436
|
+
const given = raw === undefined ? defaultValue(column.$meta) : raw;
|
|
437
|
+
if (given === undefined || given === null) {
|
|
438
|
+
if (column.$meta.notNull) {
|
|
439
|
+
throw invariantViolated(name, property, 'is required and has no default');
|
|
440
|
+
}
|
|
441
|
+
row[property] = null;
|
|
442
|
+
continue;
|
|
443
|
+
}
|
|
444
|
+
row[property] = column.$parse(given);
|
|
445
|
+
}
|
|
446
|
+
// Every wire property was validated by its own column, and no sealed one was copied.
|
|
447
|
+
return row as WireRow<Row, C>;
|
|
448
|
+
};
|
|
449
|
+
|
|
450
|
+
const schema = rowSchema<WireRow<Row, C>>(
|
|
451
|
+
{ name, table, primaryKey, persist: init.persist === true, sealed: [...sealedKeys] },
|
|
452
|
+
wireEntries,
|
|
453
|
+
parseWire,
|
|
454
|
+
);
|
|
455
|
+
|
|
391
456
|
const core: EntityCore<Row, C> = {
|
|
392
457
|
$name: name,
|
|
393
458
|
$table: table,
|
|
@@ -400,19 +465,19 @@ export const entity = <const C extends ColumnMap>(
|
|
|
400
465
|
$softDelete: softDelete,
|
|
401
466
|
$tenantColumn: tenantColumn,
|
|
402
467
|
$search: search,
|
|
403
|
-
$schema:
|
|
404
|
-
{ name, table, primaryKey, persist: init.persist === true },
|
|
405
|
-
entries,
|
|
406
|
-
parse,
|
|
407
|
-
),
|
|
468
|
+
$schema: schema,
|
|
408
469
|
get $row(): Row {
|
|
409
470
|
// Type-only. Reading it means someone expected a value where a type was meant.
|
|
410
471
|
throw invariantViolated(name, '$row', '$row is a type, not a value — use typeof x.$row');
|
|
411
472
|
},
|
|
412
473
|
$tagFor: (id) => `${cacheTag}:${id}`,
|
|
413
474
|
$parse: parse,
|
|
414
|
-
$view: <K extends keyof Row & string>(keys: readonly K[]) =>
|
|
415
|
-
|
|
475
|
+
$view: <K extends keyof Row & string>(keys: readonly K[]) => {
|
|
476
|
+
// Before the view exists: a view is an `output`, and a sealed column is in none.
|
|
477
|
+
const named = keys.find((key) => sealedKeys.has(key));
|
|
478
|
+
if (named !== undefined) throw sealedInView(name, named);
|
|
479
|
+
return viewFor<Row, K>(name, init.columns, keys);
|
|
480
|
+
},
|
|
416
481
|
$assert: (row) => assertInvariants(name, invariants, row),
|
|
417
482
|
$describe: describe,
|
|
418
483
|
$references: references,
|
|
@@ -429,5 +494,6 @@ export const entity = <const C extends ColumnMap>(
|
|
|
429
494
|
});
|
|
430
495
|
// The columns land on the entity itself so `orgs.id` is a column reference; every framework
|
|
431
496
|
// member is `$`-prefixed, which is why a column may be called `name`.
|
|
432
|
-
|
|
497
|
+
// `$schema` is restated so the result carries its real output type; it is the same object.
|
|
498
|
+
return Object.assign(core, init.columns, { $schema: schema });
|
|
433
499
|
};
|
package/src/index.ts
CHANGED
|
@@ -132,6 +132,11 @@ export type { RowBulkChange, RowChange, RowChangeOp, RowObserver } from './row-o
|
|
|
132
132
|
export { observedRepo, rowObserver, setRowObserver } from './row-observer';
|
|
133
133
|
export type { RecordsByKey, RecordsByType } from './rows-of';
|
|
134
134
|
export { hasEntityRows, projectionsIn, rowsOf } from './rows-of';
|
|
135
|
+
// Sealed columns. `sealedFields` is what a manifest or `x doctor` reads to say which columns are
|
|
136
|
+
// sealed; `sealedRepo` is the seam a driver written outside this package returns through.
|
|
137
|
+
export type { SealedField } from './sealed';
|
|
138
|
+
export { sealedFields } from './sealed';
|
|
139
|
+
export { sealedRepo } from './sealed-repo';
|
|
135
140
|
// Full-text search. The LANGUAGE set and the weights are values an app reads to build a form;
|
|
136
141
|
// `SEARCH_PROPERTY` is what a `matches` predicate names, which a hand-built `QueryPlan` needs.
|
|
137
142
|
export type { SearchInit, SearchLanguage, SearchSource, SearchVector } from './search';
|
|
@@ -194,10 +199,15 @@ export type {
|
|
|
194
199
|
RowOf,
|
|
195
200
|
RowPatch,
|
|
196
201
|
RowWrite,
|
|
202
|
+
SealedColumn,
|
|
203
|
+
SealedMeta,
|
|
204
|
+
SealedOptions,
|
|
197
205
|
SearchWeight,
|
|
206
|
+
TextColumn,
|
|
198
207
|
TimestampColumn,
|
|
199
208
|
TypeOf,
|
|
200
209
|
UuidColumn,
|
|
210
|
+
WireRow,
|
|
201
211
|
} from './types';
|
|
202
212
|
export { COLUMN_KINDS } from './types';
|
|
203
213
|
// `viewFor` stays internal: a view is reached through the entity, as `posts.$view([...])`.
|
package/src/instant.ts
CHANGED
|
@@ -57,9 +57,88 @@ export const pgInstantMicros = (text: unknown): bigint | undefined => {
|
|
|
57
57
|
};
|
|
58
58
|
|
|
59
59
|
/**
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
60
|
+
* An instant as TEXT that names its own zone — what `toISOString()` writes and what a URL, a JSON
|
|
61
|
+
* body or a hand-kept keyset position holds: `2026-01-01T00:00:03.000Z`, `…+02:00`, `… 00:00:03+00`.
|
|
62
|
+
*
|
|
63
|
+
* The zone is REQUIRED. Postgres reads a zoneless text in the session's `TimeZone`, which this
|
|
64
|
+
* process cannot know, so such a text is not an instant here — the same rule `timestamp()` holds a
|
|
65
|
+
* written value to.
|
|
66
|
+
*/
|
|
67
|
+
const ISO_INSTANT_TEXT =
|
|
68
|
+
/^(\d{4,})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2}):(\d{2})(?:\.(\d+))?\s*(Z|[+-]\d{2}(?::?\d{2})?)$/i;
|
|
69
|
+
|
|
70
|
+
const MICROS_PER_MINUTE = 60_000_000n;
|
|
71
|
+
|
|
72
|
+
const MICROS_PER_DAY = 86_400_000_000;
|
|
73
|
+
|
|
74
|
+
/** Postgres's `MAX_TZDISP_HOUR`: a numeric offset of sixteen hours or more is an error there. */
|
|
75
|
+
const MAX_OFFSET_HOURS = 15;
|
|
76
|
+
|
|
77
|
+
/** `Z`, `+02`, `+02:00`, `-0430` → minutes east of UTC; `undefined` past what Postgres accepts. */
|
|
78
|
+
const offsetMinutes = (zone: string): bigint | undefined => {
|
|
79
|
+
if (zone.toUpperCase() === 'Z') return 0n;
|
|
80
|
+
const digits = zone.slice(1).replace(':', '');
|
|
81
|
+
const hours = Number(digits.slice(0, 2));
|
|
82
|
+
const minutes = Number(digits.slice(2) || '0');
|
|
83
|
+
if (hours > MAX_OFFSET_HOURS || minutes >= 60) return undefined;
|
|
84
|
+
const east = BigInt(hours * 60 + minutes);
|
|
85
|
+
return zone.startsWith('-') ? -east : east;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The fraction as Postgres reads it: `strtod` of the digits, times a million, `rint` — so past six
|
|
90
|
+
* digits it ROUNDS, half to even, on the double (`.0000005` is a hair under the half and goes
|
|
91
|
+
* down), and `.9999996` is a whole second. JavaScript's `Number` is the same IEEE double.
|
|
92
|
+
*/
|
|
93
|
+
const fractionMicros = (digits: string | undefined): number => {
|
|
94
|
+
if (digits === undefined) return 0;
|
|
95
|
+
if (digits.length <= FRACTION_DIGITS) return Number(digits.padEnd(FRACTION_DIGITS, '0'));
|
|
96
|
+
const scaled = Number(`0.${digits}`) * 1_000_000;
|
|
97
|
+
const floor = Math.floor(scaled);
|
|
98
|
+
const above = scaled - floor;
|
|
99
|
+
if (above !== 0.5) return above > 0.5 ? floor + 1 : floor;
|
|
100
|
+
return floor % 2 === 0 ? floor : floor + 1;
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The exact microsecond epoch an ISO instant text names, or `undefined` when Postgres would refuse
|
|
105
|
+
* it — no zone, a date the calendar does not have (`2026-13-45` is an error there, never the 14th
|
|
106
|
+
* of February), an offset of sixteen hours, or a time past the end of its day.
|
|
107
|
+
*
|
|
108
|
+
* Postgres's `time_overflows`: hour ≤ 24, minute ≤ 59, second ≤ 60, and the whole time of day at
|
|
109
|
+
* most 24:00:00 — so `24:00:00` is the next midnight and `:60` the next minute, while
|
|
110
|
+
* `24:00:00.000001` and `23:59:60.5` are errors.
|
|
111
|
+
*/
|
|
112
|
+
const isoInstantMicros = (text: string): bigint | undefined => {
|
|
113
|
+
const match = ISO_INSTANT_TEXT.exec(text);
|
|
114
|
+
if (match === null) return undefined;
|
|
115
|
+
const [, year = '', month = '', day = '', hour = '', minute = '', second = '', fraction] = match;
|
|
116
|
+
const [y = 0, m = 0, d = 0] = [year, month, day].map(Number);
|
|
117
|
+
const [h = 0, min = 0, sec = 0] = [hour, minute, second].map(Number);
|
|
118
|
+
const midnight = utcSecondMillis([y, m, d, 0, 0, 0]);
|
|
119
|
+
const built = new Date(midnight);
|
|
120
|
+
// A field the calendar rolled over was never that date: 13 months, 45 days.
|
|
121
|
+
if (built.getUTCMonth() + 1 !== m || built.getUTCDate() !== d) return undefined;
|
|
122
|
+
const fsec = fractionMicros(fraction);
|
|
123
|
+
const ofDay = ((h * 60 + min) * 60 + sec) * 1_000_000 + fsec;
|
|
124
|
+
if (h > 24 || min > 59 || sec > 60 || ofDay > MICROS_PER_DAY) return undefined;
|
|
125
|
+
const offset = offsetMinutes(match[8] ?? 'Z');
|
|
126
|
+
if (offset === undefined) return undefined;
|
|
127
|
+
const local = BigInt(midnight) * MICROS_PER_MILLI + BigInt(ofDay);
|
|
128
|
+
// The text is wall-clock time AT its offset, so the instant is that many minutes earlier.
|
|
129
|
+
return local - offset * MICROS_PER_MINUTE;
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* The microsecond epoch of whatever a sort key or an operand is holding: a decoded row's `Date`
|
|
134
|
+
* (milliseconds, so the last three digits are zero), a value already counted in microseconds, the
|
|
135
|
+
* decimal a cursor carries, or an ISO instant as text. `undefined` for anything else, so a caller
|
|
136
|
+
* decides rather than guessing at `0`.
|
|
137
|
+
*
|
|
138
|
+
* The ISO text is the half Postgres always had: a `"created_at" < $1` bound to
|
|
139
|
+
* `2026-01-01T00:00:03.000Z` is parsed there as an instant, while the in-memory driver compared it
|
|
140
|
+
* to the stored `Date` by CHARACTERS — so a hand-kept keyset answered an empty page two in memory
|
|
141
|
+
* and the right one in production.
|
|
63
142
|
*/
|
|
64
143
|
export const instantMicros = (value: unknown): bigint | undefined => {
|
|
65
144
|
if (typeof value === 'bigint') return value;
|
|
@@ -67,8 +146,8 @@ export const instantMicros = (value: unknown): bigint | undefined => {
|
|
|
67
146
|
const millis = value.getTime();
|
|
68
147
|
return Number.isNaN(millis) ? undefined : BigInt(millis) * MICROS_PER_MILLI;
|
|
69
148
|
}
|
|
70
|
-
if (typeof value
|
|
71
|
-
return
|
|
149
|
+
if (typeof value !== 'string') return undefined;
|
|
150
|
+
return /^-?\d+$/.test(value) ? BigInt(value) : isoInstantMicros(value);
|
|
72
151
|
};
|
|
73
152
|
|
|
74
153
|
/**
|
package/src/memory-match.ts
CHANGED
|
@@ -91,6 +91,13 @@ export const sameValueOfKind = (
|
|
|
91
91
|
right: unknown,
|
|
92
92
|
): boolean => {
|
|
93
93
|
if (left instanceof Date && right instanceof Date) return left.getTime() === right.getTime();
|
|
94
|
+
// An instant handed over as ISO text is the instant it names, as Postgres parses the bound
|
|
95
|
+
// parameter: `eq` against a stored `Date` compared an object to a string and matched no row.
|
|
96
|
+
if (kind === 'timestamptz') {
|
|
97
|
+
const stored = instantMicros(left);
|
|
98
|
+
const given = instantMicros(right);
|
|
99
|
+
if (stored !== undefined && given !== undefined) return stored === given;
|
|
100
|
+
}
|
|
94
101
|
if (kind === 'uuid' && typeof left === 'string' && typeof right === 'string') {
|
|
95
102
|
return keyOf('uuid', left) === keyOf('uuid', right);
|
|
96
103
|
}
|