@ultimat3/entity 0.0.1 → 1.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.
Files changed (84) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +126 -40
  3. package/package.json +4 -3
  4. package/src/column.d.ts +28 -0
  5. package/src/column.d.ts.map +1 -0
  6. package/src/column.js +75 -0
  7. package/src/column.js.map +1 -0
  8. package/src/column.ts +134 -0
  9. package/src/columns.d.ts +39 -0
  10. package/src/columns.d.ts.map +1 -0
  11. package/src/columns.js +136 -0
  12. package/src/columns.js.map +1 -0
  13. package/src/columns.ts +164 -217
  14. package/src/cursor.ts +187 -0
  15. package/src/database.d.ts +21 -0
  16. package/src/database.d.ts.map +1 -0
  17. package/src/database.js +38 -0
  18. package/src/database.js.map +1 -0
  19. package/src/database.ts +62 -0
  20. package/src/describe.d.ts +16 -0
  21. package/src/describe.d.ts.map +1 -0
  22. package/src/describe.js +79 -0
  23. package/src/describe.js.map +1 -0
  24. package/src/describe.ts +106 -0
  25. package/src/entity.d.ts +58 -0
  26. package/src/entity.d.ts.map +1 -0
  27. package/src/entity.js +160 -0
  28. package/src/entity.js.map +1 -0
  29. package/src/entity.ts +246 -99
  30. package/src/errors.d.ts +18 -0
  31. package/src/errors.d.ts.map +1 -0
  32. package/src/errors.js +59 -0
  33. package/src/errors.js.map +1 -0
  34. package/src/errors.ts +27 -6
  35. package/src/expr.d.ts +41 -0
  36. package/src/expr.d.ts.map +1 -0
  37. package/src/expr.js +94 -0
  38. package/src/expr.js.map +1 -0
  39. package/src/expr.ts +231 -0
  40. package/src/index.d.ts +23 -0
  41. package/src/index.d.ts.map +1 -0
  42. package/src/index.js +12 -0
  43. package/src/index.js.map +1 -0
  44. package/src/index.ts +36 -20
  45. package/src/invariants.d.ts +36 -0
  46. package/src/invariants.d.ts.map +1 -0
  47. package/src/invariants.js +53 -0
  48. package/src/invariants.js.map +1 -0
  49. package/src/invariants.ts +53 -49
  50. package/src/pg-driver.ts +154 -0
  51. package/src/pg-row.ts +110 -0
  52. package/src/pg-sql.ts +162 -0
  53. package/src/plan.ts +82 -0
  54. package/src/query.d.ts +30 -0
  55. package/src/query.d.ts.map +1 -0
  56. package/src/query.js +74 -0
  57. package/src/query.js.map +1 -0
  58. package/src/query.ts +144 -0
  59. package/src/registry.d.ts +45 -0
  60. package/src/registry.d.ts.map +1 -0
  61. package/src/registry.js +26 -0
  62. package/src/registry.js.map +1 -0
  63. package/src/registry.ts +8 -5
  64. package/src/repo.d.ts +54 -0
  65. package/src/repo.d.ts.map +1 -0
  66. package/src/repo.js +203 -0
  67. package/src/repo.js.map +1 -0
  68. package/src/repo.ts +0 -0
  69. package/src/seed.d.ts +20 -0
  70. package/src/seed.d.ts.map +1 -0
  71. package/src/seed.js +43 -0
  72. package/src/seed.js.map +1 -0
  73. package/src/seed.ts +69 -0
  74. package/src/tenancy.d.ts +41 -0
  75. package/src/tenancy.d.ts.map +1 -0
  76. package/src/tenancy.js +57 -0
  77. package/src/tenancy.js.map +1 -0
  78. package/src/tenancy.ts +68 -19
  79. package/src/types.d.ts +99 -0
  80. package/src/types.d.ts.map +1 -0
  81. package/src/types.js +8 -0
  82. package/src/types.js.map +1 -0
  83. package/src/types.ts +94 -44
  84. package/src/view.ts +97 -0
