@ultimat3/entity 30.9.0 → 31.1.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 (58) 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.ts +40 -54
  25. package/src/enum-column.ts +8 -0
  26. package/src/index.ts +43 -27
  27. package/src/memory-match.ts +35 -5
  28. package/src/memory-observed.ts +16 -11
  29. package/src/memory-repo.ts +46 -99
  30. package/src/memory-seek.ts +12 -25
  31. package/src/memory-unique.ts +7 -7
  32. package/src/memory-update.ts +7 -2
  33. package/src/memory-upsert.ts +102 -0
  34. package/src/partition.ts +140 -0
  35. package/src/personal-data-kind.ts +15 -0
  36. package/src/personal-data.ts +66 -0
  37. package/src/pg-driver.ts +83 -30
  38. package/src/pg-write-sql.ts +39 -4
  39. package/src/plan.ts +2 -0
  40. package/src/predicate-screen.ts +36 -0
  41. package/src/query.ts +13 -4
  42. package/src/registry.ts +3 -0
  43. package/src/row-key.ts +19 -0
  44. package/src/row-observer.ts +38 -90
  45. package/src/search-index-installed.ts +24 -0
  46. package/src/search-index-memory.ts +132 -0
  47. package/src/search-index-postgres.ts +107 -0
  48. package/src/search-index-sync.ts +96 -0
  49. package/src/search-index-version.ts +93 -0
  50. package/src/search-index.ts +168 -0
  51. package/src/search-mirror-batch.ts +133 -0
  52. package/src/search-mirrored-repo.ts +98 -0
  53. package/src/search-repo.ts +91 -0
  54. package/src/search-written-rows.ts +36 -0
  55. package/src/tenant-index.ts +113 -0
  56. package/src/types.ts +12 -0
  57. package/src/write-commit.ts +28 -0
  58. 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
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';
@@ -23,15 +23,44 @@ import type { ColumnKind } from './types';
23
23
  const sign = <T extends number | bigint | string>(left: T, right: T): number =>
24
24
  left < right ? -1 : left > right ? 1 : 0;
25
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
+
26
54
  /**
27
55
  * Two values of one column, ordered as Postgres orders that column. `-1`, `0` or `1` — never a
28
56
  * difference, so a `bigint` pair needs no subtraction it cannot express in a `number`.
29
57
  */
