@ultimat3/admin 2.0.0 → 3.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 CHANGED
@@ -9,7 +9,7 @@ Two products, one package, **two entry points**: `@ultimat3/admin/dev` (`src/dev
9
9
  - **One authz.** `AdminAuthz` (`authz.ts`) is the only decision path. `action-gate.ts`, `crud.ts` and `page-guard.tsx` call `decideAll`; views render what the gate returned. Never add a second check in a view or an MCP handler.
10
10
  - **A custom page's guard is composed, never written.** `pages.ts` turns a `pages:` entry into an `AdminRoute` carrying `[admin:read, …declared]`; `routes.ts` is the only thing that may hand a page component to a router and it hands the `guardedPage()` wrapper, with `permissions[0]` already in `defineRoute({ policy })`. `AdminPageProps.ctx` is required by the type so the wrapper cannot be bypassed by calling the component directly. An empty permission list is `X_ADMIN_PAGE_UNGUARDED` at `defineAdmin` time — the one place an unauthenticated admin screen could have been born.
11
11
  - **A host that serves an admin URL itself reads its gate, never restates it.** `adminRouteFor(app, path)` (`routes.ts`) is that read, and `AdminRouteConfig.policy` is what it hands over — the same object `config.policy` carries, non-optional so a caller need not prove it exists. Typing `policy: { permission: 'admin:read' }` into a page file beside a route table that already declares one for that URL is two declarations of one URL's authz; they agree until somebody edits one. The deployed demo shipped exactly that on five pages until 1.2.0, and its `apps/admin/app/admin/route-policy.test.ts` is the rule made executable: a quoted permission anywhere under `app/admin/**/page.tsx` fails the suite. An undeclared path is `X_ADMIN_PAGE_PATH_INVALID`, listing the paths that would have worked.
12
- - **One bridge per foreign package.** `policy-bridge.ts` is the only file calling `evaluate`/`definePermissions`; `routes.ts` the only one calling `defineRoute`; `mcp.ts` the only one calling `defineAppMcp`; `dev/data.ts` the only one importing introspection (dynamically — `/_x` must stay out of the production graph).
12
+ - **One bridge per foreign package.** `policy-bridge.ts` is the only file calling `evaluate`/`definePermissions`; `routes.ts` the only one calling `defineRoute`; `mcp.ts` the only one calling `defineAppMcp`; `dev/data.ts` the only one importing introspection (dynamically — `/_x` must stay out of the production graph). Source only: a **test** declares the permissions its own `can()` fixtures use, because `definePermissions` writes a process-global registry and `bun test` seats several files in one process — `dev/data.test.ts` relied on the empty-registry-means-permissive fallback and went red the first time it shared a process with anything that imports this package.
13
13
  - **One entity surface** (`registry.ts`) — the admin reads what `entity()` actually exposes: `$name`, `$primaryKey`, `$columns[c].$meta`, `$schema`, `$describe()`. It is a structural subset so a new column kind still derives, and `RegisteredEntity` is the `tsc`-checked proof that a real `entity()` result satisfies it — never a comment claiming it does.
14
14
  - **One flattener.** `entity-columns.ts` is the only file that reads `$meta` or calls `$describe()`; everything downstream takes `AdminColumnFacts`. Money stays one property (the admin renders rows, not tables), a FK target comes back resolved from `$describe()`, and only a **generated** default (`uuid`, `now`) is read-only — `.default('free')` is a starting value.
15
15
  - **A deliberate query loop declares itself.** `search.ts` runs one indexed lookup per text field on purpose — the query IR is a conjunction, so three small indexed reads beat one unindexed `OR` — and says so through `expectedQueryLoop()` from `@ultimat3/db` (tier 1, downward, and the only thing this package imports from it). That is the one suppression mechanism: never a comment pragma and never a list of exempt call sites. A new loop here either argues for itself in a `reason` or it is an N+1.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/admin",
3
- "version": "2.0.0",
3
+ "version": "3.0.0",
4
4
  "description": "Two dashboards: the /_x framework dev panels and the generated, AI-first app admin",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -32,20 +32,20 @@
32
32
  "test": "bun test"
33
33
  },
34
34
  "dependencies": {
35
- "@ultimat3/action": "2.0.0",
36
- "@ultimat3/ai": "2.0.0",
37
- "@ultimat3/cache": "2.0.0",
38
- "@ultimat3/core": "2.0.0",
39
- "@ultimat3/db": "2.0.0",
40
- "@ultimat3/entity": "2.0.0",
41
- "@ultimat3/i18n": "2.0.0",
42
- "@ultimat3/jobs": "2.0.0",
43
- "@ultimat3/mcp": "2.0.0",
44
- "@ultimat3/money": "2.0.0",
45
- "@ultimat3/policy": "2.0.0",
46
- "@ultimat3/query": "2.0.0",
47
- "@ultimat3/render": "2.0.0",
48
- "@ultimat3/schema": "2.0.0",
49
- "@ultimat3/ui": "2.0.0"
35
+ "@ultimat3/action": "3.0.0",
36
+ "@ultimat3/ai": "3.0.0",
37
+ "@ultimat3/cache": "3.0.0",
38
+ "@ultimat3/core": "3.0.0",
39
+ "@ultimat3/db": "3.0.0",
40
+ "@ultimat3/entity": "3.0.0",
41
+ "@ultimat3/i18n": "3.0.0",
42
+ "@ultimat3/jobs": "3.0.0",
43
+ "@ultimat3/mcp": "3.0.0",
44
+ "@ultimat3/money": "3.0.0",
45
+ "@ultimat3/policy": "3.0.0",
46
+ "@ultimat3/query": "3.0.0",
47
+ "@ultimat3/render": "3.0.0",
48
+ "@ultimat3/schema": "3.0.0",
49
+ "@ultimat3/ui": "3.0.0"
50
50
  }
51
51
  }
package/src/fields.ts CHANGED
@@ -84,6 +84,22 @@ export function widgetFor(type: AdminFieldType): AdminWidget {
84
84
  * Every kind `@ultimat3/entity` can put on a column, and nothing else: a name that no column
85
85
  * builder emits would be a widget nobody can reach. `text` is the entry the length rule below
86
86
  * then refines.
87
+ *
88
+ * The kinds an EXISTING schema brings (`bigint()`, `decimal()`, `date()`, `arrayOf()`) each map to
89
+ * the widget that survives a round trip of the value the column itself produces, which is not
90
+ * always the widget the Postgres type suggests:
91
+ *
92
+ * | kind | field type | why not the obvious one |
93
+ * |---|---|---|
94
+ * | `numeric` | `text` | the row value is the exact decimal STRING, and `number-input` renders anything that is not a JS number as null — a blanked field that saves the blank back |
95
+ * | `bigint` | `text` | same reason, and it shipped as `number` before `bigint()` existed: the row value is decimal digits, because a JS `bigint` is what `JSON.stringify` throws on and a `number` loses everything past 2^53 — the range a legacy `int8` key lives in |
96
+ * | `date` | `date` | a calendar date has no zone, so it takes the `precision: 'date'` branch (`<input type="date">`) rather than the instant one |
97
+ * | `array` | `json` | `String(['a,b'])` and `String(['a','b'])` are the same string, and what a text input posts back is not an array at all |
98
+ *
99
+ * `bytea` is deliberately ABSENT. The `file` widget's value is a storage reference (`{ url, name }`
100
+ * — see `uploadValue`), and the `Uint8Array` a `bytes()` column puts on the row is not one, so
101
+ * mapping it would move the same refusal from derive time, where the fix names the edit, to every
102
+ * render of every row. There is no `vector` row for the same reason there is no vector column.
87
103
  */
88
104
  const FIELD_TYPE_BY_COLUMN_KIND: Readonly<Record<string, AdminFieldType>> = {
89
105
  uuid: 'text',
@@ -91,9 +107,12 @@ const FIELD_TYPE_BY_COLUMN_KIND: Readonly<Record<string, AdminFieldType>> = {
91
107
  char: 'text',
92
108
  boolean: 'boolean',
93
109
  integer: 'number',
94
- bigint: 'number',
110
+ bigint: 'text',
111
+ numeric: 'text',
95
112
  timestamptz: 'timestamptz',
113
+ date: 'date',
96
114
  jsonb: 'json',
115
+ array: 'json',
97
116
  money: 'money',
98
117
  };
99
118
 
@@ -146,6 +165,26 @@ export function sortable(type: AdminFieldType, column: AdminColumnFacts): boolea
146
165
  return column.index || column.unique || column.primaryKey;
147
166
  }
148
167
 
149
- export function searchable(type: AdminFieldType): boolean {
150
- return type === 'text' || type === 'textarea';
168
+ /**
169
+ * The column kinds Postgres will accept a `LIKE` against. MEASURED on Postgres 17 (PGlite), one
170
+ * statement per type: `text` and `char` answer rows; `uuid`, `numeric`, `bigint`, `integer`,
171
+ * `date`, `timestamptz`, `jsonb`, `boolean` and `text[]` all answer
172
+ * `operator does not exist: <type> ~~ unknown`, and `bytea` answers `Invalid input for bytea type`.
173
+ *
174
+ * It has to be the KIND and not the field type, because several kinds render in a text box and
175
+ * only two of them can be searched: `adminSearch` issues one `contains` filter per searchable
176
+ * field and the driver compiles `contains` to `<column> like $1` with NO cast
177
+ * (`packages/entity/src/pg-sql.ts`), so a searchable column of any other kind makes the admin's
178
+ * search box answer a database error rather than an empty result.
179
+ */
180
+ const LIKE_ABLE_COLUMN_KINDS: ReadonlySet<string> = new Set(['text', 'char']);
181
+
182
+ /**
183
+ * A text box over a column a `LIKE` can run against — both halves required. The type keeps an
184
+ * enum or a relation out of the search index the way it always has; the kind keeps out the ones
185
+ * that would throw.
186
+ */
187
+ export function searchable(type: AdminFieldType, column: AdminColumnFacts): boolean {
188
+ if (type !== 'text' && type !== 'textarea') return false;
189
+ return LIKE_ABLE_COLUMN_KINDS.has(column.kind);
151
190
  }
package/src/resource.ts CHANGED
@@ -131,7 +131,7 @@ function deriveField(
131
131
  sortable: override?.sortable ?? sortable(type, column),
132
132
  // Generated columns are excluded from search: an id is found by exact lookup, and a
133
133
  // `contains` over a uuid column is a scan that returns nothing useful.
134
- searchable: override?.searchable ?? (searchable(type) && !sensitive && !generated),
134
+ searchable: override?.searchable ?? (searchable(type, column) && !sensitive && !generated),
135
135
  ...(values === undefined ? {} : { values }),
136
136
  ...(currency === undefined ? {} : { currency }),
137
137
  ...(relation === undefined ? {} : { relation }),
package/src/widgets.tsx CHANGED
@@ -54,10 +54,17 @@ const wiring = (control: FieldControl | undefined): ControlWiring =>
54
54
  /**
55
55
  * A `date` column has no time of day, and ui's default formatter always renders one. A
56
56
  * rendered midnight is wrong in every zone but the one the value was stored in.
57
+ *
58
+ * `'UTC'`, never `options.zone`: a calendar date is zone-INDEPENDENT by construction — the value
59
+ * is `YYYY-MM-DD` and the spec parses it as UTC midnight (`date-time-view.ts` says so where it
60
+ * refuses an offsetless date-TIME). Formatting that instant in the viewer's zone moves it: an
61
+ * `effective_on` of `2026-08-18` read as `Aug 17, 2026` for every operator west of Greenwich,
62
+ * which is the exact bug `date()` exists to prevent — measured under `TZ=America/Los_Angeles`.
63
+ * The instant branch keeps `options.zone`, because an instant genuinely has one.
57
64
  */
58
- const dateOnly: DateTimeFormatter = (at, options) =>
65
+ export const formatCalendarDate: DateTimeFormatter = (at, options) =>
59
66
  new Intl.DateTimeFormat(options.locale, {
60
- timeZone: options.zone,
67
+ timeZone: 'UTC',
61
68
  dateStyle: 'medium',
62
69
  }).format(at);
63
70
 
@@ -81,7 +88,7 @@ function readView(props: WidgetProps, field: AdminField, ctx: WidgetContext): JS
81
88
  <DateTime
82
89
  value={props.value}
83
90
  timeZone={props.timeZone}
84
- format={props.precision === 'date' ? dateOnly : undefined}
91
+ format={props.precision === 'date' ? formatCalendarDate : undefined}
85
92
  />
86
93
  );
87
94
  case 'checkbox':