@ultimat3/entity 18.0.0 → 19.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 (3) hide show
  1. package/README.md +1 -0
  2. package/package.json +5 -5
  3. package/src/view.ts +74 -1
package/README.md CHANGED
@@ -47,6 +47,7 @@ directly — `output: PostView` — and the projected type flows on to the clien
47
47
  | Values are the columns' | each key is parsed by the column that declared it; no second copy of the rule |
48
48
  | Nothing is invented | a view projects a row that exists — an absent required key is missing data, not a default |
49
49
  | `$name` | `posts.view.id_title_coverUrl_status` — stable, and legal as an OpenAPI `components.schemas` key |
50
+ | It projects | a view carries the schema IR (`node`) the same way every `t.*` schema does, so `output: PostView` reaches OpenAPI, the MCP tool's `outputSchema` and the typed client — `As of 2026-09-05`; before that it validated fine and was refused at registration with `X_SCHEMA_UNSUPPORTED`, so every app re-declared its outputs as `t.object`. Column kinds publish the ROW value's shape: `bigint()`/`decimal()` are strings, `date()` is a `YYYY-MM-DD` string, `money()` is `{ minor, currency, scale? }`, `enumerated()` is an `enum`, a nullable column is `anyOf: [<type>, null]` and still required |
50
51
 
51
52
  There is no free `view(posts, [...])` function: a projection is reached through the entity, and
52
53
  every framework member is `$`-prefixed so a column may still be called `name`, `view` or `tenant`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/entity",
3
- "version": "18.0.0",
3
+ "version": "19.1.0",
4
4
  "description": "A table + its domain type + invariants the database also enforces",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -31,9 +31,9 @@
31
31
  "test": "bun test"
32
32
  },
33
33
  "dependencies": {
34
- "@ultimat3/core": "18.0.0",
35
- "@ultimat3/db": "18.0.0",
36
- "@ultimat3/schema": "18.0.0",
37
- "@ultimat3/time": "18.0.0"
34
+ "@ultimat3/core": "19.1.0",
35
+ "@ultimat3/db": "19.1.0",
36
+ "@ultimat3/schema": "19.1.0",
37
+ "@ultimat3/time": "19.1.0"
38
38
  }
39
39
  }
package/src/view.ts CHANGED
@@ -4,10 +4,73 @@
4
4
  // declaration-time failure, not a surprise on the first request.
5
5
 
6
6
  import { renderThrowable } from '@ultimat3/core';
7
- import { describeValue, type StandardSchemaV1 } from '@ultimat3/schema';
7
+ import { describeValue, type SchemaNode, type StandardSchemaV1 } from '@ultimat3/schema';
8
8
  import { invariantViolated } from './errors';
9
9
  import type { AnyColumn, ColumnMap } from './types';
10
10
 
