@cosmicdrift/kumiko-bundled-features 0.294.1 → 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.
@@ -0,0 +1,53 @@
1
+ import { fetchOne, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
2
+ import {
3
+ access,
4
+ definePagedQueryHandler,
5
+ MAX_LIST_LIMIT,
6
+ } from "@cosmicdrift/kumiko-framework/engine";
7
+ import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
8
+ import { z } from "zod";
9
+ import { tenantTable } from "../schema/tenant";
10
+ import { isSystemAdmin } from "./is-system-admin";
11
+
12
+ // Label source behind every `tenant:tenant` reference column (fw#3142). The
13
+ // entity-convention lookup, tenant:query:tenant:list, is a SystemAdmin-only
14
+ // entity-list handler, so a TenantAdmin got a 403 and every tenant cell fell
15
+ // back to the raw UUID. A SystemAdmin keeps the global reach tenant:list gave
16
+ // them; a TenantAdmin only ever sees their own tenant.
17
+ // ponytail: capped at `limit` (200 list lookup, 50 combobox) like every
18
+ // reference lookup; beyond that the cell falls back to the raw id.
19
+ export const tenantDirectoryQuery = definePagedQueryHandler({
20
+ name: "tenantDirectory",
21
+ schema: z.object({
22
+ limit: z.number().int().min(1).max(MAX_LIST_LIMIT).default(MAX_LIST_LIMIT),
23
+ }),
24
+ access: { roles: access.admin },
25
+ description:
26
+ "Lists tenants as id/label pairs for resolving tenant references to display names: the caller's own tenant for an admin, every tenant for a SystemAdmin.",
27
+ agent: { expose: false },
28
+ handler: async (query, ctx) => {
29
+ if (!ctx.systemDb) {
30
+ throw new InternalError({
31
+ message:
32
+ "tenant:query:tenant-directory requires ctx.systemDb — is r.systemScope() still set on the tenant feature?",
33
+ });
34
+ }
35
+ const { limit } = query.payload;
36
+ let tenants: readonly { id: unknown; name?: unknown }[];
37
+ if (isSystemAdmin(query.user)) {
38
+ const db = ctx.systemDb.acknowledgeCrossTenant(
39
+ "SystemAdmin reference labels span every tenant, as tenant:query:tenant:list did",
40
+ );
41
+ tenants = await selectMany(db, tenantTable, undefined, { limit });
42
+ } else {
43
+ const db = ctx.systemDb.assertTenantMatch(query.user.tenantId);
44
+ const row = await fetchOne(db, tenantTable, { id: query.user.tenantId });
45
+ tenants = row ? [row] : [];
46
+ }
47
+ const rows = tenants.map((tenant) => {
48
+ const id = String(tenant.id);
49
+ return { id, label: typeof tenant.name === "string" ? tenant.name : id };
50
+ });
51
+ return { rows, nextCursor: null };
52
+ },
53
+ });
@@ -69,19 +69,101 @@ const noteEntity: EntityDefinition = createEntity({
69
69
  },
70
70
  });
71
71
 
