@ultimat3/entity 30.8.0 → 31.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 (63) hide show
  1. package/CLAUDE.md +68 -106
  2. package/README.md +180 -8
  3. package/package.json +6 -5
  4. package/src/adapter.ts +65 -0
  5. package/src/batch-read.ts +12 -0
  6. package/src/column.ts +12 -0
  7. package/src/columns.ts +20 -8
  8. package/src/cross-tenant.ts +11 -14
  9. package/src/cursor.ts +3 -0
  10. package/src/database.ts +2 -2
  11. package/src/declared-index.ts +72 -0
  12. package/src/define-driver.ts +156 -0
  13. package/src/describe.ts +5 -0
  14. package/src/driver-delete.ts +62 -0
  15. package/src/driver-errors.ts +80 -0
  16. package/src/driver-kit-types.ts +107 -0
  17. package/src/driver-registry.ts +26 -0
  18. package/src/driver-repo-reads.ts +102 -0
  19. package/src/driver-repo-writes.ts +236 -0
  20. package/src/driver-rows.ts +108 -0
  21. package/src/driver-scope.ts +77 -0
  22. package/src/driver-write-scope.ts +107 -0
  23. package/src/entity-error.ts +10 -0
  24. package/src/entity-parse.ts +3 -2
  25. package/src/entity-patch.ts +3 -1
  26. package/src/entity.ts +40 -54
  27. package/src/enum-column.ts +8 -0
  28. package/src/index.ts +43 -27
  29. package/src/json-null.ts +13 -0
  30. package/src/memory-match.ts +51 -17
  31. package/src/memory-observed.ts +16 -11
  32. package/src/memory-repo.ts +46 -99
  33. package/src/memory-seek.ts +12 -25
  34. package/src/memory-unique.ts +7 -7
  35. package/src/memory-update.ts +7 -2
  36. package/src/memory-upsert.ts +102 -0
  37. package/src/partition.ts +140 -0
  38. package/src/personal-data-kind.ts +15 -0
  39. package/src/personal-data.ts +66 -0
  40. package/src/pg-driver.ts +83 -30
  41. package/src/pg-row.ts +7 -1
  42. package/src/pg-write-sql.ts +39 -4
  43. package/src/plan.ts +2 -0
  44. package/src/predicate-screen.ts +36 -0
  45. package/src/query.ts +13 -4
  46. package/src/registry.ts +3 -0
  47. package/src/row-key.ts +19 -0
  48. package/src/row-observer.ts +38 -90
  49. package/src/search-index-installed.ts +24 -0
  50. package/src/search-index-memory.ts +132 -0
  51. package/src/search-index-postgres.ts +107 -0
  52. package/src/search-index-sync.ts +96 -0
  53. package/src/search-index-version.ts +93 -0
  54. package/src/search-index.ts +168 -0
  55. package/src/search-mirror-batch.ts +133 -0
  56. package/src/search-mirrored-repo.ts +98 -0
  57. package/src/search-repo.ts +91 -0
  58. package/src/search-written-rows.ts +36 -0
  59. package/src/tenant-index.ts +113 -0
  60. package/src/types.ts +12 -0
  61. package/src/view.ts +2 -1
  62. package/src/write-commit.ts +28 -0
  63. package/src/write-tag.ts +6 -2
