@ultimat3/entity 1.2.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.
@@ -0,0 +1,189 @@
1
+ // Single responsibility: collapse the point lookups one request issues in the same microtask into
2
+ // a single `where id in (…)`, and hand a lookup a page already answered straight to the preload.
3
+ // A list that resolves an author per row is the N+1 this removes, and it removes it without adding
4
+ // a second way to read — `findById` keeps its signature and its meaning, and pays for one round
5
+ // trip instead of one per row.
6
+
7
+ import { type Ctx, tryUseContext } from '@ultimat3/core';
8
+ import type { DbClient } from '@ultimat3/db';
9
+ import {
10
+ type Answer,
11
+ type KeyColumn,
12
+ keyOf,
13
+ type PointRead,
14
+ readByIds,
15
+ scopeKey,
16
+ statementChunks,
17
+ } from './batch-read';
18
+ import type { EntityCore } from './entity';
19
+ import { preloadedFindById } from './jit-preload';
20
+ import { physicalName } from './pg-row';
21
+ import type { ReadShape } from './pg-sql';
22
+ import type { QueryPlan } from './tenancy';
23
+
24
+ /** One caller's lookup: the row it asked for, and the two ends of the promise it is holding. */
25
+ interface Pending {
26
+ readonly id: unknown;
27
+ /** What the answer will be filed under — `keyOf(id)`, not the id itself. */
28
+ readonly key: string;
29
+ readonly row: Promise<unknown>;
30
+ readonly settle: (row: unknown) => void;
31
+ readonly fail: (error: unknown) => void;
32
+ }
33
+
34
+ /** One statement in the making: the ids collected so far, and the read that will send them. */
35
+ interface Batch {
36
+ /** Two lookups share a statement only when they read from the same place. */
37
+ readonly client: DbClient;
38
+ /** Keyed by `String(id)`, so the same id asked for twice is one bind and one row. */
39
+ readonly pending: Map<string, Pending>;
40
+ readonly load: (ids: readonly unknown[]) => Promise<ReadonlyMap<string, Answer>>;
41
+ }
42
+
43
+ /**
44
+ * Per request, keyed by ctx identity, so a batch dies with the request that opened it — the shape
45
+ * `@ultimat3/query`'s request memo has one tier up. `entity` cannot import that one (tier 2 to
46
+ * tier 3 is upward), so it owns this one.
47
+ */
48
+ const requests = new WeakMap<object, Map<string, Batch>>();
49
+
50
+ const batchesFor = (ctx: Ctx): Map<string, Batch> => {
51
+ const key: object = ctx;
52
+ const existing = requests.get(key);
53
+ if (existing !== undefined) return existing;
54
+ const created = new Map<string, Batch>();
55
+ requests.set(key, created);
56
+ return created;
57
+ };
58
+
59
+ const pendingFor = (id: unknown, key: string): Pending => {
60
+ // The executor runs synchronously, so both are assigned before this returns. TypeScript cannot
61
+ // see through the callback, which is all the definite assignments claim.
62
+ let settle!: (row: unknown) => void;
63
+ let fail!: (error: unknown) => void;
64
+ const row = new Promise<unknown>((resolve, reject) => {
65
+ settle = resolve;
66
+ fail = reject;
67
+ });
68
+ return { id, key, row, settle, fail };
69
+ };
70
+
71
+ const openBatch = (
72
+ batches: Map<string, Batch>,
73
+ key: string,
74
+ client: DbClient,
75
+ load: Batch['load'],
76
+ ): Batch => {
77
+ const batch: Batch = { client, pending: new Map(), load };
78
+ batches.set(key, batch);
79
+ // The window is one microtask: every lookup issued before the current synchronous run ends
80
+ // shares this statement. It closes here, before the statement is sent, so a lookup arriving
81
+ // mid-flight opens the next batch instead of joining ids already on the wire.
82
+ queueMicrotask(() => {
83
+ if (batches.get(key) === batch) batches.delete(key);
84
+ // `flush` settles every caller itself, so a rejection escaping it has nobody left to hand it
85
+ // to — and an unhandled rejection ends the Bun process, which turns one bad batch into the
86
+ // whole node. Nothing is swallowed here that a caller was not already given.
87
+ void flush(batch).catch(() => undefined);
88
+ });
89
+ return batch;
90
+ };
91
+
92
+ const flush = async (batch: Batch): Promise<void> => {
93
+ const waiting = [...batch.pending.values()];
94
+ batch.pending.clear();
95
+ try {
96
+ // One statement at a time: a batch wide enough to split must not take the pool with it.
97
+ for (const chunk of statementChunks(waiting)) {
98
+ try {
99
+ const answers = await batch.load(chunk.map((entry) => entry.id));
100
+ for (const entry of chunk) {
101
+ const answer = answers.get(entry.key);
102
+ // An id the statement did not answer for is a row that is not there — `findById`'s
103
+ // `null`, never a rejection, and never another caller's row.
104
+ if (answer === undefined) entry.settle(null);
105
+ else if ('error' in answer) entry.fail(answer.error);
106
+ else entry.settle(answer.row);
107
+ }
108
+ } catch (error) {
109
+ // The statement failed, so everyone in it gets the failure the single statement would
110
+ // have handed them. Every one was returned to a caller, so none goes unhandled.
111
+ for (const entry of chunk) entry.fail(error);
112
+ }
113
+ }
114
+ } catch (error) {
115
+ // Every promise in `waiting` was handed to a caller before this batch was ever scheduled, so
116
+ // anything escaping the loop above — `statementChunks` itself, or whatever a later edit puts
117
+ // beside it — would leave them awaiting a row that can no longer arrive. Unsettled forever is
118
+ // strictly worse than failed: a rejection is a stack trace, a hang is a request that never
119
+ // answers. Failing an entry an earlier chunk already settled is a no-op, so this is safe over
120
+ // the whole list. `jit-preload.ts` gets the same property by construction, settling with an
121
+ // `Answer` rather than a rejection; this path has a real promise per caller and cannot.
122
+ for (const entry of waiting) entry.fail(error);
123
+ }
124
+ };
125
+
126
+ /**
127
+ * The shared read this lookup joins, or `undefined` when there is none to join: no request in
128
+ * scope, a composite key, a scope this cannot compare, or a client the open batch does not read
129
+ * from. Declining is always correct — the caller sends the one statement it always sent.
130
+ *
131
+ * Two shapes, one seam. A page already read answers first (one statement for a whole sequential
132
+ * loop), and what no page can answer joins the microtask batch.
133
+ */
134
+ export const coalesceFindById = <Row>(
135
+ entity: EntityCore<Row>,
136
+ client: DbClient,
137
+ plan: QueryPlan,
138
+ shape: ReadShape,
139
+ id: unknown,
140
+ ): Promise<Row | null> | undefined => {
141
+ const ctx = tryUseContext();
142
+ const [keyColumn] = entity.$primaryKey;
143
+ const declared = keyColumn === undefined ? undefined : entity.$columns[keyColumn];
144
+ if (
145
+ ctx === undefined ||
146
+ keyColumn === undefined ||
147
+ declared === undefined ||
148
+ entity.$primaryKey.length !== 1
149
+ ) {
150
+ return undefined;
151
+ }
152
+ // A seek positions a page, never a point lookup. If one ever reaches here the statement is not
153
+ // the one this batches.
154
+ if (shape.seek !== undefined) return undefined;
155
+ const at = plan.where.findIndex(
156
+ (predicate) =>
157
+ predicate.column === keyColumn && predicate.op === 'eq' && predicate.value === id,
158
+ );
159
+ if (at === -1) return undefined;
160
+ const scoped: QueryPlan = { ...plan, where: plan.where.filter((_, index) => index !== at) };
161
+ const key = scopeKey(entity, scoped, shape);
162
+ if (key === undefined) return undefined;
163
+
164
+ const keyColumnRef: KeyColumn = {
165
+ property: keyColumn,
166
+ column: physicalName(entity, keyColumn),
167
+ kind: declared.$meta.kind,
168
+ };
169
+ const read: PointRead<Row> = { entity, client, scoped, shape, key: keyColumnRef };
170
+ // A page whose foreign keys this id is one of answers for every row of that page at once, which
171
+ // is the only thing that batches a `for … of` loop: its `await` already ended the microtask.
172
+ const preloaded = preloadedFindById<Row>(ctx, read, key, id);
173
+ if (preloaded !== undefined) return preloaded;
174
+
175
+ const batches = batchesFor(ctx);
176
+ const open = batches.get(key);
177
+ // A pinned client and the ambient pool are two places to read from, and a batch is one
178
+ // statement: a lookup that does not share the open batch's client sends its own.
179
+ if (open !== undefined && open.client !== client) return undefined;
180
+ const batch = open ?? openBatch(batches, key, client, (ids) => readByIds(read, ids));
181
+
182
+ const filedAt = keyOf(keyColumnRef.kind, id);
183
+ const already = batch.pending.get(filedAt);
184
+ const pending = already ?? pendingFor(id, filedAt);
185
+ if (already === undefined) batch.pending.set(filedAt, pending);
186
+ // One batch is one entity — the key fixed that before the batch existed — so this re-attaches
187
+ // the row type the store erased rather than asserting anything new about it.
188
+ return pending.row as Promise<Row | null>;
189
+ };
package/src/column.ts CHANGED
@@ -7,11 +7,51 @@
7
7
  // physical name even though two schema modules import each other in a cycle.
