@ultimat3/entity 30.8.0 → 30.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/entity",
3
- "version": "30.8.0",
3
+ "version": "30.9.0",
4
4
  "description": "A table + its domain type + invariants the database also enforces",
5
5
  "keywords": [
6
6
  "ultimate",
@@ -43,9 +43,9 @@
43
43
  "test": "bun test"
44
44
  },
45
45
  "dependencies": {
46
- "@ultimat3/core": "30.8.0",
47
- "@ultimat3/db": "30.8.0",
48
- "@ultimat3/schema": "30.8.0",
49
- "@ultimat3/time": "30.8.0"
46
+ "@ultimat3/core": "30.9.0",
47
+ "@ultimat3/db": "30.9.0",
48
+ "@ultimat3/schema": "30.9.0",
49
+ "@ultimat3/time": "30.9.0"
50
50
  }
51
51
  }
@@ -6,6 +6,7 @@ import { describeValue } from '@ultimat3/schema';
6
6
  import { entityNow } from './clock';
7
7
  import { newId } from './columns';
8
8
  import { invariantViolated } from './errors';
9
+ import { holdsJsonNull } from './json-null';
9
10
  import { sealedMissing } from './sealed-errors';
10
11
  import type { AnyColumn, ColumnMeta } from './types';
11
12
 