72
+ // A second edge off the root (kumiko-framework#3131), so `campaign` below is
73
+ // reachable both directly from the run and one hop further out through here.
74
+ const bundleEntity: EntityDefinition = createEntity({
75
+ table: "handover_bundle",
76
+ idType: "uuid",
77
+ transferable: true,
78
+ fields: {
79
+ runId: { type: "reference", entity: "run", required: true },
80
+ label: createTextField({ personal: false, reason: "technical_reference" }),
81
+ },
82
+ });
83
+
84
+ // Hangs off the root through a plain `reference` field rather than a parentRef
85
+ // (kumiko-framework#3088) — offlot-app's campaign.vehicleId shape. Neither
86
+ // reference is required: a campaign reached through the bundle names no run,
87
+ // which is what puts it on the longer of the two paths (#3131).
88
+ const campaignEntity: EntityDefinition = createEntity({
89
+ table: "handover_campaign",
90
+ idType: "uuid",
91
+ transferable: true,
92
+ fields: {
93
+ runId: { type: "reference", entity: "run" },
94
+ bundleId: { type: "reference", entity: "bundle" },
95
+ label: createTextField({ personal: false, reason: "technical_reference" }),
96
+ },
97
+ });
98
+
99
+ // The second level: references the campaign, not the root. Nothing links it to
100
+ // the run directly, so it only moves if the graph nests.
101
+ const channelTextEntity: EntityDefinition = createEntity({
102
+ table: "handover_channel_text",
103
+ idType: "uuid",
104
+ transferable: true,
105
+ fields: {
106
+ campaignId: { type: "reference", entity: "campaign", required: true },
107
+ body: createTextField({ personal: false, reason: "technical_reference" }),
108
+ },
109
+ });
110
+
111
+ // A two-type reference cycle (kumiko-framework#3131). Terminating on one is no
112
+ // longer a property of the resolver — it follows from the mover's statements
113
+ // only matching rows still in the source tenant — and a cycle is the one shape
114
+ // the boot validator's depth check cannot measure, because how far it runs
115
+ // depends on the rows rather than the declaration.
116
+ const linkAEntity: EntityDefinition = createEntity({
117
+ table: "handover_link_a",
118
+ idType: "uuid",
119
+ transferable: true,
120
+ fields: {
121
+ runId: { type: "reference", entity: "run" },
122
+ viaB: { type: "reference", entity: "linkB" },
123
+ },
124
+ });
125
+
126
+ const linkBEntity: EntityDefinition = createEntity({
127
+ table: "handover_link_b",
128
+ idType: "uuid",
129
+ transferable: true,
130
+ fields: {
131
+ viaA: { type: "reference", entity: "linkA", required: true },
132
+ },
133
+ });
134
+
72
135
  const handoverFixturesFeature = defineFeature("handover-fixtures", (r) => {
73
136
  r.entity("run", runEntity);
74
137
  r.entity("photo", photoEntity);
75
138
  r.entity("note", noteEntity);
139
+ r.entity("bundle", bundleEntity);
140
+ r.entity("campaign", campaignEntity);
141
+ r.entity("channelText", channelTextEntity);
142
+ r.entity("linkA", linkAEntity);
143
+ r.entity("linkB", linkBEntity);
76
144
  });
77
145
 
78
146
  const runTable = buildEntityTable("run", runEntity);
79
147
  const photoTable = buildEntityTable("photo", photoEntity);
80
148
  const noteTable = buildEntityTable("note", noteEntity);
149
+ const bundleTable = buildEntityTable("bundle", bundleEntity);
150
+ const campaignTable = buildEntityTable("campaign", campaignEntity);
151
+ const channelTextTable = buildEntityTable("channelText", channelTextEntity);
152
+ const linkATable = buildEntityTable("linkA", linkAEntity);
153
+ const linkBTable = buildEntityTable("linkB", linkBEntity);
81
154
 
82
155
  const runCrud = createEventStoreExecutor(runTable, runEntity, { entityName: "run" });
83
156
  const photoCrud = createEventStoreExecutor(photoTable, photoEntity, { entityName: "photo" });
84
157
  const noteCrud = createEventStoreExecutor(noteTable, noteEntity, { entityName: "note" });
158
+ const bundleCrud = createEventStoreExecutor(bundleTable, bundleEntity, { entityName: "bundle" });
159
+ const campaignCrud = createEventStoreExecutor(campaignTable, campaignEntity, {
160
+ entityName: "campaign",
161
+ });
162
+ const channelTextCrud = createEventStoreExecutor(channelTextTable, channelTextEntity, {
163
+ entityName: "channelText",
164
+ });
165
+ const linkACrud = createEventStoreExecutor(linkATable, linkAEntity, { entityName: "linkA" });
166
+ const linkBCrud = createEventStoreExecutor(linkBTable, linkBEntity, { entityName: "linkB" });
85
167
  const fileRefCrud = createEventStoreExecutor(fileRefsTable, fileRefEntity, {
86
168
  entityName: "fileRef",
87
169
  });