8
8
 
9
9
  import { invariantViolated } from './errors';
10
- import type { AnyColumn, Column, ColumnDefault, ColumnMeta, TimestampColumn } from './types';
10
+ import type {
11
+ AnyColumn,
12
+ Column,
13
+ ColumnDefault,
14
+ ColumnMeta,
15
+ MoneyColumnNames,
16
+ TimestampColumn,
17
+ } from './types';
11
18
 
12
19
  export const snake = (value: string): string =>
13
20
  value.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase();
14
21
 
22
+ /**
23
+ * The physical column, decided in ONE place: what `.column()` declared, else `snake(property)`.
24
+ *
25
+ * Every projection reads it here — the DDL, the binding, the decoder, the invariant resolver, the
26
+ * index names — because a second `snake(property)` anywhere is a statement naming a column the
27
+ * table does not have, and the first table that proves it is somebody's production database.
28
+ */
29
+ export const columnName = (property: string, meta: ColumnMeta): string =>
30
+ meta.name ?? snake(property);
31
+
32
+ /** Money's three physical columns, resolved. `scale: null` is a table that has no scale column. */
33
+ export interface MoneyColumns {
34
+ readonly minor: string;
35
+ readonly currency: string;
36
+ readonly scale: string | null;
37
+ }
38
+
39
+ /**
40
+ * Per part, merged over the `<base>_minor` / `<base>_currency` / `<base>_scale` defaults — so a
41
+ * table that renamed one of the three does not have to restate the other two, and `.column()`
42
+ * moves the base for all of them at once.
43
+ */
44
+ export const moneyColumns = (property: string, meta: ColumnMeta): MoneyColumns => {
45
+ const base = columnName(property, meta);
46
+ const declared: MoneyColumnNames = meta.parts ?? {};
47
+ return {
48
+ minor: declared.minor ?? `${base}_minor`,
49
+ currency: declared.currency ?? `${base}_currency`,
50
+ // `undefined` takes the default; `null` is the caller saying the column is not there at all.
51
+ scale: declared.scale === undefined ? `${base}_scale` : declared.scale,
52
+ };
53
+ };
54
+
15
55
  export const GENERATED_UUID: ColumnDefault = { kind: 'generated', by: 'uuid-v7' };