@@ -0,0 +1,107 @@
1
+ // Single responsibility: the two write-side guards a custom store cannot be trusted with. The rows a
2
+ // store hands back from a write are checked against the tenants the write named — a Mongo-style
3
+ // upsert answering the STORED document of a global unique hands over another tenant's row — and a
4
+ // same-tenant reference is judged by memory's own judge, over targets the kit looked up itself.
5
+
6
+ import { keyOf } from './batch-read';
7
+ import { kindOf, valueAt } from './cursor';
8
+ import { describeReferences } from './describe';
9
+ import { driverRowOutOfScope } from './driver-errors';
10
+ import type { DriverRepo } from './driver-kit-types';
11
+ import type { EntityCore } from './entity';
12
+ import { type MemoryTables, sameTenantJudge } from './memory-same-tenant';
13
+ import { getEntity } from './registry';
14
+ import { assertRowTenant } from './tenancy';
15
+
16
+ /** How the kit reaches another entity's raw store in the same driver. */
17
+ export type RawFor = <R>(entity: EntityCore<R>) => DriverRepo<R>;
18
+
19
+ const cell = (entity: EntityCore, property: string, value: unknown): string =>
20
+ keyOf(kindOf(entity, property) ?? '', value);
21
+
22
+ /**
23
+ * Every returned row names a tenant one of the WRITTEN rows named — or, where a written row named
24
+ * none, passes the actor check `assertRowTenant` applies. Refused whole, never filtered.
25
+ */
26
+ export const assertWrittenInScope = <Row>(
27
+ driverName: string,
28
+ entity: EntityCore<Row>,
29
+ operation: string,
30
+ written: readonly Row[],
31
+ returned: readonly Row[],
32
+ ): readonly Row[] => {
33
+ const column = entity.$tenantColumn;
34
+ if (column === null) return returned;
35
+ const own = entity as EntityCore;
36
+ const named = written.map((row) => valueAt(row, column));
37
+ const allowed = new Set(
38
+ named
39
+ .filter((value) => value !== null && value !== undefined)
40
+ .map((value) => cell(own, column, value)),
41
+ );
42
+ const anyUnnamed = named.some((value) => value === null || value === undefined);
43
+ for (const row of returned) {
44
+ const value = valueAt(row, column);
45
+ if (value !== null && value !== undefined && allowed.has(cell(own, column, value))) continue;
46
+ if (anyUnnamed) {
47
+ assertRowTenant(entity.$name, column, operation, row);
48
+ continue;
49
+ }
50
+ throw driverRowOutOfScope(driverName, entity.$name, operation, column);
51
+ }
52
+ return returned;
53
+ };
54
+
55
+ /** The properties holding a same-tenant reference — a patch naming one has to be judged. */
56
+ export const sameTenantProperties = <Row>(entity: EntityCore<Row>): ReadonlySet<string> =>
57
+ entity.$tenantColumn === null
58
+ ? new Set()
59
+ : new Set(
60
+ describeReferences(entity.$name, Object.entries(entity.$columns))
61
+ .filter((edge) => edge.sameTenant !== null)
62
+ .map((edge) => edge.property),
63
+ );
64
+
65
+ /**
66
+ * `sameTenantJudge` over a table of targets fetched first: each referenced value is read from the
67
+ * target's store UNSCOPED — the judge has to find a target in another tenant to refuse it — and the
68
+ * judge then answers exactly as it does for the memory driver (`X_DB_FOREIGN_KEY_VIOLATION`).
69
+ */
70
+ export const sameTenantReferences = <Row>(entity: EntityCore<Row>, rawFor: RawFor) => {
71
+ const judge = (tables: MemoryTables) => sameTenantJudge(entity, tables);
72
+ return async (rows: readonly Row[]): Promise<void> => {
73
+ if (entity.$tenantColumn === null || rows.length === 0) return;
74
+ const edges = describeReferences(entity.$name, Object.entries(entity.$columns)).filter(
75
+ (edge) => edge.sameTenant !== null,
76
+ );
77
+ if (edges.length === 0) return;
78
+ const tables: MemoryTables = new Map();
79
+ for (const edge of edges) {
80
+ const target = getEntity(edge.targetEntity)?.core;
81
+ if (target === undefined || target.$tenantColumn === null) continue;
82
+ const values = [
83
+ ...new Set(
84
+ rows
85
+ .map((row) => valueAt(row, edge.property))
86
+ .filter((value) => value !== null && value !== undefined),
87
+ ),
88
+ ];
89
+ if (values.length === 0) continue;
90
+ const where = [{ column: edge.targetProperty, op: 'in' as const, value: values }];
91
+ const orderBy = target.$primaryKey.map((column) => ({ column, direction: 'asc' as const }));
92
+ const plan = { entity: target.$name, where, orderBy, limit: values.length };
93
+ const found = await rawFor(target).select({ plan, after: undefined, limit: values.length });
94
+ const prior = tables.get(target.$name);
95
+ tables.set(target.$name, {
96
+ entity: target,
97
+ find: (property, value) =>
98
+ found.find(
99
+ (row) =>
100
+ cell(target, property, valueAt(row, property)) === cell(target, property, value),
101
+ ) ?? prior?.find(property, value),
102
+ });
103
+ }
104
+ const verdict = judge(tables);
105
+ for (const row of rows) verdict(row, rows);
106
+ };
107
+ };
@@ -49,6 +49,10 @@ export const ENTITY_ERROR_CODES = [
49
49
  'X_ENTITY_RETAIN_UNINDEXED',
50
50
  'X_ENTITY_COUNTS_INVALID',
51
51
  'X_ENTITY_READ_ONLY',
52
+ 'X_DRIVER_UNSUPPORTED',
53
+ 'X_DRIVER_CAPABILITY_MISSING',
54
+ 'X_DRIVER_ROW_OUT_OF_SCOPE',
55
+ 'X_ENTITY_PARTITION_INVALID',
52
56
  ] as const;
