Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions src/runtime/migration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3853,8 +3853,10 @@ class MigrationScheduler {
const computation = this.handles.get(item.computationId);
if (!computation) continue;

// 硬删除属性的「审批存在性」门槛:模拟模式(scope 发现本身)没有审批可查,跳过。
if (this.audit.mode !== "simulate" && computation.dataContext.type === "property" && computation.dataContext.id.name === HARD_DELETION_PROPERTY_NAME && !hasDecision(this.options.approvedDiff, decision => decision.kind === "destructive-scope" && decision.dataContext === dataContextPath(computation.dataContext))) {
// 只有重建输出才可能把硬删除属性写为删除态并删除宿主行。state-only 只重建
// computation state,不执行输出计算,因此不需要 destructive-scope approval。
// 模拟模式(scope 发现本身)也没有审批可查,继续跳过该检查。
if (item.rebuildOutput && this.audit.mode !== "simulate" && computation.dataContext.type === "property" && computation.dataContext.id.name === HARD_DELETION_PROPERTY_NAME && !hasDecision(this.options.approvedDiff, decision => decision.kind === "destructive-scope" && decision.dataContext === dataContextPath(computation.dataContext))) {
throw new DestructiveComputedOutputError(`Migration refuses to recompute destructive property ${dataContextPath(computation.dataContext)} without approved destructive scope`);
}
if (typeof (computation as DataBasedComputation).compute !== "function" && !getEventRebuildHandler(this.options, dataContextPath(computation.dataContext))) {
Expand Down
171 changes: 171 additions & 0 deletions tests/runtime/stateOnlyHardDeletionMigration.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
import { describe, expect, test } from "vitest";
import {
Action,
Controller,
Custom,
DELETED_STATE,
Entity,
HardDeletionProperty,
Interaction,
InteractionEventEntity,
KlassByName,
MonoSystem,
NON_DELETED_STATE,
Payload,
PayloadItem,
Property,
StateMachine,
StateTransfer,
Transform,
} from "interaqt";
import { PGLiteDB } from "@drivers";
import { approveGeneratedMigrationDiff } from "./helpers/migrationApproval.js";

const STATE_ONLY_CONTEXT = "property:MigrationStateOnlyHardDeleteSample._isDeleted_";
const CHANGED_CONTEXT = "property:MigrationChangedHardDeleteSample._isDeleted_";

describe("state-only hard-deletion migration", () => {
test("applies a new hard-deletion state machine on an empty host without an output scope", async () => {
const db = new PGLiteDB();
const createSample = Interaction.create({
name: "CreateMigrationStateOnlyHardDeleteSample",
action: Action.create({ name: "createMigrationStateOnlyHardDeleteSample" }),
payload: Payload.create({ items: [PayloadItem.create({ name: "name", type: "string", required: true })] }),
});
const deleteSample = Interaction.create({
name: "DeleteMigrationStateOnlyHardDeleteSample",
action: Action.create({ name: "deleteMigrationStateOnlyHardDeleteSample" }),
payload: Payload.create({ items: [PayloadItem.create({ name: "sampleId", type: "string", required: true })] }),
});
const oldSystem = new MonoSystem(db);
oldSystem.conceptClass = KlassByName;
const oldController = new Controller({ system: oldSystem, entities: [], relations: [], eventSources: [] });
let nextController: Controller | undefined;

try {
await oldController.setup(true);
oldController.teardown();

const hardDeletion = HardDeletionProperty.create();
hardDeletion.computation = StateMachine.create({
states: [NON_DELETED_STATE, DELETED_STATE],
initialState: NON_DELETED_STATE,
transfers: [StateTransfer.create({
current: NON_DELETED_STATE,
next: DELETED_STATE,
trigger: {
recordName: InteractionEventEntity.name,
type: "create",
record: { interactionName: deleteSample.name },
},
computeTarget: (event: any) => ({ id: event.record.payload.sampleId }),
})],
});
const sampleRecord = Entity.create({
name: "MigrationStateOnlyHardDeleteSample",
properties: [Property.create({ name: "name", type: "string" }), hardDeletion],
computation: Transform.create({
eventDeps: {
CreateMigrationStateOnlyHardDeleteSample: {
recordName: InteractionEventEntity.name,
type: "create",
record: { interactionName: createSample.name },
},
},
callback(event: any) {
return event?.record?.interactionName === createSample.name
? { name: event.record.payload.name }
: null;
},
}),
});
const nextSystem = new MonoSystem(db);
nextSystem.conceptClass = KlassByName;
nextController = new Controller({
system: nextSystem,
entities: [sampleRecord],
relations: [],
eventSources: [createSample, deleteSample],
});

const raw = await nextController.generateMigrationDiff({ includeFunctionText: true, includeDestructiveScope: true });
const computation = raw.requiredDecisions.find((item: any) =>
item.kind === "computation" && item.dataContext === STATE_ONLY_CONTEXT,
);
expect(computation).toBeDefined();
const eventHandlers = Object.fromEntries(raw.requiredDecisions
.filter((item: any) => item.kind === "event-rebuild-handler")
.map((item: any) => [item.dataContext, "preserveExistingValue"]));
const approved = await approveGeneratedMigrationDiff(nextController, {
eventHandlers,
computationDecisions: Object.fromEntries(raw.requiredDecisions
.filter((item: any) => item.kind === "computation")
.map((item: any) => [item.id, "state-only"])),
});
const effectiveDiff = {
...approved,
requiredDecisions: approved.requiredDecisions.filter((item: any) => item.kind !== "destructive-scope"),
decisions: approved.decisions.filter((item: any) => item.kind !== "destructive-scope"),
safety: { ...approved.safety, destructiveScopes: [] },
};
const handlers = {
eventRebuild: {
preserveExistingValue: async ({ dataContext, record }: any) =>
dataContext.type === "property" ? record?.[dataContext.id.name] : undefined,
},
};

const plan = await nextController.migrate({ approvedDiff: effectiveDiff, handlers });

expect(plan.rebuildPlan.find((item: any) => item.dataContext === STATE_ONLY_CONTEXT))
.toMatchObject({ rebuildState: true, rebuildOutput: false });
expect(plan.deletionScope).toEqual([]);
expect(await nextSystem.storage.find("MigrationStateOnlyHardDeleteSample", undefined, undefined, ["id"]))
.toEqual([]);
} finally {
(nextController ?? oldController).teardown();
await db.close();
}
});

test("still requires a scope when changed output would delete a stored host row", async () => {
const db = new PGLiteDB();
const userV1 = Entity.create({
name: "MigrationChangedHardDeleteSample",
properties: [Property.create({ name: "name", type: "string" })],
});
const oldSystem = new MonoSystem(db);
oldSystem.conceptClass = KlassByName;
const oldController = new Controller({ system: oldSystem, entities: [userV1], relations: [] });
let nextController: Controller | undefined;

try {
await oldController.setup(true);
const stored = await oldSystem.storage.create("MigrationChangedHardDeleteSample", { name: "delete" });

const hardDeletion = HardDeletionProperty.create();
hardDeletion.computation = Custom.create({
name: "MigrationChangedHardDeleteFlag",
dataDeps: { current: { type: "property", attributeQuery: ["name"] } },
compute: async (_deps: unknown, record: any) => record.name === "delete",
});
const userV2 = Entity.create({
name: "MigrationChangedHardDeleteSample",
properties: [Property.create({ name: "name", type: "string" }), hardDeletion],
});
const nextSystem = new MonoSystem(db);
nextSystem.conceptClass = KlassByName;
nextController = new Controller({ system: nextSystem, entities: [userV2], relations: [] });
const approved = await approveGeneratedMigrationDiff(nextController, { includeDestructiveScope: false });
expect(approved.requiredDecisions.some((item: any) => item.kind === "destructive-scope")).toBe(false);

await expect(nextController.migrate({ approvedDiff: approved }))
.rejects.toThrow(`Destructive migration scope mismatch for ${CHANGED_CONTEXT}`);
expect(await oldSystem.storage.find("MigrationChangedHardDeleteSample", undefined, undefined, ["id"]))
.toEqual([{ id: stored.id }]);
} finally {
(nextController ?? oldController).teardown();
await db.close();
}
});
});
Loading