16
56
  export const GENERATED_NOW: ColumnDefault = { kind: 'generated', by: 'now' };
17
57
 
@@ -49,13 +89,37 @@ export const bindColumn = (column: AnyColumn, table: string, property: string):
49
89
  'build a new column instead of sharing one between entities',
50
90
  );
51
91
  }
52
- const binding: Binding = { table, property, name: snake(property) };
92
+ const binding: Binding = { table, property, name: columnName(property, column.$meta) };
53
93
  bindings.set(column, binding);
54
94
  return binding;
55
95
  };
56
96
 
57
97
  export const bindingOf = (column: AnyColumn): Binding | undefined => bindings.get(column);
58
98
 
99
+ /**
100
+ * A declared foreign key, resolved to where its target actually landed — `null` when the column
101
+ * declares none. The thunk exists because two schema modules import each other in a cycle, so
102
+ * this can only be answered after both have evaluated; it is answered in ONE place so the DDL
103
+ * projection (`describe.ts`) and the relation map (`relations.ts`) can never disagree about what
104
+ * a `references()` points at.
105
+ */
106
+ export const referenceBinding = (
107
+ entityName: string,
108
+ property: string,
109
+ meta: ColumnMeta,
110
+ ): Binding | null => {
111
+ if (meta.references === undefined) return null;
112
+ const target = bindingOf(meta.references());
113
+ if (target === undefined) {
114
+ throw invariantViolated(
115
+ entityName,
116
+ property,
117
+ 'references a column that belongs to no entity — pass a column of an entity() result',
118
+ );
119
+ }
120
+ return target;
121
+ };
122
+
59
123
  const literal = (value: unknown): ColumnDefault => {
60
124
  if (value === null) return { kind: 'value', value: null };
61
125
  if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
@@ -111,8 +175,30 @@ export const makeColumn = <T, Optional extends boolean>(
111
175
  ),
112
176
 
113
177
  default: (value) => makeColumn<T, true>({ ...meta, default: literal(value) }, parse, true),
178
+
179
+ column: (name) =>
180
+ makeColumn<T, Optional>({ ...meta, name: assertColumnName(name) }, parse, optional),
114
181
  });