53
57
 
54
58
  export type EntityErrorCode = (typeof ENTITY_ERROR_CODES)[number];
@@ -93,6 +97,12 @@ export const ENTITY_ERROR_TITLES: Readonly<Record<EntityErrorCode, string>> = {
93
97
  X_ENTITY_COUNTS_INVALID:
94
98
  'references(…, { countedIn }) names no integer of its parent, or is no key to its primary key',
95
99
  X_ENTITY_READ_ONLY: 'a read-only entity was asked to write a row',
100
+ X_DRIVER_UNSUPPORTED: 'a custom driver was asked for a capability it does not declare',
101
+ X_DRIVER_CAPABILITY_MISSING:
102
+ 'a custom driver declares a capability its raw repo does not implement',
103
+ X_DRIVER_ROW_OUT_OF_SCOPE: 'a custom driver returned a row outside the plan it was given',
104
+ X_ENTITY_PARTITION_INVALID:
105
+ 'entity({ partitionBy }) declares a partitioning Postgres cannot build',
96
106
  };
97
107
 
98
108
  // Registered at module load, unconditionally, in one call. Without this the registry humanises the
@@ -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
  }
package/src/entity.ts CHANGED
@@ -7,6 +7,7 @@ import type { IndexMethod } from '@ultimat3/db';
7
7
  import type { Schema } from '@ultimat3/schema';
8
8
  import { assertAppendOnlyColumns } from './append-only';
9
9
  import { assertTableName, bindColumn, columnName, moneyColumns } from './column';
10
+ import { declaredIndexOf } from './declared-index';
10
11
  import {
11
12
  assertNotRetired,
12
13
  assertScan,
@@ -26,6 +27,7 @@ import { indexName } from './index-name';
26
27
  import type { Invariant, InvariantDef } from './invariants';
27
28
  import { assertInvariants, bindInvariant } from './invariants';
28
29
  import { describeLifecycle, lifecycleHistory } from './lifecycle';
30
+ import { type PartitionInit, partitionOf } from './partition';
29
31
  import { assertReadOnly } from './read-only';
30
32
  import { recordProjection } from './record-projection';
31
33
  import type { EntityDescription, ReferenceDescription } from './registry';
@@ -38,6 +40,7 @@ import { sealedInView } from './sealed-errors';
38
40
  import type { SearchInit, SearchSource, SearchVector } from './search';
39
41
  import { searchVectorOf } from './search';
40
42
  import { resolveTenantColumn } from './tenancy';
43
+ import { coveredByTenantIndex, tenantIndexOf, withoutDerivedDuplicate } from './tenant-index';
41
44
  import type { AnyColumn, ColumnMap, IndexDef, RowOf, WireRow } from './types';
42
45
  import type { EntityView } from './view';
43
46
  import { viewFor } from './view';
@@ -125,6 +128,12 @@ export interface EntityInit<C extends ColumnMap> {
125
128
  readonly retain?: RetainInit<keyof C & string>;
126
129
  /** Every row change recorded into `<table>_history` by trigger (`history.ts`, `$history`). */
127
130
  readonly history?: HistoryInit;
131
+ /**
132
+ * Range-partitioned by a `timestamp()`/`date()` in the primary key, one partition per `interval`
133
+ * (`partition.ts`). `x db gen` writes `partition by range` and the first partitions; a scheduled
134
+ * maintenance run creates `ahead` more and drops what `retain` expired. Never beside `retain`.
135
+ */
136
+ readonly partitionBy?: PartitionInit<keyof C & string>;
128
137
  /** Set by `history.ts` on the history entity it declares: the parent's name. Never by hand. */
129
138
  readonly historyOf?: string;
130
139
  }
@@ -307,6 +316,7 @@ export const entity = <const C extends ColumnMap>(
307
316
  const physical = [
308
317
  meta.kind === 'money' ? moneyColumns(property, meta).minor : columnName(property, meta),
309
318
  ];
319
+ if (coveredByTenantIndex(physical[0] ?? '', tenant, meta.unique)) return [];
310
320
  return [
311
321
  {
312
322
  name: indexName(name, table, physical, meta.unique),
@@ -315,58 +325,9 @@ export const entity = <const C extends ColumnMap>(
315
325
  },
316
326
  ];
317
327
  }),
318
- ...(init.indexes ?? []).map((index) => {
319
- const columns = index.on.map((property) => resolve([property]));
320
- const unique = index.unique === true;
321
- // `on: []` is type-legal and names nothing: the generated DDL would be `on "posts" ()`, a
322
- // syntax error one migration later. Refused where it was written instead.
323
- if (columns.length === 0) {
324
- throw invariantViolated(name, 'index', 'an index must name at least one column');
325
- }
326
- const where = index.where?.(columnsExpr).toSql(resolve) ?? null;
327
- if (index.where !== undefined && where === null) {
328
- throw invariantViolated(
329
- name,
330
- 'index',
331
- 'a partial index predicate must be expressible in SQL; a JS predicate cannot be one',
332
- );
333
- }
334
- // Two rules Postgres has that a declaration can break, refused where the author wrote it.
335
- // `@ultimat3/db` refuses both again at `createIndex` — that is not a duplicate, it is the
336
- // guard for a description nobody built here — but its refusal lands at `x db gen` or, if a
337
- // migration was already written, inside `ROLE=migrate` as the server's own syntax error with
338
- // none of the entity's words in it.
339
- if (index.using !== undefined && index.using !== 'btree') {
340
- if (unique) {
341
- throw invariantViolated(
342
- name,
343
- 'index',
344
- `the index on (${columns.join(', ')}) is unique and ${index.using}; ` +
345
- `Postgres has no unique ${index.using} index — drop unique, or drop using`,
346
- );
347
- }
348
- if (index.order !== undefined) {
349
- throw invariantViolated(
350
- name,
351
- 'index',
352
- `the index on (${columns.join(', ')}) is ${index.using} and ${index.order}; ` +
353
- 'only a btree orders its keys — drop order, or drop using',
354
- );
355
- }
356
- }
357
- return {
358
- name: indexName(name, table, columns, unique, index.order, where, index.using),
359
- columns,
360
- unique,
361
- ...(index.order === undefined ? {} : { order: index.order }),
362
- ...(where === null ? {} : { where }),
363
- // Absent stays absent: `btree` written out would be a field every existing snapshot lacks,
364
- // and `indexMethodOf` reads the two the same way precisely so nothing regenerates.
365
- ...(index.using === undefined || index.using === 'btree' ? {} : { using: index.using }),
366
- // How it is BUILT, so not part of the name: `true` or absent, like `appendOnly`.
367
- ...(index.concurrently === true ? { concurrently: true as const } : {}),
368
- };
369
- }),
328
+ ...(init.indexes ?? []).map((index) =>
329
+ declaredIndexOf(index, { name, table, resolve, columns: columnsExpr }),
330
+ ),
370
331
  // The one index nobody declared and every search needs. Through the SAME `IndexInit` path a
371
332
  // hand-written `using: 'gin'` takes — `indexName` gives it the method discriminator, so it can
372
333
  // never collide with a btree an author declares on the same column.
@@ -381,10 +342,34 @@ export const entity = <const C extends ColumnMap>(
381
342
  },
382
343
  ]),
