@ultimat3/entity 1.2.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/CLAUDE.md +614 -0
- package/README.md +391 -11
- package/package.json +5 -4
- package/src/batch-read.ts +134 -0
- package/src/batch.ts +125 -0
- package/src/bulk-write.ts +285 -0
- package/src/coalesce.ts +175 -0
- package/src/column.ts +24 -0
- package/src/columns.ts +183 -35
- package/src/count-by.ts +148 -0
- package/src/cross-tenant.ts +76 -0
- package/src/cursor.ts +17 -3
- package/src/database.ts +35 -2
- package/src/describe.ts +82 -37
- package/src/entity.ts +41 -12
- package/src/errors.ts +346 -5
- package/src/expr.ts +65 -15
- package/src/index.ts +49 -4
- package/src/invariants.ts +56 -14
- package/src/jit-preload.ts +216 -0
- package/src/n-plus-one.ts +122 -0
- package/src/pg-driver.ts +281 -33
- package/src/pg-row.ts +32 -7
- package/src/pg-sql.ts +117 -8
- package/src/plan.ts +130 -20
- package/src/preload.ts +184 -0
- package/src/query.ts +231 -27
- package/src/registry.ts +63 -5
- package/src/relations.ts +212 -0
- package/src/repo.ts +226 -12
- package/src/tenancy.ts +194 -12
- package/src/type-pins.ts +311 -0
- package/src/types.ts +64 -12
- package/src/view.ts +8 -2
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 {
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
+
};
|