115
182
 
183
+ /**
184
+ * A physical name is spliced into DDL and into every statement as a quoted identifier, so it is
185
+ * checked where it is written rather than trusted there: an empty name produces `""`, and a name
186
+ * carrying a quote or a newline is the one value in a column declaration that could close the
187
+ * identifier. `[a-z_][a-z0-9_$]*`, which is what an unquoted Postgres identifier may be, and the
188
+ * bound is the same 63 bytes the server truncates at — a longer one silently addresses a
189
+ * different column.
190
+ */
191
+ export const assertColumnName = (name: string): string => {
192
+ if (!/^[a-z_][a-z0-9_$]*$/.test(name) || name.length > 63) {
193
+ throw invariantViolated(
194
+ 'column',
195
+ 'column-name',
196
+ `"${name}" is not a physical column name: lower-case letters, digits and underscores, at most 63 of them`,
197
+ );
198
+ }
199
+ return name;
200
+ };
201
+
116
202
  export const column = <T>(
117
203
  kind: ColumnMeta['kind'],
118
204
  parse: (value: unknown) => T,
@@ -131,4 +217,7 @@ export const makeTimestamp = <Optional extends boolean>(
131
217
  ...makeColumn<Date, Optional>(meta, parse, optional),
132
218
  defaultNow: () => makeTimestamp({ ...meta, default: GENERATED_NOW }, parse, true),
133
219
  onUpdateNow: () => makeTimestamp({ ...meta, onUpdate: GENERATED_NOW }, parse, optional),
220
+ // Overridden so `timestamp().column('created').defaultNow()` still has `defaultNow` — the
221
+ // general link returns the general column, and a builder with methods of its own keeps them.
222
+ column: (name) => makeTimestamp({ ...meta, name: assertColumnName(name) }, parse, optional),
134
223
  });