383
344
  ];
345
+ // After every other index, so one that already starts with `(tenant, pk)` is seen (`tenant-index.ts`).
346
+ const physicalKey = primaryKey.map((property) => resolve([property]));
347
+ const [tenantIndex] = tenantIndexOf({
348
+ entityName: name,
349
+ table,
350
+ tenant,
351
+ primaryKey: physicalKey,
352
+ plainView: init.view != null && init.view.materialized !== true,
353
+ covering: [
354
+ ...declared.filter((index) => index.where == null && index.using === undefined),
355
+ ...invariants.filter((inv) => inv.kind === 'unique' && inv.where == null),
356
+ ].map((each) => each.columns),
357
+ });
384
358
  // A foreign key's own index named again in `indexes` is one index, not two (`index-identity.ts`).
385
- const indexes = distinctIndexes(declared);
359
+ const indexes = distinctIndexes(
360
+ tenantIndex === undefined ? declared : [...declared, tenantIndex],
361
+ );
386
362
 
387
363
  const retain = retainOf(name, init.retain, entries, indexes, appendOnly);
364
+ const partition = partitionOf(name, init.partitionBy, {
365
+ table,
366
+ columns: entries,
367
+ primaryKey,
368
+ indexes,
369
+ uniques: invariants.filter((inv) => inv.kind === 'unique').map((inv) => inv.columns),
370
+ retain: init.retain !== undefined,
371
+ view: init.view != null,
372
+ });
388
373
  const sealed = sealedFields({ $name: name, $table: table, $columns: init.columns });
