@ultimat3/entity 29.4.0 → 30.1.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 (65) hide show
  1. package/CLAUDE.md +66 -96
  2. package/README.md +118 -8
  3. package/package.json +15 -5
  4. package/src/append-only.ts +7 -0
  5. package/src/batch-read.ts +12 -4
  6. package/src/bulk-write-send.ts +49 -0
  7. package/src/bulk-write.ts +48 -11
  8. package/src/column.ts +29 -4
  9. package/src/counter.ts +109 -0
  10. package/src/cross-tenant.ts +13 -0
  11. package/src/cursor.ts +17 -1
  12. package/src/database.ts +16 -10
  13. package/src/describe-tenancy.ts +25 -0
  14. package/src/describe.ts +144 -3
  15. package/src/entity-error.ts +33 -0
  16. package/src/entity-parse.ts +89 -0
  17. package/src/entity-patch.ts +42 -0
  18. package/src/entity.ts +90 -76
  19. package/src/errors.ts +8 -0
  20. package/src/history-actor.ts +22 -0
  21. package/src/history.ts +86 -0
  22. package/src/index.ts +20 -1
  23. package/src/jit-preload.ts +35 -25
  24. package/src/lifecycle.ts +100 -0
  25. package/src/lock-key.ts +27 -0
  26. package/src/lock-unsupported.ts +11 -0
  27. package/src/memory-aggregates.ts +99 -0
  28. package/src/memory-batch.ts +69 -0
  29. package/src/memory-journal.ts +68 -0
  30. package/src/memory-lifecycle.ts +106 -0
  31. package/src/memory-lock.ts +144 -0
  32. package/src/memory-observed.ts +121 -0
  33. package/src/memory-repo.ts +199 -186
  34. package/src/memory-same-tenant.ts +83 -0
  35. package/src/memory-seek.ts +44 -0
  36. package/src/memory-transactor.ts +94 -0
  37. package/src/memory-unique.ts +19 -4
  38. package/src/n-plus-one.ts +28 -10
  39. package/src/numeric-compare.ts +43 -0
  40. package/src/pg-aggregates.ts +185 -0
  41. package/src/pg-driver.ts +79 -166
  42. package/src/pg-lock.ts +50 -0
  43. package/src/pg-row.ts +29 -2
  44. package/src/pg-sql-aggregate.ts +31 -0
  45. package/src/pg-sql.ts +17 -1
  46. package/src/query.ts +26 -105
  47. package/src/read-aggregates.ts +138 -0
  48. package/src/read-capture.ts +22 -0
  49. package/src/registry.ts +44 -1
  50. package/src/repo.ts +34 -0
  51. package/src/retain.ts +71 -0
  52. package/src/row-observer.ts +22 -4
  53. package/src/row-security-settings.ts +20 -0
  54. package/src/sealed-repo.ts +15 -0
  55. package/src/seed.ts +43 -7
  56. package/src/series-unsupported.ts +11 -0
  57. package/src/table-locks.ts +41 -0
  58. package/src/test-driver.ts +90 -0
  59. package/src/time-bucket.ts +147 -0
  60. package/src/touch.ts +33 -0
  61. package/src/types.ts +20 -0
  62. package/src/view-entity.ts +78 -0
  63. package/src/view-issue.ts +47 -0
  64. package/src/view-repo.ts +38 -0
  65. package/src/view.ts +41 -27
package/src/describe.ts CHANGED
@@ -5,12 +5,20 @@
5
5
  // Money is the one place the projection is not one-to-one: one property becomes the two
6
6
  // physical columns that back it.
7
7
 
8
- import { columnName, moneyColumns, referenceBinding } from './column';
8
+ import { type Binding, bindingOf, columnName, moneyColumns, referenceBinding } from './column';
9
9
  import { currencyCheck, scaleCheck } from './columns';
10
+ import { EntityError, invariantViolated } from './errors';
10
11
  import type { Invariant } from './invariants';