@@ -41,7 +42,7 @@ export const rowParsers = <Row, Wire>(
41
42
  // spread of an omitted optional key produces and is the shape every existing caller has.
42
43
  const raw = input[property];
43
44
  const given = raw === undefined ? defaultValue(column.$meta) : raw;
44
- if (given === undefined || given === null) {
45
+ if (given === undefined || (given === null && !holdsJsonNull(column.$meta))) {
45
46
  if (column.$meta.notNull) {
46
47
  // A required sealed column ABSENT from a row is nearly always a spread of a repository
47
48
  // row, which does not enumerate it — so the refusal names that, and the rewrite.
@@ -72,7 +73,7 @@ export const rowParsers = <Row, Wire>(
72
73
  for (const [property, column] of wireEntries) {
73
74
  const raw = input[property];
74
75
  const given = raw === undefined ? defaultValue(column.$meta) : raw;
75
- if (given === undefined || given === null) {
76
+ if (given === undefined || (given === null && !holdsJsonNull(column.$meta))) {
76
77
  if (column.$meta.notNull) {
77
78
  throw invariantViolated(name, property, 'is required and has no default');
78
79
  }
@@ -3,6 +3,7 @@
3
3
  // would rewrite every column it did not name.
4
4
 
5
5
  import { invariantViolated } from './errors';
6
+ import { holdsJsonNull } from './json-null';
6
7
  import { copyRow } from './sealed';
7
8
  import type { AnyColumn } from './types';
8
9
 
@@ -24,7 +25,8 @@ export const parseEntityPatch = <Patch>(
24
25
  const column = Object.hasOwn(columns, property) ? columns[property] : undefined;
25
26
  const value: unknown = descriptor.get === undefined ? descriptor.value : descriptor.get();
26
27
  if (column === undefined || value === undefined) continue;
27
- if (value === null) {
28
+ // A NOT NULL `jsonb` column's `null` is the JSON value, judged by its schema below.
29
+ if (value === null && !holdsJsonNull(column.$meta)) {
28
30
  if (column.$meta.notNull) {
29
31
  throw invariantViolated(name, property, 'is required and has no default');
30
32
  }
@@ -0,0 +1,13 @@
1
+ // Single responsibility: when a `null` handed to a column is the JSON VALUE `null` rather than SQL
2
+ // NULL. One rule, read by the parsers, the Postgres binder and decoder, and the memory matcher —
3
+ // a copy in any one of them is a row the two drivers spell differently.
4
+
5
+ import type { ColumnMeta } from './types';
6
+
7
+ /**
8
+ * A NOT NULL `jsonb` column: the table holds no SQL NULL, so a `null` reaching it can only be the
9
+ * JSON literal, judged by the column's schema like any other JSON value and stored as
10
+ * `'null'::jsonb`. A NULLABLE `jsonb` column keeps `null` as SQL NULL — the clear every other
11
+ * nullable column means by it, and what `is null` answers in both drivers.
12
+ */
13
+ export const holdsJsonNull = (meta: ColumnMeta): boolean => meta.kind === 'jsonb' && meta.notNull;
@@ -5,6 +5,7 @@
5
5
  // deciding by `typeof` is answering a different question.
6
6
 
7
7
  import { keyOf } from './batch-read';
8
+ import { columnFor } from './column';
8
9
  import { arrayContains, arrayOverlaps, jsonContains, jsonHasKey } from './containment';
9
10
  import { kindOf, valueAt } from './cursor';
10
11
  import type { EntityCore } from './entity';
@@ -12,6 +13,7 @@ import { EntityError } from './errors';
12
13
  import { searchInMemory } from './feature-errors';
13
14
  import { instantMicros } from './instant';
14
15
  import { isNullish as isNull } from './is-null';
16
+ import { holdsJsonNull } from './json-null';
15
17
  import { likeMatches } from './like';
16
18
  import { DECIMAL_TEXT, numericOrder } from './numeric-compare';
17
19
  import { operatorUnknown } from './operator';
@@ -111,6 +113,10 @@ export const matchesPredicate = <Row>(
111
113
  // predicate and a money sort key both name.
112
114
  const kind = kindOf(entity, predicate.column);
113
115
  const actual = valueAt(row, predicate.column);
116
+ // The row side of every NULL test. A NOT NULL `jsonb` column holding `null` holds the JSON
117
+ // literal (`json-null.ts`), which Postgres' `is null` never matches and its `@>` compares.
118
+ const held = columnFor(entity.$columns, predicate.column);
119
+ const rowIsNull = isNull(actual) && !(held !== undefined && holdsJsonNull(held.$meta));
114
120
  /**
115
121
  * `col = <value>`, with SQL's three-valued logic on both sides: a NULL is never EQUAL to
116
122
  * anything, the other NULL included, so `equals` answers false the moment either side is one
@@ -124,34 +130,32 @@ export const matchesPredicate = <Row>(
124
130
  * `valueAt(row, 'price.minor')` has nothing to read.
125
131
  */
126
132
  const equals = (candidate: unknown): boolean =>
127
- !isNull(actual) && !isNull(candidate) && sameValueOfKind(kind, actual, candidate);
133
+ !rowIsNull && !isNull(candidate) && sameValueOfKind(kind, actual, candidate);
128
134
  // `col > NULL` is UNKNOWN in SQL and UNKNOWN is not a match, so a NULL on EITHER side matches no
129
135
  // row here either — `predicateSql` emits a bare `"col" > $1` and Postgres returns nothing. Without
130
136
  // this the fall-through compared `String(null)` as the text `"null"`, which sorts after `"5"` and
131
137
  // before `"z"`: `gt(seats, 5)` answered the null row in memory and never in production, and
132
138
  // `lt(seats, null)` answered every row. The guard is HERE and not in `compareByKind`, which also
133
139
  // orders a page — a sort puts NULLs last (`asc nulls last`) rather than dropping them.
134
- const unknown = (): boolean => isNull(actual) || isNull(predicate.value);
140
+ const unknown = (): boolean => rowIsNull || isNull(predicate.value);
135
141
  const order = (): number => compareByKind(kind, actual, predicate.value);
136
142
  switch (predicate.op) {
137
143
  // `predicateSql` compiles a null operand to `"col" is null` rather than binding it, so this
138
144
  // is the same predicate, not a widening of it.
139
145
  case 'eq':
140
- return predicate.value === null ? isNull(actual) : equals(predicate.value);
146
+ return predicate.value === null ? rowIsNull : equals(predicate.value);
141
147
  // `is distinct from` reads a NULL as a value on BOTH sides: TRUE where one side is null and
142
148
  // the other is not, FALSE where both are. A bound `undefined` is a NULL parameter there, so
143
149
  // the operand side reads through `isNull` and the two spellings mean one thing.
144
150
  case 'neq':
145
- return isNull(predicate.value) ? !isNull(actual) : !equals(predicate.value);
151
+ return isNull(predicate.value) ? !rowIsNull : !equals(predicate.value);
146
152
  // `in` reads a LIST or nothing: an operand that is not an array matches no row, which is what
147
153
  // `predicateSql` now compiles it to and what `@ultimat3/query` answers for the same operand.
148
154
  // A NULL inside the list is asked as `is null` beside the list there, for the same reason.
149
155
  case 'in':
150
156
  return (
151
157
  Array.isArray(predicate.value) &&
152
- predicate.value.some((candidate) =>
153
- isNull(candidate) ? isNull(actual) : equals(candidate),
154
- )
158
+ predicate.value.some((candidate) => (isNull(candidate) ? rowIsNull : equals(candidate)))
155
159
  );
156
160
  case 'gt':
157
161
  return !unknown() && order() > 0;
@@ -166,22 +170,22 @@ export const matchesPredicate = <Row>(
166
170
  case 'like':
167
171
  return !unknown() && likeMatches(entity.$name, String(predicate.value), String(actual));
168
172
  case 'is-null':
169
- return isNull(actual);
173
+ return rowIsNull;
170
174
  case 'is-not-null':
171
- return !isNull(actual);
175
+ return !rowIsNull;
172
176
  // The containment half, decided by the column's DECLARED kind exactly as everything above it
173
177
  // is: `@>` on a `jsonb` is recursive structural containment and `@>` on an array is plain
174
178
  // element containment, and those are two different operators that happen to share a symbol.
175
179
  // A NULL column value matches nothing, which is what the SQL answers too.
176
180
  case 'contains':
177
- return !isNull(actual) && containsBy(kind, actual, predicate.value);
181
+ return !rowIsNull && containsBy(kind, actual, predicate.value);
178
182
  case 'contained-by':
179
- return !isNull(actual) && containsBy(kind, predicate.value, actual);
183
+ return !rowIsNull && containsBy(kind, predicate.value, actual);
180
184
  // `&&` is arrays only. A `jsonb` column reaching it is refused rather than guessed at: there
181
185
  // is no `jsonb && jsonb` in Postgres, so any answer here would be one no statement can make.
182
186
  case 'overlaps':
183
187
  return (
184
- !isNull(actual) &&
188
+ !rowIsNull &&
185
189
  arrayOverlaps(
186
190
  asArray(entity, predicate, actual),
187
191
  asArray(entity, predicate, predicate.value),
package/src/pg-row.ts CHANGED
@@ -10,6 +10,7 @@ import { kindOf } from './cursor';
10
10
  import type { EntityCore } from './entity';
11
11
  import { invariantViolated } from './errors';
12
12
  import { pgInstantMicros, seekAlias } from './instant';
13
+ import { holdsJsonNull } from './json-null';
13
14
  import { arrayFromDriver, NOT_AN_ARRAY } from './pg-array-decode';
14
15
  import type { SortKey } from './tenancy';
15
16
  import type { AnyColumn, MoneyValue, RowPatch } from './types';
@@ -142,6 +143,9 @@ export const arrayLiteral = (value: unknown): string =>
142
143
  `{${(Array.isArray(value) ? value : [value]).map(arrayElement).join(',')}}`;
143
144
 
144
145
  const bindable = (column: AnyColumn, value: unknown): unknown => {
146
+ // A NOT NULL `jsonb` column's `null` is the JSON literal, `'null'` cast back by the cell — bound
147
+ // as SQL NULL it was refused `23502` by the very column whose schema had just accepted it.
148
+ if (value === null && holdsJsonNull(column.$meta)) return JSON.stringify(null);
145
149
  if (value === null || value === undefined) return null;
146
150
  // A plain object is not a bindable parameter (`X_SQL_UNSAFE`), so a `jsonb` value crosses as its
147
151
  // TEXT and `pg-sql.ts`'s cell casts it back — see `cellCast` for why the cast is `::text::jsonb`.
@@ -232,7 +236,9 @@ export const decodeRow = <Row>(entity: EntityCore<Row>, source: PhysicalRow): Ro
232
236
  column.$meta.kind === 'money' && currency !== undefined
233
237
  ? moneyOf(source, head, currency, scale)
234
238
  : source[head];
235
- if (value !== null && value !== undefined) {
239
+ // `holdsJsonNull`: what the driver hands back for a stored `'null'::jsonb` is a JS `null`, and
240
+ // in a NOT NULL `jsonb` column that is the JSON value — its schema judges it, as it judged it in.
241
+ if (value !== undefined && (value !== null || holdsJsonNull(column.$meta))) {
236
242
  // A sealed column's stored value is not the author's to parse: it goes up as it is, and the
237
243
  // sealing seam opens it — so a row somebody left in the clear is `X_SEAL_INVALID` there
238
244
  // rather than whatever the plaintext parser thinks of its length here.
package/src/view.ts CHANGED
@@ -11,6 +11,7 @@ import {
11
11
  } from '@ultimat3/schema';
12
12
  import { viewKeyUnknown } from './entity-error';
13
13
  import { invariantViolated } from './errors';
14
+ import { holdsJsonNull } from './json-null';
14
15
  import type { AnyColumn, ColumnMap } from './types';
15
16
  import { columnIssue, missingIssue } from './view-issue';
16
17
 
@@ -151,7 +152,7 @@ export const viewFor = <Row, K extends keyof Row & string>(
151
152
  const issues: StandardIssue[] = [];
152
153
  for (const [key, column] of picked) {
153
154
  const given = input[key];
154
- if (given === undefined || given === null) {
155
+ if (given === undefined || (given === null && !holdsJsonNull(column.$meta))) {
155
156
  // No default is filled in: a view projects a row that already exists, so an absent
156
157
  // required column is missing data, never a value the projection may invent.
157
158
  if (column.$meta.notNull) issues.push(missingIssue(key));