@ultimat3/entity 0.0.1 → 1.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 developerz.ai
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,60 +1,144 @@
1
1
  # @ultimat3/entity 🗄️
2
2
 
3
- An entity is **a table + its domain type + its invariants**. The first of the eight
4
- primitives; repos, admin screens, cache tags and the manifest are all derived from
5
- one `entity()` call.
3
+ An entity is **columns + the invariants that hold for every row**. The row type is derived from
4
+ the columns: `type Post = typeof posts.$row`. Declare the shape once repos, migrations, the
5
+ admin screen, cache tags and the manifest are all projections of that one call.
6
6
 
7
7
  ```ts
8
- const posts = entity({
9
- table: table('posts', { id: id(), orgId: orgId(), title: text(), ...money('price'),
10
- ...timestamps(), ...softDelete() }),
11
- type: Post,
12
- invariants: [invariant('title_not_empty', {
13
- message: 'title must not be empty',
14
- sql: 'char_length(title) > 0',
15
- holds: (p) => p.title.length > 0,
16
- })],
8
+ import {
9
+ entity, enumerated, integer, invariant, text, timestamp, url, uuid,
10
+ } from '@ultimat3/entity';
11
+
12
+ export const posts = entity('posts', {
13
+ tenant: 'orgId',
14
+ columns: {
15
+ id: uuid().primaryKey(),
16
+ orgId: uuid().references(() => orgs.id, { onDelete: 'cascade' }),
17
+ slug: text({ max: 80 }),
18
+ title: text({ max: 120 }),
19
+ coverUrl: url().nullable(),
20
+ status: enumerated(POST_STATUSES).default('draft'),
21
+ likeCount: integer().default(0),
22
+ createdAt: timestamp().defaultNow(),
23
+ updatedAt: timestamp().defaultNow().onUpdateNow(),
24
+ },
25
+ invariants: [
26
+ invariant('post_title_present', (c) => c.title.trimmed().minLength(1)),
27
+ invariant('post_slug_unique_per_org', (c) => c.unique(['orgId', 'slug'])),
28
+ invariant('post_like_count_non_negative', (c) => c.likeCount.atLeast(0)),
29
+ ],
30
+ indexes: [{ on: ['orgId', 'createdAt'], order: 'desc', where: (c) => c.status.eq('published') }],
17
31
  });
32
+
33
+ export type Post = typeof posts.$row;
34
+
35
+ export const PostView = posts.$view(['id', 'title', 'coverUrl', 'status']);
36
+ export type PostView = typeof PostView.$row;
18
37
  ```
19
38
 
39
+ ## `$view` is what leaves the server
40
+
41
+ `posts.$view([...])` returns a Standard Schema over a subset of the row, so an action names it
42
+ directly — `output: PostView` — and the projected type flows on to the client and the component.
43
+
44
+ | Rule | Detail |
45
+ |---|---|
46
+ | Keys are checked twice | unknown key ⇒ `tsc` error, and `X_INVARIANT_VIOLATED` at declaration for a JS caller |
47
+ | Values are the columns' | each key is parsed by the column that declared it; no second copy of the rule |
48
+ | Nothing is invented | a view projects a row that exists — an absent required key is missing data, not a default |
49
+ | `$name` | `posts.view.id_title_coverUrl_status` — stable, and legal as an OpenAPI `components.schemas` key |
50
+
51
+ There is no free `view(posts, [...])` function: a projection is reached through the entity, and
52
+ every framework member is `$`-prefixed so a column may still be called `name`, `view` or `tenant`.
53
+
54
+ A view the columns cannot express — a joined `authorName`, a computed `excerpt` — is a hand-written
55
+ `t.object({...})`. `t` is re-exported here, the same object `@ultimat3/schema` exports, so that file
56
+ still imports one package: `import { entity, t } from '@ultimat3/entity'`.
57
+
20
58
  ## Blessed columns
21
59
 
22
- | Helper | Emits | Why it is the only way |
60
+ | Builder | Emits | Why it is the only way |
23
61
  |---|---|---|
