New:Socket for Asana Is Now Available.Learn more
Sign In

@ultimat3/entity

Package Overview
Dependencies
Maintainers
1
Versions
24
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@ultimat3/entity - npm Package Compare versions

Comparing version
7.0.0
to
8.0.0
+67
src/array-element.ts
// Which element kinds `arrayOf()` refuses, and the one-line edit that repairs each. Split from
// `columns-data.ts`, which parses columns: a refusal POLICY and four repair strings are a second
// responsibility, and the file was 250 lines with both in it.
import { EntityError } from './errors';
/** The element kinds `arrayElement` (`pg-row.ts`) has no literal for, and why each one is refused. */
const ARRAY_ELEMENT_REFUSED = ['money', 'array', 'jsonb', 'bytea'] as const;
export type RefusedElement = (typeof ARRAY_ELEMENT_REFUSED)[number];
export const isRefusedElement = (kind: string): kind is RefusedElement =>
(ARRAY_ELEMENT_REFUSED as readonly string[]).includes(kind);
/**
* One column per refused element kind: the shape that holds the same list and can be written.
* `Object.freeze<Record<K, V>>` and never `Readonly<Record<K, V>> = Object.freeze({…})`, which
* infers the key set from the literal and would accept a fifth key in silence.
*
* Each value is a MECHANICAL edit with nothing for the reader to supply — the placeholder form
* `json(t.array(<element schema>))` was the defect: a `fix:` a reader has to complete is one they
* can complete wrongly, and `<element schema>` pasted verbatim is a syntax error. The two that
* need a second table name it (`amounts`, `blobs`) rather than saying "a child table", so every
* line is text that runs.
*/
const ARRAY_ELEMENT_FIXES = Object.freeze<Record<RefusedElement, string>>({
money:
'move the list to its own entity and relate it — ' +
"entity('amounts', { columns: { amount: money() } }) — then drop this column: an array column " +
'is ONE column and money() is three (minor, currency, scale)',
array:
'rewrite arrayOf(arrayOf(x)) as arrayOf(x) if the nesting carries no meaning, or move the ' +
"inner list to its own entity and relate it — entity('items', { columns: { value: text() } })",
jsonb:
'rewrite arrayOf(json(S)) as json(t.array(S)) with S unchanged — one jsonb column holds the ' +
'whole list and t.array still validates every member',
bytea:
'move the list to its own entity and relate it — ' +
"entity('blobs', { columns: { data: bytes() } }) — then drop this column: one row per blob, " +
'and bytea has no array literal that survives the driver',
});
/**
* An element the Postgres array literal cannot carry, refused where the schema is still being
* written. Two different reasons, one code — the situation is a single one, "this list needs a
* different column" — so only the cause and the fix branch.
*
* `money` and `array` are not ONE column: three physical columns for an amount, and a nested array
* has no unambiguous literal form. `jsonb` and `bytea` are one column each and were the silent
* half: `arrayElement` renders any object as `""`, so two objects bound as `{"",""}` and one blob
* as `{""}` (measured), while `memoryRepo` kept the value — a loss no test in this tree could see
* and only a table could show.
*
* Not `reject()`: a declaration is repaired by an EDIT, and `reject`'s
* `x entities describe column --json` is `X_DECLARATION_UNKNOWN` — no entity is named `column`, and
* there is no entity at all yet. So each fix is the edit that holds the list instead.
*/
export const arrayElementRefused = (kind: RefusedElement): EntityError => {
const singleColumn = kind === 'money' || kind === 'array';
return new EntityError({
code: 'X_INVARIANT_VIOLATED',
cause: singleColumn
? `arrayOf(${kind}) has no single column behind it — an array element is one scalar column, and ${kind === 'money' ? 'money is three (minor, currency, scale)' : 'a nested array has no unambiguous literal form'}`
: `arrayOf(${kind}) has no array literal form — every element would cross to Postgres as an empty string while memoryRepo kept the value, so the loss is invisible until the row is read back`,
fix: ARRAY_ELEMENT_FIXES[kind],
});
};
+5
-5
{
"name": "@ultimat3/entity",
"version": "7.0.0",
"version": "8.0.0",
"description": "A table + its domain type + invariants the database also enforces",

@@ -34,7 +34,7 @@ "license": "MIT",

"dependencies": {
"@ultimat3/core": "7.0.0",
"@ultimat3/db": "7.0.0",
"@ultimat3/schema": "7.0.0",
"@ultimat3/time": "7.0.0"
"@ultimat3/core": "8.0.0",
"@ultimat3/db": "8.0.0",
"@ultimat3/schema": "8.0.0",
"@ultimat3/time": "8.0.0"
}
}

@@ -13,2 +13,3 @@ // The column builders an EXISTING schema needs. `columns.ts` holds the opinionated set — one way

import { isPlainDate, type PlainDate, plainDateUtc } from '@ultimat3/time';
import { arrayElementRefused, isRefusedElement } from './array-element';
import { column } from './column';

@@ -31,5 +32,9 @@ import { invariantViolated } from './errors';