package/src/cursor.ts ADDED
@@ -0,0 +1,187 @@
1
+ // Single responsibility: what an entity cursor *means*. The codec is `@ultimat3/core`'s — this
2
+ // file decides what a page position is bound to (the plan that produced it) and how a sort value
3
+ // survives the round trip (the column's kind).
4
+ //
5
+ // Both drivers call `cursorFor` and `seekFrom` and nothing else, so a rule added to one is added
6
+ // to both. The cursor carries the sort VALUES, not just an id: seeking by id alone needs the row
7
+ // to still exist, and a row deleted between two pages would silently restart pagination.
8
+
9
+ import { CursorInvalidError, decodeCursor, encodeCursor } from '@ultimat3/core';
10
+ import type { EntityCore } from './entity';
11
+ import { invariantViolated } from './errors';
12
+ import type { QueryPlan } from './tenancy';
13
+ import type { AnyColumn, ColumnKind } from './types';
14
+
15
+ const MONEY_PARTS: Readonly<Record<string, ColumnKind>> = { minor: 'bigint', currency: 'char' };
16
+
17
+ /** Resolves `price.minor` as well as `title`; money is the one property with two parts. */
18
+ const partsOf = (path: string): { readonly property: string; readonly part?: string } => {
19
+ const [property = path, part] = path.split('.');
20
+ return part === undefined ? { property } : { property, part };
21
+ };
22
+
23
+ const columnAt = <Row>(entity: EntityCore<Row>, path: string): AnyColumn => {
24
+ const column = entity.$columns[partsOf(path).property];
25
+ if (column === undefined) {
26
+ throw invariantViolated(entity.$name, 'orderBy', `no column "${path}"`);
27
+ }
28
+ return column;
29
+ };
30
+
31
+ /** The physical type a sort key holds — what tells `revive` how to read its string back. */
32
+ const kindAt = <Row>(entity: EntityCore<Row>, path: string): ColumnKind => {
33
+ const { part } = partsOf(path);
34
+ const kind = columnAt(entity, path).$meta.kind;
35
+ if (part === undefined) {
36
+ // Money is two physical columns, so the property alone names no single sort value: the
37
+ // cursor would carry `String({ minor, currency })` and the next page would fail parsing it
38
+ // as a bare `SyntaxError` from `BigInt`, with no code and no fix. `entity()` refuses the
39
+ // same path in `resolve()`; refusing it here keeps one answer for one mistake.
40
+ if (kind !== 'money') return kind;
41
+ throw invariantViolated(
42
+ entity.$name,
43
+ 'orderBy',
44
+ `${path} is money: order by ${path}.minor or ${path}.currency`,
45
+ );
46
+ }
47
+ const money = kind === 'money' ? MONEY_PARTS[part] : undefined;
48
+ if (money === undefined) {
49
+ throw invariantViolated(entity.$name, 'orderBy', `${path} names no column part`);
50
+ }
51
+ return money;
52
+ };
53
+
54
+ export const valueAt = (row: unknown, path: string): unknown => {
55
+ const { property, part } = partsOf(path);
56
+ const record = typeof row === 'object' && row !== null ? (row as Record<string, unknown>) : {};
57
+ const base = record[property];
58
+ if (part === undefined) return base;
59
+ return typeof base === 'object' && base !== null
60
+ ? (base as Record<string, unknown>)[part]
61
+ : undefined;
62
+ };
63
+
64
+ /** Stringified so the cursor is JSON; `revive` restores the type from the column's kind. */
65
+ const serializeSortValue = (value: unknown): string => {
66
+ if (value instanceof Date) return value.toISOString();
67
+ if (typeof value === 'bigint') return value.toString();
68
+ return String(value);
69
+ };
70
+
71
+ // No `money` case: `kindAt` resolves a money sort key to the kind of the part being ordered by
72
+ // (`minor` is bigint, `currency` is char) and refuses the bare property, so the composite kind
73
+ // never reaches here. A case for it could only ever revive "[object Object]".
74
+ const reviveSortValue = (kind: ColumnKind, text: string): unknown => {
75
+ switch (kind) {
76
+ case 'timestamptz':
77
+ return new Date(text);
78
+ case 'bigint':
79
+ return BigInt(text);
80
+ case 'integer':
81
+ return Number(text);
82
+ case 'boolean':
83
+ return text === 'true';
84
+ default:
85
+ return text;
86
+ }
87
+ };
88
+
89
+ /**
90
+ * A keyset seek only has a total order when every sort column is present on every row —
91
+ * `null > 'x'` is unknown in SQL and would drop rows from the middle of a listing.
92
+ *
93
+ * Checked when a cursor is minted as well as when one is decoded: an ordering that cannot carry
94
+ * a position is the author's mistake, and reporting it on the *second* page hides it behind
95
+ * whatever page size the caller happened to use.
96
+ */
97
+ export const assertSeekable = <Row>(
98
+ entity: EntityCore<Row>,
99
+ orderBy: readonly { readonly column: string }[],
100
+ ): void => {
101
+ for (const key of orderBy) {
102
+ // Resolving the kind is the other half: it refuses a column the entity never declared and a
103
+ // money property named without its part — both mint a cursor nothing can decode.
104
+ kindAt(entity, key.column);
105
+ if (columnAt(entity, key.column).$meta.notNull) continue;
106
+ throw invariantViolated(
107
+ entity.$name,
108
+ 'cursor',
109
+ `${key.column} is nullable and cannot carry a cursor — order by a not-null column ` +
110
+ `(add .orderBy('${entity.$primaryKey[0] ?? 'id'}') or make ${key.column} not null)`,
111
+ );
112
+ }
113
+ };
114
+
115
+ /** Deterministic, and total over the value shapes a predicate can hold. */
116
+ const renderValue = (value: unknown): string => {
117
+ if (value === null || value === undefined) return 'null';
118
+ if (value instanceof Date) return value.toISOString();
119
+ if (typeof value === 'bigint') return `${value}n`;
120
+ if (Array.isArray(value)) return `[${value.map(renderValue).join(',')}]`;
121
+ if (typeof value === 'object') {
122
+ const record = value as Readonly<Record<string, unknown>>;
123
+ const keys = Object.keys(record).sort();
124
+ return `{${keys.map((key) => `${key}:${renderValue(record[key])}`).join(',')}}`;
125
+ }
126
+ return JSON.stringify(String(value));
127
+ };
128
+
129
+ /**
130
+ * What a cursor is bound to: this entity, these filters, this sort order. Not the page size — a
131
+ * client may legitimately ask for a bigger next page — and not the projection, which cannot move
132
+ * a row's position. Filters are sorted because `and` is commutative, so two chains that build the
133
+ * same predicate set page each other's cursors.
134
+ *
135
+ * Hashed rather than spelled out: a cursor is base64, not encrypted, and the caller's filter
136
+ * values are not the client's to read.
137
+ */
138
+ export const planScope = (plan: QueryPlan): string => {
139
+ const where = plan.where
140
+ .map((predicate) => `${predicate.column} ${predicate.op} ${renderValue(predicate.value)}`)
141
+ .sort()
142
+ .join('&');
143
+ const order = plan.orderBy.map((key) => `${key.column} ${key.direction}`).join(',');
144
+ return new Bun.CryptoHasher('sha256')
145
+ .update(`${plan.entity}|${where}|${order}`)
146
+ .digest('hex')
147
+ .slice(0, 16);
148
+ };
149
+
150
+ /** The cursor that continues this plan after `row`. Signed by core, scoped by the plan. */
151
+ export const cursorFor = <Row>(
152
+ entity: EntityCore<Row>,
153
+ plan: QueryPlan,
154
+ row: unknown,
155
+ id: string,
156
+ ): string => {
157
+ assertSeekable(entity, plan.orderBy);
158
+ return encodeCursor({
159
+ scope: planScope(plan),
160
+ key: plan.orderBy.map((entry) => serializeSortValue(valueAt(row, entry.column))),
161
+ id,
162
+ });
163
+ };
164
+
165
+ /**
166
+ * The keyset position a plan resumes from, revived to the types its columns hold — `undefined`
167
+ * when the plan has no cursor. A cursor that was tampered with, or taken from another entity,
168
+ * another filter or another sort order, is `X_CURSOR_INVALID` here rather than a silent page one.
169
+ */
170
+ export const seekFrom = <Row>(
171
+ entity: EntityCore<Row>,
172
+ plan: QueryPlan,
173
+ ): readonly unknown[] | undefined => {
174
+ if (plan.cursor === undefined) return undefined;
175
+ const { key } = decodeCursor(plan.cursor, planScope(plan));
176
+ assertSeekable(entity, plan.orderBy);
177
+ // Unreachable through the scope check, which already pins the sort order — kept because the
178
+ // alternative to a bad arity is `?? ''`, and that seeks from an empty string.
179
+ if (key.length !== plan.orderBy.length) {
180
+ throw new CursorInvalidError(
181
+ `it carries ${key.length} sort values, this order needs ${plan.orderBy.length}`,
182
+ );
183
+ }
184
+ return plan.orderBy.map((entry, index) =>
185
+ reviveSortValue(kindAt(entity, entry.column), String(key[index])),
186
+ );
187
+ };
@@ -0,0 +1,21 @@
1
+ import type { EntityCore } from './entity';
2
+ import type { Table } from './query';
3
+ import type { Repo } from './repo';
4
+ export type EntitySet = Readonly<Record<string, EntityCore>>;
5
+ export type Database<E extends EntitySet> = {
6
+ readonly [K in keyof E]: Table<E[K]['$row'], E[K]['$columns']>;
7
+ };
8
+ /** Where rows actually live. Postgres in production, memory in tests and before migrations. */
9
+ export interface Driver {
10
+ repo<Row>(entity: EntityCore<Row>): Repo<Row>;
11
+ }
12
+ export interface DatabaseOptions {
13
+ readonly driver?: Driver;
14
+ }
15
+ /**
16
+ * The default driver: correct semantics, no database, so `x dev` and every test run before
17
+ * the first migration exists.
18
+ */
19
+ export declare const memoryDriver: () => Driver;
20
+ export declare const database: <E extends EntitySet>(entities: E, options?: DatabaseOptions) => Database<E>;
21
+ //# sourceMappingURL=database.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"database.d.ts","sourceRoot":"","sources":["database.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,UAAU,CAAC;AAC3C,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,SAAS,CAAC;AAErC,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,QAAQ,CAAC;AAGnC,MAAM,MAAM,SAAS,GAAG,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,UAAU,CAAC,CAAC,CAAC;AAE7D,MAAM,MAAM,QAAQ,CAAC,CAAC,SAAS,SAAS,IAAI;IAC1C,QAAQ,EAAE,CAAC,IAAI,MAAM,CAAC,GAAG,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,UAAU,CAAC,CAAC;CAC/D,CAAC;AAEF,+FAA+F;AAC/F,MAAM,WAAW,MAAM;IACrB,IAAI,CAAC,GAAG,EAAE,MAAM,EAAE,UAAU,CAAC,GAAG,CAAC,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC;CAC/C;AAED,MAAM,WAAW,eAAe;IAC9B,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;CAC1B;AAED;;;GAGG;AACH,eAAO,MAAM,YAAY,QAAO,MAa/B,CAAC;AASF,eAAO,MAAM,QAAQ,GAAI,CAAC,SAAS,SAAS,YAChC,CAAC,YACF,eAAe,KACvB,QAAQ,CAAC,CAAC,CAQZ,CAAC"}
@@ -0,0 +1,38 @@
1
+ // The one typed database handle. `db.posts` exists because `posts` was declared — nobody
2
+ // writes a repository class per entity, and nobody can reach a table that is not in the set.
3
+ import { tableFor } from './query';
4
+ import { memoryRepo } from './repo';
5
+ /**
6
+ * The default driver: correct semantics, no database, so `x dev` and every test run before
7
+ * the first migration exists.
8
+ */
9
+ export const memoryDriver = () => {
10
+ const repos = new Map();
11
+ return {
12
+ repo(entity) {
13
+ const existing = repos.get(entity.$name);
14
+ // Keyed by entity name and only ever written from that same entity, so the row type a
15
+ // caller asks for is the one that was stored.
16
+ if (existing !== undefined)
17
+ return existing;
18
+ const created = memoryRepo(entity);
19
+ repos.set(entity.$name, created);
20
+ return created;
21
+ },
22
+ };
23
+ };
24
+ let shared;
25
+ const defaultDriver = () => {
26
+ shared ??= memoryDriver();
27
+ return shared;
28
+ };
29
+ export const database = (entities, options = {}) => {
30
+ const driver = options.driver ?? defaultDriver();
31
+ const tables = {};
32
+ for (const [key, entity] of Object.entries(entities)) {
33
+ tables[key] = tableFor(entity, driver.repo(entity));
34
+ }
35
+ // Built key by key from `entities`, so each table is the one `Database<E>` names.
36
+ return tables;
37
+ };
38
+ //# sourceMappingURL=database.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"database.js","sourceRoot":"","sources":["database.ts"],"names":[],"mappings":"AAAA,yFAAyF;AACzF,6FAA6F;AAI7F,OAAO,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AAEnC,OAAO,EAAE,UAAU,EAAE,MAAM,QAAQ,CAAC;AAiBpC;;;GAGG;AACH,MAAM,CAAC,MAAM,YAAY,GAAG,GAAW,EAAE;IACvC,MAAM,KAAK,GAAG,IAAI,GAAG,EAAmB,CAAC;IACzC,OAAO;QACL,IAAI,CAAM,MAAuB;YAC/B,MAAM,QAAQ,GAAG,KAAK,CAAC,GAAG,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;YACzC,sFAAsF;YACtF,8CAA8C;YAC9C,IAAI,QAAQ,KAAK,SAAS;gBAAE,OAAO,QAAqB,CAAC;YACzD,MAAM,OAAO,GAAG,UAAU,CAAM,MAAM,CAAC,CAAC;YACxC,KAAK,CAAC,GAAG,CAAC,MAAM,CAAC,KAAK,EAAE,OAAO,CAAC,CAAC;YACjC,OAAO,OAAO,CAAC;QACjB,CAAC;KACF,CAAC;AACJ,CAAC,CAAC;AAEF,IAAI,MAA0B,CAAC;AAE/B,MAAM,aAAa,GAAG,GAAW,EAAE;IACjC,MAAM,KAAK,YAAY,EAAE,CAAC;IAC1B,OAAO,MAAM,CAAC;AAChB,CAAC,CAAC;AAEF,MAAM,CAAC,MAAM,QAAQ,GAAG,CACtB,QAAW,EACX,OAAO,GAAoB,EAAE,EAChB,EAAE;IACf,MAAM,MAAM,GAAG,OAAO,CAAC,MAAM,IAAI,aAAa,EAAE,CAAC;IACjD,MAAM,MAAM,GAA4B,EAAE,CAAC;IAC3C,KAAK,MAAM,CAAC,GAAG,EAAE,MAAM,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,QAAQ,CAAC,EAAE,CAAC;QACrD,MAAM,CAAC,GAAG,CAAC,GAAG,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC,CAAC;IACtD,CAAC;IACD,kFAAkF;IAClF,OAAO,MAAqB,CAAC;AAC/B,CAAC,CAAC"}
@@ -0,0 +1,62 @@
1
+ // The one typed database handle. `db.posts` exists because `posts` was declared — nobody
2
+ // writes a repository class per entity, and nobody can reach a table that is not in the set.
3
+
4
+ import type { EntityCore } from './entity';
5
+ import type { Table } from './query';
6
+ import { tableFor } from './query';
7
+ import type { Repo } from './repo';
8
+ import { memoryRepo } from './repo';
9
+
10
+ export type EntitySet = Readonly<Record<string, EntityCore>>;
11
+
12
+ export type Database<E extends EntitySet> = {
13
+ readonly [K in keyof E]: Table<E[K]['$row'], E[K]['$columns']>;
14
+ };
15
+
16
+ /** Where rows actually live. Postgres in production, memory in tests and before migrations. */
17
+ export interface Driver {
18
+ repo<Row>(entity: EntityCore<Row>): Repo<Row>;
19
+ }
20
+
21
+ export interface DatabaseOptions {
22
+ readonly driver?: Driver;
23
+ }
24
+
25
+ /**
26
+ * The default driver: correct semantics, no database, so `x dev` and every test run before
27
+ * the first migration exists.
28
+ */
29
+ export const memoryDriver = (): Driver => {
30
+ const repos = new Map<string, unknown>();
31
+ return {
32
+ repo<Row>(entity: EntityCore<Row>): Repo<Row> {
33
+ const existing = repos.get(entity.$name);
34
+ // Keyed by entity name and only ever written from that same entity, so the row type a
35
+ // caller asks for is the one that was stored.
36
+ if (existing !== undefined) return existing as Repo<Row>;
37
+ const created = memoryRepo<Row>(entity);
38
+ repos.set(entity.$name, created);
39
+ return created;
40
+ },
41
+ };
42
+ };
43
+
44
+ let shared: Driver | undefined;
45
+
46
+ const defaultDriver = (): Driver => {
47
+ shared ??= memoryDriver();
48
+ return shared;
49
+ };
50
+
51
+ export const database = <E extends EntitySet>(
52
+ entities: E,
53
+ options: DatabaseOptions = {},
54
+ ): Database<E> => {
55
+ const driver = options.driver ?? defaultDriver();
56
+ const tables: Record<string, unknown> = {};
57
+ for (const [key, entity] of Object.entries(entities)) {
58
+ tables[key] = tableFor(entity, driver.repo(entity));
59
+ }
60
+ // Built key by key from `entities`, so each table is the one `Database<E>` names.
61
+ return tables as Database<E>;
62
+ };
@@ -0,0 +1,16 @@
1
+ import type { Invariant } from './invariants';
2
+ import type { EntityDescription } from './registry';
3
+ import type { AnyColumn, IndexDef } from './types';
4
+ export interface DescribeInput<Row> {
5
+ readonly name: string;
6
+ readonly columns: readonly (readonly [string, AnyColumn])[];
7
+ readonly primaryKey: readonly string[];
8
+ readonly invariants: readonly Invariant<Row>[];
9
+ readonly indexes: readonly IndexDef[];
10
+ readonly tags: readonly string[];
11
+ readonly cacheTag: string;
12
+ readonly softDelete: boolean;
13
+ readonly tenantColumn: string | null;
14
+ }
15
+ export declare const describeEntity: <Row>(input: DescribeInput<Row>) => EntityDescription;
16
+ //# sourceMappingURL=describe.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"describe.d.ts","sourceRoot":"","sources":["describe.ts"],"names":[],"mappings":"AAUA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAC9C,OAAO,KAAK,EAAqB,iBAAiB,EAAE,MAAM,YAAY,CAAC;AACvE,OAAO,KAAK,EAAE,SAAS,EAAc,QAAQ,EAAE,MAAM,SAAS,CAAC;AAE/D,MAAM,WAAW,aAAa,CAAC,GAAG;IAChC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,OAAO,EAAE,SAAS,CAAC,SAAS,CAAC,MAAM,EAAE,SAAS,CAAC,CAAC,EAAE,CAAC;IAC5D,QAAQ,CAAC,UAAU,EAAE,SAAS,MAAM,EAAE,CAAC;IACvC,QAAQ,CAAC,UAAU,EAAE,SAAS,SAAS,CAAC,GAAG,CAAC,EAAE,CAAC;IAC/C,QAAQ,CAAC,OAAO,EAAE,SAAS,QAAQ,EAAE,CAAC;IACtC,QAAQ,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,CAAC;IACjC,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,UAAU,EAAE,OAAO,CAAC;IAC7B,QAAQ,CAAC,YAAY,EAAE,MAAM,GAAG,IAAI,CAAC;CACtC;AA8DD,eAAO,MAAM,cAAc,GAAI,GAAG,SAAS,aAAa,CAAC,GAAG,CAAC,KAAG,iBAmB9D,CAAC"}
@@ -0,0 +1,79 @@
1
+ // The projection an entity hands the rest of the toolchain: `x.manifest.json`, the migration
2
+ // generator, the admin dashboard and `x entities`. It is a plain data snapshot on purpose —
3
+ // a consumer must be able to read the whole domain without importing a single schema module.
4
+ //
5
+ // Money is the one place the projection is not one-to-one: one property becomes the two
6
+ // physical columns that back it.
7
+ import { bindingOf, snake } from './column';
8
+ import { currencyCheck } from './columns';
9
+ import { invariantViolated } from './errors';
10
+ const referenceOf = (entityName, property, meta) => {
11
+ if (meta.references === undefined)
12
+ return null;
13
+ const target = bindingOf(meta.references());
14
+ if (target === undefined) {
15
+ throw invariantViolated(entityName, property, 'references a column that belongs to no entity — pass a column of an entity() result');
16
+ }
17
+ return `${target.table}.${target.name}`;
18
+ };
19
+ const describeColumn = (input, property, meta) => {
20
+ const physical = snake(property);
21
+ if (meta.kind === 'money') {
22
+ const currency = `${physical}_currency`;
23
+ const shared = {
24
+ notNull: meta.notNull,
25
+ primaryKey: false,
26
+ unique: false,
27
+ hasDefault: false,
28
+ references: null,
29
+ };
30
+ return [
31
+ {
32
+ property: `${property}Minor`,
33
+ column: `${physical}_minor`,
34
+ kind: 'bigint',
35
+ check: null,
36
+ ...shared,
37
+ },
38
+ {
39
+ property: `${property}Currency`,
40
+ column: currency,
41
+ kind: 'char',
42
+ check: currencyCheck(currency),
43
+ ...shared,
44
+ },
45
+ ];
46
+ }
47
+ return [
48
+ {
49
+ property,
50
+ column: physical,
51
+ kind: meta.kind,
52
+ notNull: meta.notNull,
53
+ primaryKey: meta.primaryKey || input.primaryKey.includes(property),
54
+ unique: meta.unique,
55
+ hasDefault: meta.default !== undefined,
56
+ check: meta.check?.(physical) ?? null,
57
+ references: referenceOf(input.name, property, meta),
58
+ },
59
+ ];
60
+ };
61
+ export const describeEntity = (input) => ({
62
+ name: input.name,
63
+ table: input.name,
64
+ primaryKey: input.primaryKey.map((property) => snake(property)),
65
+ columns: input.columns.flatMap(([property, column]) => describeColumn(input, property, column.$meta)),
66
+ invariants: input.invariants.map((inv) => ({
67
+ name: inv.name,
68
+ kind: inv.kind,
69
+ message: inv.message,
70
+ sql: inv.sql,
71
+ where: inv.where ?? null,
72
+ })),
73
+ indexes: input.indexes.map((index) => index.name),
74
+ tags: input.tags,
75
+ cacheTag: input.cacheTag,
76
+ softDelete: input.softDelete,
77
+ orgScoped: input.tenantColumn !== null,
78
+ });
79
+ //# sourceMappingURL=describe.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"describe.js","sourceRoot":"","sources":["describe.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,106 @@
1
+ // The projection an entity hands the rest of the toolchain: `x.manifest.json`, the migration
2
+ // generator, the admin dashboard and `x entities`. It is a plain data snapshot on purpose —
3
+ // a consumer must be able to read the whole domain without importing a single schema module.
4
+ //
5
+ // Money is the one place the projection is not one-to-one: one property becomes the two
6
+ // physical columns that back it.
7
+
8
+ import { bindingOf, snake } from './column';
9
+ import { currencyCheck } from './columns';
10
+ import { invariantViolated } from './errors';
11
+ import type { Invariant } from './invariants';
12
+ import type { ColumnDescription, EntityDescription } from './registry';
13
+ import type { AnyColumn, ColumnMeta, IndexDef } from './types';
14
+
15
+ export interface DescribeInput<Row> {
16
+ readonly name: string;
17
+ readonly columns: readonly (readonly [string, AnyColumn])[];
18
+ readonly primaryKey: readonly string[];
19
+ readonly invariants: readonly Invariant<Row>[];
20
+ readonly indexes: readonly IndexDef[];
21
+ readonly tags: readonly string[];
22
+ readonly cacheTag: string;
23
+ readonly softDelete: boolean;
24
+ readonly tenantColumn: string | null;
25
+ }
26
+
27
+ const referenceOf = (entityName: string, property: string, meta: ColumnMeta): string | null => {
28
+ if (meta.references === undefined) return null;
29
+ const target = bindingOf(meta.references());
30
+ if (target === undefined) {
31
+ throw invariantViolated(
32
+ entityName,
33
+ property,
34
+ 'references a column that belongs to no entity — pass a column of an entity() result',
35
+ );
36
+ }
37
+ return `${target.table}.${target.name}`;
38
+ };
39
+
40
+ const describeColumn = <Row>(
41
+ input: DescribeInput<Row>,
42
+ property: string,
43
+ meta: ColumnMeta,
44
+ ): readonly ColumnDescription[] => {
45
+ const physical = snake(property);
46
+ if (meta.kind === 'money') {
47
+ const currency = `${physical}_currency`;
48
+ const shared = {
49
+ notNull: meta.notNull,
50
+ primaryKey: false,
51
+ unique: false,
52
+ hasDefault: false,
53
+ references: null,
54
+ };
55
+ return [
56
+ {
57
+ property: `${property}Minor`,
58
+ column: `${physical}_minor`,
59
+ kind: 'bigint',
60
+ check: null,
61
+ ...shared,
62
+ },
63
+ {
64
+ property: `${property}Currency`,
65
+ column: currency,
66
+ kind: 'char',
67
+ check: currencyCheck(currency),
68
+ ...shared,
69
+ },
70
+ ];
71
+ }
72
+ return [
73
+ {
74
+ property,
75
+ column: physical,
76
+ kind: meta.kind,
77
+ notNull: meta.notNull,
78
+ primaryKey: meta.primaryKey || input.primaryKey.includes(property),
79
+ unique: meta.unique,
80
+ hasDefault: meta.default !== undefined,
81
+ check: meta.check?.(physical) ?? null,
82
+ references: referenceOf(input.name, property, meta),
83
+ },
84
+ ];
85
+ };
86
+
87
+ export const describeEntity = <Row>(input: DescribeInput<Row>): EntityDescription => ({
88
+ name: input.name,
89
+ table: input.name,
90
+ primaryKey: input.primaryKey.map((property) => snake(property)),
91
+ columns: input.columns.flatMap(([property, column]) =>
92
+ describeColumn(input, property, column.$meta),
93
+ ),
94
+ invariants: input.invariants.map((inv) => ({
95
+ name: inv.name,
96
+ kind: inv.kind,
97
+ message: inv.message,
98
+ sql: inv.sql,
99
+ where: inv.where ?? null,
100
+ })),
101
+ indexes: input.indexes.map((index) => index.name),
102
+ tags: input.tags,
103
+ cacheTag: input.cacheTag,
104
+ softDelete: input.softDelete,
105
+ orgScoped: input.tenantColumn !== null,
106
+ });
@@ -0,0 +1,58 @@
1
+ import type { StandardSchemaV1 } from '@ultimat3/schema';
2
+ import type { Expr, InvariantColumns } from './expr';
3
+ import type { Invariant, InvariantDef } from './invariants';
4
+ import type { EntityDescription } from './registry';
5
+ import type { ColumnMap, IndexDef, RowOf } from './types';
6
+ /** Presence of this column is what makes an entity soft-deletable — not a flag. */
7
+ export declare const SOFT_DELETE_COLUMN = "deletedAt";
8
+ export interface IndexInit<C extends ColumnMap> {
9
+ readonly on: readonly (keyof C & string)[];
10
+ readonly order?: 'asc' | 'desc';
11
+ readonly unique?: boolean;
12
+ /** Partial index predicate, written in the same language as an invariant. */
13
+ readonly where?: (columns: InvariantColumns) => Expr;
14
+ }
15
+ export interface EntityInit<C extends ColumnMap> {
16
+ readonly columns: C;
17
+ /** Composite keys only — a single key is `uuid().primaryKey()` on the column itself. */
18
+ readonly primaryKey?: readonly (keyof C & string)[];
19
+ readonly invariants?: readonly InvariantDef[];
20
+ readonly indexes?: readonly IndexInit<C>[];
21
+ /** Extra cache tags this entity participates in, beyond its own. */
22
+ readonly tags?: readonly string[];
23
+ }
24
+ /**
25
+ * The row-shaped half of an entity. Consumers that must name "some entity" without naming its
26
+ * row use `EntityCore`; `Entity` adds the columns themselves, so `orgs.id` is a column
27
+ * reference and `typeof orgs.$row` is the derived row type.
28
+ */
29
+ export interface EntityCore<Row = unknown, C extends ColumnMap = ColumnMap> {
30
+ readonly $name: string;
31
+ readonly $table: string;
32
+ readonly $columns: C;
33
+ readonly $primaryKey: readonly string[];
34
+ readonly $indexes: readonly IndexDef[];
35
+ readonly $invariants: readonly Invariant<Row>[];
36
+ readonly $tags: readonly string[];
37
+ /** `entity:<name>`. `@ultimat3/cache` invalidates by this string. */
38
+ readonly $cacheTag: string;
39
+ readonly $softDelete: boolean;
40
+ /** Property key of the tenant column, or `null`. Presence is what turns tenancy on. */
41
+ readonly $tenantColumn: string | null;
42
+ /** Phantom: `type Post = typeof posts.$row`. Reading it at runtime throws. */
43
+ readonly $row: Row;
44
+ /** The Standard Schema the columns already describe — forms and actions hand input to it. */
45
+ readonly $schema: StandardSchemaV1<unknown, Row>;
46
+ /** `entity:<name>:<id>` — row-level invalidation for live queries. */
47
+ $tagFor(id: string): string;
48
+ /** Fills declared defaults, then validates every column. Throws on a bad value. */
49
+ $parse(value: unknown): Row;
50
+ /** Runs every invariant. Called by the repository on insert and update. */
51
+ $assert(row: Row): void;
52
+ /** The CHECK/UNIQUE statements the migration emits for this entity. */
53
+ $migration(): string;
54
+ $describe(): EntityDescription;
55
+ }
56
+ export type Entity<Row, C extends ColumnMap = ColumnMap> = EntityCore<Row, C> & C;
57
+ export declare const entity: <const C extends ColumnMap>(name: string, init: EntityInit<C>) => Entity<RowOf<C>, C>;
58
+ //# sourceMappingURL=entity.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"entity.d.ts","sourceRoot":"","sources":["entity.ts"],"names":[],"mappings":"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"}