Skip to content
Merged
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
10 changes: 10 additions & 0 deletions apps/azure/src/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ export type { EvidenceSourceCursor };
export type ProcessHubRecord = import('@variscout/core').ProcessHub;
export type EvidenceSourceRecord = import('@variscout/core').EvidenceSource;
export type EvidenceSnapshotRecord = import('@variscout/core').EvidenceSnapshot;
export type ImprovementProjectRecord =
import('@variscout/core/improvementProject').ImprovementProject;

export class VariScoutDatabase extends Dexie {
projects!: Dexie.Table<ProjectRecord, string>;
Expand All @@ -47,6 +49,7 @@ export class VariScoutDatabase extends Dexie {
sustainmentReviews!: Dexie.Table<import('@variscout/core').SustainmentReview, string>;
controlHandoffs!: Dexie.Table<import('@variscout/core').ControlHandoff, string>;
evidenceSourceCursors!: Dexie.Table<EvidenceSourceCursor, [string, string]>;
improvementProjects!: Dexie.Table<ImprovementProjectRecord, string>;

constructor() {
super('VaRiScoutAzure');
Expand Down Expand Up @@ -134,6 +137,13 @@ export class VariScoutDatabase extends Dexie {
this.version(9).stores({
sustainmentRecords: 'id, investigationId, hubId, nextReviewDue, updatedAt, deletedAt',
});

// Version 10: PR-RPS-5 — ImprovementProject dedicated table.
// Mirrors the sustainmentRecords pattern (dedicated table per entity, hubId-indexed).
// No upgrade callback needed — new empty table; existing data unaffected.
this.version(10).stores({
improvementProjects: 'id, hubId, deletedAt, status, updatedAt',
});
}
}

Expand Down
38 changes: 34 additions & 4 deletions apps/azure/src/persistence/AzureHubRepository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,24 @@ export class AzureHubRepository implements HubRepository {
// full hub blob, so no existing hub needs to be loaded first. This matches
// the PWA pattern and supports the null-hub bootstrap case (first hub save).
if (action.kind === 'HUB_PERSIST_SNAPSHOT') {
await saveProcessHubToIndexedDB(action.hub);
// improvementProjects live in their own table; decompose them out of the
// hub blob before saving. Mirrors the PWA HUB_PERSIST_SNAPSHOT decomposition.
const { improvementProjects, ...hubWithoutIP } = action.hub;
await db.transaction('rw', [db.processHubs, db.improvementProjects], async () => {
await saveProcessHubToIndexedDB(hubWithoutIP);
// Drop stale rows for this hub, then bulk-put incoming snapshot rows.
const incomingProjectIds = new Set((improvementProjects ?? []).map(p => p.id));
await db.improvementProjects
.where('hubId')
.equals(action.hub.id)
.filter(p => !incomingProjectIds.has(p.id))
.delete();
if (improvementProjects && improvementProjects.length > 0) {
await db.improvementProjects.bulkPut(
improvementProjects.map(p => ({ ...p, hubId: action.hub.id }))
);
}
});
return;
}

Expand All @@ -49,13 +66,26 @@ export class AzureHubRepository implements HubRepository {
// ---------------------------------------------------------------------------

hubs: HubReadAPI = {
// hubs.get is unscoped — direct id lookup; hubs.list filters tombstones
// hubs.get is unscoped — direct id lookup; hydrates improvementProjects from dedicated table.
async get(id) {
return db.processHubs.get(id);
const hub = await db.processHubs.get(id);
if (!hub) return undefined;
const ips = await db.improvementProjects.where('hubId').equals(id).toArray();
const liveIps = ips.filter(p => p.deletedAt === null);
if (liveIps.length === 0) return hub;
return { ...hub, improvementProjects: liveIps };
},
async list() {
const all = await db.processHubs.toArray();
return all.filter(h => h.deletedAt === null);
const live = all.filter(h => h.deletedAt === null);
return Promise.all(
live.map(async hub => {
const ips = await db.improvementProjects.where('hubId').equals(hub.id).toArray();
const liveIps = ips.filter(p => p.deletedAt === null);
if (liveIps.length === 0) return hub;
return { ...hub, improvementProjects: liveIps };
})
);
},
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -104,13 +104,15 @@ describe('AzureHubRepository read APIs (Dexie tables)', () => {
await db.evidenceSources.clear();
await db.evidenceSnapshots.clear();
await db.evidenceSourceCursors.clear();
await db.improvementProjects.clear();
});

afterEach(async () => {
await db.processHubs.clear();
await db.evidenceSources.clear();
await db.evidenceSnapshots.clear();
await db.evidenceSourceCursors.clear();
await db.improvementProjects.clear();
});

// ---- hubs.get ----
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
// apps/azure/src/persistence/__tests__/AzureHubRepository.snapshot.test.ts
//
// Real-Dexie integration tests for AzureHubRepository.dispatch HUB_PERSIST_SNAPSHOT.
//
// These tests verify the decomposition contract using the actual Dexie schema
// (not mocks) via the 'fake-indexeddb/auto' polyfill. Coverage:
//
// 1. Hub is saved WITHOUT improvementProjects embedded in the hub blob
// 2. ImprovementProjects are written to the dedicated improvementProjects table
// 3. Empty improvementProjects array — table stays empty
// 4. Hub with no improvementProjects field — table stays empty
// 5. Stale IPs (present in DB but absent from the new snapshot) are deleted
//
// fake-indexeddb/auto must be the first import so Dexie sees the IndexedDB
// polyfill before db.ts runs its module-load side effects.

import 'fake-indexeddb/auto';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import type { ProcessHub } from '@variscout/core/processHub';
import type { ImprovementProject } from '@variscout/core/improvementProject';
import { AzureHubRepository } from '../AzureHubRepository';
import { db } from '../../db/schema';

// ---------------------------------------------------------------------------
// Fixture helpers — deterministic literal values
// ---------------------------------------------------------------------------

const NOW = 1_746_352_800_000;

function makeIP(
id: string,
hubId: string,
overrides: Partial<ImprovementProject> = {}
): ImprovementProject {
return {
id,
hubId,
status: 'draft',
createdAt: NOW,
deletedAt: null,
updatedAt: NOW,
metadata: { title: id },
goal: { outcomeGoal: { outcomeSpecId: 'o-1', target: 1.33 } },
sections: {
background: {},
investigationLineage: {},
approach: {},
outcomeReference: {},
},
...overrides,
};
}

function makeHub(id: string, ips: ImprovementProject[] = []): ProcessHub {
return {
id,
name: `Hub ${id}`,
createdAt: NOW,
deletedAt: null,
improvementProjects: ips,
};
}

// ---------------------------------------------------------------------------
// Setup / teardown — clear tables touched by dispatch before each test.
// ---------------------------------------------------------------------------

beforeEach(async () => {
await db.processHubs.clear();
await db.improvementProjects.clear();
});

afterEach(async () => {
await db.processHubs.clear();
await db.improvementProjects.clear();
});

// ---------------------------------------------------------------------------
// Integration tests for HUB_PERSIST_SNAPSHOT decomposition
// ---------------------------------------------------------------------------

describe('AzureHubRepository.dispatch HUB_PERSIST_SNAPSHOT — Dexie integration', () => {
it('strips improvementProjects from the hub blob and writes them to the dedicated table', async () => {
const repo = new AzureHubRepository();
const ip = makeIP('ip-1', 'hub-1');
await repo.dispatch({ kind: 'HUB_PERSIST_SNAPSHOT', hub: makeHub('hub-1', [ip]) });

// The hub row in processHubs must NOT embed improvementProjects
const hubRow = await db.processHubs.get('hub-1');
expect(hubRow).toBeDefined();
expect((hubRow as Partial<ProcessHub> | undefined)?.improvementProjects).toBeUndefined();

// The IP must have been written to the dedicated table
const ipRows = await db.improvementProjects.where('hubId').equals('hub-1').toArray();
expect(ipRows).toHaveLength(1);
expect(ipRows[0].id).toBe('ip-1');
expect(ipRows[0].hubId).toBe('hub-1');
});

it('persists a hub with empty improvementProjects array — IP table stays empty', async () => {
const repo = new AzureHubRepository();
await repo.dispatch({ kind: 'HUB_PERSIST_SNAPSHOT', hub: makeHub('hub-2', []) });

const hubRow = await db.processHubs.get('hub-2');
expect(hubRow).toBeDefined();
const ipRows = await db.improvementProjects.where('hubId').equals('hub-2').toArray();
expect(ipRows).toHaveLength(0);
});

it('persists a hub with no improvementProjects field — IP table stays empty', async () => {
const repo = new AzureHubRepository();
// Deliberately omit improvementProjects field
const hub: ProcessHub = { id: 'hub-3', name: 'Hub 3', createdAt: NOW, deletedAt: null };
await repo.dispatch({ kind: 'HUB_PERSIST_SNAPSHOT', hub });

const hubRow = await db.processHubs.get('hub-3');
expect(hubRow).toBeDefined();
const ipRows = await db.improvementProjects.where('hubId').equals('hub-3').toArray();
expect(ipRows).toHaveLength(0);
});

it('cleans up stale IPs absent from the new snapshot', async () => {
const repo = new AzureHubRepository();
// Pre-seed a stale IP directly into the table (simulates a previously saved snapshot)
await db.improvementProjects.put(makeIP('ip-stale', 'hub-4'));

// Dispatch a snapshot that has a different IP (ip-keep) but not ip-stale
await repo.dispatch({
kind: 'HUB_PERSIST_SNAPSHOT',
hub: makeHub('hub-4', [makeIP('ip-keep', 'hub-4')]),
});

const ipRows = await db.improvementProjects.where('hubId').equals('hub-4').toArray();
expect(ipRows.map(r => r.id)).toEqual(['ip-keep']);
});
});
67 changes: 60 additions & 7 deletions apps/azure/src/persistence/__tests__/AzureHubRepository.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,9 @@ import { vi, describe, it, expect, beforeEach } from 'vitest';
// vi.hoisted + vi.mock must appear before the subject imports so that vitest
// can hoist them above the import block at transform time.
const mocks = vi.hoisted(() => ({
saveProcessHubToIndexedDB: vi.fn<() => Promise<void>>(),
applyAction: vi.fn<() => Promise<void>>(),
saveProcessHubToIndexedDB:
vi.fn<(hub: import('@variscout/core/processHub').ProcessHub) => Promise<void>>(),
applyAction: vi.fn<(action: import('@variscout/core/actions').HubAction) => Promise<void>>(),
}));

vi.mock('../../services/localDb', () => ({
Expand All @@ -38,18 +39,33 @@ vi.mock('../applyAction', () => ({

// db/schema is not used in dispatch tests — db access is blocked by the mock.
// We mock the db module as well to prevent Dexie from attempting to open IndexedDB.
// improvementProjects and transaction are included because HUB_PERSIST_SNAPSHOT now
// decomposes improvementProjects within a Dexie transaction.
vi.mock('../../db/schema', () => ({
db: {
processHubs: { get: vi.fn(), toArray: vi.fn(), put: vi.fn(), clear: vi.fn() },
evidenceSources: { get: vi.fn(), where: vi.fn(), toArray: vi.fn(), clear: vi.fn() },
evidenceSnapshots: { get: vi.fn(), where: vi.fn(), toArray: vi.fn(), clear: vi.fn() },
evidenceSourceCursors: { get: vi.fn(), clear: vi.fn() },
improvementProjects: {
get: vi.fn(),
where: vi.fn(() => ({
equals: vi.fn(() => ({ filter: vi.fn(() => ({ delete: vi.fn().mockResolvedValue(0) })) })),
})),
bulkPut: vi.fn().mockResolvedValue([]),
clear: vi.fn(),
},
// transaction executes the callback immediately (no real transaction scope needed in mocks).
transaction: vi.fn((_mode: string, _tables: unknown[], callback: () => Promise<void>) =>
callback()
),
},
}));

import { AzureHubRepository } from '../AzureHubRepository';
import type { ProcessHub, OutcomeSpec } from '@variscout/core/processHub';
import type { HubAction } from '@variscout/core/actions';
import type { ImprovementProject } from '@variscout/core/improvementProject';

// ---------------------------------------------------------------------------
// Fixture helpers
Expand Down Expand Up @@ -78,6 +94,25 @@ function makeOutcome(id: string, deletedAt: number | null = null): OutcomeSpec {
};
}

function makeIP(id: string, hubId: string): ImprovementProject {
return {
id,
hubId,
status: 'draft',
createdAt: NOW,
deletedAt: null,
updatedAt: NOW,
metadata: { title: id },
goal: { outcomeGoal: { outcomeSpecId: 'o-1', target: 1.33 } },
sections: {
background: {},
investigationLineage: {},
approach: {},
outcomeReference: {},
},
};
}

// ---------------------------------------------------------------------------
// Dispatch tests
// ---------------------------------------------------------------------------
Expand All @@ -93,12 +128,26 @@ describe('AzureHubRepository dispatch', () => {
mocks.applyAction.mockResolvedValue(undefined);
});

it('HUB_PERSIST_SNAPSHOT calls saveProcessHubToIndexedDB with the action hub', async () => {
const hub = makeHub({ id: 'hub-azure-1', name: 'Bootstrap Hub' });
await repo.dispatch({ kind: 'HUB_PERSIST_SNAPSHOT', hub });
it('HUB_PERSIST_SNAPSHOT calls saveProcessHubToIndexedDB with improvementProjects stripped', async () => {
// Fixture includes improvementProjects so the test actually exercises the
// decomposition path (without IPs the assertion would pass vacuously because
// hubWithoutIP === hub structurally when no IPs are present).
const hubWithIPs = makeHub({
id: 'hub-azure-1',
name: 'Bootstrap Hub',
improvementProjects: [makeIP('ip-1', 'hub-azure-1')],
});
await repo.dispatch({ kind: 'HUB_PERSIST_SNAPSHOT', hub: hubWithIPs });

expect(mocks.saveProcessHubToIndexedDB).toHaveBeenCalledOnce();
expect(mocks.saveProcessHubToIndexedDB).toHaveBeenCalledWith(hub);
// saveProcessHubToIndexedDB must receive a hub WITHOUT improvementProjects
expect(mocks.saveProcessHubToIndexedDB).toHaveBeenCalledWith(
expect.not.objectContaining({ improvementProjects: expect.anything() })
);
// The other hub fields must be preserved
const callArg = mocks.saveProcessHubToIndexedDB.mock.calls[0][0] as ProcessHub;
expect(callArg.id).toBe(hubWithIPs.id);
expect(callArg.name).toBe(hubWithIPs.name);
});

it('HUB_PERSIST_SNAPSHOT returns undefined (bootstrap path)', async () => {
Expand All @@ -107,10 +156,14 @@ describe('AzureHubRepository dispatch', () => {
});

it('HUB_PERSIST_SNAPSHOT does not throw even when called multiple times', async () => {
const hub = makeHub();
const hub = makeHub({ improvementProjects: [makeIP('ip-x', 'hub-azure-1')] });
await expect(repo.dispatch({ kind: 'HUB_PERSIST_SNAPSHOT', hub })).resolves.toBeUndefined();
await expect(repo.dispatch({ kind: 'HUB_PERSIST_SNAPSHOT', hub })).resolves.toBeUndefined();
expect(mocks.saveProcessHubToIndexedDB).toHaveBeenCalledTimes(2);
// Both calls must have received a hub without improvementProjects
for (const [callArg] of mocks.saveProcessHubToIndexedDB.mock.calls) {
expect(callArg).not.toHaveProperty('improvementProjects');
}
});

it('propagates errors from saveProcessHubToIndexedDB', async () => {
Expand Down
Loading