30
58
  export const compareByKind = (
31
59
  kind: ColumnKind | undefined,
32
- left: unknown,
33
- right: unknown,
60
+ given: unknown,
61
+ operand: unknown,
34
62
  ): number => {
63
+ const [left, right] = [asInteger(kind, given), asInteger(kind, operand)];
35
64
  // NULL is the LARGEST value, which is what `order by` means in Postgres and what `nulls last`
36
65
  // ascending / `nulls first` descending spell out (`pg-sql.ts`'s `orderSql`, `@ultimat3/query`'s
37
66
  // `compareValues`). Two absences are equal, so the next sort key decides — without this rule a
@@ -64,7 +93,7 @@ export const compareByKind = (
64
93
  // `undefined` means the pair is not a numeric comparison, and it falls through as text.
65
94
  const numeric = numericOrder(kind !== undefined && DECIMAL_TEXT.has(kind), left, right);
66
95
  if (numeric !== undefined) return numeric;
67
- return sign(String(left), String(right));
96
+ return byCodePoint(String(left), String(right));
68
97
  };
69
98
 
70
99
  /**
@@ -76,9 +105,10 @@ export const compareByKind = (
76
105
  */
77
106
  export const sameValueOfKind = (
78
107
  kind: ColumnKind | undefined,
79
- left: unknown,
80
- right: unknown,
108
+ given: unknown,
109
+ operand: unknown,
81
110
  ): boolean => {
111
+ const [left, right] = [asInteger(kind, given), asInteger(kind, operand)];
82
112
  if (left instanceof Date && right instanceof Date) return left.getTime() === right.getTime();
83
113
  // An instant handed over as ISO text is the instant it names, as Postgres parses the bound
84
114
  // parameter: `eq` against a stored `Date` compared an object to a string and matched no row.
@@ -1,10 +1,10 @@
1
- // Single responsibility: the memory driver's half of `@ultimat3/db`'s statement seam. Generated
1
+ // Single responsibility: the memory driver's half of the `db.statement` event. Generated
2
2
  // tests run on this driver, so a loop it never reported was a `statements` assertion that could not
3
3
  // fail. One event per repository operation, attributed exactly as `postgresRepo` attributes its
4
4
  // statement; nothing is sent, so `text` names the operation and holds no SQL.
5
5
 
6
- import { tryUseContext } from '@ultimat3/core';
7
- import { expectedQueryLoopReason, statementObserver } from '@ultimat3/db';
6
+ import { callerStack, instrument, subscribed, tryUseContext } from '@ultimat3/core';
7
+ import { expectedQueryLoopReason } from '@ultimat3/db';
8
8
  import type { MemoryRepo } from './repo';
9
9
 
10
10
  /** What one operation reports: the verb `statementKind` reads, and its row count. */
@@ -48,11 +48,13 @@ const report = (
48
48
  op: string,
49
49
  verb: Reported['verb'],
50
50
  rows: number,
51
+ callerStack: string | undefined,
51
52
  error?: unknown,
52
53
  ): void => {
53
- const observer = statementObserver();
54
- if (observer === undefined) return;
55
- observer.onStatement({
54
+ if (!subscribed('db.statement')) return;
55
+ // Emitted here, not through `@ultimat3/db`: nothing is sent, so no funnel sees it — this IS the
56
+ // memory driver's funnel, and a subscriber must not be able to tell the two drivers apart.
57
+ instrument('db.statement', {
56
58
  text: `${verb} ${entity}.${op} (memory driver, no SQL sent)`,
57
59
  values: [],
58
60
  // Nothing crossed a wire, and a duration made up of `Map` reads would only rank as noise.
@@ -61,6 +63,7 @@ const report = (
61
63
  ...(error === undefined ? {} : { error }),
62
64
  attribution: { entity, op },
63
65
  expected: expectedQueryLoopReason(),
66
+ callerStack,
64
67
  });
65
68
  };
66
69
 
@@ -86,7 +89,7 @@ const pointLookups = (): (() => boolean) => {
86
89
 
87
90
  type AnyMethod = (...args: readonly unknown[]) => Promise<unknown>;
88
91
 
89
- /** `repo`, each statement-shaped method reporting once it settles, as the funnels report. */
92
+ /** `repo`, each statement-shaped method emitting once it settles, as the funnels emit. */
90
93
  export function observedMemoryRepo<Row>(entity: string, repo: MemoryRepo<Row>): MemoryRepo<Row> {
91
94
  const wrapped: Record<string, AnyMethod> = {};
92
95
  const firstInMicrotask = pointLookups();
@@ -94,26 +97,28 @@ export function observedMemoryRepo<Row>(entity: string, repo: MemoryRepo<Row>):
94
97
  const method = repo[op] as AnyMethod;
95
98
  const { verb, rows } = OPERATIONS[op];
96
99
  wrapped[op] = async (...args) => {
97
- if (statementObserver() === undefined) return method(...args);
100
+ if (!subscribed('db.statement')) return method(...args);
98
101
  // Decided as the call is made: by the time the row is back the microtask has moved on.
99
102
  const reports = op !== 'findById' || firstInMicrotask();
100
103
  // `postgresRepo` attributes an aggregate to its function, never to `aggregate`.
101
104
  const name = op === 'aggregate' ? String(args[0]) : op;
105
+ // Before the `await`, as the Postgres funnel reads it: nothing unless capture is on.
106
+ const stack = callerStack();
102
107
  let answer: unknown;
103
108
  try {
104
109
  answer = await method(...args);
105
110
  } catch (error) {
106
- if (reports) report(entity, name, verb, 0, error);
111
+ if (reports) report(entity, name, verb, 0, stack, error);
107
112
  throw error;
108
113
  }
109
- if (reports) report(entity, name, verb, rows(answer));
114
+ if (reports) report(entity, name, verb, rows(answer), stack);
110
115
  return answer;
111
116
  };
112
117
  }
113
118
  for (const op of LOCKS) {
114
119
  const method = repo[op] as AnyMethod;
115
120
  wrapped[op] = async (...args) => {
116
- report(entity, op, 'select', 1);
121
+ report(entity, op, 'select', 1, callerStack());
117
122
  return method(...args);
118
123
  };
119
124
  }