11
- import type { ColumnDescription, EntityDescription, ReferenceDescription } from './registry';
12
+ import type { LifecycleDescription } from './lifecycle';
13
+ import type {
14
+ ColumnDescription,
15
+ EntityDescription,
16
+ ReferenceDescription,
17
+ SameTenantKey,
18
+ } from './registry';
12
19
  import type { SearchVector } from './search';
13
20
  import type { AnyColumn, ColumnMeta, IndexDef } from './types';
21
+ import type { ViewQuery } from './view-entity';
14
22
 
15
23
  export interface DescribeInput<Row> {
16
24
  readonly name: string;
@@ -28,6 +36,16 @@ export interface DescribeInput<Row> {
28
36
  readonly search?: SearchVector | null;
29
37
  /** `entity({ appendOnly: true })` — projected only when true. */
30
38
  readonly appendOnly?: boolean;
39
+ /** `entity({ retired: [...] })` — physical names, projected only when there are any. */
40
+ readonly retired?: readonly string[] | undefined;
41
+ /** `entity({ shared: '<why>' })` — projected only when declared. */
42
+ readonly shared?: string | undefined;
43
+ /** `entity({ scan: '<why>' })` — projected only when declared. */
44
+ readonly scan?: string | undefined;
45
+ /** `entity({ view })` — projected only when declared. */
46
+ readonly view?: ViewQuery | null | undefined;
47
+ /** `retain`, `history`, `countedIn` counters — each projected only when declared (`lifecycle.ts`). */
48
+ readonly lifecycle?: LifecycleDescription | undefined;
31
49
  }
32
50
 
33
51
  /**
@@ -54,6 +72,42 @@ const describeSearchColumn = (search: SearchVector): ColumnDescription => ({
54
72
  generated: search.expression,
55
73
  });
56
74
 
75
+ /**
76
+ * Whether this edge holds its two ends to one tenant. Decided here because a thunk can only be
77
+ * answered once both modules evaluated, and from the bindings because they alone know each side's
78
+ * physical tenant column.
79
+ *
80
+ * Both ends scoped, and neither end IS the tenant column: `orgId -> orgs.id` is the tenant's own
81
+ * root, where a composite would pair a column with itself. A `crossTenant` reason on an edge that
82
+ * could never be same-tenant is refused, or it would state a reason for nothing and read as a
83
+ * decision someone took.
84
+ */
85
+ const sameTenantOf = (
86
+ entityName: string,
87
+ property: string,
88
+ meta: ColumnMeta,
89
+ own: Binding | undefined,
90
+ target: Binding,
91
+ ): SameTenantKey | null => {
92
+ const column = own?.tenant ?? null;
93
+ const targetColumn = target.tenant;
94
+ const eligible =
95
+ own !== undefined &&
96
+ column !== null &&
97
+ targetColumn !== null &&
98
+ own.name !== column &&
99
+ target.name !== targetColumn;
100
+ if (!eligible) {
101
+ if (meta.crossTenant === undefined) return null;
102
+ throw new EntityError({
103
+ code: 'X_INVARIANT_VIOLATED',
104
+ cause: `${entityName}.${property} states crossTenant on an edge that is not same-tenant: ${target.table} and ${entityName} do not both carry a tenant, or the column is the tenant column itself`,
105
+ fix: `references(() => ${target.table}.${target.property}) — in the columns of ${entityName}, with the crossTenant option removed`,
106
+ });
107
+ }
108
+ return meta.crossTenant === undefined ? { column, targetColumn } : null;
109
+ };
110
+
57
111
  /**
58
112
  * The foreign keys an entity declares, resolved through the one binding resolver. Money is
59
113
  * skipped for the reason the DDL projection drops a reference there too: one property is two
@@ -75,9 +129,11 @@ export const describeReferences = (
75
129
  column: columnName(property, meta),
76
130
  nullable: !meta.notNull,
77
131
  targetEntity: target.table,
132
+ targetTable: target.physicalTable,
78
133
  targetProperty: target.property,
79
134
  targetColumn: target.name,
80
135
  onDelete: meta.onDelete ?? null,
136
+ sameTenant: sameTenantOf(entityName, property, meta, bindingOf(column), target),
81
137
  },
82
138
  ];
83
139
  });
@@ -108,6 +164,75 @@ export const sqlTypeOf = (meta: ColumnMeta): string => {
108
164
  return meta.kind;
109
165
  };
110
166
 
167
+ /**
168
+ * At declaration, on the physical names: the same word in both lists is a column read by the release
169
+ * that says it stopped, and `x db gen` could only answer later and further away. Never through
170
+ * `describeEntity`: that answers a `references()` thunk, and a self-reference or a schema cycle is
171
+ * still in its temporal dead zone at this point.
172
+ */
173
+ export const assertNotRetired = (
174
+ entityName: string,
175
+ entries: readonly (readonly [string, AnyColumn])[],
176
+ retired: readonly string[] | undefined,
177
+ ): void => {
178
+ for (const [property, column] of entries) {
179
+ const parts = column.$meta.kind === 'money' ? moneyColumns(property, column.$meta) : null;
180
+ const physical =
181
+ parts === null
182
+ ? [columnName(property, column.$meta)]
183
+ : [parts.minor, parts.currency, ...(parts.scale === null ? [] : [parts.scale])];
184
+ const held = physical.find((each) => retired?.includes(each) === true);
185
+ if (held !== undefined) {
186
+ throw invariantViolated(
187
+ entityName,
188
+ held,
189
+ 'is listed under retired and still declared in columns; a column is one or the other',
190
+ );
191
+ }
192
+ }
193
+ };
194
+
195
+ /**
196
+ * `shared` is a reason, so a blank one is no reason: refused where it was written, never read as a
197
+ * declaration someone took. On an entity WITH a tenant column it states a reason for nothing — the
198
+ * stance `sameTenantOf` takes on a `crossTenant` reason for an edge that cannot be same-tenant.
199
+ */
200
+ export const assertShared = (
201
+ entityName: string,
202
+ table: string,
203
+ shared: string | undefined,
204
+ tenantColumn: string | null,
205
+ ): void => {
206
+ if (shared === undefined) return;
207
+ if (shared.trim() === '') {
208
+ throw new EntityError({
209
+ code: 'X_ENTITY_TENANT_UNDECLARED',
210
+ cause: `${entityName} declares shared with no reason: a blank reason says nothing about why its rows belong to no tenant`,
211
+ fix: `entity('${entityName}', { shared: '<why it belongs to no tenant>' }) — or drop shared, add orgId: uuid().tenant() to its columns, then x db gen "${table} tenant"`,
212
+ });
213
+ }
214
+ if (tenantColumn !== null) {
215
+ throw new EntityError({
216
+ code: 'X_INVARIANT_VIOLATED',
217
+ cause: `${entityName} declares shared and has the tenant column ${tenantColumn}: its rows belong to a tenant, so the reason explains nothing`,
218
+ fix: `entity('${entityName}', { … }) — with the shared option removed`,
219
+ });
220
+ }
221
+ };
222
+
223
+ /**
224
+ * `scan` is a reason, like `shared`: a blank one is no reason, and a table a plan check skips with
225
+ * no argument is a silenced finding nobody can tell from a considered one.
226
+ */
227
+ export const assertScan = (entityName: string, scan: string | undefined): void => {
228
+ if (scan === undefined || scan.trim() !== '') return;
229
+ throw new EntityError({
230
+ code: 'X_INVARIANT_VIOLATED',
231
+ cause: `${entityName} declares scan with no reason: a blank reason says nothing about why the table stays small enough to read whole`,
232
+ fix: `entity('${entityName}', { scan: '<why it stays small, e.g. a closed catalog of 8 rows>' }) — or drop scan and add the index x exercise --json names`,
233
+ });
234
+ };
235
+
111
236
  const describeColumn = <Row>(
112
237
  input: DescribeInput<Row>,
113
238
  property: string,
@@ -179,10 +304,12 @@ const describeColumn = <Row>(
179
304
  // Rendered from the resolved record, so the string a migration reads and the record a
180
305
  // traversal reads can never disagree about what a `references()` points at.
181
306
  references:
182
- reference === undefined ? null : `${reference.targetEntity}.${reference.targetColumn}`,
307
+ reference === undefined ? null : `${reference.targetTable}.${reference.targetColumn}`,
183
308
  // Off the resolved reference, never off `meta` again: a rule with no key is not a thing, and
184
309
  // reading the option twice is two places for the pair to disagree.
185
310
  onDelete: reference?.onDelete ?? null,
311
+ // `true`-or-absent's cousin: absent, so no entity without such an edge moves the hash.
312
+ ...(reference?.sameTenant == null ? {} : { sameTenant: reference.sameTenant }),
186
313
  },
187
314
  ];
188
315
  };
