@ultimat3/entity 0.0.1

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 ADDED
@@ -0,0 +1,69 @@
1
+ # @ultimat3/entity 🗄️
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.
6
+
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
+ })],
17
+ });
18
+ ```
19
+
20
+ ## Blessed columns
21
+
22
+ | Helper | Emits | Why it is the only way |
23
+ |---|---|---|
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.
35
+
36
+ ## Invariants run twice
37
+
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.
41
+
42
+ ```sql
43
+ ALTER TABLE "posts" ADD CONSTRAINT "posts_title_not_empty_check" CHECK (char_length(title) > 0);
44
+ ```
45
+
46
+ ## Repositories
47
+
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.
53
+
54
+ ## Tenancy is a guard
55
+
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.
58
+
59
+ ## Errors
60
+
61
+ `X_ENTITY_DUPLICATE` · `X_INVARIANT_VIOLATED` · `X_TENANCY_UNSCOPED` · `X_DB_DRIFT` ·
62
+ `X_NOT_FOUND`
63
+
64
+ ## Boundaries
65
+
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.
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "@ultimat3/entity",
3
+ "version": "0.0.1",
4
+ "description": "A table + its domain type + invariants the database also enforces",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/developerz-ai/ultimate.git",
10
+ "directory": "packages/entity"
11
+ },
12
+ "publishConfig": {
13
+ "access": "public",
14
+ "provenance": true
15
+ },
16
+ "exports": {
17
+ ".": "./src/index.ts"
18
+ },
19
+ "files": [
20
+ "src",
21
+ "!src/**/*.test.ts",
22
+ "README.md",
23
+ "LICENSE"
24
+ ],
25
+ "engines": {
26
+ "bun": ">=1.3.0"
27
+ },
28
+ "scripts": {
29
+ "typecheck": "tsc --noEmit -p tsconfig.json",
30
+ "test": "bun test"
31
+ },
32
+ "dependencies": {
33
+ "@ultimat3/core": "^0.0.1",
34
+ "@ultimat3/schema": "^0.0.1"
35
+ }
36
+ }
package/src/columns.ts ADDED
@@ -0,0 +1,255 @@
1
+ // The blessed column helpers. There is exactly one way to store an id, a timestamp,
2
+ // money, a locale and a time zone — the alternatives (float money, naive timestamps,
3
+ // a single implied currency) are the bugs this file exists to make unreachable.
4
+ import { uuid } from '@ultimat3/core';
5
+ import { invariantViolated } from './errors';
6
+ import type { ColumnDef, ColumnMap, IndexDef, TableDef } from './types';
7
+
8
+ const snake = (value: string): string => value.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase();
9
+
10
+ const reject = (column: string, rule: string, detail: string): never => {
11
+ throw invariantViolated(column, rule, detail);
12
+ };
13
+
14
+ interface ColumnOptions {
15
+ readonly name?: string;
16
+ readonly comment?: string;
17
+ }
18
+
19
+ const base = <T>(
20
+ kind: ColumnDef<T>['kind'],
21
+ parse: (value: unknown) => T,
22
+ overrides: Partial<ColumnDef<T>> = {},
23
+ ): ColumnDef<T> => ({
24
+ // '' means "derive from the property key in table()", so a column is declared once.
25
+ name: '',
26
+ kind,
27
+ notNull: true,
28
+ primaryKey: false,
29
+ unique: false,
30
+ index: false,
31
+ parse,
32
+ ...overrides,
33
+ });
34
+
35
+ const asString =
36
+ (label: string) =>
37
+ (value: unknown): string => {
38
+ if (typeof value === 'string') return value;
39
+ return reject(label, 'type', `expected a string, got ${typeof value}`);
40
+ };
41
+
42
+ const asDate =
43
+ (label: string) =>
44
+ (value: unknown): Date => {
45
+ if (value instanceof Date) return value;
46
+ if (typeof value === 'string' || typeof value === 'number') {
47
+ const parsed = new Date(value);
48
+ if (!Number.isNaN(parsed.getTime())) return parsed;
49
+ }
50
+ return reject(label, 'type', `expected a Date or ISO-8601 string, got ${String(value)}`);
51
+ };
52
+
53
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
54
+
55
+ const asUuid =
56
+ (label: string) =>
57
+ (value: unknown): string => {
58
+ if (typeof value === 'string' && UUID.test(value)) return value;
59
+ return reject(label, 'format', `expected a uuid, got ${String(value)}`);
60
+ };
61
+
62
+ /** uuid v7: time-ordered, so a primary key index stays append-friendly. */
63
+ export const newId = (): string => uuid();
64
+
65
+ export const id = (options: ColumnOptions = {}): ColumnDef<string> =>
66
+ base<string>('uuid', asUuid('id'), {
67
+ ...options,
68
+ name: options.name ?? 'id',
69
+ primaryKey: true,
70
+ default: { kind: 'generated', by: 'uuid-v7' },
71
+ });
72
+
73
+ export const text = (options: ColumnOptions & { readonly check?: string } = {}) =>
74
+ base<string>('text', asString(options.name ?? 'text'), options);
75
+
76
+ const asBoolean =
77
+ (label: string) =>
78
+ (value: unknown): boolean =>
79
+ typeof value === 'boolean'
80
+ ? value
81
+ : reject(label, 'type', `expected a boolean, got ${typeof value}`);
82
+
83
+ const asInteger =
84
+ (label: string) =>
85
+ (value: unknown): number =>
86
+ typeof value === 'number' && Number.isSafeInteger(value)
87
+ ? value
88
+ : reject(label, 'type', `expected a safe integer, got ${String(value)}`);
89
+
90
+ const asCurrency =
91
+ (label: string) =>
92
+ (value: unknown): string =>
93
+ typeof value === 'string' && /^[A-Z]{3}$/.test(value)
94
+ ? value
95
+ : reject(label, 'iso-4217', `expected a 3-letter ISO-4217 code, got ${String(value)}`);
96
+
97
+ export const boolean = (options: ColumnOptions = {}): ColumnDef<boolean> =>
98
+ base<boolean>('boolean', asBoolean(options.name ?? 'boolean'), options);
99
+
100
+ export const integer = (
101
+ options: ColumnOptions & { readonly check?: string } = {},
102
+ ): ColumnDef<number> => base<number>('integer', asInteger(options.name ?? 'integer'), options);
103
+
104
+ /** UTC always. A `timestamp without time zone` column is not expressible here. */
105
+ export const timestamps = (): {
106
+ readonly createdAt: ColumnDef<Date>;
107
+ readonly updatedAt: ColumnDef<Date>;
108
+ } => ({
109
+ createdAt: base<Date>('timestamptz', asDate('createdAt'), {
110
+ name: 'created_at',
111
+ default: { kind: 'generated', by: 'now' },
112
+ index: true,
113
+ }),
114
+ updatedAt: base<Date>('timestamptz', asDate('updatedAt'), {
115
+ name: 'updated_at',
116
+ default: { kind: 'generated', by: 'now' },
117
+ }),
118
+ });
119
+
120
+ export type MoneyColumns<N extends string> = {
121
+ readonly [K in `${N}Minor`]: ColumnDef<bigint>;
122
+ } & {
123
+ readonly [K in `${N}Currency`]: ColumnDef<string>;
124
+ };
125
+
126
+ const asMinorUnits =
127
+ (label: string) =>
128
+ (value: unknown): bigint => {
129
+ if (typeof value === 'bigint') return value;
130
+ if (typeof value === 'number') {
131
+ if (!Number.isInteger(value)) {
132
+ return reject(
133
+ label,
134
+ 'money-minor-units',
135
+ `got the float ${value}; money is integer minor units — 12.34 EUR is 1234n, not 12.34`,
136
+ );
137
+ }
138
+ return BigInt(value);
139
+ }
140
+ if (typeof value === 'string' && /^-?\d+$/.test(value)) return BigInt(value);
141
+ return reject(label, 'money-minor-units', `expected integer minor units, got ${String(value)}`);
142
+ };
143
+
144
+ /**
145
+ * Two columns, always: minor units as bigint and an ISO-4217 code. A single-currency
146
+ * assumption is a migration nobody wants to write later, and a float is a rounding
147
+ * bug nobody wants to debug.
148
+ */
149
+ export const money = <N extends string>(name: N): MoneyColumns<N> =>
150
+ ({
151
+ [`${name}Minor`]: base<bigint>('bigint', asMinorUnits(`${name}Minor`), {
152
+ name: `${snake(name)}_minor`,
153
+ }),
154
+ [`${name}Currency`]: base<string>('char', asCurrency(`${name}Currency`), {
155
+ name: `${snake(name)}_currency`,
156
+ length: 3,
157
+ check: `${snake(name)}_currency ~ '^[A-Z]{3}$'`,
158
+ }),
159
+ }) as unknown as MoneyColumns<N>;
160
+
161
+ /** IANA identifier, validated by `Intl` at write time and by a CHECK in the database. */
162
+ export const tz = (options: ColumnOptions = {}): ColumnDef<string> => {
163
+ const label = options.name ?? 'tz';
164
+ return base<string>(
165
+ 'text',
166
+ (value) => {
167
+ if (typeof value === 'string') {
168
+ try {
169
+ new Intl.DateTimeFormat('en', { timeZone: value }).format(0);
170
+ return value;
171
+ } catch {
172
+ return reject(label, 'iana-tz', `${value} is not an IANA time zone`);
173
+ }
174
+ }
175
+ return reject(label, 'iana-tz', `expected an IANA time zone, got ${typeof value}`);
176
+ },
177
+ { ...options, check: `${snake(label)} ~ '^[A-Za-z0-9_+/-]{3,64}$'` },
178
+ );
179
+ };
180
+
181
+ export const locale = (options: ColumnOptions = {}): ColumnDef<string> => {
182
+ const label = options.name ?? 'locale';
183
+ return base<string>('text', asString(label), {
184
+ ...options,
185
+ check: `${snake(label)} ~ '^[a-z]{2,3}(-[A-Za-z0-9]{2,8})*$'`,
186
+ });
187
+ };
188
+
189
+ export const slug = (options: ColumnOptions = {}): ColumnDef<string> => {
190
+ const label = options.name ?? 'slug';
191
+ return base<string>('text', asString(label), {
192
+ ...options,
193
+ unique: true,
194
+ check: `${snake(label)} ~ '^[a-z0-9]+(-[a-z0-9]+)*$'`,
195
+ });
196
+ };
197
+
198
+ /** Takes its own parser: a jsonb column without a schema is an untyped hole. */
199
+ export const jsonb = <T>(parse: (value: unknown) => T, options: ColumnOptions = {}): ColumnDef<T> =>
200
+ base<T>('jsonb', parse, options);
201
+
202
+ /** Presence of this column is what makes an entity soft-deletable — not a flag. */
203
+ export const softDelete = (): { readonly deletedAt: ColumnDef<Date | null> } => ({
204
+ deletedAt: base<Date | null>(
205
+ 'timestamptz',
206
+ (value) => (value === null || value === undefined ? null : asDate('deletedAt')(value)),
207
+ { name: 'deleted_at', notNull: false, index: true },
208
+ ),
209
+ });
210
+
211
+ /** Presence of this column is what makes an entity tenant-scoped. See tenancy.ts. */
212
+ export const orgId = (options: ColumnOptions & { readonly table?: string } = {}) =>
213
+ base<string>('uuid', asUuid('orgId'), {
214
+ name: options.name ?? 'org_id',
215
+ index: true,
216
+ references: { table: options.table ?? 'orgs', column: 'id', onDelete: 'cascade' },
217
+ });
218
+
219
+ export const nullable = <T>(column: ColumnDef<T>): ColumnDef<T | null> => ({
220
+ ...column,
221
+ notNull: false,
222
+ parse: (value) => (value === null || value === undefined ? null : column.parse(value)),
223
+ });
224
+
225
+ export const references = <T>(column: ColumnDef<T>, target: string, targetColumn = 'id') => ({
226
+ ...column,
227
+ index: true,
228
+ references: { table: target, column: targetColumn },
229
+ });
230
+
231
+ /**
232
+ * Composes columns into a table, filling every column name that was left to the
233
+ * property key (`orgId` -> `org_id`) so a physical name is written at most once.
234
+ */
235
+ export const table = <C extends ColumnMap>(name: string, columns: C): TableDef<C> => {
236
+ const resolved: Record<string, ColumnDef<unknown>> = {};
237
+ const primaryKey: string[] = [];
238
+ const indexes: IndexDef[] = [];
239
+ for (const [property, column] of Object.entries(columns)) {
240
+ const physical = column.name === '' ? snake(property) : column.name;
241
+ resolved[property] = { ...column, name: physical };
242
+ if (column.primaryKey) primaryKey.push(physical);
243
+ if (column.unique) {
244
+ indexes.push({ name: `${name}_${physical}_key`, columns: [physical], unique: true });
245
+ } else if (column.index) {
246
+ indexes.push({ name: `${name}_${physical}_idx`, columns: [physical], unique: false });
247
+ }
248
+ }
249
+ return {
250
+ name,
251
+ columns: resolved as unknown as C,
252
+ primaryKey: primaryKey.length > 0 ? primaryKey : ['id'],
253
+ indexes,
254
+ };
255
+ };
package/src/entity.ts ADDED
@@ -0,0 +1,120 @@
1
+ // `entity()` is the first primitive: a table, its domain type, and the invariants
2
+ // that hold for every row. Everything downstream (repo, admin UI, cache tags, the
3
+ // manifest) is derived from this one declaration.
4
+ import type { StandardSchemaV1 } from '@ultimat3/schema';
5
+ import { invariantViolated } from './errors';
6
+ import { assertInvariants, type Invariant, invariantsToSql } from './invariants';
7
+ import { type EntityDescription, registerEntity } from './registry';
8
+ import { isOrgScoped } from './tenancy';
9
+ import type { ColumnMap, TableDef } from './types';
10
+
11
+ export type EntitySchema<T> = StandardSchemaV1<unknown, T>;
12
+
13
+ export interface EntityInit<T, C extends ColumnMap> {
14
+ /** Defaults to the table name. Must be unique across the app. */
15
+ readonly name?: string;
16
+ readonly table: TableDef<C>;
17
+ readonly type: EntitySchema<T>;
18
+ readonly invariants?: readonly Invariant<T>[];
19
+ /** Extra cache tags this entity participates in, beyond its own. */
20
+ readonly tags?: readonly string[];
21
+ /** Defaults to the presence of a `deletedAt` column. */
22
+ readonly softDelete?: boolean;
23
+ }
24
+
25
+ export interface Entity<T, C extends ColumnMap = ColumnMap> {
26
+ readonly name: string;
27
+ readonly table: TableDef<C>;
28
+ readonly type: EntitySchema<T>;
29
+ readonly invariants: readonly Invariant<T>[];
30
+ readonly tags: readonly string[];
31
+ /** `entity:<name>`. `@ultimat3/cache` invalidates by this string. */
32
+ readonly cacheTag: string;
33
+ readonly softDelete: boolean;
34
+ readonly orgScoped: boolean;
35
+ /** `entity:<name>:<id>` — row-level invalidation for live queries. */
36
+ tagFor(id: string): string;
37
+ /** Validates an unknown value into the domain type. Throws on failure. */
38
+ parse(value: unknown): T;
39
+ /** Runs every invariant. Called by the repository on insert and update. */
40
+ assert(row: T): void;
41
+ /** The CHECK/UNIQUE statements the migration emits for this entity. */
42
+ migration(): string;
43
+ describe(): EntityDescription;
44
+ }
45
+
46
+ interface LooseResult<T> {
47
+ readonly value?: T;
48
+ readonly issues?: readonly { readonly message: string }[] | undefined;
49
+ }
50
+
51
+ const parseWith = <T>(schema: EntitySchema<T>, name: string, value: unknown): T => {
52
+ const result = schema['~standard'].validate(value);
53
+ if (result instanceof Promise) {
54
+ throw invariantViolated(name, 'schema', 'entity types must validate synchronously');
55
+ }
56
+ const outcome = result as LooseResult<T>;
57
+ if (outcome.issues !== undefined && outcome.issues.length > 0) {
58
+ throw invariantViolated(name, 'schema', outcome.issues.map((i) => i.message).join('; '));
59
+ }
60
+ return outcome.value as T;
61
+ };
62
+
63
+ export const entity = <T, C extends ColumnMap>(init: EntityInit<T, C>): Entity<T, C> => {
64
+ const name = init.name ?? init.table.name;
65
+ const invariants = init.invariants ?? [];
66
+ const softDelete = init.softDelete ?? Object.hasOwn(init.table.columns, 'deletedAt');
67
+ const orgScoped = isOrgScoped(init.table);
68
+ const cacheTag = `entity:${name}`;
69
+
70
+ const describe = (): EntityDescription => ({
71
+ name,
72
+ table: init.table.name,
73
+ primaryKey: init.table.primaryKey,
74
+ columns: Object.entries(init.table.columns).map(([property, column]) => ({
75
+ property,
76
+ column: column.name,
77
+ kind: column.kind,
78
+ notNull: column.notNull,
79
+ primaryKey: column.primaryKey,
80
+ unique: column.unique,
81
+ hasDefault: column.default !== undefined,
82
+ check: column.check ?? null,
83
+ references:
84
+ column.references === undefined
85
+ ? null
86
+ : `${column.references.table}.${column.references.column}`,
87
+ })),
88
+ invariants: invariants.map((inv) => ({
89
+ name: inv.name,
90
+ kind: inv.kind,
91
+ message: inv.message,
92
+ sql: inv.sql,
93
+ where: inv.where ?? null,
94
+ })),
95
+ indexes: init.table.indexes.map((index) => index.name),
96
+ tags: [cacheTag, ...(init.tags ?? [])],
97
+ cacheTag,
98
+ softDelete,
99
+ orgScoped,
100
+ });
101
+
102
+ const built: Entity<T, C> = {
103
+ name,
104
+ table: init.table,
105
+ type: init.type,
106
+ invariants,
107
+ tags: [cacheTag, ...(init.tags ?? [])],
108
+ cacheTag,
109
+ softDelete,
110
+ orgScoped,
111
+ tagFor: (id) => `${cacheTag}:${id}`,
112
+ parse: (value) => parseWith(init.type, name, value),
113
+ assert: (row) => assertInvariants(name, invariants, row),
114
+ migration: () => invariantsToSql(init.table.name, invariants),
115
+ describe,
116
+ };
117
+
118
+ registerEntity({ name, tableName: init.table.name, describe });
119
+ return built;
120
+ };
package/src/errors.ts ADDED
@@ -0,0 +1,73 @@
1
+ // The entity layer's stable error codes. Each factory produces the exact command
2
+ // that fixes the situation — `X_DB_DRIFT` is the flagship: it names the table, the
3
+ // column and the generator invocation.
4
+ import { UltimateError } from '@ultimat3/core';
5
+
6
+ export const ENTITY_ERROR_CODES = [
7
+ 'X_ENTITY_DUPLICATE',
8
+ 'X_INVARIANT_VIOLATED',
9
+ 'X_TENANCY_UNSCOPED',
10
+ 'X_DB_DRIFT',
11
+ 'X_NOT_FOUND',
12
+ ] as const;
13
+
14
+ export type EntityErrorCode = (typeof ENTITY_ERROR_CODES)[number];
15
+
16
+ export const ENTITY_ERROR_TITLES: Readonly<Record<EntityErrorCode, string>> = {
17
+ X_ENTITY_DUPLICATE: 'two entities claim the same name',
18
+ X_INVARIANT_VIOLATED: 'a domain invariant rejected this row',
19
+ X_TENANCY_UNSCOPED: 'a tenant-scoped query has no org predicate',
20
+ X_DB_DRIFT: 'schema differs from migrations',
21
+ X_NOT_FOUND: 'no row for that id',
22
+ };
23
+
24
+ export class EntityError extends UltimateError {
25
+ constructor(init: { code: EntityErrorCode; cause: string; fix: string }) {
26
+ super({
27
+ code: init.code,
28
+ cause: init.cause,
29
+ fix: init.fix,
30
+ docs: `https://ultimate.dev/errors/${init.code}`,
31
+ });
32
+ this.name = 'EntityError';
33
+ }
34
+ }
35
+
36
+ export const entityDuplicate = (name: string, existingTable: string): EntityError =>
37
+ new EntityError({
38
+ code: 'X_ENTITY_DUPLICATE',
39
+ cause: `entity "${name}" is already registered for table "${existingTable}"`,
40
+ fix: `x entities list --json # then rename one of the two entity({ name }) declarations`,
41
+ });
42
+
43
+ export const invariantViolated = (
44
+ entityName: string,
45
+ invariantName: string,
46
+ message: string,
47
+ ): EntityError =>
48
+ new EntityError({
49
+ code: 'X_INVARIANT_VIOLATED',
50
+ cause: `${entityName}.${invariantName}: ${message}`,
51
+ fix: `x entity explain ${entityName} --json # shows the invariant and its SQL CHECK`,
52
+ });
53
+
54
+ export const tenancyUnscoped = (entityName: string, operation: string): EntityError =>
55
+ new EntityError({
56
+ code: 'X_TENANCY_UNSCOPED',
57
+ cause: `${entityName}.${operation}() was built without an org predicate but the entity has an orgId column`,
58
+ fix: `pass { orgId } to ${entityName}.${operation}(), or wrap the plan with orgScoped(entity, orgId, plan)`,
59
+ });
60
+
61
+ export const dbDrift = (tableName: string, columnName: string): EntityError =>
62
+ new EntityError({
63
+ code: 'X_DB_DRIFT',
64
+ cause: `table "${tableName}" has column "${columnName}" not present in any migration`,
65
+ fix: `x db gen "add ${columnName}"`,
66
+ });
67
+
68
+ export const notFound = (entityName: string, id: string): EntityError =>
69
+ new EntityError({
70
+ code: 'X_NOT_FOUND',
71
+ cause: `${entityName} ${id} does not exist (or is soft-deleted)`,
72
+ fix: `x db query "select id from ${entityName} limit 5" --json # confirm the id you expect`,
73
+ });
package/src/index.ts ADDED
@@ -0,0 +1,79 @@
1
+ // The public surface of @ultimat3/entity. Explicit, never `export *`.
2
+
3
+ export type { MoneyColumns } from './columns';
4
+ export {
5
+ boolean,
6
+ id,
7
+ integer,
8
+ jsonb,
9
+ locale,
10
+ money,
11
+ newId,
12
+ nullable,
13
+ orgId,
14
+ references,
15
+ slug,
16
+ softDelete,
17
+ table,
18
+ text,
19
+ timestamps,
20
+ tz,
21
+ } from './columns';
22
+ export type { Entity, EntityInit, EntitySchema } from './entity';
23
+ export { entity } from './entity';
24
+ export type { EntityErrorCode } from './errors';
25
+ export {
26
+ dbDrift,
27
+ ENTITY_ERROR_CODES,
28
+ ENTITY_ERROR_TITLES,
29
+ EntityError,
30
+ entityDuplicate,
31
+ invariantViolated,
32
+ notFound,
33
+ tenancyUnscoped,
34
+ } from './errors';
35
+ export type { CheckInit, Invariant, InvariantKind, UniqueInit } from './invariants';
36
+ export {
37
+ assertInvariants,
38
+ constraintName,
39
+ invariant,
40
+ invariantsToSql,
41
+ toSql,
42
+ unique,
43
+ } from './invariants';
44
+ export type {
45
+ ColumnDescription,
46
+ EntityDescription,
47
+ InvariantDescription,
48
+ RegistryEntry,
49
+ } from './registry';
50
+ export {
51
+ clearRegistry,
52
+ describeEntities,
53
+ entityNames,
54
+ getEntity,
55
+ registerEntity,
56
+ } from './registry';
57
+ export type { FindManyArgs, Page, Repo, RepoOptions, Transactor, Tx } from './repo';
58
+ export { decodeCursor, encodeCursor, memoryRepo, memoryTransactor } from './repo';
59
+ export type { Operator, Predicate, QueryPlan, SortDirection } from './tenancy';
60
+ export {
61
+ assertScoped,
62
+ describePlan,
63
+ emptyPlan,
64
+ hasOrgPredicate,
65
+ isOrgScoped,
66
+ ORG_COLUMN,
67
+ orgScoped,
68
+ } from './tenancy';
69
+ export type {
70
+ ColumnDef,
71
+ ColumnDefault,
72
+ ColumnKind,
73
+ ColumnMap,
74
+ IndexDef,
75
+ ReferenceDef,
76
+ RowOf,
77
+ TableDef,
78
+ } from './types';
79
+ export { columnNames, hasColumn } from './types';
@@ -0,0 +1,96 @@
1
+ // A domain invariant is written once and enforced twice: in the app on every write,
2
+ // and in Postgres as a CHECK or UNIQUE constraint emitted into the migration. The
3
+ // database can therefore never disagree with the code — a bulk import, a psql
4
+ // session or a second service all hit the same rule.
5
+ import { invariantViolated } from './errors';
6
+
7
+ export type InvariantKind = 'check' | 'unique';
8
+
9
+ export interface Invariant<T> {
10
+ /** Becomes the constraint name: `<table>_<name>_check`. Keep it snake_case. */
11
+ readonly name: string;
12
+ readonly kind: InvariantKind;
13
+ /** Safe to log and useful to an agent: says what was expected, not what leaked. */
14
+ readonly message: string;
15
+ /** SQL predicate for `check`, or the column list for `unique`. */
16
+ readonly sql: string;
17
+ readonly columns: readonly string[];
18
+ /** Partial-constraint predicate, e.g. `deleted_at is null`. */
19
+ readonly where?: string;
20
+ readonly holds: (row: T) => boolean;
21
+ }
22
+
23
+ export interface CheckInit<T> {
24
+ readonly message: string;
25
+ /** Postgres predicate over physical column names. */
26
+ readonly sql: string;
27
+ readonly holds: (row: T) => boolean;
28
+ readonly columns?: readonly string[];
29
+ readonly where?: string;
30
+ }
31
+
32
+ /** `invariant('price_positive', { sql: 'price_minor > 0', holds: (p) => p.priceMinor > 0n })` */
33
+ export const invariant = <T>(name: string, init: CheckInit<T>): Invariant<T> => ({
34
+ name,
35
+ kind: 'check',
36
+ message: init.message,
37
+ sql: init.sql,
38
+ columns: init.columns ?? [],
39
+ ...(init.where === undefined ? {} : { where: init.where }),
40
+ holds: init.holds,
41
+ });
42
+
43
+ export interface UniqueInit<T> {
44
+ readonly message: string;
45
+ readonly columns: readonly string[];
46
+ /** In-app duplicate detection needs the store, so the app check defaults to true. */
47
+ readonly holds?: (row: T) => boolean;
48
+ readonly where?: string;
49
+ }
50
+
51
+ /**
52
+ * Uniqueness cannot be decided from a single row, so the app-side check is a no-op
53
+ * by default and the database is the authority. The unique index is still declared
54
+ * here so it lives next to the rule it implements.
55
+ */
56
+ export const unique = <T>(name: string, init: UniqueInit<T>): Invariant<T> => ({
57
+ name,
58
+ kind: 'unique',
59
+ message: init.message,
60
+ sql: init.columns.join(', '),
61
+ columns: init.columns,
62
+ ...(init.where === undefined ? {} : { where: init.where }),
63
+ holds: init.holds ?? (() => true),
64
+ });
65
+
66
+ export const constraintName = (
67
+ table: string,
68
+ inv: { readonly name: string; readonly kind: InvariantKind },
69
+ ): string => `${table}_${inv.name}_${inv.kind === 'check' ? 'check' : 'key'}`;
70
+
71
+ /** The DDL the migration emits. One statement, terminated, ready to diff. */
72
+ export const toSql = <T>(table: string, inv: Invariant<T>): string => {
73
+ const name = constraintName(table, inv);
74
+ if (inv.kind === 'check') {
75
+ return `ALTER TABLE "${table}" ADD CONSTRAINT "${name}" CHECK (${inv.sql});`;
76
+ }
77
+ const where = inv.where === undefined ? '' : ` WHERE ${inv.where}`;
78
+ const columns = inv.columns.map((column) => `"${column}"`).join(', ');
79
+ return `CREATE UNIQUE INDEX "${name}" ON "${table}" (${columns})${where};`;
80
+ };
81
+
82
+ export const invariantsToSql = <T>(table: string, invariants: readonly Invariant<T>[]): string =>
83
+ invariants.map((inv) => toSql(table, inv)).join('\n');
84
+
85
+ /** Runs on every write. Reports every violation at once so one round trip fixes all. */
86
+ export const assertInvariants = <T>(
87
+ entityName: string,
88
+ invariants: readonly Invariant<T>[],
89
+ row: T,
90
+ ): void => {
91
+ const failed = invariants.filter((inv) => !inv.holds(row));
92
+ if (failed.length === 0) return;
93
+ const first = failed[0];
94
+ if (first === undefined) return;
95
+ throw invariantViolated(entityName, first.name, failed.map((inv) => inv.message).join('; '));
96
+ };
@@ -0,0 +1,70 @@
1
+ // The entity registry. Every `entity()` call registers here, which is what makes
2
+ // `x.manifest.json`, the admin dashboard generator and the migration emitter able to
3
+ // see the whole domain without importing it — and what makes a duplicate name a
4
+ // build error rather than a silent last-one-wins.
5
+ import { entityDuplicate } from './errors';
6
+
7
+ export interface ColumnDescription {
8
+ readonly property: string;
9
+ readonly column: string;
10
+ readonly kind: string;
11
+ readonly notNull: boolean;
12
+ readonly primaryKey: boolean;
13
+ readonly unique: boolean;
14
+ readonly hasDefault: boolean;
15
+ readonly check: string | null;
16
+ readonly references: string | null;
17
+ }
18
+
19
+ export interface InvariantDescription {
20
+ readonly name: string;
21
+ readonly kind: 'check' | 'unique';
22
+ readonly message: string;
23
+ readonly sql: string;
24
+ readonly where: string | null;
25
+ }
26
+
27
+ export interface EntityDescription {
28
+ readonly name: string;
29
+ readonly table: string;
30
+ readonly primaryKey: readonly string[];
31
+ readonly columns: readonly ColumnDescription[];
32
+ readonly invariants: readonly InvariantDescription[];
33
+ readonly indexes: readonly string[];
34
+ readonly tags: readonly string[];
35
+ readonly cacheTag: string;
36
+ readonly softDelete: boolean;
37
+ readonly orgScoped: boolean;
38
+ }
39
+
40
+ export interface RegistryEntry {
41
+ readonly name: string;
42
+ readonly tableName: string;
43
+ describe(): EntityDescription;
44
+ }
45
+
46
+ const entities = new Map<string, RegistryEntry>();
47
+
48
+ export const registerEntity = <E extends RegistryEntry>(entry: E): E => {
49
+ const existing = entities.get(entry.name);
50
+ if (existing !== undefined && existing !== entry) {
51
+ throw entityDuplicate(entry.name, existing.tableName);
52
+ }
53
+ entities.set(entry.name, entry);
54
+ return entry;
55
+ };
56
+
57
+ export const getEntity = (name: string): RegistryEntry | undefined => entities.get(name);
58
+
59
+ export const entityNames = (): readonly string[] => [...entities.keys()].sort();
60
+
61
+ /** Deterministic order: the manifest is a build artefact and must diff cleanly. */
62
+ export const describeEntities = (): readonly EntityDescription[] =>
63
+ entityNames().map((name) => {
64
+ const entry = entities.get(name);
65
+ if (entry === undefined) throw entityDuplicate(name, 'unknown');
66
+ return entry.describe();
67
+ });
68
+
69
+ /** Test seam. Production code never unregisters an entity. */
70
+ export const clearRegistry = (): void => entities.clear();
package/src/repo.ts ADDED
Binary file
package/src/tenancy.ts ADDED
@@ -0,0 +1,86 @@
1
+ // Multi-tenancy is a guard, not a convention. An entity with an `orgId` column can
2
+ // only be queried through a plan that carries an org predicate; building one without
3
+ // it throws `X_TENANCY_UNSCOPED` at the seam instead of leaking another tenant's rows.
4
+ import { tenancyUnscoped } from './errors';
5
+ import type { TableDef } from './types';
6
+
7
+ export type Operator =
8
+ | 'eq'
9
+ | 'neq'
10
+ | 'in'
11
+ | 'gt'
12
+ | 'gte'
13
+ | 'lt'
14
+ | 'lte'
15
+ | 'like'
16
+ | 'is-null'
17
+ | 'is-not-null';
18
+
19
+ export interface Predicate {
20
+ readonly column: string;
21
+ readonly op: Operator;
22
+ readonly value?: unknown;
23
+ }
24
+
25
+ export type SortDirection = 'asc' | 'desc';
26
+
27
+ export interface QueryPlan {
28
+ readonly entity: string;
29
+ readonly where: readonly Predicate[];
30
+ readonly orderBy: readonly { readonly column: string; readonly direction: SortDirection }[];
31
+ readonly limit: number;
32
+ readonly cursor?: string;
33
+ }
34
+
35
+ export const ORG_COLUMN = 'orgId';
36
+
37
+ /** True when the table declares an `orgId` column — presence is the switch. */
38
+ export const isOrgScoped = (table: TableDef): boolean => Object.hasOwn(table.columns, ORG_COLUMN);
39
+
40
+ export const emptyPlan = (entity: string, limit = 50): QueryPlan => ({
41
+ entity,
42
+ where: [],
43
+ orderBy: [],
44
+ limit,
45
+ });
46
+
47
+ export const hasOrgPredicate = (plan: QueryPlan): boolean =>
48
+ plan.where.some((predicate) => predicate.column === ORG_COLUMN);
49
+
50
+ /** Adds the org predicate exactly once; calling it twice is not an error. */
51
+ export const orgScoped = (plan: QueryPlan, orgId: string): QueryPlan =>
52
+ hasOrgPredicate(plan)
53
+ ? plan
54
+ : { ...plan, where: [...plan.where, { column: ORG_COLUMN, op: 'eq', value: orgId }] };
55
+
56
+ /**
57
+ * Called by every repository operation. Runtime here, and a build-time check in
58
+ * `x verify` that no query for a tenant-scoped entity is constructed without it.
59
+ */
60
+ export const assertScoped = (
61
+ entityName: string,
62
+ table: TableDef,
63
+ operation: string,
64
+ plan: QueryPlan,
65
+ ): void => {
66
+ if (!isOrgScoped(table)) return;
67
+ if (hasOrgPredicate(plan)) return;
68
+ throw tenancyUnscoped(entityName, operation);
69
+ };
70
+
71
+ /** Debug/`x db explain` rendering. Values stay out: a plan is safe to log. */
72
+ export const describePlan = (plan: QueryPlan): string => {
73
+ const where = plan.where
74
+ .map((predicate) => `${predicate.column} ${predicate.op} ?`)
75
+ .join(' and ');
76
+ const order = plan.orderBy.map((entry) => `${entry.column} ${entry.direction}`).join(', ');
77
+ return [
78
+ `from ${plan.entity}`,
79
+ where === '' ? null : `where ${where}`,
80
+ order === '' ? null : `order by ${order}`,
81
+ `limit ${plan.limit}`,
82
+ plan.cursor === undefined ? null : 'after cursor',
83
+ ]
84
+ .filter((part): part is string => part !== null)
85
+ .join(' ');
86
+ };
package/src/types.ts ADDED
@@ -0,0 +1,77 @@
1
+ // The narrow structural types this package consumes. Drizzle is the production
2
+ // backing for `TableDef`/`ColumnDef` (see README), but declaring the shape we use
3
+ // instead of depending on the ORM keeps the generated SQL readable and keeps this
4
+ // package free of a dependency an agent would have to learn to read.
5
+
6
+ /** Postgres types the blessed column helpers emit. No `float` for money, ever. */
7
+ export type ColumnKind =
8
+ | 'uuid'
9
+ | 'text'
10
+ | 'char'
11
+ | 'boolean'
12
+ | 'integer'
13
+ | 'bigint'
14
+ | 'numeric'
15
+ | 'timestamptz'
16
+ | 'date'
17
+ | 'jsonb';
18
+
19
+ export type ColumnDefault =
20
+ | { readonly kind: 'sql'; readonly expression: string }
21
+ | { readonly kind: 'value'; readonly value: string | number | boolean | null }
22
+ | { readonly kind: 'generated'; readonly by: 'uuid-v7' | 'now' };
23
+
24
+ export interface ReferenceDef {
25
+ readonly table: string;
26
+ readonly column: string;
27
+ readonly onDelete?: 'cascade' | 'restrict' | 'set null';
28
+ }
29
+
30
+ export interface ColumnDef<T> {
31
+ /** snake_case physical name; the property key is the camelCase domain name. */
32
+ readonly name: string;
33
+ readonly kind: ColumnKind;
34
+ readonly notNull: boolean;
35
+ readonly primaryKey: boolean;
36
+ readonly unique: boolean;
37
+ readonly length?: number;
38
+ readonly default?: ColumnDefault;
39
+ /** SQL expression emitted as a CHECK next to the column. */
40
+ readonly check?: string;
41
+ readonly references?: ReferenceDef;
42
+ readonly index: boolean;
43
+ readonly comment?: string;
44
+ /**
45
+ * Runtime guard AND the carrier of the column's TypeScript type. Every write goes
46
+ * through it, which is how `money()` can refuse a float instead of rounding it.
47
+ */
48
+ readonly parse: (value: unknown) => T;
49
+ }
50
+
51
+ export type ColumnMap = Readonly<Record<string, ColumnDef<unknown>>>;
52
+
53
+ export interface IndexDef {
54
+ readonly name: string;
55
+ readonly columns: readonly string[];
56
+ readonly unique: boolean;
57
+ /** Partial index predicate — soft-deleted rows are excluded with this. */
58
+ readonly where?: string;
59
+ }
60
+
61
+ export interface TableDef<C extends ColumnMap = ColumnMap> {
62
+ readonly name: string;
63
+ readonly columns: C;
64
+ readonly primaryKey: readonly string[];
65
+ readonly indexes: readonly IndexDef[];
66
+ }
67
+
68
+ /** The row type a table describes, derived from its columns' parse signatures. */
69
+ export type RowOf<C extends ColumnMap> = {
70
+ readonly [K in keyof C]: C[K] extends ColumnDef<infer T> ? T : never;
71
+ };
72
+
73
+ export const columnNames = (table: TableDef): readonly string[] =>
74
+ Object.values(table.columns).map((column) => column.name);
75
+
76
+ export const hasColumn = (table: TableDef, property: string): boolean =>
77
+ Object.hasOwn(table.columns, property);