24
- | `id()` | `uuid` pk, v7 default | time-ordered keys keep the pk index append-friendly |
25
- | `timestamps()` | `created_at`/`updated_at` `timestamptz` | UTC storage is not a per-table decision |
26
- | `money('price')` | `price_minor bigint` + `price_currency char(3)` | never a float, never one implied currency |
27
- | `tz()` | `text` + regex CHECK, `Intl`-validated | an offset is not a time zone |
28
- | `locale()`, `slug()` | `text` + CHECK | format is enforced by the database too |
29
- | `orgId()` | `uuid` + FK + index | its presence is what turns on tenancy |
30
- | `softDelete()` | `deleted_at timestamptz null` | its presence is what turns on soft delete |
31
- | `jsonb(parse)` | `jsonb` | a jsonb column without a parser is an untyped hole |
32
-
33
- Physical names are derived from the property key (`orgId` → `org_id`); write a name
34
- once or not at all.
62
+ | `uuid()` | `uuid`; `.primaryKey()` defaults to v7 | time-ordered keys keep the pk index append-friendly |
63
+ | `timestamp()` | `timestamptz` | UTC storage is not a per-table decision; there is no naive variant |
64
+ | `money()` | `<name>_minor bigint` + `<name>_currency char(3)` | never a float, never one implied currency |
65
+ | `enumerated(v)` | `text` + CHECK | a variant is a one-line migration, not `ALTER TYPE` |
66
+ | `tz(zones)`, `locale(tags)` | `text` + CHECK, `Intl`-validated at declaration | an offset is not a time zone |
67
+ | `text({ max })`, `integer()`, `boolean()`, `url()` | `text`/`integer`/`boolean` + CHECK | format is enforced by the database too |
68
+
69
+ Chain: `.primaryKey()` · `.nullable()` · `.unique()` · `.default(v)` · `.defaultNow()` ·
70
+ `.onUpdateNow()` · `.references(() => other.id, { onDelete })` · `.tenant()`. Physical names are
71
+ derived from the property key (`orgId` → `org_id`); a name is written once, or never.
35
72
 
36
73
  ## Invariants run twice
37
74
 
38
- Written once, enforced in the app on every write **and** in Postgres as a CHECK or a
39
- unique index (`toSql()`). The database can never disagree with the code a bulk
40
- import or a `psql` session hits the same rule.
75
+ One declaration, two enforcement points: the app checks it on every write, and the migration
76
+ emits it. A bulk import or a `psql` session hits the same rule.
41
77
 
42
78
  ```sql
43
- ALTER TABLE "posts" ADD CONSTRAINT "posts_title_not_empty_check" CHECK (char_length(title) > 0);
79
+ ALTER TABLE "posts" ADD CONSTRAINT "posts_post_like_count_non_negative_check" CHECK (like_count >= 0);
80
+ CREATE UNIQUE INDEX "posts_post_slug_unique_per_org_key" ON "posts" ("org_id", "slug");
44
81
  ```
45
82
 
46
- ## Repositories
83
+ A rule written as a JS predicate — `c.slug.matches(isValidSlug)`, `c.satisfies(fn, [...])` —
84
+ still runs on write, reports `kind: 'assert'` and `sql: null`, and is what `x verify` warns
85
+ about: a rule the database does not know is a rule a migration script can violate.
86
+
87
+ ## One typed handle
88
+
89
+ ```ts
90
+ export const db = database({ orgs, posts });
91
+
92
+ db.posts.where({ orgId }).orderBy('createdAt').limit(50).page(); // { rows, nextCursor }
93
+ ```
47
94
 
48
- `Repo<T>` takes an explicit `tx` on every write so the transactional outbox can join
49
- the request's transaction. Pagination is **cursor-only**: `OFFSET` is wrong under
50
- concurrent writes because an insert before the offset shifts every later page, so a
51
- client silently skips and repeats rows. `memoryRepo()` is the default driver
52
- (tests, `x dev` before the first migration); Drizzle + Postgres is production.
95
+ `db.posts` exists because `posts` was declared. Pagination is **cursor-only**: `OFFSET` is wrong
96
+ under concurrent writes, because an insert before the offset shifts every later page and the
97
+ client silently skips and repeats rows.
98
+
99
+ `nextCursor` is signed by `@ultimat3/core` and scoped to the plan that produced it this entity,
100
+ these filters, this sort order. A tampered cursor, or one taken from another listing, is
101
+ `X_CURSOR_INVALID` rather than a silent page one. The page size is deliberately outside the scope:
102
+ asking for a bigger next page is the same query.
103
+
104
+ ## Two drivers, one meaning
105
+
106
+ ```ts
107
+ database({ orgs, posts }); // memoryDriver() — the default
108
+ database({ orgs, posts }, { driver: postgresDriver() }); // production
109
+ ```
110
+
111
+ | | `memoryDriver()` | `postgresDriver()` |
112
+ |---|---|---|
113
+ | Rows live | in a `Map` | in Postgres |
114
+ | For | tests, `x dev` before the first migration | production |
115
+ | Transaction | `memoryTransactor()` — undo closures | `postgresTransactor()` — real `BEGIN`/`COMMIT` |
116
+
117
+ They are not two implementations of an idea. They share the plan (scope, sort order, page size),
118
+ the cursor codec and the `Repo<T>` contract, so a page taken in a test means the same thing as a
119
+ page taken in production. `postgresDriver()` takes no connection: `db()` from `@ultimat3/db`
120
+ returns the open transaction when there is one, so a repository call inside `withTransaction`
121
+ joins it without being told — which is how a job's outbox row lands atomically with the write
122
+ that enqueued it.
123
+
124
+ Every value is bound to `$n` and every identifier is resolved through the entity, so a column
125
+ name can only be one the entity declared and a row value can never become SQL.
53
126
 
