@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.
- package/package.json +9 -9
- package/src/audit/__tests__/audit-screens.boot.test.ts +2 -1
- package/src/audit/feature.ts +0 -2
- package/src/delivery/__tests__/delivery-log-query.integration.test.ts +5 -1
- package/src/delivery/__tests__/delivery-screens.boot.test.ts +8 -2
- package/src/delivery/__tests__/delivery-security.integration.test.ts +8 -2
- package/src/delivery/changes.json +6 -0
- package/src/delivery/feature.ts +2 -0
- package/src/tenant/__tests__/member-directory.integration.test.ts +179 -0
- package/src/tenant/__tests__/tenant-directory.integration.test.ts +124 -0
- package/src/tenant/changes.json +20 -6
- package/src/tenant/constants.ts +6 -0
- package/src/tenant/feature.ts +4 -0
- package/src/tenant/handlers/is-system-admin.ts +5 -0
- package/src/tenant/handlers/member-directory.query.ts +74 -0
- package/src/tenant/handlers/tenant-directory.query.ts +53 -0
- package/src/tenant-handover/__tests__/claim.integration.test.ts +288 -1
- package/src/tenant-handover/__tests__/transfer-graph.test.ts +178 -0
- package/src/tenant-handover/changes.json +7 -0
- package/src/tenant-handover/move-entity-graph.ts +161 -56
- package/src/tenant-handover/transfer-graph.ts +104 -24
|
@@ -26,9 +26,9 @@ import {
|
|
|
26
26
|
extractTableName,
|
|
27
27
|
physicalColumnName,
|
|
28
28
|
} from "@cosmicdrift/kumiko-framework/db";
|
|
29
|
-
import type
|
|
29
|
+
import { MAX_TRANSFER_DEPTH, type Registry } from "@cosmicdrift/kumiko-framework/engine";
|
|
30
30
|
import { UnprocessableError } from "@cosmicdrift/kumiko-framework/errors";
|
|
31
|
-
import {
|
|
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
|
|
118
|
-
readonly
|
|
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.
|
|
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
|
|
134
|
-
//
|
|
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
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
|
3
|
-
//
|
|
4
|
-
// depth is read off the existing
|
|
5
|
-
// maintained per-app list).
|
|
6
|
-
//
|
|
7
|
-
//
|
|
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
|
|
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
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
// only
|
|
28
|
-
//
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
):
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
122
|
+
|
|
123
|
+
return byParent;
|
|
44
124
|
}
|