feat: hive setup data model and API (Beute L1)
Each colony can now hold its hive setup (Ist-Zustand) as validated JSON with an append-only change log: - catalog: hive systems and part types with dimensions, placement rules and state fields; Liebig-Zander 10 seeded with fixed ids (owner's equipment incl. Bausperre, Futterzarge, Nassenheider) - pure domain module (operations, validator, derived facts) with shared JSON fixtures the frontend runs too - API: catalog (hive-systems:manage), setup create/change/history/ state at a day (colonies:read/update), 409 HIVE_CONFLICT with the current state, idempotent writes, HiveChanged event - inspections can carry hive ops saved in the same transaction - backup export/import of the new tables (backup wins per colony) - docs: ARCHITECTURE section 13, Annahmen 95-100 Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
8982d0796a
commit
b48fb157c8
141 files changed
+15211
-17
No files matched your search
@@ -601,6 +601,156 @@ describe('Backup round trip (e2e)', () => {
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it(
|
||||
'restores hive setup and history into a fresh install with the seeded catalog',
|
||||
async () => {
|
||||
const systemId = 'hive-system-zander-liebig-10';
|
||||
const day = new Date('2026-07-02T00:00:00.000Z');
|
||||
const state = { schemaVersion: 1, stack: [{ id: 'p1', kind: 'FLOOR' }] };
|
||||
const apiary = await prisma.apiary.create({
|
||||
data: { name: `Hive-Stand ${suffix}` },
|
||||
});
|
||||
const colony = await prisma.colony.create({
|
||||
data: {
|
||||
code: `HV-${suffix}`,
|
||||
apiaryId: apiary.id,
|
||||
colonyType: 'PRODUCTION_COLONY',
|
||||
},
|
||||
});
|
||||
const inspection = await prisma.inspection.create({
|
||||
data: { colonyId: colony.id, userId, date: day },
|
||||
});
|
||||
await prisma.hiveSetup.create({
|
||||
data: { colonyId: colony.id, systemId, version: 2, state },
|
||||
});
|
||||
await prisma.hiveChange.createMany({
|
||||
data: [
|
||||
{
|
||||
colonyId: colony.id,
|
||||
version: 1,
|
||||
occurredOn: day,
|
||||
source: 'INIT',
|
||||
userId,
|
||||
ops: [],
|
||||
stateAfter: state,
|
||||
},
|
||||
{
|
||||
colonyId: colony.id,
|
||||
version: 2,
|
||||
occurredOn: day,
|
||||
source: 'INSPECTION',
|
||||
inspectionId: inspection.id,
|
||||
userId,
|
||||
ops: [{ op: 'SET_NOTE' }],
|
||||
stateAfter: state,
|
||||
note: 'Beute',
|
||||
},
|
||||
],
|
||||
});
|
||||
const catalogCounts = async () => ({
|
||||
systems: await prisma.hiveSystem.count(),
|
||||
partTypes: await prisma.hivePartType.count(),
|
||||
});
|
||||
const catalogBefore = await catalogCounts();
|
||||
expect(catalogBefore.systems).toBeGreaterThan(0);
|
||||
expect(catalogBefore.partTypes).toBeGreaterThan(0);
|
||||
const hiveRows = async () => ({
|
||||
setup: await prisma.hiveSetup.findUnique({
|
||||
where: { colonyId: colony.id },
|
||||
}),
|
||||
changes: await prisma.hiveChange.findMany({
|
||||
where: { colonyId: colony.id },
|
||||
orderBy: { version: 'asc' },
|
||||
}),
|
||||
});
|
||||
const before = await hiveRows();
|
||||
|
||||
try {
|
||||
const bundle = await backup.exportAll();
|
||||
const ownColonyRows = (bundle.data.colony as Row[]).filter(
|
||||
(row) => row.id === colony.id,
|
||||
);
|
||||
const exportedHive = {
|
||||
hiveSystem: bundle.data.hiveSystem as Row[],
|
||||
hivePartType: bundle.data.hivePartType as Row[],
|
||||
colony: ownColonyRows,
|
||||
inspection: (bundle.data.inspection as Row[]).filter(
|
||||
(row) => row.id === inspection.id,
|
||||
),
|
||||
hiveSetup: (bundle.data.hiveSetup as Row[]).filter(
|
||||
(row) => row.colonyId === colony.id,
|
||||
),
|
||||
hiveChange: (bundle.data.hiveChange as Row[]).filter(
|
||||
(row) => row.colonyId === colony.id,
|
||||
),
|
||||
};
|
||||
expect(exportedHive.hiveSetup).toHaveLength(1);
|
||||
expect(exportedHive.hiveChange).toHaveLength(2);
|
||||
expect(exportedHive.hiveSystem.some((row) => row.id === systemId)).toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
// The colony is lost; the catalog rows stay, as a fresh install has
|
||||
// them from the migration.
|
||||
await prisma.hiveChange.deleteMany({ where: { colonyId: colony.id } });
|
||||
await prisma.hiveSetup.delete({ where: { colonyId: colony.id } });
|
||||
await prisma.inspection.delete({ where: { id: inspection.id } });
|
||||
await prisma.colony.delete({ where: { id: colony.id } });
|
||||
|
||||
const summary = await backup.importAll(
|
||||
{
|
||||
version: 3,
|
||||
exportedAt: new Date().toISOString(),
|
||||
data: exportedHive,
|
||||
},
|
||||
userId,
|
||||
{ trusted: true },
|
||||
);
|
||||
expect(summary.imported).toMatchObject({
|
||||
hiveSetup: 1,
|
||||
hiveChange: 2,
|
||||
});
|
||||
expect(await hiveRows()).toEqual(before);
|
||||
expect(await catalogCounts()).toEqual(catalogBefore);
|
||||
|
||||
// A second restore over an existing, diverged setup: the backup wins.
|
||||
await prisma.hiveSetup.update({
|
||||
where: { colonyId: colony.id },
|
||||
data: { version: 9 },
|
||||
});
|
||||
await prisma.hiveChange.create({
|
||||
data: {
|
||||
colonyId: colony.id,
|
||||
version: 9,
|
||||
occurredOn: day,
|
||||
source: 'CARD',
|
||||
userId,
|
||||
ops: [],
|
||||
stateAfter: state,
|
||||
},
|
||||
});
|
||||
await backup.importAll(
|
||||
{
|
||||
version: 3,
|
||||
exportedAt: new Date().toISOString(),
|
||||
data: exportedHive,
|
||||
},
|
||||
userId,
|
||||
{ trusted: true },
|
||||
);
|
||||
expect(await hiveRows()).toEqual(before);
|
||||
expect(await catalogCounts()).toEqual(catalogBefore);
|
||||
} finally {
|
||||
await prisma.hiveChange.deleteMany({ where: { colonyId: colony.id } });
|
||||
await prisma.hiveSetup.deleteMany({ where: { colonyId: colony.id } });
|
||||
await prisma.inspection.deleteMany({ where: { colonyId: colony.id } });
|
||||
await prisma.colony.deleteMany({ where: { id: colony.id } });
|
||||
await prisma.apiary.deleteMany({ where: { id: apiary.id } });
|
||||
}
|
||||
},
|
||||
ROUND_TRIP_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
it('removes the file a restored document row replaced', async () => {
|
||||
const directory = path.join(
|
||||
path.resolve(process.env.DOCUMENTS_STORAGE_PATH ?? './uploads'),
|
||||
|
||||
Reference in new issue
Block a user