54
127
  ## Tenancy is a guard
55
128
 
56
- An entity with an `orgId` column can only be queried through a plan carrying an org
57
- predicate. Without one: `X_TENANCY_UNSCOPED`, at the seam, every time.
129
+ `tenant: 'orgId'` on the entity names the column outright. Omit it and it is inferred a
130
+ `.tenant()` column, else one named `orgId` so an entity never becomes unscoped by forgetting the
131
+ key; name a column that does not exist and the declaration fails with `X_INVARIANT_VIOLATED`.
132
+
133
+ Either way, every read then needs an org predicate. Without one: `X_TENANCY_UNSCOPED`, at the
134
+ seam, every time. Writes are reads: `update(id, patch)` and `delete(id)` build the same plan, so
135
+ an id alone never addresses a row, and another tenant's id is `X_NOT_FOUND` rather than theirs.
136
+
137
+ ## Seeds
138
+
139
+ `defineSeed('dev', async ({ insert, id }) => …)`. `id('post:tenancy')` is a UUID v5 of the label,
140
+ so the same fixture graph gets the same ids on every machine. Rows go through the columns and
141
+ the invariants, which makes a seed a test of the schema as well.
58
142
 
59
143
  ## Errors
60
144
 
@@ -63,7 +147,9 @@ predicate. Without one: `X_TENANCY_UNSCOPED`, at the seam, every time.
63
147
 
64
148
  ## Boundaries
65
149
 
66
- Tier 2. Imports `@ultimat3/core` and `@ultimat3/schema` only. There is deliberately no
67
- `drizzle-orm` dependency: `ColumnDef`/`TableDef` are the narrow structural types this
68
- package consumes, so generated SQL stays readable and an agent can self-correct
69
- against it. `@ultimat3/cache` invalidates by the `entity:<name>` tag string.
150
+ Tier 2. Imports `@ultimat3/core`, `@ultimat3/schema` and `@ultimat3/db` only `db` is tier 1
151
+ (it imports `core` and nothing else), which is what keeps `Driver` and its production
152
+ implementation in one package instead of two. No `drizzle-orm` dependency:
153
+ `types.ts` declares the narrow structural column vocabulary this package consumes, so the
154
+ generated SQL stays readable and an agent can self-correct against it. `@ultimat3/cache`
155
+ invalidates by the `entity:<name>` tag.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/entity",
3
- "version": "0.0.1",
3
+ "version": "1.1.0",
4
4
  "description": "A table + its domain type + invariants the database also enforces",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -30,7 +30,8 @@
30
30
  "test": "bun test"
31
31
  },
32
32
  "dependencies": {
33
- "@ultimat3/core": "^0.0.1",
34
- "@ultimat3/schema": "^0.0.1"
33
+ "@ultimat3/core": "1.1.0",
34
+ "@ultimat3/db": "1.1.0",
35
+ "@ultimat3/schema": "1.1.0"
35
36
  }
36
37
  }