*
* The object is bound as an object, never as a string: a JSON string parameter is stored as a JSON
* *string* by Postgres (measured — `'{"a":1}'` comes back as the text, not the object), so
* stringifying here would change the value's type in the table.
* The value crosses to Postgres as TEXT and is cast back — `bindValues` calls `JSON.stringify` and
* `cellCast` (`pg-sql.ts`) writes `::text::jsonb` — and both halves are load-bearing. The driver
* seam refuses a plain object as a parameter (`X_SQL_UNSAFE`), so the object cannot cross as
* itself; and under a bare `$1::jsonb` the server describes the parameter as `jsonb`, Bun's `sql`
* JSON-ENCODES the string it was handed, and `{"a":1}` lands as a JSON *string* — `jsonb_typeof`
* answers `string` (measured, Postgres 17.10). Pinning the parameter to `text` first is what makes
* the server parse the characters, so neither half may be changed without the other.
*/

@@ -185,13 +190,7 @@ export const json = <T>(schema: StandardSchemaV1<unknown, T>): Column<T> =>

*
* Money and arrays of arrays are refused rather than approximated: money is three physical columns
* and cannot be one array element, and a nested array has no unambiguous literal form.
* Four element kinds are refused rather than approximated — see `arrayElementRefused`.
*/
export const arrayOf = <T>(element: Column<T>): Column<readonly T[]> => {
const kind = element.$meta.kind;
if (kind === 'money' || kind === 'array') {
reject(
'array',
`arrayOf(${kind}) has no single column behind it — an array element is one scalar column`,
);
}
if (isRefusedElement(kind)) throw arrayElementRefused(kind);
return column<readonly T[]>(

@@ -198,0 +197,0 @@ 'array',

@@ -70,5 +70,9 @@ // The projection an entity hands the rest of the toolchain: `x.manifest.json`, the migration

const element = meta.element?.$meta;
// `arrayOf` refuses an element that is not one scalar column, so this is total in practice;
// `text[]` is the answer that keeps a description renderable rather than throwing inside a
// projection, which is the one place an error has no caller to instruct.
// The element KIND is bounded by `arrayOf`, which refuses money, a nested array, `jsonb` and
// `bytea` at declaration — it refused only the first two until 2026-08, so this line emitted a
// real `jsonb[]`/`bytea[]` for a column `bindValues` wrote as `{"",""}`: a DDL type for a value
// that could not survive the trip. What is NOT bounded is `element` itself, which is absent on
// any `ColumnMeta` nobody built through `arrayOf()`; `text[]` keeps such a description
// renderable rather than throwing inside a projection, the one place an error has no caller to
// instruct.
return `${element === undefined ? 'text' : sqlTypeOf(element)}[]`;

@@ -75,0 +79,0 @@ }

@@ -34,2 +34,5 @@ // Single responsibility: what a `Predicate` MEANS in the in-memory driver — equality, ordering and

/** Absent and NULL are one thing to a predicate: a column the projection left out is not a value. */
const isNull = (value: unknown): boolean => value === null || value === undefined;
const sign = <T extends number | bigint | string>(left: T, right: T): number =>

@@ -143,2 +146,9 @@ left < right ? -1 : left > right ? 1 : 0;

const same = (candidate: unknown): boolean => sameValueOfKind(kind, actual, candidate);
// `col > NULL` is UNKNOWN in SQL and UNKNOWN is not a match, so a NULL on EITHER side matches no
// row here either — `predicateSql` emits a bare `"col" > $1` and Postgres returns nothing. Without
// this the fall-through compared `String(null)` as the text `"null"`, which sorts after `"5"` and
// before `"z"`: `gt(seats, 5)` answered the null row in memory and never in production, and
// `lt(seats, null)` answered every row. The guard is HERE and not in `compareByKind`, which also
// orders a page — a sort puts NULLs last (`asc nulls last`) rather than dropping them.
const unknown = (): boolean => isNull(actual) || isNull(predicate.value);
const order = (): number => compareByKind(kind, actual, predicate.value);

@@ -155,18 +165,18 @@ switch (predicate.op) {

case 'gt':
return order() > 0;
return !unknown() && order() > 0;
case 'gte':
return order() >= 0;
return !unknown() && order() >= 0;
case 'lt':
return order() < 0;
return !unknown() && order() < 0;
case 'lte':
return order() <= 0;
return !unknown() && order() <= 0;
// Real LIKE semantics, so `'draft%'` means "starts with" here exactly as it does in Postgres.
// Treating the pattern as a substring would make the two drivers disagree.
case 'like':
return likePattern(entity.$name, String(predicate.value)).test(String(actual));
return !unknown() && likePattern(entity.$name, String(predicate.value)).test(String(actual));
case 'is-null':
return actual === null || actual === undefined;
return isNull(actual);
case 'is-not-null':
return actual !== null && actual !== undefined;
return !isNull(actual);
}
};

@@ -105,2 +105,8 @@ // Single responsibility: the two-way map between a physical Postgres row and an entity row.

* is nothing at all.
*
* The `object` branch is the LAST resort and never a declared column's value: `arrayOf()` refuses
* `jsonb`, `bytea`, `money` and a nested array at declaration (`columns-data.ts`) precisely because
* this line has no literal for them and rendered every one as `""` — silently, and only against a
* real table, since `memoryRepo` stores the value it was handed. A `Date` is the one object shape
* with a literal, so it is named above.
*/

@@ -107,0 +113,0 @@ const arrayElement = (value: unknown): string => {

Sorry, the diff of this file is too big to display