@ultimat3/entity 29.3.2 → 30.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 (67) hide show
  1. package/CLAUDE.md +66 -96
  2. package/README.md +125 -8
  3. package/package.json +15 -5
  4. package/src/append-only.ts +7 -0
  5. package/src/batch-read.ts +12 -4
  6. package/src/bulk-write-send.ts +49 -0
  7. package/src/bulk-write.ts +48 -11
  8. package/src/column.ts +29 -4
  9. package/src/counter.ts +109 -0
  10. package/src/cross-tenant.ts +13 -0
  11. package/src/cursor.ts +17 -1
  12. package/src/database.ts +16 -10
  13. package/src/describe-tenancy.ts +25 -0
  14. package/src/describe.ts +146 -3
  15. package/src/entity-error.ts +33 -0
  16. package/src/entity-parse.ts +89 -0
  17. package/src/entity-patch.ts +42 -0
  18. package/src/entity.ts +95 -98
  19. package/src/errors.ts +8 -0
  20. package/src/history-actor.ts +22 -0
  21. package/src/history.ts +86 -0
  22. package/src/index-identity.ts +46 -0
  23. package/src/index-init.ts +12 -0
  24. package/src/index.ts +20 -1
  25. package/src/jit-preload.ts +35 -25
  26. package/src/lifecycle.ts +100 -0
  27. package/src/lock-key.ts +27 -0
  28. package/src/lock-unsupported.ts +11 -0
  29. package/src/memory-aggregates.ts +99 -0
  30. package/src/memory-batch.ts +69 -0
  31. package/src/memory-journal.ts +68 -0
  32. package/src/memory-lifecycle.ts +106 -0
  33. package/src/memory-lock.ts +144 -0
  34. package/src/memory-observed.ts +121 -0
  35. package/src/memory-repo.ts +199 -186
  36. package/src/memory-same-tenant.ts +83 -0
  37. package/src/memory-seek.ts +44 -0
  38. package/src/memory-transactor.ts +94 -0
  39. package/src/memory-unique.ts +19 -4
  40. package/src/n-plus-one.ts +28 -10
  41. package/src/numeric-compare.ts +43 -0
  42. package/src/pg-aggregates.ts +185 -0
  43. package/src/pg-driver.ts +79 -166
  44. package/src/pg-lock.ts +50 -0
  45. package/src/pg-row.ts +29 -2
  46. package/src/pg-sql-aggregate.ts +31 -0
  47. package/src/pg-sql.ts +17 -1
  48. package/src/query.ts +26 -105
  49. package/src/read-aggregates.ts +138 -0
  50. package/src/read-capture.ts +22 -0
  51. package/src/registry.ts +49 -1
  52. package/src/repo.ts +34 -0
  53. package/src/retain.ts +71 -0
  54. package/src/row-observer.ts +22 -4
  55. package/src/row-security-settings.ts +20 -0
  56. package/src/sealed-repo.ts +15 -0
  57. package/src/seed.ts +43 -7
  58. package/src/series-unsupported.ts +11 -0
  59. package/src/table-locks.ts +41 -0
  60. package/src/test-driver.ts +90 -0
  61. package/src/time-bucket.ts +147 -0
  62. package/src/touch.ts +33 -0
  63. package/src/types.ts +22 -0
  64. package/src/view-entity.ts +78 -0
  65. package/src/view-issue.ts +47 -0
  66. package/src/view-repo.ts +38 -0
  67. package/src/view.ts +41 -27
package/src/batch-read.ts CHANGED
@@ -5,6 +5,7 @@
5
5
 
6
6
  import type { DbClient } from '@ultimat3/db';
7
7
  import type { EntityCore } from './entity';
8
+ import { canonicalDecimal, canonicalInteger } from './numeric-compare';
8
9
  import { decodeRow, type PhysicalRow } from './pg-row';
9
10
  import { type ReadShape, selectStatement } from './pg-sql';
10
11
  import type { QueryPlan } from './tenancy';
@@ -35,11 +36,18 @@ export type Answer = { readonly row: unknown } | { readonly error: unknown };
35
36
  * The string both sides of a batch are filed under — the requested id, and the key of a row that
36
37
  * came back. Postgres compares a `uuid` as a value and prints it lower-cased, so an id handed in
37
38
  * upper case matches the row *there* and would miss it here, which would make an answer depend on
