feat: hive setup data model and API (Beute L1)
Backend Image / build-and-push (push) Successful in 5m5s
Frontend Image / build-and-push (push) Successful in 11m2s

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:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-15 21:23:23 +02:00
1 parent 8982d0796a
commit b48fb157c8
141 files changed
+15211 -17

No files matched your search

+150
View File
@@ -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'),