@@ -0,0 +1,205 @@
1
+ // The column builders an EXISTING schema needs. `columns.ts` holds the opinionated set — one way
2
+ // to store an id, an instant, money — and every one of them is a decision this framework made for
3
+ // a table it was going to create. These are the shapes a table already has: a `jsonb` payload, a
4
+ // `numeric(18,8)` rate, a calendar `date`, an `int8` id past 2^53, a `bytea` blob, a `text[]`.
5
+ //
6
+ // Two rules run through all of them. A value crossing the driver is parsed by the column that
7
+ // declared it, because the two drivers disagree about what they hand back (`int8` is a string from
8
+ // Bun's `sql` and a `bigint` from PGlite — measured); and nothing here is an `any` hole, so `json()`
9
+ // takes a schema and validates through it.
10
+
11
+ import { describeValue, formatIssues, type StandardSchemaV1, validate } from '@ultimat3/schema';
12
+ import { isPlainDate, type PlainDate, plainDateUtc } from '@ultimat3/time';
13
+ import { column } from './column';
14
+ import { invariantViolated } from './errors';
15
+ import type { AnyColumn, Column, ColumnMeta } from './types';
16
+
17
+ const reject = (rule: string, detail: string): never => {
18
+ throw invariantViolated('column', rule, detail);
19
+ };
20
+
21
+ /** The rejected value as its SHAPE, never its content — `columns.ts` explains why at length. */
22
+ const got = (value: unknown): string => `got ${describeValue(value)}`;
23
+
24
+ /**
25
+ * A `jsonb` column whose CONTENTS are validated. The schema is required and that is the point: a
26
+ * `json()` returning `unknown` is the `any` hole this framework forbids, and a column is the worst
27
+ * place for one — the value arrives from the DATABASE as often as from a caller, so the row type
28
+ * would be a claim nothing ever checked.
29
+ *
30
+ * The object is bound as an object, never as a string: a JSON string parameter is stored as a JSON
31
+ * *string* by Postgres (measured — `'{"a":1}'` comes back as the text, not the object), so
32
+ * stringifying here would change the value's type in the table.
33
+ */
34
+ export const json = <T>(schema: StandardSchemaV1<unknown, T>): Column<T> =>
35
+ column<T>('jsonb', (value) => {
36
+ const result = validate(schema, value);
37
+ if (result.issues === undefined) return result.value;
38
+ // The ISSUES, never the value: `formatIssues` renders path + message, and a column rejection
39
+ // reaches the caller and the log line where a value has no key left to redact.
40
+ return reject(
41
+ 'json',
42
+ `does not match the column's schema — ${formatIssues(result.issues).join('; ')}`,
43
+ );
44
+ });
45
+
46
+ const DIGITS = /^-?\d+$/;
47
+
48
+ /**
49
+ * `bigint`, whose row type is a decimal STRING. Neither alternative survives contact:
50
+ * a JS `bigint` is what `JSON.stringify` throws on — the reason `money.minor` is a `number` — and
51
+ * a `number` silently loses digits past 2^53, which is precisely the range a legacy `int8` key or
52
+ * a snowflake id lives in. A string holds every value the column can and crosses every wire this
53
+ * framework generates.
54
+ *
55
+ * Both driver spellings arrive here and leave as one: Bun's `sql` returns `int8` as a string and
56
+ * PGlite returns a `bigint`, and a row that meant two things by driver is the drift this package
57
+ * exists to refuse.
58
+ */
59
+ export const bigint = (): Column<string> =>
60
+ column<string>('bigint', (value) => {
61
+ if (typeof value === 'bigint') return value.toString();
62
+ if (typeof value === 'number') {
63
+ return Number.isSafeInteger(value)
64
+ ? String(value)
65
+ : reject(
66
+ 'bigint',
67
+ `${String(value)} is past ±2^53, where a JS number is no longer exact — pass the digits as a string`,
68
+ );
69
+ }
70
+ return typeof value === 'string' && DIGITS.test(value)
71
+ ? value
72
+ : reject('bigint', `expected whole digits, ${got(value)}`);
73
+ });
74
+
75
+ export interface DecimalOptions {
76
+ /** Emits `numeric(precision, scale)`. Both, or neither — a bare `numeric` is unbounded. */
77
+ readonly precision?: number;
78
+ readonly scale?: number;
79
+ }
80
+
81
+ /**
82
+ * `numeric(p, s)`, whose row type is the exact decimal STRING Postgres returns. Money is the one
83
+ * decimal this framework has an opinion about (integer minor units plus a currency, always); this
84
+ * is every other one — a tax rate, an FX rate, a measurement — where the precision is the column's
85
+ * and no JS number holds it.
86
+ *
87
+ * It is deliberately NOT arithmetic-friendly. A framework that handed back a float here would be
88
+ * the float-money bug with a different name, and one that shipped a decimal type would be shipping
89
+ * a numeric tower: the honest thing a driver already does is give you the digits.
90
+ */
91
+ export const decimal = (options: DecimalOptions = {}): Column<string> => {
92
+ const { precision, scale } = options;
93
+ if ((precision === undefined) !== (scale === undefined)) {
94
+ reject('numeric', 'precision and scale are declared together — numeric(18, 8), or neither');
95
+ }
96
+ if (precision !== undefined && scale !== undefined) {
97
+ if (!Number.isInteger(precision) || precision < 1 || precision > 1000) {
98
+ reject('numeric', `precision must be 1..1000, ${got(precision)}`);
99
+ }
100
+ if (!Number.isInteger(scale) || scale < 0 || scale > precision) {
101
+ reject('numeric', `scale must be 0..precision, ${got(scale)}`);
102
+ }
103
+ }
104
+ const shape = /^-?\d+(\.\d+)?$/;
105
+ return column<string>(
106
+ 'numeric',
107
+ (value) => {
108
+ const text = typeof value === 'number' ? decimalOfNumber(value) : value;
109
+ if (typeof text !== 'string' || !shape.test(text)) {
110
+ return reject('numeric', `expected a decimal number, ${got(value)}`);
111
+ }
112
+ const digits = text.replace('-', '').split('.');
113
+ const fraction = digits[1]?.length ?? 0;
114
+ if (scale !== undefined && fraction > scale) {
115
+ return reject(
116
+ 'numeric',
117
+ `${text} has ${fraction} decimal places and the column stores ${scale} — Postgres would round it, silently`,
118
+ );
119
+ }
120
+ if (
121
+ precision !== undefined &&
122
+ (digits[0] ?? '').replace(/^0+(?=\d)/, '').length > precision - (scale ?? 0)
123
+ ) {
124
+ return reject('numeric', `${text} does not fit numeric(${precision}, ${scale ?? 0})`);
125
+ }
126
+ return text;
127
+ },
128
+ precision === undefined || scale === undefined ? {} : { precision, numericScale: scale },
129
+ );
130
+ };
131
+
132
+ /**
133
+ * A float is accepted only where it is exactly representable as written — anything else is the
134
+ * rounding this column exists to refuse, and refusing it at the write is the only place the caller
135
+ * still knows what they meant.
136
+ */
137
+ const decimalOfNumber = (value: number): string =>
138
+ Number.isFinite(value) ? String(value) : 'not-a-number';
139
+
140
+ /**
141
+ * A `date`: a calendar date, with no time and therefore no zone. The row type is
142
+ * `@ultimat3/time`'s `PlainDate`, which is why this is not `timestamp()` with the clock zeroed —
143
+ * `effective_on` is the date a rate applies, and stored as an instant it is a different date on
144
+ * either side of midnight for half the planet.
145
+ *
146
+ * A driver hands a `date` column back as a `Date` at UTC midnight (measured: Bun's `sql` and
147
+ * PGlite both), so that is the one conversion here, by its own name. The value written is the
148
+ * string: binding a `Date` to a `date` parameter fails outright on a server whose client zone has
149
+ * no name Postgres knows (`time zone "gmt-0500" not recognized`, measured on 17.10).
150
+ */
151
+ export const date = (): Column<PlainDate> =>
152
+ column<PlainDate>('date', (value) => {
153
+ if (value instanceof Date) {
154
+ return Number.isNaN(value.getTime())
155
+ ? reject('date', `expected a calendar date, ${got(value)}`)
156
+ : plainDateUtc(value);
157
+ }
158
+ return isPlainDate(value)
159
+ ? value
160
+ : reject('date', `expected a YYYY-MM-DD calendar date, ${got(value)}`);
161
+ });
162
+
163
+ /**
164
+ * `bytea`. The row type is a plain `Uint8Array` and both drivers are normalised into one: Bun's
165
+ * `sql` returns a `Buffer`, PGlite a `Uint8Array`, and the two serialise differently
166
+ * (`{"type":"Buffer","data":[…]}` against `{"0":…}`) — so a row read through one driver and the
167
+ * same row read through the other would not be the same object on any wire.
168
+ */
169
+ export const bytes = (): Column<Uint8Array> =>
170
+ column<Uint8Array>('bytea', (value) => {
171
+ if (!(value instanceof Uint8Array)) {
172
+ return reject('bytea', `expected bytes, ${got(value)}`);
173
+ }
174
+ // Already the plain form: the overwhelmingly common case, and it costs one prototype read.
175
+ return Object.getPrototypeOf(value) === Uint8Array.prototype ? value : new Uint8Array(value);
176
+ });
177
+
178
+ /**
179
+ * `<element>[]` — a Postgres array of a SCALAR column. The element is a column, so its own
180
+ * `$parse` decides every member: `arrayOf(text({ max: 40 }))` refuses a 41-character tag exactly
181
+ * where a `text()` column would.
182
+ *
183
+ * Money and arrays of arrays are refused rather than approximated: money is three physical columns
184
+ * and cannot be one array element, and a nested array has no unambiguous literal form.
185
+ */
186
+ export const arrayOf = <T>(element: Column<T>): Column<readonly T[]> => {
187
+ const kind = element.$meta.kind;
188
+ if (kind === 'money' || kind === 'array') {
189
+ reject(
190
+ 'array',
191
+ `arrayOf(${kind}) has no single column behind it — an array element is one scalar column`,
192
+ );
193
+ }
194
+ return column<readonly T[]>(
195
+ 'array',
196
+ (value) => {
197
+ if (!Array.isArray(value)) return reject('array', `expected an array, ${got(value)}`);
198
+ return value.map((member) => element.$parse(member));
199
+ },
200
+ { element: element as AnyColumn },
201
+ );
202
+ };
203
+
204
+ /** The element's own kind, for the projections that need the physical type. */
205
+ export const elementMeta = (meta: ColumnMeta): ColumnMeta | undefined => meta.element?.$meta;