@ultimat3/entity 31.6.0 → 31.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -619,7 +619,7 @@ export const find = async (orgId: string, q: string) => {
619
619
 
620
620
  | Rule | Detail |
621
621
  |---|---|
622
- | `.search(term)` asks the installed index | built-in: the `matches` predicate in the statement (today's SQL). Any other: ids first (at most `MAX_PAGE_SIZE`), then the chain reads those rows with its own tenancy, soft delete, order and cursor |
622
+ | `.search(term)` asks the installed index | built-in: the `matches` predicate in the statement (today's SQL). Any other: ids first — 10,000 (`MAX_PAGE_SIZE`) or more hits are refused with `X_INVARIANT_VIOLATED`, never truncated — then the chain reads those rows with its own tenancy, soft delete, order and cursor |
623
623
  | a document has a **version** | the row's `onUpdateNow()` instant, epoch ms. A searchable entity indexed by anything declares one — `updatedAt: timestamp().defaultNow().onUpdateNow()` — or `searchDocumentOf` and `reindex()` refuse (`X_INVARIANT_VIOLATED`) |
624
624
  | the version is STRICTLY increasing per row | a write of a searchable entity — a soft delete included — stores its version no earlier than the stored one + 1 ms: Postgres `greatest($now, updated_at + interval '1 millisecond')` in the `update` and the upsert's `on conflict` set; the memory driver in its merge (`advanceVersion`); a `defineDriver` store in ITS write, atomically per row — `DriverRepo.update`'s `advance` argument, and `advanceVersion(entity, stored, merged, (p) => plan.set.includes(p))` in its own `upsert` (`entityDriverConformance` refuses a store that skips either). So two writes inside one millisecond are two versions, and the index keeps the second. The column may run ahead of the clock by a millisecond per same-ms write. Other entities' `onUpdateNow()` columns are the clock's reading, unchanged |
625
625
  | keep only newer | an index applies a document only when its version is newer than what it holds for that id: a document, or the tombstone `remove(scope, ids, version)` leaves (a delete's: past the row's last version and no earlier than its instant). Equal = replay. EXCEPT a `committed` document — the write path's, `index(docs, { committed: true })` — which replaces a tombstone whatever its version: a key deleted and written again is live at once, inside one millisecond too. `reindex`'s reads are not `committed`, so a stale read never brings a deleted row back |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/entity",
3
- "version": "31.6.0",
3
+ "version": "31.7.0",
4
4
  "description": "A table + its domain type + invariants the database also enforces",
5
5
  "keywords": [
6
6
  "ultimate",
@@ -44,9 +44,9 @@
44
44
  "test": "bun test"
45
45
  },
46
46
  "dependencies": {
47
- "@ultimat3/core": "31.6.0",
48
- "@ultimat3/db": "31.6.0",
49
- "@ultimat3/schema": "31.6.0",
50
- "@ultimat3/time": "31.6.0"
47
+ "@ultimat3/core": "31.7.0",
48
+ "@ultimat3/db": "31.7.0",
49
+ "@ultimat3/schema": "31.7.0",
50
+ "@ultimat3/time": "31.7.0"
51
51
  }
52
52
  }
package/src/lifecycle.ts CHANGED
@@ -13,6 +13,7 @@ import type { EntityCore } from './entity';
13
13
  import { invariantViolated } from './errors';
14
14
  import { type HistoryEntity, type HistoryInit, historyEntity, historyIgnored } from './history';
15
15
  import type { Retain } from './retain';
16
+ import { sealedFields } from './sealed';
16
17
  import type { ColumnMap } from './types';
17
18
 
18
19
  /** What `$describe()` gains, each absent unless declared so no other entity's hash moves. */
@@ -68,6 +69,12 @@ function exactColumns(core: EntityCore): Pick<HistoryDescriptionLike, 'text' | '
68
69
  };
69
70
  }
70
71
 
72
+ /** Sealed columns never enter an image (`HistoryDescriptionLike.sealed`). Absent when none. */
73
+ function sealedColumns(core: EntityCore): Pick<HistoryDescriptionLike, 'sealed'> {
74
+ const sealed = sealedFields(core).map((field) => field.column);
75
+ return sealed.length === 0 ? {} : { sealed };
76
+ }
77
+
71
78
  const physical = (core: EntityCore, property: string): string => {
72
79
  const column = core.$columns[property];
73
80
  return column === undefined ? property : columnName(property, column.$meta);
@@ -96,6 +103,7 @@ export function describeLifecycle(
96
103
  ]),