@@ -241,5 +368,19 @@ export const describeEntity = <Row>(input: DescribeInput<Row>): EntityDescriptio
241
368
  // `true` or absent, never `false`: `x verify`'s source drift hashes this projection as JSON,
242
369
  // and an entity that never said `appendOnly` must hash exactly what it hashed before.
243
370
  ...(input.appendOnly === true ? { appendOnly: true as const } : {}),
371
+ ...((input.retired ?? []).length === 0 ? {} : { retired: input.retired }),
372
+ // Absent unless declared, like `appendOnly`: no entity that never said it moves the hash.
373
+ ...(input.shared === undefined ? {} : { shared: input.shared }),
374
+ ...(input.scan === undefined ? {} : { scan: input.scan }),
375
+ // Absent on every table, so no entity that never said `view` moves the hash.
376
+ ...(input.view === null || input.view === undefined
377
+ ? {}
378
+ : {
379
+ view: {
380
+ sql: input.view.sql,
381
+ ...(input.view.materialized ? { materialized: true as const } : {}),
382
+ },
383
+ }),
384
+ ...input.lifecycle,
244
385
  };
245
386
  };
@@ -40,6 +40,14 @@ export const ENTITY_ERROR_CODES = [
40
40
  'X_ENTITY_SEALED_IN_VIEW',
41
41
  'X_ENTITY_APPEND_ONLY',
42
42
  'X_ENTITY_OPERATOR_UNKNOWN',
43
+ 'X_LOCK_UNSUPPORTED',
44
+ 'X_TEST_DRIVER_CLIENT_CHANGED',
45
+ 'X_ENTITY_TENANT_UNDECLARED',
46
+ 'X_ENTITY_SERIES_UNSUPPORTED',
47
+ 'X_ENTITY_SERIES_INVALID',
48
+ 'X_ENTITY_VIEW_READ_ONLY',
49
+ 'X_ENTITY_RETAIN_UNINDEXED',
50
+ 'X_ENTITY_COUNTS_INVALID',
43
51
  ] as const;
