@ultimat3/entity 1.1.0 → 2.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.
package/src/plan.ts CHANGED
@@ -4,15 +4,28 @@
4
4
  // driver applies is worse than none: the test passes and production leaks another tenant's rows.
5
5
 
6
6
  import type { EntityCore } from './entity';
7
- import { invariantViolated } from './errors';
7
+ import { EntityError, invariantViolated, patchEmpty, writeUnfiltered } from './errors';
8
8
  import type { FindManyArgs, RepoOptions } from './repo';
9
- import type { Predicate, QueryPlan } from './tenancy';
10
- import { assertScoped } from './tenancy';
9
+ import type { Predicate, QueryPlan, SortKey } from './tenancy';
10
+ import { scopedPlan } from './tenancy';
11
11
 
12
12
  /** A page is bounded by default; an unbounded read is a production incident waiting for traffic. */
13
13
  export const DEFAULT_PAGE_SIZE = 50;
14
14
 
15
- /** Id-addressed operations need exactly one key. A composite key is a `findMany({ where })`. */
15
+ /**
16
+ * And a page the caller DID bound is bounded too. The default above only covers a read nobody
17
+ * sized; `limit(input.pageSize)` on a number that arrived over the wire is the same incident with
18
+ * an argument in front of it — the statement binds it, the server answers with every row, and the
19
+ * driver allocates and decodes all of them before the handler sees one. So the ceiling is a memory
20
+ * bound, not a preference, and it lives here rather than in either driver because both read one
21
+ * number or they stop meaning the same thing.
22
+ *
23
+ * Above `DEFAULT_BACKFILL_BATCH` (1,000) by an order of magnitude on purpose: a sweep declaring a
24
+ * wider batch is a tuning decision, and only a page nobody could have meant is refused.
25
+ */
26
+ export const MAX_PAGE_SIZE = 10_000;
27
+
28
+ /** Id-addressed operations need exactly one key. A composite key is addressed by every column. */
16
29
  export const singleKeyOf = <Row>(entity: EntityCore<Row>, operation: string): string => {
17
30
  const [only] = entity.$primaryKey;
18
31
  if (entity.$primaryKey.length !== 1 || only === undefined) {
@@ -20,45 +33,82 @@ export const singleKeyOf = <Row>(entity: EntityCore<Row>, operation: string): st
20
33
  entity.$name,
21
34
  operation,
22
35
  `${entity.$name} has a composite primary key (${entity.$primaryKey.join(', ')}) — ` +
23
- 'use findMany({ where }) instead of an id',
36
+ // The old wording sent every reader, whatever they were doing, to `findMany({ where })`,
37
+ // which is a read: a composite-key row looked unwritable because the one error that fires
38
+ // on `delete(id)`/`update(id, …)` named no write. Every surface gets named now.
39
+ 'name every key column: findMany({ where }) to read, ' +
40
+ 'updateWhere({ … }, patch) to patch, deleteWhere({ … }) to remove',
24
41
  );
25
42
  }
26
43
  return only;
27
44
  };
28
45
 
