@ultimat3/entity 30.9.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 (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,66 @@
1
+ // The readers a GDPR export or erase job walks by: every column `.personalData(kind)` marked, per
2
+ // entity and across the registry. The mark is `$meta.personalData`, never on `$describe()` — being
3
+ // personal data changes no column, so it must not move the drift hash.
4
+
5
+ import { columnName } from './column';
6
+ import type { EntityCore } from './entity';
7
+ import type { PersonalDataKind } from './personal-data-kind';
8
+ import { registeredEntities } from './registry';
9
+ import type { AnyColumn, ColumnKind } from './types';
10
+
11
+ // The kind set's home is `personal-data-kind.ts` (the chain reads it); the barrel takes it from here.
12
+ export type { PersonalDataKind } from './personal-data-kind';
13
+ export { PERSONAL_DATA_KINDS } from './personal-data-kind';
14
+
15
+ /** The kind a column was marked with, or `undefined` for a column that holds no personal data. */
16
+ export const personalDataKindOf = (column: AnyColumn): PersonalDataKind | undefined =>
17
+ column.$meta.personalData;
18
+
19
+ /** One marked column of one entity, resolved to where it landed. */
20
+ export interface PersonalDataColumn {
21
+ /** The row property — what a repository write names. */
22
+ readonly property: string;
23
+ /** The physical column (the base name, for a money column's parts). */
24
+ readonly column: string;
25
+ readonly kind: PersonalDataKind;
26
+ readonly columnKind: ColumnKind;
27
+ /** Whether an erase may write `null`; otherwise it must write a replacement value. */
28
+ readonly nullable: boolean;
29
+ }
30
+
31
+ /** Every marked column of `entity`, in declaration order. */
32
+ export const personalDataOf = (
33
+ entity: Pick<EntityCore<unknown>, '$columns'>,
34
+ ): readonly PersonalDataColumn[] =>
35
+ Object.entries(entity.$columns).flatMap(([property, column]) => {
36
+ const kind = column.$meta.personalData;
37
+ if (kind === undefined) return [];
38
+ return [
39
+ {
40
+ property,
41
+ column: columnName(property, column.$meta),
42
+ kind,
43
+ columnKind: column.$meta.kind,
44
+ nullable: !column.$meta.notNull,
45
+ },
46
+ ];
47
+ });
48
+
49
+ /** A registered entity holding personal data, with the tenant a subject's rows are scoped by. */
50
+ export interface PersonalDataEntity {
51
+ readonly entity: EntityCore<unknown>;
52
+ /** The tenant column's property, or `null` for an untenanted entity. */
53
+ readonly tenantColumn: string | null;
54
+ readonly columns: readonly PersonalDataColumn[];
55
+ }
56
+
57
+ /** Every registered entity carrying at least one marked column, in the registry's name order. */
58
+ export const personalDataEntities = (): readonly PersonalDataEntity[] =>
59
+ registeredEntities().flatMap((entry) => {
60
+ const core = entry.core;
61
+ if (core === undefined) return [];
62
+ const columns = personalDataOf(core);
63
+ return columns.length === 0
64
+ ? []
65
+ : [{ entity: core, tenantColumn: core.$tenantColumn, columns }];
66
+ });
package/src/pg-driver.ts CHANGED
@@ -13,6 +13,7 @@ import {
13
13
  db,
14
14
  refuseAbandoned,
15
15
  type SqlFragment,
16
+ sql,
16
17
  withStatementAttribution,
17
18
  } from '@ultimat3/db';
18
19
  import { appendOnlyRepo } from './append-only';
@@ -21,8 +22,9 @@ import { writeChunks } from './bulk-write-send';
21
22
  import { entityNow } from './clock';
22
23
  import { coalesceFindById } from './coalesce';
23
24
  import { narrowRow } from './columns';
24
- import { cursorFor, seekFrom, valueAt } from './cursor';
25
+ import { cursorFor, seekFrom } from './cursor';
25
26
  import type { Driver } from './database';
27
+ import { noteDriverStore } from './driver-registry';
26
28
  import { type EntityCore, SOFT_DELETE_COLUMN } from './entity';
27
29
  import { notFound, repoClientPinned } from './errors';
28
30
  import { stampHistoryActor } from './history-actor';
@@ -37,7 +39,10 @@ import { postgresTransactor } from './pg-transactor';
37
39
  import { type ConflictTarget, deleteStatement, updateStatement } from './pg-write-sql';
38
40
  import { deletePlan, idPlan, readPlan, updatePlan } from './plan';
39
41
  import type { FindManyArgs, Repo, UpsertArgs } from './repo';
42
+ import { rowKey } from './row-key';
40
43
  import { sealedRepo } from './sealed-repo';
44
+ import { deletionStamp } from './search-index-version';
45
+ import { writtenRowsSink } from './search-written-rows';
41
46
  import type { QueryPlan } from './tenancy';
42
47
  import { assertRowTenant } from './tenancy';
43
48
  import type { RowWrite } from './types';
@@ -104,14 +109,25 @@ export const postgresRepo = <Row>(
104
109
  const attributed = <T>(op: string, send: () => Promise<T>): Promise<T> =>
105
110
  withStatementAttribution(entity.$name, op, send);
106
111
 
107
- const idOf = (row: Row): string =>
108
- entity.$primaryKey.map((property) => String(valueAt(row, property))).join('');
112
+ /** The cursor's id — `row-key.ts`, the spelling the memory driver stores a row under. */
113
+ const idOf = (row: Row): string => rowKey(entity, row);
109
114
 
110
115
  /** Where a batch stops being wide — before `writeRows` or `upsertPlan` read a row. `narrowRow`
111
116
  * says why the position matters, and `memoryRepo` narrows at the same one. */
112
117
  const narrowed = (batch: readonly RowWrite<Row>[]): readonly Row[] =>
113
118
  batch.map((row) => narrowRow<Row>(entity.$columns, row));
114
119
 
120
+ /**
121
+ * A soft delete's SET list: the stamp, plus a searchable entity's version — advanced past the
122
+ * stored one in the statement (`pg-write-sql.ts`), so a deletion is a newer version too.
123
+ */
124
+ const softDeletion = (): ReadonlyMap<string, unknown> =>
125
+ new Map(
126
+ Object.entries(
127
+ deletionStamp(entity as EntityCore<unknown>, SOFT_DELETE_COLUMN, entityNow()),
128
+ ).map(([property, value]) => [physicalName(entity, property), value]),
129
+ );
130
+
115
131
  const one = async (plan: QueryPlan, args: FindManyArgs): Promise<Row | null> => {
116
132
  const [found] = await client().query<PhysicalRow>(
117
133
  selectStatement(entity, plan, shapeOf(args), 1),
@@ -124,20 +140,54 @@ export const postgresRepo = <Row>(
124
140
  * delete hides the row without losing it; the column's presence is the switch, and `shapeOf({})`
125
141
  * keeps the `deleted_at is null` clause so a second call cannot move an existing stamp forward.
126
142
  *
127
- * No `returning`: both callers send this through `execute()` and read a count, so the rows would
128
- * be a whole tenant's table crossing the wire for nobody — the same cost `updateWhere` used to
129
- * pay unconditionally, on the sibling path that looked like the one doing it right.
143
+ * `returning` only when the search mirror asked for the rows, and only up to the judged bound
144
+ * (`counted`): otherwise both callers read a count, and the rows would be a whole tenant's table
145
+ * crossing the wire for nobody.
130
146
  */
131
- const removal = (plan: QueryPlan): SqlFragment =>
147
+ /** What a removal matches: the live rows of a soft delete, every row of a hard one. */
148
+ const removalShape = (): ReadShape => shapeOf({ includeDeleted: !entity.$softDelete });
149
+
150
+ const removal = (plan: QueryPlan, returning: boolean): SqlFragment =>
132
151
  entity.$softDelete
133
- ? updateStatement(
134
- entity,
135
- plan,
136
- new Map([[physicalName(entity, SOFT_DELETE_COLUMN), entityNow()]]),
137
- shapeOf({}),
138
- false,
139
- )
140
- : deleteStatement(entity, plan);
152
+ ? updateStatement(entity, plan, softDeletion(), shapeOf({}), returning)
153
+ : returning
154
+ ? sql`${deleteStatement(entity, plan)} returning *`
155
+ : deleteStatement(entity, plan);
156
+
157
+ /**
158
+ * A count, or — when the search mirror asked (`search-written-rows.ts`) — the same statement with
159
+ * `returning *`, its rows handed over and counted. The rows ride only for a searchable entity
160
+ * under an external index, and only when a count says they fit the judged bound: past
161
+ * `MAX_ASSERTED_ROWS` the write stays a count and the mirror logs the rows it could not name. A
162
+ * concurrent writer can drift the count, which moves the bound, never the write.
163
+ */
164
+ const counted = async (
165
+ op: string,
166
+ plan: QueryPlan,
167
+ shape: ReadShape,
168
+ statement: (returning: boolean) => SqlFragment,
169
+ ): Promise<number> => {
170
+ const sink = writtenRowsSink();
171
+ const fits =
172
+ sink !== undefined &&
173
+ Number(
174
+ (
175
+ await attributed(op, () =>
176
+ client().one<{ count: unknown }>(countStatement(entity, plan, shape)),
177
+ )
178
+ )?.count ?? 0,
179
+ ) <= MAX_ASSERTED_ROWS;
180
+ if (sink === undefined || !fits) {
181
+ return attributed(op, () => writing(() => client().execute(statement(false))));
182
+ }
183
+ return attributed(op, () =>
184
+ writing(async () => {
185
+ const rows = await client().query<PhysicalRow>(statement(true));
186
+ sink(rows.map((row) => decodeRow(entity, row)));
187
+ return rows.length;
188
+ }),
189
+ );
190
+ };
141
191
 
142
192
  /**
143
193
  * Every write goes out through here: the ONE place the request's preloaded rows are dropped (a
@@ -172,7 +222,9 @@ export const postgresRepo = <Row>(
172
222
  const statement = updateStatement(entity, plan, values, shape, true);
173
223
  return judged(async (send) => {
174
224
  const written = await attributed(op, () => writing(() => send.query<PhysicalRow>(statement)));
175
- for (const row of written) entity.$assert(decodeRow(entity, row));
225
+ const decoded = written.map((row) => decodeRow(entity, row));
226
+ for (const row of decoded) entity.$assert(row);
227
+ writtenRowsSink()?.(decoded);
176
228
  return written.length;
177
229
  });
178
230
  };
@@ -183,13 +235,8 @@ export const postgresRepo = <Row>(
183
235
  */
184
236
  const removed = (op: string, plan: QueryPlan): Promise<number> =>
185
237
  entity.$softDelete && hasJsOnlyInvariant(entity.$invariants)
186
- ? judgedRows(
187
- op,
188
- plan,
189
- new Map([[physicalName(entity, SOFT_DELETE_COLUMN), entityNow()]]),
190
- shapeOf({}),
191
- )
192
- : attributed(op, () => writing(() => client().execute(removal(plan))));
238
+ ? judgedRows(op, plan, softDeletion(), shapeOf({}))
239
+ : counted(op, plan, removalShape(), (returning) => removal(plan, returning));
193
240
 
194
241
  /**
195
242
  * The one insert path, for one row or ten thousand: the batch's own column list, split into as
@@ -372,8 +419,9 @@ export const postgresRepo = <Row>(
372
419
  // decoded each one, asserted nothing about any of them, and answered with a number.
373
420
  // `deleteWhere` was the tell: same predicate, same table, `execute()` and a count.
374
421
  if (!hasJsOnlyInvariant(entity.$invariants)) {
375
- const statement = updateStatement(entity, plan, values, shape, false);
376
- return attributed(op, () => writing(() => client().execute(statement)));
422
+ return counted(op, plan, shape, (returning) =>
423
+ updateStatement(entity, plan, values, shape, returning),
424
+ );
377
425
  }
378
426
  return judgedRows(op, plan, values, shape);
379
427
  },
@@ -399,8 +447,13 @@ export const postgresRepo = <Row>(
399
447
  * `database(entities, { driver: postgresDriver() })` — the one line that moves an app off the
400
448
  * in-memory default. Repos are stateless, so there is nothing to memoise.
401
449
  */
402
- export const postgresDriver = (config: PostgresDriverOptions = {}): Driver => ({
403
- repo: <Row>(entity: EntityCore<Row>) => postgresRepo(entity, config),
404
- transactor: () =>
405
- postgresTransactor(config.client === undefined ? {} : { client: config.client }),
406
- });
450
+ export const postgresDriver = (config: PostgresDriverOptions = {}): Driver => {
451
+ // Noted as it is built, so `x verify`'s drift step can tell a Postgres app from one whose store
452
+ // owns its own schema (`driver-registry.ts`). `testDriver()` builds one, so it is noted too.
453
+ noteDriverStore('sql', 'postgres');
454
+ return {
455
+ repo: <Row>(entity: EntityCore<Row>) => postgresRepo(entity, config),
456
+ transactor: () =>
457
+ postgresTransactor(config.client === undefined ? {} : { client: config.client }),
458
+ };
459
+ };
@@ -13,6 +13,7 @@ import { identifier, join, raw, type SqlFragment, sql } from '@ultimat3/db';
13
13
  import { columnName } from './column';
14
14
  import type { EntityCore } from './entity';
15
15
  import { conditions, type ReadShape } from './pg-sql';
16
+ import { advancingVersionColumn } from './search-index-version';
16
17
  import type { QueryPlan } from './tenancy';
17
18
 
18
19
  /** `on conflict (…) do update set …`, or `do nothing` when there is nothing to overwrite. */
@@ -39,12 +40,41 @@ export interface InsertShape {
39
40
  */
40
41
  const DEFAULT_CELL = raw('default');
41
42
 
42
- const conflictSql = (conflict: ConflictTarget): SqlFragment => {
43
+ /**
44
+ * A searchable entity's version column, physical — the one a write must ADVANCE past the stored
45
+ * value (`search-index-version.ts`), so two writes inside one millisecond are two versions.
46
+ */
47
+ const advancingColumn = <Row>(entity: EntityCore<Row>): string | undefined => {
48
+ const property = advancingVersionColumn(entity as EntityCore<unknown>);
49
+ const meta = property === undefined ? undefined : entity.$columns[property]?.$meta;
50
+ return property === undefined || meta === undefined ? undefined : columnName(property, meta);
51
+ };
52
+
53
+ /**
54
+ * `VERSION_STEP_MS` as an interval — a closed literal like `default` above, and the same one
55
+ * millisecond (`search-version-monotonic.test.ts` pins the two together).
56
+ */
57
+ export const VERSION_STEP_SQL = raw("interval '1 millisecond'");
58
+
59
+ /** `greatest(next, stored + 1 ms)`: the version moves forward even when the clock has not. */
60
+ const advanced = (column: string, next: SqlFragment, stored: SqlFragment): SqlFragment =>
61
+ sql`${identifier(column)} = greatest(${next}, ${stored} + ${VERSION_STEP_SQL})`;
62
+
63
+ const conflictSql = <Row>(entity: EntityCore<Row>, conflict: ConflictTarget): SqlFragment => {
43
64
  const target = join(conflict.columns.map(identifier));
65
+ const version = advancingColumn(entity);
44
66
  return conflict.set.length === 0
45
67
  ? sql` on conflict (${target}) do nothing`
46
68
  : sql` on conflict (${target}) do update set ${join(
47
- conflict.set.map((column) => sql`${identifier(column)} = excluded.${identifier(column)}`),
69
+ conflict.set.map((column) =>
70
+ column === version
71
+ ? advanced(
72
+ column,
73
+ sql`excluded.${identifier(column)}`,
74
+ sql`${identifier(entity.$table)}.${identifier(column)}`,
75
+ )
76
+ : sql`${identifier(column)} = excluded.${identifier(column)}`,
77
+ ),
48
78
  )}`;
49
79
  };
50
80
 
@@ -96,7 +126,7 @@ export const insertStatement = <Row>(
96
126
  ),
97
127
  )})`,
98
128
  );
99
- const conflict = shape.conflict === undefined ? sql`` : conflictSql(shape.conflict);
129
+ const conflict = shape.conflict === undefined ? sql`` : conflictSql(entity, shape.conflict);
100
130
  return sql`insert into ${identifier(entity.$table)} (${join(
101
131
  shape.columns.map(identifier),
102
132
  )}) values ${join(tuples)}${conflict} returning *`;
@@ -117,8 +147,13 @@ export const updateStatement = <Row>(
117
147
  returning: boolean,
118
148
  ): SqlFragment => {
119
149
  const json = jsonColumns(entity);
150
+ const version = advancingColumn(entity);
120
151
  return sql`update ${identifier(entity.$table)} set ${join(
121
- [...values].map(([column, value]) => sql`${identifier(column)} = ${cell(json, column, value)}`),
152
+ [...values].map(([column, value]) =>
153
+ column === version
154
+ ? advanced(column, cell(json, column, value), identifier(column))
155
+ : sql`${identifier(column)} = ${cell(json, column, value)}`,
156
+ ),
122
157
  )} where ${conditions(entity, plan, shape)}${returning ? sql` returning *` : sql``}`;
123
158
  };
124
159
 
package/src/plan.ts CHANGED
@@ -12,6 +12,7 @@ import {
12
12
  patchEmpty,
13
13
  writeUnfiltered,
14
14
  } from './errors';
15
+ import { screenPredicates } from './predicate-screen';
15
16
  import type { FindManyArgs, RepoOptions } from './repo';
16
17
  import type { Predicate, QueryPlan, SortKey } from './tenancy';
17
18
  import { scopedPlan } from './tenancy';
@@ -116,6 +117,7 @@ export const assertFinitePageSize = (entityName: string, rows: number): number =
116
117
  export const planFor = <Row>(entity: EntityCore<Row>, args: FindManyArgs): QueryPlan => {
117
118
  const orderBy = totalOrder(entity, args.orderBy ?? []);
118
119
  assertSeekable(entity, orderBy);
120
+ screenPredicates(entity, args.where ?? []);
119
121
  const scoped =
120
122
  args.orgId === undefined || entity.$tenantColumn === null
121
123
  ? []
@@ -0,0 +1,36 @@
1
+ // Single responsibility: refuse, at plan time, a predicate no driver can answer — so the refusal
2
+ // never depends on whether a table holds rows. Memory judged an operator only against a row, and
3
+ // answered `[]` for an unknown one on an empty table where Postgres refused; a whole money value
4
+ // compared as one column is the same shape: Postgres has no column to compare it with.
5
+
6
+ import { columnFor } from './column';
7
+ import type { EntityCore } from './entity';
8
+ import { invariantViolated } from './errors';
9
+ import { isOperator, operatorUnknown } from './operator';
10
+ import type { Predicate } from './tenancy';
11
+
12
+ /** A NULL test on a whole money property asks about its amount (`pg-sql.ts` `nullTestPath`). */
13
+ const isNullTest = (predicate: Predicate): boolean =>
14
+ predicate.op === 'is-null' ||
15
+ predicate.op === 'is-not-null' ||
16
+ ((predicate.op === 'eq' || predicate.op === 'neq') && predicate.value === null);
17
+
18
+ export const screenPredicates = <Row>(
19
+ entity: EntityCore<Row>,
20
+ where: readonly Predicate[],
21
+ ): void => {
22
+ for (const predicate of where) {
23
+ if (!isOperator(predicate.op)) {
24
+ throw operatorUnknown(entity.$name, predicate.column, predicate.op);
25
+ }
26
+ const column = columnFor(entity.$columns, predicate.column);
27
+ if (column?.$meta.kind === 'money' && !isNullTest(predicate)) {
28
+ const property = predicate.column;
29
+ throw invariantViolated(
30
+ entity.$name,
31
+ property,
32
+ `${property} is money: name ${property}.minor or ${property}.currency`,
33
+ );
34
+ }
35
+ }
36
+ };
package/src/query.ts CHANGED
@@ -18,6 +18,7 @@ import { type ReadAggregates, readAggregates } from './read-aggregates';
18
18
  import type { Repo, RepoOptions, UpsertArgs } from './repo';
19
19
  import { pickRow } from './sealed';
20
20
  import { SEARCH_PROPERTY } from './search';
21
+ import { searchedRepo } from './search-repo';
21
22
  import { type TableLocks, tableLocks } from './table-locks';
22
23
  import type { Predicate, QueryPlan, SortDirection, SortKey } from './tenancy';
23
24
  import { stampedColumns, touch } from './touch';
@@ -74,6 +75,10 @@ export interface ReadBuilder<Row, S extends SealedKeySet = NoSealedKeys>
74
75
  * chain can serve: `ts_rank` is a computed value and the cursor carries columns, so the order
75
76
  * stays the one the caller declared. An entity with no searchable column is `X_SEARCH_UNDECLARED`
76
77
  * and the in-memory driver is `X_SEARCH_IN_MEMORY` — never a different answer from the two.
78
+ *
79
+ * The match is the INSTALLED `SearchIndex`'s (`searchIndex()`, `runtime.ts`'s `searchIndex`).
80
+ * The built-in Postgres one is this predicate in the statement, as above; any other index is
81
+ * asked for ids first (at most `MAX_PAGE_SIZE`), which the chain then reads (`search-repo.ts`).
77
82
  */
78
83
  search(term: string): ReadBuilder<Row, S>;
79
84
  orderBy(
@@ -379,10 +384,14 @@ export const tableFor = <Row, C extends ColumnMap>(
379
384
  related?: RelatedTables,
380
385
  ): Table<Row, C> => ({
381
386
  // One cast: `sealed-repo.ts` refuses at runtime, `SealedKeysOf<C>` is the same rule as a type.
382
- ...(builder<Row, Row>(entity, repo, EMPTY, (row) => row, related) as ReadBuilder<
383
- Row,
384
- SealedKeysOf<C>
385
- >),
387
+ // Reads go through `searchedRepo`: a `.search()` asks the installed `SearchIndex` (`search-repo.ts`).
388
+ ...(builder<Row, Row>(
389
+ entity,
390
+ searchedRepo(entity, repo),
391
+ EMPTY,
392
+ (row) => row,
393
+ related,
394
+ ) as ReadBuilder<Row, SealedKeysOf<C>>),
386
395
  insert: async (values, options) => repo.insert(entity.$parse(values), options),
387
396
  insertAll: async (rows, options) =>
388
397
  repo.insertAll(
package/src/registry.ts CHANGED
@@ -12,6 +12,7 @@ import type {
12
12
  import type { EntityCore } from './entity';
13
13
  import { entityDuplicate } from './entity-error';
14
14
  import type { InvariantKind } from './invariants';
15
+ import type { PartitionDescription } from './partition';
15
16
  import type { RecordProjection } from './record-projection';
16
17
  import type { ColumnDefault, OnDelete } from './types';
17
18
 
@@ -177,6 +178,8 @@ export interface EntityDescription {
177
178
  readonly retain?: RetainDescriptionLike;
178
179
  /** On a history entity: its parent's name. */
179
180
  readonly historyOf?: string;
181
+ /** `entity({ partitionBy })`, checked (`partition.ts`). Absent otherwise. `x db gen` reads it. */
182
+ readonly partition?: PartitionDescription;
180
183
  }
181
184
 
182
185
  export interface RegistryEntry {
package/src/row-key.ts ADDED
@@ -0,0 +1,19 @@
1
+ // Single responsibility: a row's identity as one string — the key the memory driver stores a row
2
+ // under and the id both drivers hand `cursorFor`. One definition, so the two cannot drift.
3
+
4
+ import { keyOf } from './batch-read';
5
+ import { kindOf, valueAt } from './cursor';
6
+ import type { EntityCore } from './entity';
7
+
8
+ /**
9
+ * Each primary-key value spelled as `keyOf` spells it (a `uuid` lower-cased, a number by value),
10
+ * so `findById(UPPER)` finds the row Postgres finds. A single key is that spelling alone; a
11
+ * composite one is a JSON array of them — joined with `''`, `('a', 'bc')` and `('ab', 'c')` were
12
+ * one key, and the memory driver stored the second row over the first.
13
+ */
14
+ export const rowKey = <Row>(entity: EntityCore<Row>, row: unknown): string => {
15
+ const keys = entity.$primaryKey.map((property) =>
16
+ keyOf(kindOf(entity, property) ?? '', valueAt(row, property)),
17
+ );
18
+ return keys.length === 1 ? (keys[0] ?? '') : JSON.stringify(keys);
19
+ };