@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,113 @@
1
+ // The one index every tenant table needs and nobody declares: `(tenant, primary key)`, the exact
2
+ // shape of every scoped read — `where org_id = $1 order by id` — whether the tenant column was
3
+ // marked `.tenant()`, named by `tenant:` or inferred from `orgId`.
4
+
5
+ import { indexName } from './index-name';
6
+ import type { ReferenceDescription, RegistryEntry } from './registry';
7
+ import { registeredEntities, registryGeneration } from './registry';
8
+ import type { IndexDef } from './types';
9
+
10
+ export interface TenantIndexInput {
11
+ readonly entityName: string;
12
+ readonly table: string;
13
+ /** The tenant column's PHYSICAL name, `null` on an untenanted table. */
14
+ readonly tenant: string | null;
15
+ /** The primary key's physical columns, in declared order. */
16
+ readonly primaryKey: readonly string[];
17
+ /** A plain (non-materialized) view holds no index at all. */
18
+ readonly plainView: boolean;
19
+ /**
20
+ * The column lists of every non-partial btree index and unique constraint the table already
21
+ * declares. One that STARTS with `(tenant, pk…)` serves the scoped read already.
22
+ */
23
+ readonly covering: readonly (readonly string[])[];
24
+ }
25
+
26
+ const startsWith = (list: readonly string[], prefix: readonly string[]): boolean =>
27
+ prefix.length <= list.length && prefix.every((column, at) => list[at] === column);
28
+
29
+ /**
30
+ * Zero or one index. Measured at 500k rows / 1,000 tenants: a tenant page took 43.7 ms with no
31
+ * index, 2.9 ms on `(org_id)` and 1.5 ms on `(org_id, id)` — the composite serves the filter AND
32
+ * the keyset order, so it replaces the single-column index `.tenant()` used to get rather than
33
+ * joining it (see `coveredByTenantIndex`). None when the primary key already leads with the
34
+ * tenant: its own index is this one.
35
+ *
36
+ * Built `concurrently`: adding it to a live table must not hold SHARE for the whole build, and on
37
+ * a table the same migration creates `@ultimat3/db` builds it inline anyway.
38
+ */
39
+ export const tenantIndexOf = (input: TenantIndexInput): readonly IndexDef[] => {
40
+ const { tenant, primaryKey } = input;
41
+ // `leadingColumn`, not `primaryKey[0]`: a column name, which `secret-compare` would read as a key.
42
+ const [leadingColumn] = primaryKey;
43
+ if (tenant === null || input.plainView || leadingColumn === tenant) return [];
44
+ const columns = [tenant, ...primaryKey.filter((column) => column !== tenant)];
45
+ if (input.covering.some((list) => startsWith(list, columns))) return [];
46
+ return [
47
+ {
48
+ name: indexName(input.entityName, input.table, columns, false),
49
+ columns,
50
+ unique: false,
51
+ concurrently: true,
52
+ },
53
+ ];
54
+ };
55
+
56
+ /**
57
+ * Whether a column's own single-column, non-unique index is a prefix of the tenant index — or of a
58
+ * primary key that leads with the tenant — and so is not built a second time. A unique tenant
59
+ * column keeps its constraint: uniqueness is a rule, not an access path.
60
+ */
61
+ export const coveredByTenantIndex = (
62
+ physical: string,
63
+ tenant: string | null,
64
+ unique: boolean,
65
+ ): boolean => !unique && tenant !== null && physical === tenant;
66
+
67
+ /**
68
+ * Another entity's references, or none when resolving them refuses: that entity's own
69
+ * `$describe()` raises the refusal with its own name, and THIS entity's description must not fail
70
+ * for it. Skipping it only keeps an index — the safe direction.
71
+ */
72
+ const referencesOf = (entry: RegistryEntry): readonly ReferenceDescription[] => {
73
+ try {
74
+ return entry.references();
75
+ } catch {
76
+ return [];
77
+ }
78
+ };
79
+
80
+ /** `<table>|<column>` of every same-tenant reference target, memoised on the registry generation. */
81
+ let targets: { readonly generation: number; readonly keys: ReadonlySet<string> } | undefined;
82
+
83
+ const sameTenantTargets = (): ReadonlySet<string> => {
84
+ const generation = registryGeneration();
85
+ if (targets !== undefined && targets.generation === generation) return targets.keys;
86
+ const keys = new Set<string>();
87
+ for (const entry of registeredEntities()) {
88
+ for (const reference of referencesOf(entry)) {
89
+ if (reference.sameTenant != null) {
90
+ keys.add(`${reference.targetTable}|${reference.targetColumn}`);
91
+ }
92
+ }
93
+ }
94
+ targets = { generation, keys };
95
+ return keys;
96
+ };
97
+
98
+ /**
99
+ * Describe time, when every entity is registered: the target of a same-tenant reference gets a
100
+ * UNIQUE `(tenant, key)` from `@ultimat3/db` (`same-tenant.ts`), which serves the scoped read, so
101
+ * the non-unique tenant index beside it would be the same index twice. Not decided at `entity()`:
102
+ * the referencing entity may be declared later, and a thunk is never resolved at declaration.
103
+ */
104
+ export const withoutDerivedDuplicate = (
105
+ table: string,
106
+ primaryKey: readonly string[],
107
+ tenantIndex: IndexDef | undefined,
108
+ indexes: readonly IndexDef[],
109
+ ): readonly IndexDef[] => {
110
+ if (tenantIndex === undefined || primaryKey.length !== 1) return indexes;
111
+ if (!sameTenantTargets().has(`${table}|${primaryKey[0]}`)) return indexes;
112
+ return indexes.filter((index) => index !== tenantIndex);
113
+ };
package/src/types.ts CHANGED
@@ -7,6 +7,7 @@
7
7
  // from the column set instead of being written a second time as a hand-maintained schema.
