@cosmicdrift/kumiko-bundled-features 0.295.0 → 0.296.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.
@@ -26,9 +26,9 @@ import {
26
26
  extractTableName,
27
27
  physicalColumnName,
28
28
  } from "@cosmicdrift/kumiko-framework/db";
29
- import type { Registry } from "@cosmicdrift/kumiko-framework/engine";
29
+ import { MAX_TRANSFER_DEPTH, type Registry } from "@cosmicdrift/kumiko-framework/engine";
30
30
  import { UnprocessableError } from "@cosmicdrift/kumiko-framework/errors";
31
- import { resolveChildCandidates } from "./transfer-graph";
31
+ import { resolveTransferAdjacency, type TransferEdge } from "./transfer-graph";
32
32
 
33
33
  const FILE_REFS_TABLE = "file_refs";
34
34
  const EVENTS_TABLE = "kumiko_events";
@@ -114,24 +114,91 @@ async function moveChildRows(args: {
114
114
  readonly idCol: string;
115
115
  readonly tenantCol: string;
116
116
  readonly pkCol: string;
117
- readonly rootEntityType: string;
118
- readonly rootRowId: string;
117
+ readonly parentEntityType: string;
118
+ readonly parentRowIds: readonly string[];
119
119
  readonly sourceTenantId: string;
120
120
  readonly destinationTenantId: string;
121
121
  }): Promise<readonly string[]> {
122
122
  const rows = await executeRawQuery<{ id: string }>(
123
123
  args.db,
124
124
  `UPDATE "${args.tableName}" SET "${args.tenantCol}" = $1 ` +
125
- `WHERE "${args.typeCol}" = $2 AND "${args.idCol}" = $3 AND "${args.tenantCol}" = $4 ` +
125
+ `WHERE "${args.typeCol}" = $2 AND "${args.idCol}" = ANY($3) AND "${args.tenantCol}" = $4 ` +
126
126
  `RETURNING "${args.pkCol}" AS id`,
127
- [args.destinationTenantId, args.rootEntityType, args.rootRowId, args.sourceTenantId],
127
+ [args.destinationTenantId, args.parentEntityType, args.parentRowIds, args.sourceTenantId],
128
128
  );
129
129
  return rows.map((row) => row.id);
130
130
  }
131
131
 
132
+ // A `reference` field names its target entity in the schema, so unlike a
133
+ // parentRef there is no type discriminator column to match on — the column
134
+ // itself only ever holds ids of that one entity.
135
+ async function moveReferencedChildRows(args: {
136
+ readonly db: DbRunner;
137
+ readonly tableName: string;
138
+ readonly refCol: string;
139
+ readonly tenantCol: string;
140
+ readonly pkCol: string;
141
+ readonly parentRowIds: readonly string[];
142
+ readonly sourceTenantId: string;
143
+ readonly destinationTenantId: string;
144
+ }): Promise<readonly string[]> {
145
+ const rows = await executeRawQuery<{ id: string }>(
146
+ args.db,
147
+ `UPDATE "${args.tableName}" SET "${args.tenantCol}" = $1 ` +
148
+ `WHERE "${args.refCol}" = ANY($2) AND "${args.tenantCol}" = $3 ` +
149
+ `RETURNING "${args.pkCol}" AS id`,
150
+ [args.destinationTenantId, args.parentRowIds, args.sourceTenantId],
151
+ );
152
+ return rows.map((row) => row.id);
153
+ }
154
+
155
+ // Resolves one edge against the ids its parent type just moved, and reports
156
+ // which rows changed hands.
157
+ async function moveEdgeRows(args: {
158
+ readonly db: DbRunner;
159
+ readonly registry: Registry;
160
+ readonly edge: TransferEdge;
161
+ readonly parentRowIds: readonly string[];
162
+ readonly sourceTenantId: string;
163
+ readonly destinationTenantId: string;
164
+ }): Promise<readonly string[]> {
165
+ const { db, registry, edge, parentRowIds, sourceTenantId, destinationTenantId } = args;
166
+ const table = entityTableFromRegistry(registry, edge.entityName, edge.entity);
167
+ const tableName = extractTableName(table);
168
+ const tenantCol = physicalColumnName(table, "tenantId");
169
+ const pkCol = physicalColumnName(table, "id");
170
+
171
+ if (edge.link.kind === "parentRef") {
172
+ return moveChildRows({
173
+ db,
174
+ tableName,
175
+ typeCol: physicalColumnName(table, edge.link.typeField),
176
+ idCol: physicalColumnName(table, edge.link.idField),
177
+ tenantCol,
178
+ pkCol,
179
+ parentEntityType: edge.parentEntityName,
180
+ parentRowIds,
181
+ sourceTenantId,
182
+ destinationTenantId,
183
+ });
184
+ }
185
+ return moveReferencedChildRows({
186
+ db,
187
+ tableName,
188
+ refCol: physicalColumnName(table, edge.link.field),
189
+ tenantCol,
190
+ pkCol,
191
+ parentRowIds,
192
+ sourceTenantId,
193
+ destinationTenantId,
194
+ });
195
+ }
196
+
132
197
  // Everything the root ownership write does NOT cover: the root's own event