@@ -106,6 +188,11 @@ beforeAll(async () => {
106
188
  await unsafeCreateEntityTable(stack.db, runEntity, "run");
107
189
  await unsafeCreateEntityTable(stack.db, photoEntity, "photo");
108
190
  await unsafeCreateEntityTable(stack.db, noteEntity, "note");
191
+ await unsafeCreateEntityTable(stack.db, bundleEntity, "bundle");
192
+ await unsafeCreateEntityTable(stack.db, campaignEntity, "campaign");
193
+ await unsafeCreateEntityTable(stack.db, channelTextEntity, "channelText");
194
+ await unsafeCreateEntityTable(stack.db, linkAEntity, "linkA");
195
+ await unsafeCreateEntityTable(stack.db, linkBEntity, "linkB");
109
196
  await unsafeCreateEntityTable(stack.db, fileRefEntity);
110
197
  });
111
198
 
@@ -116,7 +203,7 @@ afterAll(async () => {
116
203
  beforeEach(async () => {
117
204
  stack.events.reset();
118
205
  await stack.db.unsafe?.(
119
- `TRUNCATE kumiko_events, kumiko_snapshots, handover_run, handover_photo, handover_note, file_refs RESTART IDENTITY CASCADE`,
206
+ `TRUNCATE kumiko_events, kumiko_snapshots, handover_run, handover_photo, handover_note, handover_bundle, handover_campaign, handover_channel_text, handover_link_a, handover_link_b, file_refs RESTART IDENTITY CASCADE`,
120
207
  );
121
208
  });
122
209
 
@@ -144,6 +231,75 @@ async function seedNote(tenantId: TenantId, hostId: string, body: string): Promi
144
231
  return String(result.data.id);
145
232
  }
146
233
 
234
+ async function seedBundle(tenantId: TenantId, runId: string, label: string): Promise<string> {
235
+ const user = createSystemUser(tenantId);
236
+ const db = createTenantDb(stack.db, tenantId, "system");
237
+ const result = await bundleCrud.create({ runId, label }, user, db);
238
+ if (!result.isSuccess) throw new Error(`seedBundle failed: ${result.error.message}`);
239
+ return String(result.data.id);
240
+ }
241
+
242
+ async function seedCampaign(tenantId: TenantId, runId: string, label: string): Promise<string> {
243
+ const user = createSystemUser(tenantId);
244
+ const db = createTenantDb(stack.db, tenantId, "system");
245
+ const result = await campaignCrud.create({ runId, label }, user, db);
246
+ if (!result.isSuccess) throw new Error(`seedCampaign failed: ${result.error.message}`);
247
+ return String(result.data.id);
248
+ }
249
+
250
+ async function seedCampaignInBundle(
251
+ tenantId: TenantId,
252
+ bundleId: string,
253
+ label: string,
254
+ ): Promise<string> {
255
+ const user = createSystemUser(tenantId);
256
+ const db = createTenantDb(stack.db, tenantId, "system");
257
+ const result = await campaignCrud.create({ bundleId, label }, user, db);
258
+ if (!result.isSuccess) throw new Error(`seedCampaignInBundle failed: ${result.error.message}`);
259
+ return String(result.data.id);
260
+ }
261
+
262
+ async function seedChannelText(
263
+ tenantId: TenantId,
264
+ campaignId: string,
265
+ body: string,
266
+ ): Promise<string> {
267
+ const user = createSystemUser(tenantId);
268
+ const db = createTenantDb(stack.db, tenantId, "system");
269
+ const result = await channelTextCrud.create({ campaignId, body }, user, db);
270
+ if (!result.isSuccess) throw new Error(`seedChannelText failed: ${result.error.message}`);
271
+ return String(result.data.id);
272
+ }
273
+
274
+ async function seedLinkA(
275
+ tenantId: TenantId,
276
+ parent: { readonly runId: string } | { readonly viaB: string },
277
+ ): Promise<string> {
278
+ const user = createSystemUser(tenantId);
279
+ const db = createTenantDb(stack.db, tenantId, "system");
280
+ const result = await linkACrud.create(parent, user, db);
281
+ if (!result.isSuccess) throw new Error(`seedLinkA failed: ${result.error.message}`);
282
+ return String(result.data.id);
283
+ }
284
+
285
+ async function seedLinkB(tenantId: TenantId, viaA: string): Promise<string> {
286
+ const user = createSystemUser(tenantId);
287
+ const db = createTenantDb(stack.db, tenantId, "system");
288
+ const result = await linkBCrud.create({ viaA }, user, db);
289
+ if (!result.isSuccess) throw new Error(`seedLinkB failed: ${result.error.message}`);
290
+ return String(result.data.id);
291
+ }
292
+
293
+ // Closes the cycle in the DATA, not just in the schema: `linkA` can only point
294
+ // back once its `linkB` exists. Without this the return edge matches no row at
295
+ // all and a walk would terminate for the wrong reason.
296
+ async function pointLinkABack(tenantId: TenantId, linkAId: string, viaB: string): Promise<void> {
297
+ const user = createSystemUser(tenantId);
298
+ const db = createTenantDb(stack.db, tenantId, "system");
299
+ const result = await linkACrud.update({ id: linkAId, version: 1, changes: { viaB } }, user, db);
300
+ if (!result.isSuccess) throw new Error(`pointLinkABack failed: ${result.error.message}`);
301
+ }
302
+
147
303
  async function seedFileRef(
148
304
  tenantId: TenantId,
149
305
  entityType: string,
@@ -254,6 +410,137 @@ describe("tenant-handover :: claim", () => {
254
410
  expect(await readTenantId("handover_photo", otherPhotoId)).toBe(SOURCE_TENANT);
255
411
  });
256
412
 
413
+ // kumiko-framework#3088: before this, resolveChildCandidates only knew
414
+ // parentRef, so a graph hanging off `reference` fields moved the root row
415
+ // alone and left its children in the source tenant.
416
+ test("claims a two-level reference graph whole: root -> campaign -> channelText", async () => {
417
+ const runId = await seedRun(SOURCE_TENANT, "my run");
418
+ const campaignId = await seedCampaign(SOURCE_TENANT, runId, "spring");
419
+ const channelTextId = await seedChannelText(SOURCE_TENANT, campaignId, "for sale");
420
+
421
+ // A second run with its own campaign and text — proves the walk follows
422
+ // the identified row's edges, not every row of a participating type.
423
+ const otherRunId = await seedRun(SOURCE_TENANT, "someone else's run");
424
+ const otherCampaignId = await seedCampaign(SOURCE_TENANT, otherRunId, "summer");
425
+ const otherChannelTextId = await seedChannelText(SOURCE_TENANT, otherCampaignId, "theirs");
426
+
427
+ const dest = destinationUser(1);
428
+ const data = await stack.http.writeOk<{
429
+ movedEntities: Record<string, number>;
430
+ }>(CLAIM, { token: grantFor(runId), entityType: "run" }, dest);
431
+
432
+ expect(data.movedEntities).toEqual({ run: 1, campaign: 1, channelText: 1 });
433
+
434
+ expect(await readTenantId("handover_run", runId)).toBe(dest.tenantId);
435
+ expect(await readTenantId("handover_campaign", campaignId)).toBe(dest.tenantId);
436
+ // The second level: reachable only through the campaign, never named by
437
+ // the grant.
438
+ expect(await readTenantId("handover_channel_text", channelTextId)).toBe(dest.tenantId);
439
+
440
+ // Event history follows at every level, not just for the root.
441
+ expect(
442
+ (await loadAggregate(stack.db, channelTextId, dest.tenantId)).some(
443
+ (e) => e.type === "channelText.created",
444
+ ),
445
+ ).toBe(true);
446
+ expect(await loadAggregate(stack.db, channelTextId, SOURCE_TENANT)).toHaveLength(0);
447
+
448
+ // The unrelated run's whole chain stayed put.
449
+ expect(await readTenantId("handover_run", otherRunId)).toBe(SOURCE_TENANT);
450
+ expect(await readTenantId("handover_campaign", otherCampaignId)).toBe(SOURCE_TENANT);
451
+ expect(await readTenantId("handover_channel_text", otherChannelTextId)).toBe(SOURCE_TENANT);
452
+ });
453
+
454
+ // kumiko-framework#3131: `campaign` is reachable one hop from the run AND two
455
+ // hops through the bundle. The level-wise walk ran the campaign->channelText
456
+ // edge once, on the first level it was reachable, so the texts under the
457
+ // campaign found on the longer path stayed in the source tenant — silently,
458
+ // the same failure class as #3088 one level deeper.
459
+ test("follows a type reached by two paths of different length down both", async () => {
460
+ const runId = await seedRun(SOURCE_TENANT, "my run");
461
+ const bundleId = await seedBundle(SOURCE_TENANT, runId, "bundle");
462
+ const nearCampaignId = await seedCampaign(SOURCE_TENANT, runId, "straight off the run");
463
+ const farCampaignId = await seedCampaignInBundle(SOURCE_TENANT, bundleId, "via the bundle");
464
+ const nearTextId = await seedChannelText(SOURCE_TENANT, nearCampaignId, "near");
465
+ const farTextId = await seedChannelText(SOURCE_TENANT, farCampaignId, "far");
466
+
467
+ const dest = destinationUser(1);
468
+ const data = await stack.http.writeOk<{ movedEntities: Record<string, number> }>(
469
+ CLAIM,
470
+ { token: grantFor(runId), entityType: "run" },
471
+ dest,
472
+ );
473
+
474
+ expect(data.movedEntities).toEqual({ run: 1, bundle: 1, campaign: 2, channelText: 2 });
475
+
476
+ expect(await readTenantId("handover_bundle", bundleId)).toBe(dest.tenantId);
477
+ expect(await readTenantId("handover_campaign", nearCampaignId)).toBe(dest.tenantId);
478
+ expect(await readTenantId("handover_campaign", farCampaignId)).toBe(dest.tenantId);
479
+ expect(await readTenantId("handover_channel_text", nearTextId)).toBe(dest.tenantId);
480
+ // The row the old walk left behind.
481
+ expect(await readTenantId("handover_channel_text", farTextId)).toBe(dest.tenantId);
482
+
483
+ // History follows the late-found rows too, not just their projection.
484
+ expect(await loadAggregate(stack.db, farTextId, SOURCE_TENANT)).toHaveLength(0);
485
+ expect(
486
+ (await loadAggregate(stack.db, farTextId, dest.tenantId)).some(
487
+ (e) => e.type === "channelText.created",
488
+ ),
489
+ ).toBe(true);
490
+ });
491
+
492
+ // A cycle used to terminate because each edge ran at most once. That rule is
493
+ // gone (kumiko-framework#3131) — termination now rests on every statement
494
+ // filtering `tenant_id = source` and returning only the rows it flipped, so a
495
+ // row enters the worklist exactly once. Nothing else bounds the walk, which is
496
+ // why the rows below point at each other BOTH ways: the return edge has to
497
+ // match `linkA` and be turned away by the tenant filter, not miss it.
498
+ test("terminates on a reference cycle instead of running the rounds out", async () => {
499
+ const runId = await seedRun(SOURCE_TENANT, "my run");
500
+ const linkAId = await seedLinkA(SOURCE_TENANT, { runId });
501
+ const linkBId = await seedLinkB(SOURCE_TENANT, linkAId);
502
+ await pointLinkABack(SOURCE_TENANT, linkAId, linkBId);
503
+
504
+ const dest = destinationUser(1);
505
+ const data = await stack.http.writeOk<{ movedEntities: Record<string, number> }>(
506
+ CLAIM,
507
+ { token: grantFor(runId), entityType: "run" },
508
+ dest,
509
+ );
510
+
511
+ expect(data.movedEntities).toEqual({ run: 1, linkA: 1, linkB: 1 });
512
+ expect(await readTenantId("handover_link_a", linkAId)).toBe(dest.tenantId);
513
+ expect(await readTenantId("handover_link_b", linkBId)).toBe(dest.tenantId);
514
+ });
515
+
516
+ // The boot validator skips cycles when measuring depth, so this graph boots
517
+ // clean and only the mover can catch it. Failing the whole claim is the
518
+ // point: the alternative is moving the first five hops and leaving the rest,
519
+ // which is the silent partial move #3088 exists to end.
520
+ test("fails the claim when the rounds run out with rows still leading somewhere", async () => {
521
+ const runId = await seedRun(SOURCE_TENANT, "my run");
522
+ const a1 = await seedLinkA(SOURCE_TENANT, { runId });
523
+ const b1 = await seedLinkB(SOURCE_TENANT, a1);
524
+ const a2 = await seedLinkA(SOURCE_TENANT, { viaB: b1 });
525
+ const b2 = await seedLinkB(SOURCE_TENANT, a2);
526
+ const a3 = await seedLinkA(SOURCE_TENANT, { viaB: b2 });
527
+
528
+ const dest = destinationUser(1);
529
+ const err = await stack.http.writeErr(
530
+ CLAIM,
531
+ { token: grantFor(runId), entityType: "run" },
532
+ dest,
533
+ );
534
+
535
+ expect(err.httpStatus).toBe(422);
536
+ expectErrorIncludes(err, "transfer_graph_too_deep");
537
+
538
+ // Rolled back whole: neither the root nor the rows five hops in moved.
539
+ expect(await readTenantId("handover_run", runId)).toBe(SOURCE_TENANT);
540
+ expect(await readTenantId("handover_link_a", a1)).toBe(SOURCE_TENANT);
541
+ expect(await readTenantId("handover_link_a", a3)).toBe(SOURCE_TENANT);
542
+ });
543
+
257
544
  test("replaying the same grant fails the same way an invalid one would, and changes nothing", async () => {
258
545
  const runId = await seedRun(SOURCE_TENANT, "my run");
259
546
  const dest = destinationUser(1);
@@ -0,0 +1,178 @@
1
+ // Adjacency resolution alone (kumiko-framework#3088, #3131) — the traversal
2
+ // and its depth limit live in the mover now and are covered end-to-end in
3
+ // claim.integration.test.ts. What matters here is that every declared edge is
4
+ // reported against the right parent type, and that the shapes the mover must
5
+ // never see are filtered out.
6
+
7
+ import { describe, expect, test } from "bun:test";
8
+ import {
9
+ createEntity,
10
+ createTextField,
11
+ type EntityDefinition,
12
+ type Registry,
13
+ } from "@cosmicdrift/kumiko-framework/engine";
14
+ import { resolveTransferAdjacency, type TransferAdjacency } from "../transfer-graph";
15
+
16
+ function entity(opts: {
17
+ readonly references?: Readonly<Record<string, string>>;
18
+ readonly parentRefTo?: readonly string[];
19
+ readonly transferable?: boolean;
20
+ }): EntityDefinition {
21
+ const references = Object.fromEntries(
22
+ Object.entries(opts.references ?? {}).map(([field, target]) => [
23
+ field,
24
+ { type: "reference" as const, entity: target },
25
+ ]),
26
+ );
27
+ return createEntity({
28
+ table: "t",
29
+ idType: "uuid",
30
+ ...(opts.transferable !== false && { transferable: true }),
31
+ ...(opts.parentRefTo && {
32
+ parentRef: {
33
+ entityTypeField: "hostType",
34
+ entityIdField: "hostId",
35
+ allowedTypes: opts.parentRefTo,
36
+ },
37
+ }),
38
+ fields: {
39
+ ...references,
40
+ ...(opts.parentRefTo && {
41
+ hostType: createTextField({ personal: false, reason: "technical_reference" }),
42
+ hostId: createTextField({ personal: false, reason: "technical_reference" }),
43
+ }),
44
+ label: createTextField({ personal: false, reason: "technical_reference" }),
45
+ },
46
+ });
47
+ }
48
+
49
+ function registryOf(entities: Readonly<Record<string, EntityDefinition>>): Registry {
50
+ const map = new Map(Object.entries(entities));
51
+ return { getAllEntities: () => map } as unknown as Registry;
52
+ }
53
+
54
+ function childrenOf(adjacency: TransferAdjacency, parentEntityName: string): string[] {
55
+ return (adjacency.get(parentEntityName) ?? []).map((edge) => edge.entityName).sort();
56
+ }
57
+
58
+ describe("resolveTransferAdjacency", () => {
59
+ // Adjacency is depth-free on purpose: `channelText` hangs off `campaign`
60
+ // whether the mover reaches `campaign` in the first round or the third. That
61
+ // is what lets a type reached by two paths of different length still have its
62
+ // descendants walked (#3131).
63
+ test("keys each edge by the type it hangs off, not by its distance from the root", () => {
64
+ const adjacency = resolveTransferAdjacency(
65
+ registryOf({
66
+ run: entity({}),
67
+ campaign: entity({ references: { runId: "run" } }),
68
+ channelText: entity({ references: { campaignId: "campaign" } }),
69
+ }),
70
+ "run",
71
+ );
72
+
73
+ expect(childrenOf(adjacency, "run")).toEqual(["campaign"]);
74
+ expect(childrenOf(adjacency, "campaign")).toEqual(["channelText"]);
75
+ });
76
+
77
+ test("reports parentRef edges against every type the parentRef accepts", () => {
78
+ const adjacency = resolveTransferAdjacency(
79
+ registryOf({
80
+ run: entity({}),
81
+ campaign: entity({ references: { runId: "run" } }),
82
+ photo: entity({ parentRefTo: ["run", "campaign"] }),
83
+ }),
84
+ "run",
85
+ );
86
+
87
+ expect(childrenOf(adjacency, "run")).toEqual(["campaign", "photo"]);
88
+ expect(childrenOf(adjacency, "campaign")).toEqual(["photo"]);
89
+ });
90
+
91
+ // Collapsing these by entity name would leave one edge's rows behind, which
92
+ // is the silent partial move #3088 exists to end.
93
+ test("keeps both edges when one entity points at two types in the graph", () => {
94
+ const adjacency = resolveTransferAdjacency(
95
+ registryOf({
96
+ run: entity({}),
97
+ campaign: entity({ references: { runId: "run" } }),
98
+ note: entity({ references: { runId: "run", campaignId: "campaign" } }),
99
+ }),
100
+ "run",
101
+ );
102
+
103
+ expect(childrenOf(adjacency, "run")).toEqual(["campaign", "note"]);
104
+ expect(childrenOf(adjacency, "campaign")).toEqual(["note"]);
105
+ });
106
+
107
+ test("keeps both edges when one entity points at the same type through two fields", () => {
108
+ const adjacency = resolveTransferAdjacency(
109
+ registryOf({
110
+ run: entity({}),
111
+ note: entity({ references: { runId: "run", otherRunId: "run" } }),
112
+ }),
113
+ "run",
114
+ );
115
+
116
+ const fields = (adjacency.get("run") ?? []).map((edge) =>
117
+ edge.link.kind === "reference" ? edge.link.field : edge.link.idField,
118
+ );
119
+ expect(fields.sort()).toEqual(["otherRunId", "runId"]);
120
+ });
121
+
122
+ // Without this the mover would drag unrelated rows of the root's own type
123
+ // across the tenant boundary along with the one that was actually claimed.
124
+ test("never reports the root as someone else's child, even on a self reference", () => {
125
+ const adjacency = resolveTransferAdjacency(
126
+ registryOf({ run: entity({ references: { parentRunId: "run" } }) }),
127
+ "run",
128
+ );
129
+
130
+ expect(childrenOf(adjacency, "run")).toEqual([]);
131
+ });
132
+
133
+ // A cycle is no longer the resolver's problem: it reports the edges, and the
134
+ // mover terminates because an already-moved row stops matching the source
135
+ // tenant. Adjacency just has to describe the cycle faithfully.
136
+ test("reports both directions of a reference cycle", () => {
137
+ const adjacency = resolveTransferAdjacency(
138
+ registryOf({
139
+ run: entity({}),
140
+ a: entity({ references: { runId: "run", bId: "b" } }),
141
+ b: entity({ references: { aId: "a" } }),
142
+ }),
143
+ "run",
144
+ );
145
+
146
+ expect(childrenOf(adjacency, "a")).toEqual(["b"]);
147
+ expect(childrenOf(adjacency, "b")).toEqual(["a"]);
148
+ });
149
+
150
+ // `transferable` is enforced by the mover against candidates that turn out
151
+ // to have rows — the resolver stays broad on purpose (see its header).
152
+ test("reports a non-transferable child so the mover can reject it by name", () => {
153
+ const adjacency = resolveTransferAdjacency(
154
+ registryOf({
155
+ run: entity({}),
156
+ note: entity({ references: { runId: "run" }, transferable: false }),
157
+ }),
158
+ "run",
159
+ );
160
+
161
+ expect(childrenOf(adjacency, "run")).toEqual(["note"]);
162
+ });
163
+
164
+ test("ignores a multiple reference, which the boot validator rejects up front", () => {
165
+ const bulk = createEntity({
166
+ table: "t",
167
+ idType: "uuid",
168
+ transferable: true,
169
+ fields: {
170
+ runIds: { type: "reference", entity: "run", multiple: true },
171
+ label: createTextField({ personal: false, reason: "technical_reference" }),
172
+ },
173
+ });
174
+ const adjacency = resolveTransferAdjacency(registryOf({ run: entity({}), bulk }), "run");
175
+
176
+ expect(childrenOf(adjacency, "run")).toEqual([]);
177
+ });
178
+ });
@@ -1,4 +1,11 @@
1
1
  [
2
+ {
3
+ "version": "0.296.0",
4
+ "type": "improvement",
5
+ "title": "Transfer graph follows reference edges and nested levels (fw#3088, fw#3131)",
6
+ "detail": "`resolveChildCandidates` knew exactly one edge kind: an entity's `parentRef`. An app whose graph hangs together through ordinary `{ type: \"reference\" }` fields got a silent partial handover — the root row moved and its children stayed in the source tenant. `parentRef` is also one level deep by construction, so even declaring it could not express a root → child → grandchild chain. Both together are the data loss kumiko-framework#3035 warns about, reachable without doing anything wrong.\nThe resolver now reads static adjacency off the declarations — which edges lead away from each type — and the mover drives the traversal from the rows it moves. An entity is reached when it declares a `parentRef` naming a type in the graph, or a single-valued `reference` field pointing at one. Every batch of rows that actually changed hands becomes the parent ids for the edges leading away from its type, so the same edge runs again whenever a later round discovers more rows of its parent type — what a type reachable by two paths of different length needs. The root is never collected as its own descendant, and a reference cycle terminates on row-level idempotency: every statement filters on the source tenant and returns only the rows it flipped, so a row enters the worklist exactly once. `transferable: true` remains the only gate on what actually moves.",
7
+ "migration": "A handover now moves rows it previously left behind. An entity with `transferable: true` that points at the handover root through a plain `reference` field was silently skipped before and travels with the root from this version on, and so does anything hanging off that entity, up to five hops from the root. A type that two paths of different length lead to has its descendants moved along both. Check every entity declaring `transferable: true` and confirm it should move with its host — an entity that should NOT travel needs `transferable` removed, which is also what the existing named `entity_not_transferable` error reports at claim time. Two new boot errors, both scoped to `transferable: true` entities only: a `multiple` reference on such an entity is rejected (its jsonb array cannot be matched by the mover, so its rows would be left behind), and a transferable reference chain longer than five edges is rejected with the offending path. The boot check measures transferable reference chains only, while the mover counts hops of both edge kinds, and it cannot measure a cycle at all, so for the shapes boot cannot see the mover carries the same guarantee at claim time: rows still leading somewhere after five hops fail the claim with `transfer_graph_too_deep` and roll the whole handover back, rather than moving part of it."
8
+ },
2
9
  {
3
10
  "version": "0.289.0",
4
11
  "type": "improvement",