44
52
 
45
53
  export type EntityErrorCode = (typeof ENTITY_ERROR_CODES)[number];
@@ -74,6 +82,15 @@ export const ENTITY_ERROR_TITLES: Readonly<Record<EntityErrorCode, string>> = {
74
82
  X_ENTITY_SEALED_IN_VIEW: 'a sealed column is named in a view',
75
83
  X_ENTITY_APPEND_ONLY: 'an append-only entity was asked to rewrite or remove a row',
76
84
  X_ENTITY_OPERATOR_UNKNOWN: 'a predicate names an operator outside the vocabulary',
85
+ X_LOCK_UNSUPPORTED: 'a repository has no lock',
86
+ X_TEST_DRIVER_CLIENT_CHANGED: 'The installed client changed inside a testDriver() transaction',
87
+ X_ENTITY_TENANT_UNDECLARED: 'a child of a tenant-owned table has no tenant',
88
+ X_ENTITY_SERIES_UNSUPPORTED: 'a repository has no series',
89
+ X_ENTITY_SERIES_INVALID: 'a time series asked for something no bucket can answer',
90
+ X_ENTITY_VIEW_READ_ONLY: 'a view entity was asked to write a row',
91
+ X_ENTITY_RETAIN_UNINDEXED: 'entity({ retain }) names a column no index leads with',
92
+ X_ENTITY_COUNTS_INVALID:
93
+ 'references(…, { countedIn }) names no integer of its parent, or is no key to its primary key',
77
94
  };