8
8
 
9
9
  import type { IndexMethod } from '@ultimat3/db';
10
+ import type { PersonalDataKind } from './personal-data-kind';
10
11
 
11
12
  /**
12
13
  * Postgres types the builders emit. `money` expands to `bigint` + `char(3)` (+ a nullable
@@ -163,6 +164,12 @@ export interface ColumnMeta {
163
164
  * one GIN index per column.
164
165
  */
165
166
  readonly searchable?: SearchWeight;
167
+ /**
168
+ * Presence marks this column as personal data, and the value is what kind. Read by a subject
169
+ * export or erase (`personal-data.ts`); never on `$describe()` — it changes no DDL, so it must
170
+ * not move the drift hash.
171
+ */
172
+ readonly personalData?: PersonalDataKind;
166
173
  /**
167
174
  * The state machine this column's values move through, when one was declared. Built once at
168
175
  * declaration (`stateMachineOf`) so an illegal table is a refusal where it was written, and held
@@ -239,6 +246,11 @@ export interface Column<T, Optional extends boolean = false> {
239
246
  * only — every other kind is refused here, where the chain was written.
240
247
  */
241
248
  searchable(weight?: SearchWeight): Column<T, Optional>;
249
+ /**
250
+ * Marks the column as personal data of `kind` — what `accountExport`/`accountErase` walk. No DDL
251
+ * moves, so marking an existing column is not a migration.
252
+ */
253
+ personalData(kind: PersonalDataKind): Column<T, Optional>;
242
254
  }
243
255
 
244
256
  /**
@@ -0,0 +1,28 @@
1
+ // When a write's consequences may be told: at COMMIT. One rule for the row observer and the search
2
+ // mirror, so a change neither reports nor indexes before a rollback could still erase it.
3
+
4
+ import { currentTx } from '@ultimat3/db';
5
+ import { memoryJournal } from './memory-journal';
6
+ import type { RepoOptions } from './repo';
7
+
8
+ /** What a commit effect is registered on. */
9
+ export interface CommitScope {
10
+ onCommit?(effect: () => void): void;
11
+ }
12
+
13
+ /**
14
+ * The transaction a write joins — the caller's `options.tx`, the ambient `withTransaction` a
15
+ * Postgres repository joins, or the ambient memory one — or `undefined` outside one, where the
16
+ * write is already durable.
17
+ */
18
+ export const commitScope = (options: RepoOptions | undefined): CommitScope | undefined => {
19
+ const tx: CommitScope | undefined = options?.tx ?? currentTx() ?? memoryJournal();
20
+ return tx?.onCommit === undefined ? undefined : tx;
21
+ };
22
+
23
+ /** Run `effect` when the write's transaction commits, never on rollback; outside one, at once. */
24
+ export const afterCommit = (options: RepoOptions | undefined, effect: () => void): void => {
25
+ const tx = commitScope(options);
26
+ if (tx === undefined) effect();
27
+ else tx.onCommit?.(effect);
28
+ };
package/src/write-tag.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  // tells the page that wrote it that this is its own echo. The in-process row observer reads the
6
6
  // same fact off the request scope and needs none of this.
7
7
 
8
- import { currentWriteOrigin, WRITE_ORIGIN_WAL_PREFIX } from '@ultimat3/core';
8
+ import { currentWriteOrigin, runToCompletion, WRITE_ORIGIN_WAL_PREFIX } from '@ultimat3/core';
9
9
  import { currentTx, type DbClient, type DbTx, db, sql, withTransaction } from '@ultimat3/db';
10
10
 
11
11
  /** Transactions this process already opened with the message: one per transaction is enough. */
@@ -28,7 +28,11 @@ const CAN_EMIT = sql`select exists (select 1 from pg_catalog.pg_proc where prona
28
28
 
29
29
  function canEmit(client: DbClient): Promise<boolean> {
30
30
  if (emits === undefined) {
31
- const asked = client.one<{ ok: unknown }>(CAN_EMIT).then((row) => row?.ok === true);
31
+ // Outside the asking request's abort: the answer is every concurrent caller's, and the pool
32
+ // cancels a read whose scope hangs up — which answered `false` to all of them, untagged.
33
+ const asked = runToCompletion(() => client.one<{ ok: unknown }>(CAN_EMIT)).then(
34
+ (row) => row?.ok === true,
35
+ );
32
36
  emits = asked;
33
37
  // A probe that FAILED answered nothing: the next write asks again.
34
38
  asked.catch(() => {