133
- // history + attached files, plus every parentRef-linked child's rows, event
134
- // history, and attached files. Returns a count per moved entity name (the
198
+ // history + attached files, plus the rows, event history and attached files of
199
+ // every descendant the declared transfer graph reaches, however deep and by
200
+ // however many paths (#3088, #3131).
201
+ // Returns a count per moved entity name (the
135
202
  // write handler turns this into the audit entry) — `fileRef` is a single
136
203
  // pooled count across root + every child, since it is not itself part of the
137
204
  // declared transfer graph (see files-tenant-data's own handover coverage for
@@ -171,57 +238,95 @@ export async function moveTransferGraph(args: {
171
238
  }),
172
239
  );
173
240
 
174
- for (const candidate of resolveChildCandidates(registry, rootEntityName)) {
175
- const parentRef = candidate.entity.parentRef;
176
- if (!parentRef) continue; // resolveChildCandidates already filtered on this — narrows for TS
177
- const table = entityTableFromRegistry(registry, candidate.entityName, candidate.entity);
178
- const tableName = extractTableName(table);
179
- const typeCol = physicalColumnName(table, parentRef.entityTypeField);
180
- const idCol = physicalColumnName(table, parentRef.entityIdField);
181
- const tenantCol = physicalColumnName(table, "tenantId");
182
- const pkCol = physicalColumnName(table, "id");
183
-
184
- const childIds = await moveChildRows({
185
- db,
186
- tableName,
187
- typeCol,
188
- idCol,
189
- tenantCol,
190
- pkCol,
191
- rootEntityType: rootEntityName,
192
- rootRowId,
193
- sourceTenantId,
194
- destinationTenantId,
195
- });
196
- if (childIds.length === 0) continue;
197
-
198
- // Moved first, validated second — safe only because everything here
199
- // shares the root UPDATE's transaction (see file header): throwing now
200
- // rolls this move back together with the root's, not just this one.
201
- if (candidate.entity.transferable !== true) {
202
- throw new UnprocessableError("entity_not_transferable", {
203
- i18nKey: "errors.tenantHandover.entityNotTransferable",
204
- details: { entityName: candidate.entityName },
205
- });
241
+ const adjacency = resolveTransferAdjacency(registry, rootEntityName);
242
+
243
+ // A worklist, not a level-wise walk (#3131): each batch of rows that actually
244
+ // changed hands becomes the parent ids for the edges leading away from its
245
+ // type. The same edge therefore runs again whenever a later round discovers
246
+ // more rows of its parent type, which is what a type reachable by two paths
247
+ // of different length needs.
248
+ //
249
+ // Termination rests on row-level idempotency, NOT on visiting each edge once:
250
+ // every statement filters on `tenantCol = sourceTenantId` and RETURNS only
251
+ // the rows it flipped, so a row enters this worklist exactly once and the
252
+ // source tenant holds finitely many. Do not add an edge-level visited set to
253
+ // "bound" this — that is precisely the bug #3131 removed.
254
+ let pending: readonly { readonly entityName: string; readonly rowIds: readonly string[] }[] = [
255
+ { entityName: rootEntityName, rowIds: [rootRowId] },
256
+ ];
257
+
258
+ for (let round = 0; round < MAX_TRANSFER_DEPTH && pending.length > 0; round++) {
259
+ const discovered: { entityName: string; rowIds: readonly string[] }[] = [];
260
+
261
+ for (const { entityName, rowIds } of pending) {
262
+ for (const edge of adjacency.get(entityName) ?? []) {
263
+ const childIds = await moveEdgeRows({
264
+ db,
265
+ registry,
266
+ edge,
267
+ parentRowIds: rowIds,
268
+ sourceTenantId,
269
+ destinationTenantId,
270
+ });
271
+ // skip: this edge found nothing hanging off these particular rows —
272
+ // either the app declares the link but no row uses it, or the rows were
273
+ // already moved by an edge reached earlier.
274
+ if (childIds.length === 0) continue;
275
+
276
+ // Moved first, validated second — safe only because everything here
277
+ // shares the root UPDATE's transaction (see file header): throwing now
278
+ // rolls this move back together with the root's, not just this one.
279
+ if (edge.entity.transferable !== true) {
280
+ throw new UnprocessableError("entity_not_transferable", {
281
+ i18nKey: "errors.tenantHandover.entityNotTransferable",
282
+ details: { entityName: edge.entityName },
283
+ });
284
+ }
285
+
286
+ // `+=`, not `=`: one entity type can be reached by more than one edge
287
+ // (`note.vehicleId` and `note.campaignId`), and assigning would drop the
288
+ // earlier count.
289
+ movedCounts[edge.entityName] = (movedCounts[edge.entityName] ?? 0) + childIds.length;
290
+ discovered.push({ entityName: edge.entityName, rowIds: childIds });
291
+
292
+ await moveEventHistory({
293
+ db,
294
+ aggregateType: edge.entityName,
295
+ aggregateIds: childIds,
296
+ sourceTenantId,
297
+ destinationTenantId,
298
+ });
299
+ trackFileMove(
300
+ await moveFileRefs({
301
+ db,
302
+ entityType: edge.entityName,
303
+ entityIds: childIds,
304
+ sourceTenantId,
305
+ destinationTenantId,
306
+ }),
307
+ );
308
+ }
206
309
  }
207
310
 
208
- movedCounts[candidate.entityName] = childIds.length;
209
- await moveEventHistory({
210
- db,
211
- aggregateType: candidate.entityName,
212
- aggregateIds: childIds,
213
- sourceTenantId,
214
- destinationTenantId,
311
+ pending = discovered;
312
+ }
313
+
314
+ // The depth limit is a safety net, not a licence to move part of a graph: if
315
+ // rounds ran out while rows remain whose type still leads somewhere, the
316
+ // declaration reaches further than the mover does, and returning quietly
317
+ // would leave exactly the orphaned rows #3088 is about.
318
+ //
319
+ // Rounds count hops of both edge kinds, while the boot validator measures
320
+ // reference chains between transferable entities only. A graph that fills the
321
+ // limit with reference hops and then adds a parentRef child therefore boots
322
+ // clean and fails here instead — the residual the validator cannot see, and
323
+ // the reason this check has to exist rather than trusting boot alone.
324
+ const stranded = pending.find(({ entityName }) => (adjacency.get(entityName) ?? []).length > 0);
325
+ if (stranded !== undefined) {
326
+ throw new UnprocessableError("transfer_graph_too_deep", {
327
+ i18nKey: "errors.tenantHandover.transferGraphTooDeep",
328
+ details: { entityName: stranded.entityName },
215
329
  });
216
- trackFileMove(
217
- await moveFileRefs({
218
- db,
219
- entityType: candidate.entityName,
220
- entityIds: childIds,
221
- sourceTenantId,
222
- destinationTenantId,
223
- }),
224
- );
225
330
  }
226
331
 
227
332
  return movedCounts;
@@ -1,16 +1,41 @@
1
1
  // Resolves the declared transfer graph for a root entity type: the root
2
- // itself, plus every registered entity whose `parentRef` can name it as a
3
- // host (kumiko-framework#3035, framework CLAUDE.md premise 4 — the graph
4
- // depth is read off the existing parentRef declaration, never a hand-
5
- // maintained per-app list). parentRef is one level deep by construction (the
6
- // boot validator at engine/boot-validator/parent-ref.ts rejects a host that
7
- // itself declares a parentRef), so this resolves in one registry pass.
2
+ // itself, plus every registered entity that reaches it through a declared
3
+ // schema edge (kumiko-framework#3035, #3088; framework CLAUDE.md premise 4 —
4
+ // the graph depth is read off the existing declarations, never a hand-
5
+ // maintained per-app list).
6
+ //
7
+ // Two edge kinds carry a graph (#3088):
8
+ // - `parentRef`, the polymorphic join-row link (entityTypeField +
9
+ // entityIdField). One level deep by construction — the boot validator at
10
+ // engine/boot-validator/parent-ref.ts rejects a host that itself declares
11
+ // a parentRef.
12
+ // - a plain `reference` field, whose `entity` names its target directly.
13
+ // These nest freely.
14
+ //
15
+ // What comes out is static adjacency, not a walk: which edges lead away from
16
+ // each type, read straight off the declarations. The mover drives the actual
17
+ // traversal from the row ids it moves (#3131), because how deep a type sits
18
+ // depends on the rows found, not on the schema alone.
19
+ //
20
+ // `transferable: true` stays the only gate on what actually moves, and it is
21
+ // enforced by the caller against edges that turn out to have rows rather than
22
+ // here: a declaration names which types an edge accepts, never proof that any
23
+ // row currently travels it. Narrowing here would need the very query the caller
24
+ // is about to run anyway.
8
25
 
9
26
  import type { EntityDefinition, Registry } from "@cosmicdrift/kumiko-framework/engine";
10
27
 
11
- export type TransferChildCandidate = {
28
+ export type TransferEdgeLink =
29
+ | { readonly kind: "parentRef"; readonly typeField: string; readonly idField: string }
30
+ | { readonly kind: "reference"; readonly field: string };
31
+
32
+ export type TransferEdge = {
12
33
  readonly entityName: string;
13
34
  readonly entity: EntityDefinition;
35
+ /** The already-resolved type this edge hangs off — its moved row ids are
36
+ * the parent ids the mover matches this edge's rows against. */
37
+ readonly parentEntityName: string;
38
+ readonly link: TransferEdgeLink;
14
39
  };
15
40
 
16
41
  export function resolveTransferableRoot(
@@ -21,24 +46,79 @@ export function resolveTransferableRoot(
21
46
  return entity?.transferable === true ? entity : undefined;
22
47
  }
23
48
 
24
- // Broad on purpose: a candidate's `parentRef.allowedTypes` is a declaration
25
- // of which host TYPES it accepts, not proof any row of it currently points
26
- // at THIS root instance. The caller queries actual rows per candidate and
27
- // only enforces `transferable` on candidates that turn out to have matching
28
- // rows — narrowing here to "has rows" would need the very query the caller
29
- // is about to run anyway, and would make an unrelated candidate with
30
- // `allowedTypes: undefined` (any host) silently invisible instead of
31
- // query-checked.
32
- export function resolveChildCandidates(
49
+ // A `multiple` reference stores a jsonb array rather than a single id column,
50
+ // which the mover's `= ANY($ids)` match cannot address. The boot validator
51
+ // (engine/boot-validator/transfer-graph.ts) rejects that combination up front,
52
+ // so skipping it here can only ever hit an entity that is not transferable —
53
+ // never a silent partial move.
54
+ function referenceEdgesFrom(entityName: string, entity: EntityDefinition): readonly TransferEdge[] {
55
+ const edges: TransferEdge[] = [];
56
+ for (const [fieldName, field] of Object.entries(entity.fields)) {
57
+ if (field.type !== "reference") continue;
58
+ if (field.multiple === true) continue;
59
+ edges.push({
60
+ entityName,
61
+ entity,
62
+ parentEntityName: field.entity,
63
+ link: { kind: "reference", field: fieldName },
64
+ });
65
+ }
66
+ return edges;
67
+ }
68
+
69
+ // A parentRef names the types it accepts; without `allowedTypes` it accepts
70
+ // any of them, so the edge is recorded against every registered type.
71
+ function parentRefEdgesFrom(
72
+ entityName: string,
73
+ entity: EntityDefinition,
74
+ allEntityNames: readonly string[],
75
+ ): readonly TransferEdge[] {
76
+ const parentRef = entity.parentRef;
77
+ if (!parentRef) return [];
78
+ const link: TransferEdgeLink = {
79
+ kind: "parentRef",
80
+ typeField: parentRef.entityTypeField,
81
+ idField: parentRef.entityIdField,
82
+ };
83
+ return (parentRef.allowedTypes ?? allEntityNames).map((parentEntityName) => ({
84
+ entityName,
85
+ entity,
86
+ parentEntityName,
87
+ link,
88
+ }));
89
+ }
90
+
91
+ /** Outgoing edges per type: keyed by the type an edge hangs off, so the mover
92
+ * can ask "what hangs off the rows I just moved" for any type it reaches, at
93
+ * any depth, as often as it reaches it. */
94
+ export type TransferAdjacency = ReadonlyMap<string, readonly TransferEdge[]>;
95
+
96
+ // A type can legitimately be reached by more than one edge — `note.vehicleId`
97
+ // and `note.campaignId` are distinct, and collapsing them by entity name would
98
+ // leave one edge's rows behind, the silent partial move #3088 exists to end.
99
+ // Both are kept here; the mover runs each against whatever parent ids it has.
100
+ export function resolveTransferAdjacency(
33
101
  registry: Registry,
34
102
  rootEntityType: string,
35
- ): readonly TransferChildCandidate[] {
36
- const candidates: TransferChildCandidate[] = [];
37
- for (const [entityName, entity] of registry.getAllEntities()) {
38
- const parentRef = entity.parentRef;
39
- if (!parentRef) continue;
40
- if (parentRef.allowedTypes && !parentRef.allowedTypes.includes(rootEntityType)) continue;
41
- candidates.push({ entityName, entity });
103
+ ): TransferAdjacency {
104
+ const entities = registry.getAllEntities();
105
+ const allEntityNames = [...entities.keys()];
106
+ const byParent = new Map<string, TransferEdge[]>();
107
+
108
+ for (const [entityName, entity] of entities) {
109
+ // skip: the root is never collected as someone else's descendant. A self
110
+ // reference would otherwise drag unrelated rows of the root's own type
111
+ // across the tenant boundary along with the one that was claimed.
112
+ if (entityName === rootEntityType) continue;
113
+ for (const edge of [
114
+ ...parentRefEdgesFrom(entityName, entity, allEntityNames),
115
+ ...referenceEdgesFrom(entityName, entity),
116
+ ]) {
117
+ const edges = byParent.get(edge.parentEntityName) ?? [];
118
+ edges.push(edge);
119
+ byParent.set(edge.parentEntityName, edges);
120
+ }
42
121
  }
43
- return candidates;
122
+
123
+ return byParent;
44
124
  }