11
+ /** What `bigint()` puts on a row: the digits, as a string — `columns-data.ts` parses by this. */
12
+ const BIGINT_PATTERN = '^-?\\d+$';
13
+ /** What `date()` puts on a row: `@ultimat3/time`'s `PlainDate`, a `YYYY-MM-DD` string. */
14
+ const PLAIN_DATE_PATTERN = '^\\d{4}-\\d{2}-\\d{2}$';
15
+
16
+ /**
17
+ * A column as the schema IR reads it. This is what lets `output: PostView` reach OpenAPI, the
18
+ * typed client and an MCP tool: before it, a view validated fine and was refused at projection
19
+ * time with `X_SCHEMA_UNSUPPORTED` (measured 2026-09-05), so every app re-declared its outputs as
20
+ * `t.object` and pinned them to the columns by hand.
21
+ *
22
+ * Each case publishes the ROW value's shape, which is not always the SQL type's: `bigint()` and
23
+ * `decimal()` are `Column<string>` — the digits, because a JS number is inexact past ±2^53 and a
24
+ * float is the money bug with a different name — and `date()` is a calendar-date string, not an
25
+ * instant. A generated client typed off this document has to agree with what `$parse` returns,
26
+ * so those three are strings here. `jsonb` and `bytea` are `unknown`: a `json()` column's schema
27
+ * is not on its `$meta`, and bytes have no JSON form at all.
28
+ */
29
+ export const columnNode = (column: AnyColumn): SchemaNode => {
30
+ const meta = column.$meta;
31
+ const base = ((): SchemaNode => {
32
+ switch (meta.kind) {
33
+ case 'uuid':
34
+ return { kind: 'string', format: 'uuid' };
35
+ case 'text':
36
+ case 'char':
37
+ return meta.values !== undefined
38
+ ? { kind: 'enum', values: meta.values }
39
+ : { kind: 'string', ...(meta.length === undefined ? {} : { maxLength: meta.length }) };
40
+ case 'boolean':
41
+ return { kind: 'boolean' };
42
+ case 'integer':
43
+ return { kind: 'number', integer: true };
44
+ case 'bigint':
45
+ return {
46
+ kind: 'string',
47
+ pattern: BIGINT_PATTERN,
48
+ description: 'bigint as a decimal string',
49
+ };
50
+ case 'numeric':
51
+ return { kind: 'string', description: 'exact decimal as a string' };
52
+ case 'timestamptz':
53
+ return { kind: 'date' };
54
+ case 'date':
55
+ return {
56
+ kind: 'string',
57
+ pattern: PLAIN_DATE_PATTERN,
58
+ description: 'calendar date, YYYY-MM-DD',
59
+ };
60
+ case 'array':
61
+ return {
62
+ kind: 'array',
63
+ items: meta.element === undefined ? { kind: 'unknown' } : columnNode(meta.element),
64
+ };
65
+ case 'money':
66
+ return { kind: 'money' };
67
+ default:
68
+ return { kind: 'unknown' };
69
+ }
70
+ })();
71
+ return meta.notNull ? base : { ...base, nullable: true };
72
+ };
73
+
11
74
  /**
12
75
  * A row projection, usable anywhere a schema is. `$row` is the phantom that carries the type
13
76
  * (`type PostView = typeof PostView.$row`); `$name` is how a manifest or an OpenAPI document
@@ -15,6 +78,8 @@ import type { AnyColumn, ColumnMap } from './types';
15
78
  */
16
79
  export interface EntityView<Row, K extends keyof Row & string>
17
80
  extends StandardSchemaV1<unknown, Pick<Row, K>> {
81
+ /** The schema IR, so a view is introspectable wherever a `t.object` is. */
82
+ readonly node: SchemaNode;
18
83
  readonly $name: string;
19
84
  readonly $keys: readonly K[];
20
85
  /** Phantom: `type PostView = typeof PostView.$row`. Reading it at runtime throws. */
@@ -48,6 +113,12 @@ export const viewFor = <Row, K extends keyof Row & string>(
48
113
  return [key, column] as const;
49
114
  });
50
115
  const name = viewName(entityName, keys);
116
+ // No `description`: the view's name is `$name`, and OpenAPI keys the component by the ACTION's
117
+ // output name, so a description repeating `posts.view.id_title` would be noise on every field.
118
+ const node: SchemaNode = {
119
+ kind: 'object',
120
+ properties: Object.fromEntries(picked.map(([key, column]) => [key, columnNode(column)])),
121
+ };
51
122
 
52
123
  const parse = (value: unknown): Pick<Row, K> => {
53
124
  if (typeof value !== 'object' || value === null) {
@@ -91,6 +162,8 @@ export const viewFor = <Row, K extends keyof Row & string>(
91
162
  }
92
163
  },
93
164
  },
165
+ // `node` is the duck-typed key `nodeOf()` reads — the same one every `t.*` schema carries.
166
+ node,
94
167
  $name: name,
95
168
  $keys: keys,
96
169
  get $row(): Pick<Row, K> {