46
+ /**
47
+ * The sort keys a read actually runs with: the caller's, then whatever the primary key still adds.
48
+ * The primary key is always the final key — a cursor needs a total order, or two rows with the same
49
+ * sort value straddle a page boundary.
50
+ *
51
+ * Exported because a chain can be judged before it runs: `inBatches()` refuses an ordering that
52
+ * cannot carry a cursor, and it has to be looking at the order the driver will send rather than at
53
+ * the one the caller typed.
54
+ */
55
+ export const totalOrder = <Row>(
56
+ entity: EntityCore<Row>,
57
+ ordered: readonly SortKey[],
58
+ ): readonly SortKey[] => [
59
+ ...ordered,
60
+ ...entity.$primaryKey
61
+ .filter((property) => !ordered.some((entry) => entry.column === property))
62
+ .map((property) => ({ column: property, direction: 'asc' as const })),
63
+ ];
64
+
65
+ /**
66
+ * What a page size has to be before a statement carries it: rows, whole, at least one and at most
67
+ * `MAX_PAGE_SIZE`. The three refusals `assertBatchable` already applies to `inBatches(size)`, and
68
+ * deliberately the same code and the same voice — `limit(0)` and `inBatches(0)` are one mistake in
69
+ * two calls, and two codes would make a caller decide which to catch.
70
+ *
71
+ * Called from `limit()` on the chain, so the refusal lands on the line the author wrote, AND from
72
+ * `planFor` below, so `findMany({ limit })` straight at the repository — a generated client, a
73
+ * query, a third-party driver's caller — cannot route around it. One function, so the two can
74
+ * never disagree about what a page may be.
75
+ */
76
+ export const assertPageSize = (entityName: string, rows: number): void => {
77
+ if (Number.isSafeInteger(rows) && rows >= 1 && rows <= MAX_PAGE_SIZE) return;
78
+ throw new EntityError({
79
+ code: 'X_INVARIANT_VIOLATED',
80
+ cause: `${entityName}.limit(${String(rows)}) — a page is a whole number of rows, at least one and at most ${MAX_PAGE_SIZE}`,
81
+ fix: `${entityName}.limit(${DEFAULT_PAGE_SIZE}) # or, to visit every row the filter matches, ${entityName}.inBatches(1000) — one page per statement, and never a table in memory`,
82
+ });
83
+ };
84
+
29
85
  export const planFor = <Row>(entity: EntityCore<Row>, args: FindManyArgs): QueryPlan => {
86
+ if (args.limit !== undefined) assertPageSize(entity.$name, args.limit);
30
87
  const scoped =
31
88
  args.orgId === undefined || entity.$tenantColumn === null
32
89
  ? []
33
90
  : [{ column: entity.$tenantColumn, op: 'eq', value: args.orgId } satisfies Predicate];
34
- const ordered = args.orderBy ?? [];
35
91
  return {
36
92
  entity: entity.$name,
37
93
  where: [...(args.where ?? []), ...scoped],
38
- // The primary key is always the final sort key: a cursor needs a total order, or two
39
- // rows with the same sort value straddle a page boundary.
40
- orderBy: [
41
- ...ordered,
42
- ...entity.$primaryKey
43
- .filter((property) => !ordered.some((entry) => entry.column === property))
44
- .map((property) => ({ column: property, direction: 'asc' as const })),
45
- ],
94
+ orderBy: totalOrder(entity, args.orderBy ?? []),
46
95
  limit: args.limit ?? DEFAULT_PAGE_SIZE,
47
96
  ...(args.cursor === undefined || args.cursor === null ? {} : { cursor: args.cursor }),
48
97
  ...(args.select === undefined ? {} : { select: args.select }),
49
98
  };
50
99
  };
51
100
 
52
- /** The plan for a read. Throws `X_TENANCY_UNSCOPED` before a single row is considered. */
101
+ /**
102
+ * The plan for a read, scoped to the acting actor's tenant before a single row is considered. The
103
+ * caller's own `orgId` is not what scopes it — it is checked against the actor and refused when it
104
+ * disagrees (`X_TENANCY_ACTOR_MISMATCH`), because an `orgId` that arrived as action input is a
105
+ * value the caller chose.
106
+ */
53
107
  export const readPlan = <Row>(
54
108
  entity: EntityCore<Row>,
55
109
  args: FindManyArgs,
56
110
  operation: string,
57
- ): QueryPlan => {
58
- const plan = planFor(entity, args);
59
- assertScoped(entity.$name, entity.$tenantColumn, operation, plan);
60
- return plan;
61
- };
111
+ ): QueryPlan => scopedPlan(entity.$name, entity.$tenantColumn, operation, planFor(entity, args));
62
112
 