78
95
 
79
96
  // Registered at module load, unconditionally, in one call. Without this the registry humanises the
@@ -147,6 +164,22 @@ export const invariantViolated = (
147
164
  fix: `x entities show ${entityName} --json # shows the invariant and its SQL CHECK`,
148
165
  });
149
166
 
167
+ /**
168
+ * A `$view` key naming no column. Its own fix, never `describeCommand`: `x entities show` loads the
169
+ * app, and this throw is what stops the load, so that line re-failed with this finding. The file
170
+ * is the loader's to name (`Finding.at`): a view is built with no idea where it was called.
171
+ */
172
+ export const viewKeyUnknown = (
173
+ entityName: string,
174
+ key: string,
175
+ columns: readonly string[],
176
+ ): EntityError =>
177
+ new EntityError({
178
+ code: 'X_INVARIANT_VIOLATED',
179
+ cause: `${entityName}.view: $view(['${key}']) names no column — pick from: ${columns.join(', ')}`,
180
+ fix: `$view(['${columns[0] ?? ''}']) # edit the call on ${entityName}, in the file this finding names; pick from: ${columns.join(', ')}`,
181
+ });
182
+
150
183
  export const entityDuplicate = (name: string, existingTable: string): EntityError =>
151
184
  new EntityError({
152
185
  code: 'X_ENTITY_DUPLICATE',
@@ -0,0 +1,89 @@
1
+ // Single responsibility: an entity's two row parsers — `$parse`, the whole row with declared
2
+ // defaults filled, and the wire parser `$schema` validates with, which neither requires nor copies a
3
+ // sealed column. Split from `entity.ts` at its line ceiling; both close over the same column list.
4
+
5
+ import { describeValue } from '@ultimat3/schema';
6
+ import { entityNow } from './clock';
7
+ import { newId } from './columns';
8
+ import { invariantViolated } from './errors';
9
+ import { sealedMissing } from './sealed-errors';
10
+ import type { AnyColumn, ColumnMeta } from './types';
11
+
12
+ const defaultValue = (meta: ColumnMeta): unknown => {
13
+ const declared = meta.default;
14
+ if (declared === undefined) return undefined;
15
+ if (declared.kind === 'value') return declared.value;
16
+ return declared.by === 'uuid-v7' ? newId() : entityNow();
17
+ };
18
+
19
+ export const rowParsers = <Row, Wire>(
20
+ name: string,
21
+ entries: readonly (readonly [string, AnyColumn])[],
22
+ sealedKeys: ReadonlySet<string>,
23
+ ): { readonly parse: (value: unknown) => Row; readonly parseWire: (value: unknown) => Wire } => {
24
+ /** The columns that leave the server: every one that is not sealed. */
25
+ const wireEntries = entries.filter(([property]) => !sealedKeys.has(property));
26
+
27
+ const parse = (value: unknown): Row => {
28
+ if (typeof value !== 'object' || value === null) {
29
+ // `describeValue`, never `String(value)`: this message is a `$parse` failure, which reaches
30
+ // the caller and the log line — and the whole row is the last value in the framework that
31
+ // may be echoed there. Same renderer as the column builders (`columns.ts`) use.
32
+ throw invariantViolated(name, 'row', `expected an object, got ${describeValue(value)}`);
33
+ }
34
+ const input = value as Readonly<Record<string, unknown>>;
35
+ const row: Record<string, unknown> = {};
36
+ for (const [property, column] of entries) {
37
+ // `=== undefined`, never `??`: an explicit `null` is the caller CLEARING a nullable column,
38
+ // and `??` read it as absence and wrote the column's declared default straight back — so
39
+ // `update(id, { status: null })` reported success and stored `'draft'`, with nothing on any
40
+ // surface to say otherwise. A present `undefined` still means absence, which is what a
41
+ // spread of an omitted optional key produces and is the shape every existing caller has.
42
+ const raw = input[property];
43
+ const given = raw === undefined ? defaultValue(column.$meta) : raw;
44
+ if (given === undefined || given === null) {
45
+ if (column.$meta.notNull) {
46
+ // A required sealed column ABSENT from a row is nearly always a spread of a repository
47
+ // row, which does not enumerate it — so the refusal names that, and the rewrite.
48
+ throw given === undefined && sealedKeys.has(property)
49
+ ? sealedMissing(name, property)
50
+ : invariantViolated(name, property, 'is required and has no default');
51
+ }
52
+ row[property] = null;
53
+ continue;
54
+ }
55
+ row[property] = column.$parse(given);
56
+ }
57
+ // Every property was validated by its own column above, so the shape is the derived row.
58
+ return row as Row;
59
+ };
60
+
61
+ /**
62
+ * The row as `$schema` validates it: the sealed columns are neither required nor returned, so a
63
+ * handler's row — which holds their plaintext — parses to a value that does not.
64
+ */
65
+ const parseWire = (value: unknown): Wire => {
66
+ if (sealedKeys.size === 0) return parse(value) as unknown as Wire;
67
+ if (typeof value !== 'object' || value === null) {
68
+ throw invariantViolated(name, 'row', `expected an object, got ${describeValue(value)}`);
69
+ }
70
+ const input = value as Readonly<Record<string, unknown>>;
71
+ const row: Record<string, unknown> = {};
72
+ for (const [property, column] of wireEntries) {
73
+ const raw = input[property];
74
+ const given = raw === undefined ? defaultValue(column.$meta) : raw;
75
+ if (given === undefined || given === null) {
76
+ if (column.$meta.notNull) {
77
+ throw invariantViolated(name, property, 'is required and has no default');
78
+ }
79
+ row[property] = null;
80
+ continue;
81
+ }
82
+ row[property] = column.$parse(given);
83
+ }
84
+ // Every wire property was validated by its own column, and no sealed one was copied.
85
+ return row as Wire;
86
+ };
87
+
88
+ return { parse, parseWire };
89
+ };
@@ -0,0 +1,42 @@
1
+ // Single responsibility: a patch judged by the columns it names, with the same issues `$parse`
2
+ // raises for a whole row. Absent stays absent: a patch fills no default, or `update(id, { a })`
3
+ // would rewrite every column it did not name.
4
+
5
+ import { invariantViolated } from './errors';
6
+ import { copyRow } from './sealed';
7
+ import type { AnyColumn } from './types';
8
+
9
+ /**
10
+ * The patch, each named column parsed. A property present with `undefined` is absent, the reading
11
+ * `namedColumns` and `bindValues` both give it. `copyRow`, because a row passed back whole keeps
12
+ * its server-only (sealed) properties: they are named by own key, enumerable or not, and parsed
13
+ * like the rest. A key no column declares stays for the statement builder's own refusal, and an
14
+ * empty patch reaches `X_PATCH_EMPTY` as it did.
15
+ */
16
+ export const parseEntityPatch = <Patch>(
17
+ name: string,
18
+ columns: Readonly<Record<string, AnyColumn>>,
19
+ patch: Patch,
20
+ ): Patch => {
21
+ if (typeof patch !== 'object' || patch === null) return patch;
22
+ const parsed = copyRow(patch as object);
23
+ for (const [property, descriptor] of Object.entries(Object.getOwnPropertyDescriptors(parsed))) {
24
+ const column = Object.hasOwn(columns, property) ? columns[property] : undefined;
25
+ const value: unknown = descriptor.get === undefined ? descriptor.value : descriptor.get();
26
+ if (column === undefined || value === undefined) continue;
27
+ if (value === null) {
28
+ if (column.$meta.notNull) {
29
+ throw invariantViolated(name, property, 'is required and has no default');
30
+ }
31
+ continue;
32
+ }
33
+ const { enumerable } = descriptor;
34
+ Object.defineProperty(parsed, property, {
35
+ value: column.$parse(value),
36
+ enumerable: enumerable === true,
37
+ writable: true,
38
+ configurable: true,
39
+ });
40
+ }
41
+ return parsed as Patch;
42
+ };