97
104
  ],
98
105
  ...exactColumns(core),
106
+ ...sealedColumns(core),
99
107
  },
100
108
  }),
101
109
  ...(counters.length === 0 ? {} : { counts: counters.map(describeCounter) }),
@@ -11,6 +11,7 @@ import { SOFT_DELETE_COLUMN } from './entity';
11
11
  import { historyIgnored } from './history';
12
12
  import type { MemoryTables } from './memory-same-tenant';
13
13
  import type { Tx } from './repo';
14
+ import { sealedFields } from './sealed';
14
15
 
15
16
  const field = (row: unknown, property: string): unknown =>
16
17
  typeof row === 'object' && row !== null ? (row as Record<string, unknown>)[property] : undefined;
@@ -18,10 +19,15 @@ const field = (row: unknown, property: string): unknown =>
18
19
  const jsonValue = (value: unknown): unknown =>
19
20
  value instanceof Date ? value.toISOString() : value === undefined ? null : value;
20
21
 
21
- /** The stored row as `to_jsonb` spells it: physical names, money as its columns. */
22
+ /**
23
+ * The stored row as the trigger spells it: physical names, money as its columns, and no sealed
24
+ * column — its stored string is ciphertext, and a lookup one is deterministic: an equality oracle.
25
+ */
22
26
  export function imageOf(entity: EntityCore, row: unknown): Record<string, unknown> {
23
27
  const image = new Map<string, unknown>();
28
+ const sealed = new Set(sealedFields(entity).map((each) => each.property));
24
29
  for (const [property, column] of Object.entries(entity.$columns)) {
30
+ if (sealed.has(property)) continue;
25
31
  const value = field(row, property);
26
32
  if (column.$meta.kind !== 'money') {
27
33
  image.set(columnName(property, column.$meta), jsonValue(value));
@@ -38,6 +38,21 @@ const keyOf = (entity: EntityCore<unknown>): string => {
38
38
  return key;
39
39
  };
40
40
 
41
+ /**
42
+ * A full answer is refused, never read: the index stops at `MAX_PAGE_SIZE` hits, so a full list
43
+ * cannot say whether more matched, and an `in` over it would count, group and page a sample as
44
+ * if it were every match — where the built-in index counts them all. Not `MAX_PAGE_SIZE + 1`: that
45
+ * is past the limit every adapter accepts (`searchLimitOf`), so it would break the ones that check
46
+ * it. The cost is exactly `MAX_PAGE_SIZE` matches being refused too. The index is asked by term and
47
+ * tenant alone, so the chain's other filters cannot narrow what it answers: the term must.
48
+ */
49
+ const tooManyHits = (entity: EntityCore<unknown>, indexName: string): EntityError =>
50
+ new EntityError({
51
+ code: 'X_INVARIANT_VIOLATED',
52
+ cause: `${entity.$name}.search(<term>) matched ${MAX_PAGE_SIZE} or more rows in the '${indexName}' search index — it answers at most ${MAX_PAGE_SIZE} ids, so a count, a breakdown or a page over them would be a sample, not every match`,
53
+ fix: `${entity.$name}.search('<a more specific term>') # every word must match, so each one added narrows the hits; or installSearchIndex(postgresSearchIndex()) to match inside the statement, unbounded`,
54
+ });
55
+
41
56
  /** `args` with every `matches` predicate replaced by the ids the installed index answers. */
42
57
  const resolved = async (entity: EntityCore<unknown>, args: FindManyArgs): Promise<FindManyArgs> => {
43
58
  const where = args.where ?? [];
@@ -56,6 +71,7 @@ const resolved = async (entity: EntityCore<unknown>, args: FindManyArgs): Promis
56
71
  term: String(predicate.value ?? ''),
57
72
  limit: MAX_PAGE_SIZE,
58
73
  });
74
+ if (hits.length >= MAX_PAGE_SIZE) throw tooManyHits(entity, index.name);
59
75
  swapped.push({ column: keyOf(entity), op: 'in', value: hits.map((hit) => hit.id) });
60
76
  }
61
77
  return { ...args, where: swapped };