63
113
  /**
64
114
  * The plan for an id-addressed write. A write is a query too: without the same guard,
@@ -80,3 +130,63 @@ export const idPlan = <Row>(
80
130
  },
81
131
  operation,
82
132
  );
133
+
134
+ /**
135
+ * The columns a filter or a patch actually names. An `undefined` property is dropped rather than
136
+ * used: `deleteWhere({ postId })` where `postId` came back undefined must reduce to an empty
137
+ * filter and be refused, never to `post_id = null` — or worse, on a driver that ignores the bind,
138
+ * to no predicate at all. `updateWhere(filter, { lastReadAt })` is the same mistake, one argument
139
+ * along, so both guards count the same way.
140
+ */
141
+ export const namedColumns = (values: unknown): readonly (readonly [string, unknown])[] =>
142
+ Object.entries(
143
+ typeof values === 'object' && values !== null ? (values as Record<string, unknown>) : {},
144
+ ).filter(([, value]) => value !== undefined);
145
+
146
+ /** The filter a filtered write is allowed to run with: never the empty one. */
147
+ const boundedWhere = <Row>(
148
+ entity: EntityCore<Row>,
149
+ filter: Partial<Row>,
150
+ operation: string,
151
+ ): Predicate[] => {
152
+ const where = namedColumns(filter).map(
153
+ ([column, value]): Predicate => ({ column, op: 'eq', value }),
154
+ );
155
+ if (where.length === 0) throw writeUnfiltered(entity.$name, operation, entity.$primaryKey);
156
+ return where;
157
+ };
158
+
159
+ /**
160
+ * The plan for a filtered delete. The guard order is the point: an empty filter is refused before
161
+ * tenancy runs, so `deleteWhere({}, { orgId })` cannot pass by virtue of the org predicate the
162
+ * framework added — that predicate bounds the blast radius to one tenant, which is still every
163
+ * row that tenant has.
164
+ *
165
+ * `limit` is the read default and is not a bound on the delete: neither driver pages a delete.
166
+ */
167
+ export const deletePlan = <Row>(
168
+ entity: EntityCore<Row>,
169
+ filter: Partial<Row>,
170
+ options: RepoOptions | undefined,
171
+ operation: string,
172
+ ): QueryPlan =>
173
+ readPlan(entity, { ...options, where: boundedWhere(entity, filter, operation) }, operation);
174
+
175
+ /**
176
+ * The plan for a filtered update. Same filter guard as the delete, from the same function, plus
177
+ * the patch: a write that names no columns is refused rather than counted, because "4 rows
178
+ * updated" for a statement that set nothing is the answer nobody can act on.
179
+ */
180
+ export const updatePlan = <Row>(
181
+ entity: EntityCore<Row>,
182
+ filter: Partial<Row>,
183
+ patch: Partial<Row>,
184
+ options: RepoOptions | undefined,
185
+ operation: string,
186
+ ): QueryPlan => {
187
+ const where = boundedWhere(entity, filter, operation);
188
+ if (namedColumns(patch).length === 0) {
189
+ throw patchEmpty(entity.$name, operation, Object.keys(entity.$columns));
190
+ }
191
+ return readPlan(entity, { ...options, where }, operation);
192
+ };
package/src/preload.ts ADDED
@@ -0,0 +1,184 @@
1
+ // Single responsibility: the eager preload. `preload('author')` is one extra
2
+ // `select … where <key> in (…)` over the values the page already carries, and its rows attached to
3
+ // that page under the relation's name — the declarative form of what a point lookup batches for
4
+ // itself, and the exact line an N+1 warning tells the reader to write.
5
+ //
6
+ // Nothing new is declared: the relation is the `references()` already written (`relations.ts`).
7
+ // The other table is read through the same driver this one came from, so a preload against the
8
+ // in-memory driver means what a preload against Postgres means.
9
+
10
+ import { keyOf, MAX_IDS_PER_STATEMENT, statementChunks } from './batch-read';
11
+ import { valueAt } from './cursor';
12
+ import type { EntityCore } from './entity';
13
+ import { EntityError } from './errors';
14
+ import type { Relation } from './relations';
15
+ import type { Repo } from './repo';
16
+ import type { Predicate } from './tenancy';
17
+
18
+ /** The other side of a relation: the entity, and where its rows live. */
19
+ export interface RelatedTable {
20
+ readonly entity: EntityCore;
21
+ readonly repo: Repo<unknown>;
22
+ }
23
+
24
+ /**
25
+ * How one table reaches another. `database()` supplies it over the set it was declared with and
26
+ * through the driver that set was given, so a preload reads rows from where this table's own rows
27
+ * come from — and a table can no more preload an entity the set never named than it can be indexed
28
+ * off `db` by one.
29
+ */
30
+ export type RelatedTables = (entityName: string) => RelatedTable | undefined;
31
+
32
+ /**
33
+ * Not `invariantViolated`: its fix opens `x entity explain`, which describes invariants nobody
34
+ * wrote here. What repairs this is one edit to the `database()` call — a relation whose other end
35
+ * is outside the set is a set that is missing an entity, and the preload declines rather than
36
+ * reaching around the handle for it.
37
+ */
38
+ const unreachable = (entityName: string, relation: Relation): EntityError =>
39
+ new EntityError({
40
+ code: 'X_INVARIANT_VIOLATED',
41
+ cause: `${entityName}.preload('${relation.name}') reads ${relation.to}, which this table cannot reach — a table reads the entities its own database() call named`,
42
+ fix: `x entities list --json # then widen the database() call that built db.${entityName} to database({ ${entityName}, ${relation.to} }, options)`,
43
+ });
44
+
45
+ /**
46
+ * The tenant predicate the page was read under, carried onto the related read when BOTH entities
47
+ * are scoped by a column of that same name. Never across two differently-named tenant columns: a
48
+ * value that scopes one entity is a guess on another, and a guess here is a cross-tenant read.
49
+ *
50
+ * Both ends are checked, not just the target's. A source scoped by `workspaceId` may still carry
51
+ * an ordinary `orgId` predicate of its own — a filter, not its tenancy — and matching on the
52
+ * target's column name alone would lift that filter into the target's tenant scope and hand the
53
+ * preload rows from a tenant nobody proved this reader owns.
54
+ *
55
+ * Carrying nothing is not a failure of this function — the related read builds its own plan, so
56
+ * `assertScoped` refuses it there, in the words a caller can act on.
57
+ */
58
+ const tenantScope = (
59
+ source: EntityCore,
60
+ target: EntityCore,
61
+ where: readonly Predicate[],
62
+ ): readonly Predicate[] => {
63
+ const column = target.$tenantColumn;
64
+ if (column === null || source.$tenantColumn !== column) return [];
65
+ const carried = where.find((predicate) => predicate.column === column && predicate.op === 'eq');
66
+ return carried === undefined ? [] : [carried];
67
+ };
68
+
69
+ /**
70
+ * Every row on the other side, in as few statements as the bind count allows: one per 500 keys —
71
+ * the bound a batched point read already lives under — and one more only when a page comes back
72
+ * genuinely full. A `belongsTo` over a page of 50 is exactly one statement.
73
+ *
74
+ * The page loop is what keeps a `hasMany` honest: a relation with more rows than one page holds
75
+ * costs another statement rather than silently returning the first page of them.
76
+ */
77
+ const relatedRows = async (
78
+ target: RelatedTable,
79
+ relation: Relation,
80
+ values: readonly unknown[],
81
+ scope: readonly Predicate[],
82
+ ): Promise<readonly unknown[]> => {
83
+ const rows: unknown[] = [];
84
+ for (const chunk of statementChunks(values)) {
85
+ let cursor: string | null = null;
86
+ do {
87
+ const page = await target.repo.findMany({
88
+ where: [{ column: relation.remoteKey, op: 'in', value: chunk }, ...scope],
89
+ limit: MAX_IDS_PER_STATEMENT,
90
+ cursor,
91
+ });
92
+ rows.push(...page.rows);
93
+ cursor = page.nextCursor;
94
+ } while (cursor !== null);
95
+ }
96
+ return rows;
97
+ };
98
+
99
+ /** Related rows filed under the key they attach to — in the order the read returned them. */
100
+ const indexed = async (
101
+ target: RelatedTable,
102
+ relation: Relation,
103
+ kind: string,
104
+ values: readonly unknown[],
105
+ scope: readonly Predicate[],
106
+ ): Promise<ReadonlyMap<string, readonly unknown[]>> => {
107
+ const index = new Map<string, unknown[]>();
108
+ if (values.length === 0) return index;
109
+ const found = await relatedRows(target, relation, values, scope);
110
+ for (const row of found) {
111
+ const at = keyOf(kind, valueAt(row, relation.remoteKey));
112
+ const bucket = index.get(at);
113
+ if (bucket === undefined) index.set(at, [row]);
114
+ else bucket.push(row);
115
+ }
116
+ return index;
117
+ };
118
+
119
+ /** The distinct keys a page carried, spelled as the batch spells them. A null key is not a key. */
120
+ const distinctKeys = (kind: string, keys: readonly unknown[]): readonly unknown[] => {
121
+ const seen = new Set<string>();
122
+ const values: unknown[] = [];
123
+ for (const key of keys) {
124
+ if (key === null || key === undefined) continue;
125
+ const at = keyOf(kind, key);
126
+ if (seen.has(at)) continue;
127
+ seen.add(at);
128
+ values.push(key);
129
+ }
130
+ return values;
131
+ };
132
+
133
+ export interface PreloadRead<Source> {
134
+ readonly entity: EntityCore<Source>;
135
+ /** `undefined` for a table built by hand — `tableFor(entity, repo)` reaches no other table. */
136
+ readonly related: RelatedTables | undefined;
137
+ readonly relations: readonly Relation[];
138
+ /** The page's own predicates: what the related read inherits its tenant scope from. */
139
+ readonly where: readonly Predicate[];
140
+ }
141
+
142
+ /**
143
+ * The page, with every named relation attached to it. `source` carries the key values (a
144
+ * projection may have dropped them from the rows the caller sees) and `rows` are the rows the
145
+ * caller gets, in the same order — the attachment is by position, so the two are always one page.
146
+ *
147
+ * A relation resolves to a row or `null` when it is a `belongsTo` and to an array when it is a
148
+ * `hasMany`, always present: "this post has no author" and "nobody preloaded the author" must not
149
+ * read the same at the call site. Relations resolve concurrently — two `preload()` calls are two
150
+ * statements in flight, never one after the other — and attach in the order they were named.
151
+ */
152
+ export const preloaded = async <Source, Row>(
153
+ read: PreloadRead<Source>,
154
+ source: readonly Source[],
155
+ rows: readonly Row[],
156
+ ): Promise<readonly Row[]> => {
157
+ if (read.relations.length === 0 || rows.length === 0) return rows;
158
+ const resolved = await Promise.all(
159
+ read.relations.map(async (relation) => {
160
+ const target = read.related?.(relation.to);
161
+ if (target === undefined) throw unreachable(read.entity.$name, relation);
162
+ // The declaring column's own kind: a foreign key mirrors the key it points at, so both ends
163
+ // of the edge are spelled the way a batched point read already spells them.
164
+ const kind = read.entity.$columns[relation.localKey]?.$meta.kind ?? '';
165
+ const keys = source.map((row) => valueAt(row, relation.localKey));
166
+ const scope = tenantScope(read.entity, target.entity, read.where);
167
+ const index = await indexed(target, relation, kind, distinctKeys(kind, keys), scope);
168
+ return { relation, kind, keys, index };
169
+ }),
170
+ );
171
+ // A copy per row: the in-memory driver hands back the row it stores, and attaching to that one
172
+ // would write a relation into the table itself.
173
+ const attached = rows.map((row) => ({ ...row }) as Record<string, unknown>);
174
+ for (const { relation, kind, keys, index } of resolved) {
175
+ for (const [position, row] of attached.entries()) {
176
+ const key = keys[position];
177
+ const found = key === null || key === undefined ? undefined : index.get(keyOf(kind, key));
178
+ row[relation.name] = relation.kind === 'hasMany' ? (found ?? []) : (found?.[0] ?? null);
179
+ }
180
+ }
181
+ // Built from the caller's own rows, one property added per relation named — which is exactly
182
+ // what `Row & { [name]: unknown }` says at the call site.
183
+ return attached as readonly Row[];
184
+ };