38
- * whether some other lookup shared the microtask. Every other kind compares by its bytes, and
39
- * lower-casing a text key would merge two rows Postgres keeps apart.
39
+ * whether some other lookup shared the microtask. An integer or `numeric` is compared by value too:
40
+ * `'007'` matched the row and missed its answer here, filed under `'7'`. One the column could not
41
+ * hold stays as written, so the statement still carries it and Postgres answers its own refusal.
42
+ * Every other kind compares by its bytes, and lower-casing a text key would merge two rows
43
+ * Postgres keeps apart.
40
44
  */
41
- export const keyOf = (kind: string, value: unknown): string =>
42
- kind === 'uuid' ? String(value).toLowerCase() : String(value);
45
+ export const keyOf = (kind: string, value: unknown): string => {
46
+ if (kind === 'uuid') return String(value).toLowerCase();
47
+ if (kind === 'integer' || kind === 'bigint') return canonicalInteger(value) ?? String(value);
48
+ if (kind === 'numeric') return canonicalDecimal(value) ?? String(value);
49
+ return String(value);
50
+ };
43
51
 
44
52
  /** A scope value has to be comparable as a string, or two scopes cannot be told apart. */
45
53
  const scopeValue = (value: unknown): string | undefined => {
@@ -0,0 +1,49 @@
1
+ // Single responsibility: sending a batch's chunks so the batch is one write. `bulk-write.ts` decides
2
+ // what the chunks are; this decides what holds them together, split from `pg-driver.ts`, which
3
+ // resolves the connection and hands it over.
4
+
5
+ import { type DbClient, withTransaction } from '@ultimat3/db';
6
+ import { insertChunks } from './bulk-write';
7
+ import type { EntityCore } from './entity';
8
+ import { hasJsOnlyInvariant } from './invariants';
9
+ import { decodeRow, type PhysicalRow } from './pg-row';
10
+ import { type InsertShape, insertStatement } from './pg-write-sql';
11
+
12
+ /**
13
+ * One chunk is one autocommit statement, atomic by itself, with no `BEGIN`. Several are one
14
+ * transaction — a SAVEPOINT inside an open one — or a batch failing in its last chunk would leave
15
+ * the earlier ones stored, and a job retrying it would meet its own partial write. The memory
16
+ * driver leaves nothing, and the two mean one thing.
17
+ *
18
+ * `direct` is resolved by the CALLER first, so a pinned repository inside a transaction opened on
19
+ * another client is refused (`X_REPO_CLIENT_PINNED`) before anything opens, and `pinned` is where
20
+ * the transaction opens when the repository has one, as in `judgedWrite`.
21
+ *
22
+ * An updating upsert with an app-only rule takes a transaction at any width: what a collision
23
+ * stored is the incoming row merged into the stored one, and only `returning *` shows that row. The
24
+ * memory driver judges the same merge in `write`, so without this the rule held only there.
25
+ */
26
+ export const writeChunks = async <Row>(
27
+ entity: EntityCore<Row>,
28
+ bound: readonly ReadonlyMap<string, unknown>[],
29
+ shape: InsertShape,
30
+ direct: DbClient,
31
+ pinned: DbClient | undefined,
32
+ ): Promise<readonly Row[]> => {
33
+ const chunks = insertChunks(bound, shape.columns.length);
34
+ const judges =
35
+ shape.conflict !== undefined &&
36
+ shape.conflict.set.length > 0 &&
37
+ hasJsOnlyInvariant(entity.$invariants);
38
+ const send = async (on: DbClient): Promise<readonly Row[]> => {
39
+ const written: Row[] = [];
40
+ for (const chunk of chunks) {
41
+ const stored = await on.query<PhysicalRow>(insertStatement(entity, chunk, shape));
42
+ for (const row of stored) written.push(decodeRow(entity, row));
43
+ }
44
+ if (judges) for (const row of written) entity.$assert(row);
45
+ return written;
46
+ };
47
+ if (chunks.length === 1 && !judges) return send(direct);
48
+ return withTransaction(send, pinned === undefined ? {} : { client: pinned });
49
+ };
package/src/bulk-write.ts CHANGED
@@ -9,7 +9,7 @@ import { columnFor } from './column';
9
9
  import { valueAt } from './cursor';
10
10
  import { type EntityCore, SOFT_DELETE_COLUMN } from './entity';
11
11
  import { EntityError, invariantViolated } from './errors';
12
- import { columnsOf } from './pg-row';
12
+ import { allColumns, columnsOf } from './pg-row';
13
13
  import type { RowPatch } from './types';
14
14
  import { uniqueConstraints } from './unique-constraints';
15
15
 
@@ -31,11 +31,14 @@ export interface UpsertPlan {
31
31
  const owns = (row: unknown, property: string): boolean =>
32
32
  typeof row === 'object' && row !== null && Object.hasOwn(row, property);
33
33
 
34
+ /** A value this row writes: present and not `undefined`, the same two tests `bindValues` makes. */
35
+ const writes = (row: unknown, property: string): boolean =>
36
+ owns(row, property) && (row as Readonly<Record<string, unknown>>)[property] !== undefined;
37
+
34
38
  /**
35
- * The properties a batch writes: every declared column at least one row names, in declaration
36
- * order. `Object.hasOwn` decides it, exactly as `bindValues` does — a property present and
37
- * `undefined` is a value the caller wrote, and dropping it here would insert a column the update
38
- * set then skipped.
39
+ * The columns a batch's statement lists: every declared column at least one row names, in
40
+ * declaration order. A present `undefined` keeps its place here and binds no value, so its cell is
41
+ * `default`. What a COLLISION overwrites is a narrower question: `upsertPlan` asks `writes`.
39
42
  */
40
43
  export const namedProperties = <Row>(
41
44
  entity: EntityCore<Row>,
@@ -53,6 +56,19 @@ export const insertColumns = <Row>(
53
56
  return column === undefined ? [] : columnsOf(property, column);
54
57
  });
55
58
 
59
+ /**
60
+ * The column list one statement of this batch carries. Rows naming nothing are all defaults, which
61
+ * only a column list can spell for several rows at once: no list at all is
62
+ * `insert into t () values ()`, a syntax error the framework would have written.
63
+ */
64
+ export const statementColumns = <Row>(
65
+ entity: EntityCore<Row>,
66
+ rows: readonly RowPatch<Row>[],
67
+ ): readonly string[] => {
68
+ const named = insertColumns(entity, namedProperties(entity, rows));
69
+ return named.length > 0 ? named : allColumns(entity);
70
+ };
71
+
56
72
  /**
57
73
  * Rows per statement: the bind budget divided by one row's width, never fewer than one. Splitting
58
74
  * can only cost round trips; not splitting would fail a write that succeeded one row at a time.
@@ -146,6 +162,27 @@ const uniqueTargets = <Row>(entity: EntityCore<Row>): readonly (readonly string[
146
162
  const sameColumns = (left: readonly string[], right: readonly string[]): boolean =>
147
163
  left.length === right.length && [...left].sort().join() === [...right].sort().join();
148
164
 
165
+ /**
166
+ * What a batch overwrites on a collision: the columns its rows write, refusing a batch whose rows
167
+ * disagree. `writes`, not `owns`: a present `undefined` is absent in every write there is, and
168
+ * overwriting with it would hand the stored row the column's default — the loss `unevenBatch`
169
+ * refuses. `tableFor` asks this of the CALLER's rows, before `$parse` makes every row name every
170
+ * column; a repository handed rows directly asks it in `upsertPlan`.
171
+ */
172
+ export const overwrittenBy = <Row>(
173
+ entity: EntityCore<Row>,
174
+ rows: readonly RowPatch<Row>[],
175
+ ): readonly string[] => {
176
+ const properties = Object.keys(entity.$columns).filter((property) =>
177
+ rows.some((row) => writes(row, property)),
178
+ );
179
+ for (const [position, row] of rows.entries()) {
180
+ const missing = properties.find((property) => !writes(row, property));
181
+ if (missing !== undefined) throw unevenBatch(entity.$name, position, missing);
182
+ }
183
+ return properties;
184
+ };
185
+
149
186
  /**
150
187
  * The conflict target, and what a collision overwrites. `'nothing'` overwrites nothing by
151
188
  * definition; `'update'` takes every column the batch writes except three closed sets — the
@@ -169,13 +206,14 @@ const sameColumns = (left: readonly string[], right: readonly string[]): boolean
169
206
  * carry the tenant column under `'update'` (another tenant's row, silently rewritten), a batch
170
207
  * whose rows name different columns under `'update'` (`excluded.<col>` is the column's default for
171
208
  * a row that omitted it, so "leave it alone" is not what happens), and a target that leaves
172
- * nothing to write.
209
+ * nothing to write. `overwrites` is `UpsertArgs.overwrites`: named, it replaces what the rows say.
173
210
  */
174
211
  export const upsertPlan = <Row>(
175
212
  entity: EntityCore<Row>,
176
213
  rows: readonly RowPatch<Row>[],
177
214
  onConflict: readonly string[],
178
215
  onMatch: 'update' | 'nothing',
216
+ overwrites?: readonly string[],
179
217
  ): UpsertPlan => {
180
218
  if (onConflict.length === 0) throw noConflictTarget(entity.$name);
181
219
  for (const property of onConflict) {
@@ -201,11 +239,10 @@ export const upsertPlan = <Row>(
201
239
  if (tenant !== null && !onConflict.includes(tenant)) {
202
240
  throw crossTenantUpsert(entity.$name, tenant, onConflict);
203
241
  }
204
- const properties = namedProperties(entity, rows);
205
- for (const [position, row] of rows.entries()) {
206
- const missing = properties.find((property) => !owns(row, property));
207
- if (missing !== undefined) throw unevenBatch(entity.$name, position, missing);
208
- }
242
+ const properties =
243
+ overwrites === undefined
244
+ ? overwrittenBy(entity, rows)
245
+ : Object.keys(entity.$columns).filter((property) => overwrites.includes(property));
209
246
  const spared = new Set([
210
247
  ...onConflict,
211
248
  ...entity.$primaryKey,
package/src/column.ts CHANGED
@@ -82,11 +82,16 @@ export const BARE: Omit<ColumnMeta, 'kind'> = {
82
82
  };
83
83
 
84
84
  export interface Binding {
85
+ /** The entity's NAME, what a relation and the registry key on. */
85
86
  readonly table: string;
87
+ /** The physical table, what a foreign key names. Equal to `table` unless `{ table }` was said. */
88
+ readonly physicalTable: string;
86
89
  /** camelCase key on the row. */
87
90
  readonly property: string;
88
91
  /** snake_case physical column name. */
89
92
  readonly name: string;
93
+ /** The physical tenant column of the entity that owns this column, `null` when it has none. */
94
+ readonly tenant: string | null;
90
95
  }
91
96
 
92
97
  const bindings = new WeakMap<AnyColumn, Binding>();
@@ -96,7 +101,13 @@ const bindings = new WeakMap<AnyColumn, Binding>();
96
101
  * one between two entities would give it two physical names and silently mis-name a foreign
97
102
  * key, so a second binding is a declaration-time error.
98
103
  */
99
- export const bindColumn = (column: AnyColumn, table: string, property: string): Binding => {
104
+ export const bindColumn = (
105
+ column: AnyColumn,
106
+ table: string,
107
+ property: string,
108
+ tenant: string | null = null,
109
+ physicalTable: string = table,
110
+ ): Binding => {
100
111
  const existing = bindings.get(column);
101
112
  if (existing !== undefined && existing.table !== table) {
102
113
  throw invariantViolated(
@@ -109,8 +120,10 @@ export const bindColumn = (column: AnyColumn, table: string, property: string):
109
120
  // The DERIVED name too, not only a declared one: `snake(property)` lower-cases and nothing else.
110
121
  const binding: Binding = {
111
122
  table,
123
+ physicalTable,
112
124
  property,
113
125
  name: assertColumnName(columnName(property, column.$meta)),
126
+ tenant,
114
127
  };
115
128
  bindings.set(column, binding);
116
129
  return binding;
@@ -205,17 +218,29 @@ export const makeColumn = <T, Optional extends boolean>(
205
218
 
206
219
  tenant: () => makeColumn<T, Optional>({ ...meta, tenant: true, index: true }, parse, optional),
207
220
 
208
- references: (target, options = {}) =>
209
- makeColumn<T, Optional>(
221
+ references: (target, options = {}) => {
222
+ // The rule `crossTenant()` already sets: an escape with no argument reads like a forgotten key,
223
+ // and the next reader cannot tell a considered edge from an accident.
224
+ if (options.crossTenant !== undefined && options.crossTenant.trim() === '') {
225
+ refuseColumn(
226
+ 'references',
227
+ 'crossTenant was given a blank reason, so the same-tenant key it lifts carries no argument',
228
+ "uuid().references(() => target.id, { crossTenant: 'why this edge spans tenants' }) # or drop the option and keep the same-tenant key",
229
+ );
230
+ }
231
+ return makeColumn<T, Optional>(
210
232
  {
211
233
  ...meta,
212
234
  references: target,
213
235
  index: true,
214
236
  ...(options.onDelete === undefined ? {} : { onDelete: options.onDelete }),
237
+ ...(options.crossTenant === undefined ? {} : { crossTenant: options.crossTenant }),
238
+ ...(options.countedIn === undefined ? {} : { countedIn: options.countedIn }),
215
239
  },
216
240
  parse,
217
241
  optional,
218
- ),
242
+ );
243
+ },
219
244
 
220
245
  default: (value) => makeColumn<T, true>({ ...meta, default: literal(value) }, parse, true),
221
246
 
package/src/counter.ts ADDED
@@ -0,0 +1,109 @@
1
+ // Single responsibility: what `references(() => posts.id, { countedIn: () => posts.likeCount })`
2
+ // means once both schema modules have evaluated — the parent column resolved, held to being an
3
+ // integer of the entity the key points at, and projected for `@ultimat3/db` (which emits the
4
+ // trigger) and for the memory driver (which mirrors it).
5
+
6
+ import type { CounterDescriptionLike } from '@ultimat3/db';
7
+ import { bindingOf, columnName } from './column';
8
+ import type { EntityCore } from './entity';
9
+ import { SOFT_DELETE_COLUMN } from './entity';
10
+ import { EntityError } from './errors';
11
+ import { getEntity, registeredEntities, registryGeneration } from './registry';
12
+ import type { AnyColumn } from './types';
13
+
14
+ /** One resolved counter: both entities, both properties. */
15
+ export interface Counter {
16
+ readonly parent: EntityCore;
17
+ /** The counting integer's property on the parent. */
18
+ readonly column: string;
19
+ readonly child: EntityCore;
20
+ /** The child's foreign-key property. */
21
+ readonly references: string;
22
+ }
23
+
24
+ const countsInvalid = (entity: string, property: string, reason: string): EntityError =>
25
+ new EntityError({
26
+ code: 'X_ENTITY_COUNTS_INVALID',
27
+ cause: `${entity}.${property} declares countedIn, and ${reason}`,
28
+ fix: `x db gen "count" # after ${property}: uuid().references(() => <parent>.id, { countedIn: () => <parent>.<count> }) in entity('${entity}'), where <count> is integer().default(0)`,
29
+ });
30
+
31
+ /** The counter one child key declares, resolved and checked. `null` when it declares none. */
32
+ export function counterOf(child: EntityCore, property: string, column: AnyColumn): Counter | null {
33
+ const target = column.$meta.countedIn;
34
+ if (target === undefined) return null;
35
+ const counting = target();
36
+ const binding = bindingOf(counting);
37
+ const parent = binding === undefined ? undefined : getEntity(binding.table)?.core;
38
+ if (binding === undefined || parent === undefined) {
39
+ throw countsInvalid(child.$name, property, 'its countedIn column belongs to no entity');
40
+ }
41
+ if (counting.$meta.kind !== 'integer') {
42
+ throw countsInvalid(
43
+ child.$name,
44
+ property,
45
+ `${binding.table}.${binding.property} is no integer()`,
46
+ );
47
+ }
48
+ // `NULL + 1` is NULL in the trigger and memory counts from 0: a nullable count means two things.
49
+ if (!counting.$meta.notNull) {
50
+ throw countsInvalid(child.$name, property, `${binding.table}.${binding.property} is nullable`);
51
+ }
52
+ const points =
53
+ column.$meta.references === undefined ? undefined : bindingOf(column.$meta.references());
54
+ const key = parent.$primaryKey.length === 1 ? parent.$primaryKey[0] : undefined;
55
+ if (points?.table !== parent.$name || points.property !== key) {
56
+ throw countsInvalid(
57
+ child.$name,
58
+ property,
59
+ `it is no foreign key to ${parent.$name}'s single primary key, so it cannot count into it`,
60
+ );
61
+ }
62
+ return { parent, column: binding.property, child, references: property };
63
+ }
64
+
65
+ let cached: { readonly generation: number; readonly counters: readonly Counter[] } | undefined;
66
+
67
+ /**
68
+ * Every counter declared on the keys of every registered entity — memoised on
69
+ * `registryGeneration()` as `relationMap()` is, because the memory driver asks on every write.
70
+ */
71
+ export function registeredCounters(): readonly Counter[] {
72
+ const generation = registryGeneration();
73
+ if (cached?.generation === generation) return cached.counters;
74
+ const counters = registeredEntities().flatMap((entry) => {
75
+ const child = entry.core;
76
+ if (child === undefined) return [];
77
+ return Object.entries(child.$columns).flatMap(([property, column]) => {
78
+ const counter = counterOf(child, property, column);
79
+ return counter === null ? [] : [counter];
80
+ });
81
+ });
82
+ cached = { generation, counters };
83
+ return counters;
84
+ }
85
+
86
+ /** The counters kept ON `parent`'s columns. */
87
+ export const countersOf = (parent: EntityCore): readonly Counter[] =>
88
+ registeredCounters().filter((counter) => counter.parent.$name === parent.$name);
89
+
90
+ /** The counters whose CHILD is `entity`: what a write to it must move. */
91
+ export const countersTargeting = (entity: string): readonly Counter[] =>
92
+ registeredCounters().filter((counter) => counter.child.$name === entity);
93
+
94
+ const physical = (entity: EntityCore, property: string): string => {
95
+ const column = entity.$columns[property];
96
+ return column === undefined ? property : columnName(property, column.$meta);
97
+ };
98
+
99
+ /** The projection `@ultimat3/db` compiles to a trigger on the child. */
100
+ export function describeCounter(counter: Counter): CounterDescriptionLike {
101
+ const key = counter.parent.$primaryKey[0] ?? '';
102
+ return {
103
+ column: physical(counter.parent, counter.column),
104
+ key: physical(counter.parent, key),
105
+ table: counter.child.$table,
106
+ references: physical(counter.child, counter.references),
107
+ softDelete: counter.child.$softDelete ? physical(counter.child, SOFT_DELETE_COLUMN) : null,
108
+ };
109
+ }
@@ -20,6 +20,18 @@ export const CROSS_TENANT_SCOPE = 'tenancy:cross';
20
20
 
21
21
  const storage = asyncContext<string>('the cross-tenant reason');
22
22
 
23
+ let witness: ((reason: string) => void) | undefined;
24
+
25
+ /**
26
+ * Install the one process-wide witness of every scope opened, or remove it with `undefined`. The
27
+ * caller is `@ultimat3/testing`'s tenant proof: a job that crossed tenants under a declared reason
28
+ * is a sweep, one that crossed without one is a leak, and only the scope itself knows which. One
29
+ * witness, not a list (`setStatementObserver`'s rule), told only after the capability is proven.
30
+ */
31
+ export function setCrossTenantObserver(next: ((reason: string) => void) | undefined): void {
32
+ witness = next;
33
+ }
34
+
23
35
  /**
24
36
  * Run `fn` with the tenant guard lifted — every read and write it issues, at any depth and across
25
37
  * every `await`, may span tenants. For the three cases that genuinely have no single tenant: an
@@ -47,6 +59,7 @@ export function crossTenant<T>(reason: string, fn: () => T): T {
47
59
  "pass why the read spans tenants: crossTenant('nightly expiry sweeps every org', fn)",
48
60
  );
49
61
  assertCrossTenant(reason);
62
+ witness?.(reason);
50
63
  return storage.run(reason, fn);
51
64
  }
52
65
 
package/src/cursor.ts CHANGED
@@ -191,6 +191,13 @@ const assertAliasFits = <Row>(entity: EntityCore<Row>, path: string): void => {
191
191
  );
192
192
  };
193
193
 
194
+ /**
195
+ * The kinds with no text a seek can bind back: `bytea` cut a page at nothing and answered an empty
196
+ * page 2 with no error, an array answered `22P02` there, and `jsonb` threw a `TypeError` on the
197
+ * memory side and at the cursor on Postgres.
198
+ */
199
+ const UNSEEKABLE: ReadonlySet<ColumnKind> = new Set<ColumnKind>(['jsonb', 'bytea', 'array']);
200
+
194
201
  /**
195
202
  * A keyset seek only has a total order when every sort column is present on every row —
196
203
  * `null > 'x'` is unknown in SQL and would drop rows from the middle of a listing.
@@ -209,7 +216,16 @@ export const assertSeekable = <Row>(
209
216
  for (const key of orderBy) {
210
217
  // Resolving the kind is the other half: it refuses a column the entity never declared and a
211
218
  // money property named without its part — both mint a cursor nothing can decode.
212
- if (kindAt(entity, key.column) === 'timestamptz') assertAliasFits(entity, key.column);
219
+ const kind = kindAt(entity, key.column);
220
+ if (kind === 'timestamptz') assertAliasFits(entity, key.column);
221
+ if (UNSEEKABLE.has(kind)) {
222
+ throw invariantViolated(
223
+ entity.$name,
224
+ 'cursor',
225
+ `${key.column} is ${kind}, which no cursor can carry a position in: page 2 came back ` +
226
+ 'empty or as an error, depending on the row count. Order by a scalar column',
227
+ );
228
+ }
213
229
  if (columnAt(entity, key.column).$meta.notNull) continue;
214
230
  // An ORDINARY nullable key is orderable, `As of 2026-08-24`: NULL has a declared place
215
231
  // (`asc nulls last` / `desc nulls first`), the cursor carries that place, and the seek reaches
package/src/database.ts CHANGED
@@ -3,6 +3,7 @@
3
3
 
4
4
  import type { EntityCore } from './entity';
5
5
  import { memoryRepo, memoryTransactor } from './memory-repo';
6
+ import type { MemoryTables } from './memory-same-tenant';
6
7
  import type { RelatedTables } from './preload';
7
8
  import type { Table } from './query';
8
9
  import { tableFor } from './query';
@@ -46,17 +47,22 @@ export const memoryDriver = (): Driver => {
46
47
  // Held separately from `repos` so the reset is a call on the repository the tables already
47
48
  // resolved, never a replacement of it.
48
49
  const resets: (() => void)[] = [];
50
+ // The repositories as a same-tenant reference reads them: below the sealing and tenant seams.
51
+ const stored: MemoryTables = new Map();
52
+ const repo = <Row>(entity: EntityCore<Row>): Repo<Row> => {
53
+ const existing = repos.get(entity.$name);
54
+ // Keyed by entity name and only ever written from that same entity, so the row type a
55
+ // caller asks for is the one that was stored.
56
+ if (existing !== undefined) return existing as Repo<Row>;
57
+ const created = memoryRepo<Row>(entity, [], stored);
58
+ repos.set(entity.$name, created);
59
+ resets.push(() => created.reset());
60
+ // Its history table beside it, as the migration creates both: the parent's writes land there.
61
+ if (entity.$history !== null) repo(entity.$history);
62
+ return created;
63
+ };
49
64
  return {
50
- repo<Row>(entity: EntityCore<Row>): Repo<Row> {
51
- const existing = repos.get(entity.$name);
52
- // Keyed by entity name and only ever written from that same entity, so the row type a
53
- // caller asks for is the one that was stored.
54
- if (existing !== undefined) return existing as Repo<Row>;
55
- const created = memoryRepo<Row>(entity);
56
- repos.set(entity.$name, created);
57
- resets.push(() => created.reset());
58
- return created;
59
- },
65
+ repo,
60
66
  reset() {
61
67
  for (const reset of resets) reset();
62
68
  },
@@ -0,0 +1,25 @@
1
+ // Single responsibility: the tenant column of every registered tenant table, physical names only —
2
+ // what `x db gen` writes a row-security policy over. Beside the description, never on it: `x
3
+ // verify` hashes `describeEntities()`, and every tenant app's hash would move for a fact it has.
4
+
5
+ import { registeredEntities } from './registry';
6
+
7
+ /** One tenant table: its physical table and the physical column its rows are scoped by. */
8
+ export interface TenantColumnDescription {
9
+ readonly table: string;
10
+ readonly column: string;
11
+ }
12
+
13
+ /**
14
+ * In the registry's own order (by entity name, then sorted by table for a stable migration). An
15
+ * entry with no `core` is a hand-registered test double, never an `entity()`: it declares nothing.
16
+ */
17
+ export const describeTenancy = (): readonly TenantColumnDescription[] =>
18
+ registeredEntities()
19
+ .flatMap((entry): TenantColumnDescription[] => {
20
+ const property = entry.core?.$tenantColumn ?? null;
21
+ if (property === null) return [];
22
+ const column = entry.describe().columns.find((candidate) => candidate.property === property);
23
+ return column === undefined ? [] : [{ table: entry.tableName, column: column.column }];
24
+ })
25
+ .sort((a, b) => (a.table < b.table ? -1 : a.table > b.table ? 1 : 0));