389
374
  assertSealedDeclaration({
390
375
  name,
@@ -411,7 +396,7 @@ export const entity = <const C extends ColumnMap>(
411
396
  columns: entries,
412
397
  primaryKey,
413
398
  invariants,
414
- indexes,
399
+ indexes: withoutDerivedDuplicate(table, physicalKey, tenantIndex, indexes),
415
400
  tags,
416
401
  cacheTag,
417
402
  softDelete,
@@ -424,6 +409,7 @@ export const entity = <const C extends ColumnMap>(
424
409
  readOnly: init.readOnly,
425
410
  view: viewQuery,
426
411
  lifecycle: describeLifecycle(core as EntityCore, retain, init.historyOf),
412
+ partition,
427
413
  });
428
414
  assertNotRetired(name, entries, init.retired);
429
415
  const references = (): readonly ReferenceDescription[] => describeReferences(name, entries);
@@ -14,6 +14,14 @@ import type { ColumnMeta, EnumeratedColumn } from './types';
14
14
  * transaction on older servers.
15
15
  */
16
16
  export const enumerated = <const V extends readonly string[]>(values: V): EnumeratedColumn<V> => {
17
+ // An empty set renders `x in ()`, which `x db migrate` dies on with 42601 — refused here instead.
18
+ if (values.length === 0) {
19
+ refuseColumn(
20
+ 'enum',
21
+ 'the set of allowed values is empty — no row could ever hold this column',
22
+ "enumerated(['draft', 'published']) — declare at least one value; a column with no legal value is a column to delete",
23
+ );
24
+ }
17
25
  const allowed = new Set<string>(values);
18
26
  const parse = (value: unknown): V[number] =>
19
27
  typeof value === 'string' && allowed.has(value)
package/src/index.ts CHANGED
@@ -1,5 +1,12 @@
1
1
  // The public surface of @ultimat3/entity. Explicit, never `export *`.
2
2
 
3
+ // The `entity.*` payloads are core's (the bus is tier 0); re-exported, never a second shape.
4
+ export type {
5
+ EntityBulkEvent,
6
+ EntityCrossTenantEvent,
7
+ EntityRowEvent,
8
+ EntityRowOp,
9
+ } from '@ultimat3/core';
3
10
  /** Re-exported so an `entity` file needs one import, not two. Same object as schema's. */
4
11
  export type { Infer } from '@ultimat3/schema';
5
12
  export { t } from '@ultimat3/schema';
@@ -32,10 +39,11 @@ export type { Counter } from './counter';
32
39
  export { countersOf, describeCounter } from './counter';
33
40
  // `crossTenantReason` stays internal: an app that could read the flag would have a second way to
34
41
  // reason about tenant scope — branch on it — next to the one way, which is entering the scope.
35
- export { CROSS_TENANT_SCOPE, crossTenant, setCrossTenantObserver } from './cross-tenant';
42
+ export { CROSS_TENANT_SCOPE, crossTenant } from './cross-tenant';
43
+ // Leaves, so a reader of a row's key (`@ultimat3/jobs`' reindex sweep) skips `./adapter`'s driver kit.
44
+ export { kindOf } from './cursor';
36
45
  export type { Database, DatabaseOptions, Driver, EntitySet } from './database';
37
46
  export { database, defaultDriver, memoryDriver } from './database';
38
- export type { DescribeInput } from './describe';
39
47
  export type { TenantColumnDescription } from './describe-tenancy';
40
48
  export { describeTenancy } from './describe-tenancy';
41
49
  export type { Entity, EntityCore, EntityInit } from './entity';
@@ -46,41 +54,24 @@ export { entity, SOFT_DELETE_COLUMN } from './entity';
46
54
  export type {
47
55
  EntityErrorCode,
48
56
  PreloadCandidate,
49
- QueryLoopBatch,
50
- WriteLoopBatch,
51
57
  } from './errors';
52
58
  // Every error factory this package owns, its three tenancy siblings included: `Driver` is public,
53
59
  // so a third-party driver has to be able to raise the same refusals the two shipped ones do.
54
60
  export {
55
- crossTenantDenied,
56
61
  ENTITY_ERROR_CODES,
57
62
  ENTITY_ERROR_TITLES,
58
63
  EntityError,
59
64
  entityDuplicate,
60
65
  invariantViolated,
61
66
  notFound,
62
- nPlusOneQuery,
63
- nPlusOneWrite,
64
67
  patchEmpty,
65
68
  preloadUnknownRelation,
66
- repoClientPinned,
67
- tenancyActorMismatch,
68
69
  tenancyActorOrgRequired,
69
70
  tenancyRowMismatch,
70
- tenancyUnscoped,
71
- writeUnfiltered,
72
71
  } from './errors';
73
72
  export type { ColumnExpr, Expr, InvariantColumns, Resolve } from './expr';
74
73
  export { iff } from './expr';
75
74
  /** The two DECLARED capabilities' refusals — a third-party driver raises the same ones. */
76
- export type { IllegalTransition } from './feature-errors';
77
- export {
78
- searchInMemory,
79
- searchUndeclared,
80
- stateConflict,
81
- stateTransitionIllegal,
82
- stateUndeclared,
83
- } from './feature-errors';
84
75
  export type { HistoryColumns, HistoryEntity, HistoryInit } from './history';
85
76
  export type { IndexInit } from './index-init';
86
77
  export type { Invariant, InvariantDef, InvariantKind } from './invariants';
@@ -94,7 +85,20 @@ export { memoryRepo, memoryTransactor } from './memory-repo';
94
85
  export type { StatementLoop } from './n-plus-one';
95
86
  export { N_PLUS_ONE_THRESHOLD, nPlusOne, preloadsFor } from './n-plus-one';
96
87
  export { isOperator, OPERATORS } from './operator';
88
+ // `entity({ partitionBy })`, checked at declaration (`partition.ts`); the SQL is `@ultimat3/db`'s.
89
+ export type {
90
+ PartitionContext,
91
+ PartitionDescription,
92
+ PartitionInit,
93
+ } from './partition';
94
+ export { partitionOf } from './partition';
97
95
  export { persistedRecordTypes } from './persisted-types';
96
+ // GDPR: the personal-data column marker and its readers (`personal-data.ts`).
97
+ export type { PersonalDataColumn, PersonalDataEntity, PersonalDataKind } from './personal-data';
98
+ export {
99
+ personalDataEntities,
100
+ personalDataOf,
101
+ } from './personal-data';
98
102
  // For a change feed holding a table name and raw columns: `entityForTable` + `decodeRow` is the row
99
103
  // the app declared, money and all. Exported for `@ultimat3/realtime` (plan 101, 06 i).
100
104
  export type { PgArray } from './pg-array-literal';
@@ -154,8 +158,8 @@ export type {
154
158
  UpsertArgs,
155
159
  } from './repo';
156
160
  export type { Retain, RetainInit } from './retain';
157
- export type { RowBulkChange, RowChange, RowChangeOp, RowObserver } from './row-observer';
158
- export { observedRepo, rowObserver, setRowObserver } from './row-observer';
161
+ export { rowKey } from './row-key';
162
+ export { observedRepo } from './row-observer';
159
163
  export { rowSecuritySettings } from './row-security-settings';
160
164
  export type { RecordsByKey, RecordsByType } from './rows-of';
161
165
  export { hasEntityRows, projectionsIn, rowsOf } from './rows-of';
@@ -168,14 +172,27 @@ export { sealedRepo } from './sealed-repo';
168
172
  // `SEARCH_PROPERTY` is what a `matches` predicate names, which a hand-built `QueryPlan` needs.
169
173
  export type { SearchInit, SearchLanguage, SearchSource, SearchVector } from './search';
170
174
  export {
171
- DEFAULT_SEARCH_COLUMN,
172
- DEFAULT_SEARCH_LANGUAGE,
173
- DEFAULT_SEARCH_WEIGHT,
174
- isSearchLanguage,
175
- isSearchWeight,
176
175
  SEARCH_LANGUAGES,
177
176
  SEARCH_WEIGHTS,
178
177
  } from './search';
178
+ // The searchIndex port: types, the document builder, the installed index, the two shipped ones.
179
+ export type {
180
+ SearchDocument,
181
+ SearchHit,
182
+ SearchIndex,
183
+ SearchIndexOptions,
184
+ SearchListEntry,
185
+ SearchListPage,
186
+ SearchListQuery,
187
+ SearchQuery,
188
+ SearchScope,
189
+ } from './search-index';
190
+ export { searchDocumentOf } from './search-index';
191
+ export { searchIndex } from './search-index-installed';
192
+ export { memorySearchIndex } from './search-index-memory';
193
+ export type { PostgresSearchIndexOptions } from './search-index-postgres';
194
+ export { postgresSearchIndex } from './search-index-postgres';
195
+ export { searchVersionColumn, searchVersionOf } from './search-index-version';
179
196
  export type {
180
197
  Seed,
181
198
  SeedContext,
@@ -195,7 +212,6 @@ export {
195
212
  canMove,
196
213
  isState,
197
214
  isTerminal,
198
- movesFrom,
199
215
  stateMachineOf,
200
216
  } from './state-machine';
201
217
  export type { Operator, Predicate, QueryPlan, SortDirection, SortKey } from './tenancy';
@@ -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';
@@ -21,15 +23,44 @@ import type { ColumnKind } from './types';
21
23
  const sign = <T extends number | bigint | string>(left: T, right: T): number =>
22
24
  left < right ? -1 : left > right ? 1 : 0;
23
25
 
26
+ /**
27
+ * Text in CODE-POINT order, which is byte order over UTF-8 and so Postgres' `C` collation — the
28
+ * order `auth`'s memory adapter already keeps. JS `<` compares UTF-16 code units, which puts an
29
+ * astral character (lead surrogate 0xD800–0xDBFF) ahead of U+E000–U+FFFF: `'😀' < '!'` is true
30
+ * here and false there, so a page cut on a text key differed between dev and prod. The first
31
+ * differing unit decides; `codePointAt` there reads the whole character when it is a lead surrogate.
32
+ */
33
+ export const byCodePoint = (left: string, right: string): number => {
34
+ const shorter = Math.min(left.length, right.length);
35
+ for (let at = 0; at < shorter; at += 1) {
36
+ if (left.charCodeAt(at) !== right.charCodeAt(at)) {
37
+ return sign(left.codePointAt(at) ?? 0, right.codePointAt(at) ?? 0);
38
+ }
39
+ }
40
+ return sign(left.length, right.length);
41
+ };
42
+
43
+ /**
44
+ * An `integer()` operand written as TEXT is the number Postgres parses it as: the parameter takes
45
+ * the column's type, so `id = '007'` finds row 7 there, and `===` against `7` found nothing here.
46
+ * Only an integer literal is read; anything else stays as written and compares as text.
47
+ */
48
+ const INTEGER_TEXT = /^\s*[+-]?\d+\s*$/;
49
+ const asInteger = (kind: ColumnKind | undefined, value: unknown): unknown =>
50
+ kind === 'integer' && typeof value === 'string' && INTEGER_TEXT.test(value)
51
+ ? Number(value)
52
+ : value;
53
+
24
54
  /**
25
55
  * Two values of one column, ordered as Postgres orders that column. `-1`, `0` or `1` — never a
26
56
  * difference, so a `bigint` pair needs no subtraction it cannot express in a `number`.
27
57
  */
28
58
  export const compareByKind = (
29
59
  kind: ColumnKind | undefined,
30
- left: unknown,
31
- right: unknown,
60
+ given: unknown,
61
+ operand: unknown,
32
62
  ): number => {
63
+ const [left, right] = [asInteger(kind, given), asInteger(kind, operand)];
33
64
  // NULL is the LARGEST value, which is what `order by` means in Postgres and what `nulls last`
34
65
  // ascending / `nulls first` descending spell out (`pg-sql.ts`'s `orderSql`, `@ultimat3/query`'s
35
66
  // `compareValues`). Two absences are equal, so the next sort key decides — without this rule a
@@ -62,7 +93,7 @@ export const compareByKind = (
62
93
  // `undefined` means the pair is not a numeric comparison, and it falls through as text.
63
94
  const numeric = numericOrder(kind !== undefined && DECIMAL_TEXT.has(kind), left, right);
64
95
  if (numeric !== undefined) return numeric;
65
- return sign(String(left), String(right));
96
+ return byCodePoint(String(left), String(right));
66
97
  };
67
98
 
68
99
  /**
@@ -74,9 +105,10 @@ export const compareByKind = (
74
105
  */
75
106
  export const sameValueOfKind = (
76
107
  kind: ColumnKind | undefined,
77
- left: unknown,
78
- right: unknown,
108
+ given: unknown,
109
+ operand: unknown,
79
110
  ): boolean => {
111
+ const [left, right] = [asInteger(kind, given), asInteger(kind, operand)];
80
112
  if (left instanceof Date && right instanceof Date) return left.getTime() === right.getTime();
81
113
  // An instant handed over as ISO text is the instant it names, as Postgres parses the bound
82
114
  // parameter: `eq` against a stored `Date` compared an object to a string and matched no row.
@@ -111,6 +143,10 @@ export const matchesPredicate = <Row>(
111
143
  // predicate and a money sort key both name.
112
144
  const kind = kindOf(entity, predicate.column);
113
145
  const actual = valueAt(row, predicate.column);
146
+ // The row side of every NULL test. A NOT NULL `jsonb` column holding `null` holds the JSON
147
+ // literal (`json-null.ts`), which Postgres' `is null` never matches and its `@>` compares.
148
+ const held = columnFor(entity.$columns, predicate.column);
149
+ const rowIsNull = isNull(actual) && !(held !== undefined && holdsJsonNull(held.$meta));
114
150
  /**
115
151
  * `col = <value>`, with SQL's three-valued logic on both sides: a NULL is never EQUAL to
116
152
  * anything, the other NULL included, so `equals` answers false the moment either side is one
@@ -124,34 +160,32 @@ export const matchesPredicate = <Row>(
124
160
  * `valueAt(row, 'price.minor')` has nothing to read.
125
161
  */
126
162
  const equals = (candidate: unknown): boolean =>
127
- !isNull(actual) && !isNull(candidate) && sameValueOfKind(kind, actual, candidate);
163
+ !rowIsNull && !isNull(candidate) && sameValueOfKind(kind, actual, candidate);
128
164
  // `col > NULL` is UNKNOWN in SQL and UNKNOWN is not a match, so a NULL on EITHER side matches no
129
165
  // row here either — `predicateSql` emits a bare `"col" > $1` and Postgres returns nothing. Without
130
166
  // this the fall-through compared `String(null)` as the text `"null"`, which sorts after `"5"` and
131
167
  // before `"z"`: `gt(seats, 5)` answered the null row in memory and never in production, and
132
168
  // `lt(seats, null)` answered every row. The guard is HERE and not in `compareByKind`, which also
133
169
  // orders a page — a sort puts NULLs last (`asc nulls last`) rather than dropping them.
134
- const unknown = (): boolean => isNull(actual) || isNull(predicate.value);
170
+ const unknown = (): boolean => rowIsNull || isNull(predicate.value);
135
171
  const order = (): number => compareByKind(kind, actual, predicate.value);
136
172
  switch (predicate.op) {
137
173
  // `predicateSql` compiles a null operand to `"col" is null` rather than binding it, so this
138
174
  // is the same predicate, not a widening of it.
139
175
  case 'eq':
140
- return predicate.value === null ? isNull(actual) : equals(predicate.value);
176
+ return predicate.value === null ? rowIsNull : equals(predicate.value);
141
177
  // `is distinct from` reads a NULL as a value on BOTH sides: TRUE where one side is null and
142
178
  // the other is not, FALSE where both are. A bound `undefined` is a NULL parameter there, so
143
179
  // the operand side reads through `isNull` and the two spellings mean one thing.
144
180
  case 'neq':
145
- return isNull(predicate.value) ? !isNull(actual) : !equals(predicate.value);
181
+ return isNull(predicate.value) ? !rowIsNull : !equals(predicate.value);
146
182
  // `in` reads a LIST or nothing: an operand that is not an array matches no row, which is what
147
183
  // `predicateSql` now compiles it to and what `@ultimat3/query` answers for the same operand.
148
184
  // A NULL inside the list is asked as `is null` beside the list there, for the same reason.
149
185
  case 'in':
150
186
  return (
151
187
  Array.isArray(predicate.value) &&
152
- predicate.value.some((candidate) =>
153
- isNull(candidate) ? isNull(actual) : equals(candidate),
154
- )
188
+ predicate.value.some((candidate) => (isNull(candidate) ? rowIsNull : equals(candidate)))
155
189
  );
156
190
  case 'gt':
157
191
  return !unknown() && order() > 0;
@@ -166,22 +200,22 @@ export const matchesPredicate = <Row>(
166
200
  case 'like':
167
201
  return !unknown() && likeMatches(entity.$name, String(predicate.value), String(actual));
168
202
  case 'is-null':
169
- return isNull(actual);
203
+ return rowIsNull;
170
204
  case 'is-not-null':
171
- return !isNull(actual);
205
+ return !rowIsNull;
172
206
  // The containment half, decided by the column's DECLARED kind exactly as everything above it
173
207
  // is: `@>` on a `jsonb` is recursive structural containment and `@>` on an array is plain
174
208
  // element containment, and those are two different operators that happen to share a symbol.
175
209
  // A NULL column value matches nothing, which is what the SQL answers too.
176
210
  case 'contains':
177
- return !isNull(actual) && containsBy(kind, actual, predicate.value);
211
+ return !rowIsNull && containsBy(kind, actual, predicate.value);
178
212
  case 'contained-by':
179
- return !isNull(actual) && containsBy(kind, predicate.value, actual);
213
+ return !rowIsNull && containsBy(kind, predicate.value, actual);
180
214
  // `&&` is arrays only. A `jsonb` column reaching it is refused rather than guessed at: there
181
215
  // is no `jsonb && jsonb` in Postgres, so any answer here would be one no statement can make.
182
216
  case 'overlaps':
183
217
  return (
184
- !isNull(actual) &&
218
+ !rowIsNull &&
185
219
  arrayOverlaps(
186
220
  asArray(entity, predicate, actual),
187
221
  asArray(entity, predicate, predicate.value),