package/src/column.ts ADDED
@@ -0,0 +1,134 @@
1
+ // The chain every column builder is made of. Each link returns a new column, so a chain reads
2
+ // in declaration order and a builder is never mutated behind someone's back.
3
+ //
4
+ // Where a column landed (table, property key, physical name) is recorded in a binding rather
5
+ // than on the column: the author writes the property key once, `entity()` derives `orgId` ->
6
+ // `org_id` from it, and a lazy `.references(() => orgs.id)` can still resolve the target's
7
+ // physical name even though two schema modules import each other in a cycle.
8
+
9
+ import { invariantViolated } from './errors';
10
+ import type { AnyColumn, Column, ColumnDefault, ColumnMeta, TimestampColumn } from './types';
11
+
12
+ export const snake = (value: string): string =>
13
+ value.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase();
14
+
15
+ export const GENERATED_UUID: ColumnDefault = { kind: 'generated', by: 'uuid-v7' };
16
+ export const GENERATED_NOW: ColumnDefault = { kind: 'generated', by: 'now' };
17
+
18
+ /** Every column starts here: not null, no key, no index. */
19
+ export const BARE: Omit<ColumnMeta, 'kind'> = {
20
+ notNull: true,
21
+ primaryKey: false,
22
+ unique: false,
23
+ index: false,
24
+ tenant: false,
25
+ };
26
+
27
+ export interface Binding {
28
+ readonly table: string;
29
+ /** camelCase key on the row. */
30
+ readonly property: string;
31
+ /** snake_case physical column name. */
32
+ readonly name: string;
33
+ }
34
+
35
+ const bindings = new WeakMap<AnyColumn, Binding>();
36
+
37
+ /**
38
+ * Called once per column by `entity()`. A column object belongs to exactly one table: sharing
39
+ * one between two entities would give it two physical names and silently mis-name a foreign
40
+ * key, so a second binding is a declaration-time error.
41
+ */
42
+ export const bindColumn = (column: AnyColumn, table: string, property: string): Binding => {
43
+ const existing = bindings.get(column);
44
+ if (existing !== undefined && existing.table !== table) {
45
+ throw invariantViolated(
46
+ table,
47
+ property,
48
+ `this column is already bound to ${existing.table}.${existing.name}; ` +
49
+ 'build a new column instead of sharing one between entities',
50
+ );
51
+ }
52
+ const binding: Binding = { table, property, name: snake(property) };
53
+ bindings.set(column, binding);
54
+ return binding;
55
+ };
56
+
57
+ export const bindingOf = (column: AnyColumn): Binding | undefined => bindings.get(column);
58
+
59
+ const literal = (value: unknown): ColumnDefault => {
60
+ if (value === null) return { kind: 'value', value: null };
61
+ if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
62
+ return { kind: 'value', value };
63
+ }
64
+ throw invariantViolated(
65
+ 'column',
66
+ 'default',
67
+ `a default must be a literal; got ${typeof value}. For an instant use timestamp().defaultNow()`,
68
+ );
69
+ };
70
+
71
+ export const makeColumn = <T, Optional extends boolean>(
72
+ meta: ColumnMeta,
73
+ parse: (value: unknown) => T,
74
+ optional: Optional,
75
+ ): Column<T, Optional> => ({
76
+ $meta: meta,
77
+ $parse: parse,
78
+ $optional: optional,
79
+
80
+ primaryKey: () => {
81
+ // Only a uuid key can be generated for the caller; any other key must be supplied.
82
+ const generated = meta.kind === 'uuid' && meta.default === undefined;
83
+ return makeColumn<T, boolean>(
84
+ { ...meta, primaryKey: true, ...(generated ? { default: GENERATED_UUID } : {}) },
85
+ parse,
86
+ generated || meta.default !== undefined,
87
+ );
88
+ },
89
+
90
+ nullable: () =>
91
+ makeColumn<T | null, Optional>(
92
+ { ...meta, notNull: false },
93
+ (value) => (value === null || value === undefined ? null : parse(value)),
94
+ optional,
95
+ ),
96
+
97
+ unique: () => makeColumn<T, Optional>({ ...meta, unique: true }, parse, optional),
98
+
99
+ tenant: () => makeColumn<T, Optional>({ ...meta, tenant: true, index: true }, parse, optional),
100
+
101
+ references: (target, options = {}) =>
102
+ makeColumn<T, Optional>(
103
+ {
104
+ ...meta,
105
+ references: target,
106
+ index: true,
107
+ ...(options.onDelete === undefined ? {} : { onDelete: options.onDelete }),
108
+ },
109
+ parse,
110
+ optional,
111
+ ),
112
+
113
+ default: (value) => makeColumn<T, true>({ ...meta, default: literal(value) }, parse, true),
114
+ });
115
+
116
+ export const column = <T>(
117
+ kind: ColumnMeta['kind'],
118
+ parse: (value: unknown) => T,
119
+ extra: Partial<ColumnMeta> = {},
120
+ ): Column<T> => makeColumn<T, false>({ ...BARE, ...extra, kind }, parse, false);
121
+
122
+ /**
123
+ * `timestamptz`, always. There is no naive-timestamp builder and there will not be one: a
124
+ * `timestamp without time zone` is a bug that only surfaces twice a year.
125
+ */
126
+ export const makeTimestamp = <Optional extends boolean>(
127
+ meta: ColumnMeta,
128
+ parse: (value: unknown) => Date,
129
+ optional: Optional,
130
+ ): TimestampColumn<Optional> => ({
131
+ ...makeColumn<Date, Optional>(meta, parse, optional),
132
+ defaultNow: () => makeTimestamp({ ...meta, default: GENERATED_NOW }, parse, true),
133
+ onUpdateNow: () => makeTimestamp({ ...meta, onUpdate: GENERATED_NOW }, parse, optional),
134
+ });