From d00d8942a8ad3da410d32aa6388a668f4ad04203 Mon Sep 17 00:00:00 2001 From: Alan Lail Date: Fri, 25 Sep 2026 16:14:14 -0400 Subject: [PATCH] Addresses ext:opencase data in the documents index --- .../case/endpoints/CreateFramework.ts | 10 +-- .../case/endpoints/GetCFItemAssociations.ts | 4 +- .../case/endpoints/ListFrameworks.ts | 4 +- .../__tests__/CreateFramework.fork.test.ts | 30 ++++--- .../__tests__/GetCFItemAssociations.test.ts | 14 +-- .../__tests__/ListFrameworks.test.ts | 2 - .../persistence/file/FileFrameworkStore.ts | 86 +++++++++++-------- .../FileFrameworkStore.restart.test.ts | 55 ++++++++++++ 8 files changed, 144 insertions(+), 61 deletions(-) diff --git a/apps/opencase/src/application/case/endpoints/CreateFramework.ts b/apps/opencase/src/application/case/endpoints/CreateFramework.ts index 26f6a07..d042f77 100644 --- a/apps/opencase/src/application/case/endpoints/CreateFramework.ts +++ b/apps/opencase/src/application/case/endpoints/CreateFramework.ts @@ -7,7 +7,7 @@ import { CFAssociation } from '../../../domain/case/entities/CFAssociation' import { CFRubric } from '../../../domain/case/entities/CFRubric' import { CFPackage } from '../../../domain/case/entities/CFPackage' import { JsonSchemaValidator } from '../../../infrastructure/validation/JsonSchemaValidator' -import type { FileFrameworkStore } from '../../../infrastructure/persistence/file/FileFrameworkStore' +import { type FileFrameworkStore, getOpenCaseIsModifiedFromSource } from '../../../infrastructure/persistence/file/FileFrameworkStore' import { mintForkedIdentifiers, stripNonDataFields, type RawComparableBundle } from '../services/mirrorFork' export interface CreateFrameworkCommand { @@ -199,8 +199,8 @@ export class CreateFramework { const existingMeta = this.store.getDocumentMetadata(tenantId, caseVersion, resolvedStorageKey) const existingPkg = existingMeta ? await this.pkgRepo.load(tenantId, caseVersion, resolvedStorageKey) : null - if (existingMeta?.isModifiedFromSource !== undefined && existingPkg) { - preserveUris = { preserveUris: existingMeta.isModifiedFromSource === false } + if (getOpenCaseIsModifiedFromSource(existingMeta) !== undefined && existingPkg) { + preserveUris = { preserveUris: getOpenCaseIsModifiedFromSource(existingMeta) === false } const candidate = buildEntities(cfDocPayload, cfItemsPayload, cfAssociationsPayload, cfRubricsPayload, preserveUris) const existingComparable: RawComparableBundle = { @@ -222,9 +222,9 @@ export class CreateFramework { const priorOpencase = (existingComparable.CFDocument as any)?.extensions?.['ext:opencase'] const priorOpencaseObj = (priorOpencase && typeof priorOpencase === 'object') ? priorOpencase : {} - let newIsModifiedFromSource = existingMeta.isModifiedFromSource + let newIsModifiedFromSource = getOpenCaseIsModifiedFromSource(existingMeta) - if (dataChanged && existingMeta.isModifiedFromSource === false) { + if (dataChanged && getOpenCaseIsModifiedFromSource(existingMeta) === false) { // Fork event: mint fresh local identifiers for the document, // items, associations, and rubrics. The document's storage // location doesn't change — it keeps living under its pre-fork diff --git a/apps/opencase/src/application/case/endpoints/GetCFItemAssociations.ts b/apps/opencase/src/application/case/endpoints/GetCFItemAssociations.ts index d799308..e101736 100644 --- a/apps/opencase/src/application/case/endpoints/GetCFItemAssociations.ts +++ b/apps/opencase/src/application/case/endpoints/GetCFItemAssociations.ts @@ -1,7 +1,7 @@ import { type CFPackageRepository } from '../ports/CFPackageRepository' import { type CaseVersion, type SourcedId, type TenantId } from '../../../domain/case/value-objects/Identifiers' import { logger } from '../../../infrastructure/logging/Logger' -import { type FileFrameworkStore } from '../../../infrastructure/persistence/file/FileFrameworkStore' +import { type FileFrameworkStore, getOpenCaseAlignmentParticipants } from '../../../infrastructure/persistence/file/FileFrameworkStore' export interface GetCFItemAssociationsQuery { tenantId: TenantId @@ -50,7 +50,7 @@ export class GetCFItemAssociations { meta => meta.frameworkType === 'Alignment' && !meta.archived && - meta.alignmentParticipants?.some(p => p.identifier === pkg.document.sourcedId) + getOpenCaseAlignmentParticipants(meta)?.some(p => p.identifier === pkg.document.sourcedId) ) const crossAssociations = ( diff --git a/apps/opencase/src/application/case/endpoints/ListFrameworks.ts b/apps/opencase/src/application/case/endpoints/ListFrameworks.ts index 712f7b4..4e4c216 100644 --- a/apps/opencase/src/application/case/endpoints/ListFrameworks.ts +++ b/apps/opencase/src/application/case/endpoints/ListFrameworks.ts @@ -1,5 +1,5 @@ import { type CaseVersion, type TenantId } from '../../../domain/case/value-objects/Identifiers' -import { type FileFrameworkStore } from '../../../infrastructure/persistence/file/FileFrameworkStore' +import { type FileFrameworkStore, getOpenCaseAlignmentParticipants } from '../../../infrastructure/persistence/file/FileFrameworkStore' import { logger } from '../../../infrastructure/logging/Logger' export interface ListFrameworksQuery { @@ -37,7 +37,7 @@ export class ListFrameworks { if (!query.includeArchived && doc.archived === true) continue if (query.frameworkType && doc.frameworkType !== query.frameworkType) continue if (query.participantId) { - const participates = doc.alignmentParticipants?.some(p => p.identifier === query.participantId) + const participates = getOpenCaseAlignmentParticipants(doc)?.some(p => p.identifier === query.participantId) if (!participates) continue } diff --git a/apps/opencase/src/application/case/endpoints/__tests__/CreateFramework.fork.test.ts b/apps/opencase/src/application/case/endpoints/__tests__/CreateFramework.fork.test.ts index 6c2713a..4092f8c 100644 --- a/apps/opencase/src/application/case/endpoints/__tests__/CreateFramework.fork.test.ts +++ b/apps/opencase/src/application/case/endpoints/__tests__/CreateFramework.fork.test.ts @@ -81,8 +81,10 @@ describe('CreateFramework — mirror fork behavior', () => { title: 'Source Framework', lastChangeDateTime: new Date('2024-01-01T00:00:00Z'), currentFile: 'frameworks/doc-123/doc-123_v0001.json', - sourcePackageURI: 'https://source.example.org/ims/case/v1p1/CFPackages/doc-123', - isModifiedFromSource: false + openCaseExtensions: { + sourcePackageURI: 'https://source.example.org/ims/case/v1p1/CFPackages/doc-123', + isModifiedFromSource: false + } } mockStore.resolveStorageKey.mockImplementation((_t, v) => (v === caseVersion ? docId : null)) mockStore.getDocumentMetadata.mockReturnValue(existingMeta) @@ -118,8 +120,10 @@ describe('CreateFramework — mirror fork behavior', () => { title: 'Source Framework', lastChangeDateTime: new Date('2024-01-01T00:00:00Z'), currentFile: 'frameworks/doc-123/doc-123_v0001.json', - sourcePackageURI: 'https://source.example.org/ims/case/v1p1/CFPackages/doc-123', - isModifiedFromSource: false + openCaseExtensions: { + sourcePackageURI: 'https://source.example.org/ims/case/v1p1/CFPackages/doc-123', + isModifiedFromSource: false + } } mockStore.resolveStorageKey.mockImplementation((_t, v) => (v === caseVersion ? docId : null)) mockStore.getDocumentMetadata.mockReturnValue(existingMeta) @@ -163,8 +167,10 @@ describe('CreateFramework — mirror fork behavior', () => { title: 'Source Framework', lastChangeDateTime: new Date('2024-01-01T00:00:00Z'), currentFile: 'frameworks/doc-123/doc-123_v0001.json', - sourcePackageURI: 'https://source.example.org/ims/case/v1p1/CFPackages/doc-123', - isModifiedFromSource: false + openCaseExtensions: { + sourcePackageURI: 'https://source.example.org/ims/case/v1p1/CFPackages/doc-123', + isModifiedFromSource: false + } } mockStore.resolveStorageKey.mockImplementation((_t, v) => (v === caseVersion ? docId : null)) mockStore.getDocumentMetadata.mockReturnValue(existingMeta) @@ -200,8 +206,10 @@ describe('CreateFramework — mirror fork behavior', () => { title: 'Source Framework', lastChangeDateTime: new Date('2024-01-01T00:00:00Z'), currentFile: 'frameworks/doc-123/doc-123_v0001.json', - sourcePackageURI: 'https://source.example.org/ims/case/v1p1/CFPackages/doc-123', - isModifiedFromSource: false + openCaseExtensions: { + sourcePackageURI: 'https://source.example.org/ims/case/v1p1/CFPackages/doc-123', + isModifiedFromSource: false + } } mockStore.resolveStorageKey.mockImplementation((_t, v) => (v === caseVersion ? docId : null)) mockStore.getDocumentMetadata.mockReturnValue(existingMeta) @@ -237,8 +245,10 @@ describe('CreateFramework — mirror fork behavior', () => { title: 'Source Framework', lastChangeDateTime: new Date('2024-01-01T00:00:00Z'), currentFile: 'frameworks/doc-123/doc-123_v0002.json', - sourcePackageURI: 'https://source.example.org/ims/case/v1p1/CFPackages/doc-123', - isModifiedFromSource: true + openCaseExtensions: { + sourcePackageURI: 'https://source.example.org/ims/case/v1p1/CFPackages/doc-123', + isModifiedFromSource: true + } } mockStore.resolveStorageKey.mockImplementation((_t, v) => (v === caseVersion ? docId : null)) mockStore.getDocumentMetadata.mockReturnValue(existingMeta) diff --git a/apps/opencase/src/application/case/endpoints/__tests__/GetCFItemAssociations.test.ts b/apps/opencase/src/application/case/endpoints/__tests__/GetCFItemAssociations.test.ts index 51dc18f..aafa9b0 100644 --- a/apps/opencase/src/application/case/endpoints/__tests__/GetCFItemAssociations.test.ts +++ b/apps/opencase/src/application/case/endpoints/__tests__/GetCFItemAssociations.test.ts @@ -195,10 +195,12 @@ describe('GetCFItemAssociations', () => { currentFile: 'frameworks/align-doc-1/align-doc-1_v0001.json', frameworkType: 'Alignment', archived: false, - alignmentParticipants: [ - { identifier: docId, uri: `/ims/case/v1p1/CFDocuments/${docId}` }, - { identifier: targetDocId, uri: `/ims/case/v1p1/CFDocuments/${targetDocId}` } - ] + openCaseExtensions: { + alignmentParticipants: [ + { identifier: docId, uri: `/ims/case/v1p1/CFDocuments/${docId}` }, + { identifier: targetDocId, uri: `/ims/case/v1p1/CFDocuments/${targetDocId}` } + ] + } } ]) mockStore.resolveStorageKey.mockReturnValue(alignDocId) @@ -250,7 +252,9 @@ describe('GetCFItemAssociations', () => { currentFile: 'frameworks/align-doc-archived/align-doc-archived_v0001.json', frameworkType: 'Alignment', archived: true, - alignmentParticipants: [{ identifier: docId, uri: `/ims/case/v1p1/CFDocuments/${docId}` }] + openCaseExtensions: { + alignmentParticipants: [{ identifier: docId, uri: `/ims/case/v1p1/CFDocuments/${docId}` }] + } } ]) mockRepository.load.mockResolvedValue(pkg) diff --git a/apps/opencase/src/application/case/endpoints/__tests__/ListFrameworks.test.ts b/apps/opencase/src/application/case/endpoints/__tests__/ListFrameworks.test.ts index 23c33b2..af85577 100644 --- a/apps/opencase/src/application/case/endpoints/__tests__/ListFrameworks.test.ts +++ b/apps/opencase/src/application/case/endpoints/__tests__/ListFrameworks.test.ts @@ -212,7 +212,6 @@ describe('ListFrameworks', () => { title: 'Alignment Framework', lastChangeDateTime: new Date('2024-01-01T00:00:00Z'), currentFile: 'frameworks/doc-1/doc-1_v0001.json', - alignmentParticipants: [{ identifier: 'participant-1', uri: '/ims/case/v1p1/CFDocuments/participant-1' }], openCaseExtensions: { alignmentParticipants: [{ identifier: 'participant-1', uri: '/ims/case/v1p1/CFDocuments/participant-1' }] } } ] @@ -239,7 +238,6 @@ describe('ListFrameworks', () => { title: 'Alignment Framework', lastChangeDateTime: new Date('2024-01-01T00:00:00Z'), currentFile: 'frameworks/doc-1/doc-1_v0001.json', - alignmentParticipants: openCaseExtensions.alignmentParticipants, openCaseExtensions } ] diff --git a/apps/opencase/src/infrastructure/persistence/file/FileFrameworkStore.ts b/apps/opencase/src/infrastructure/persistence/file/FileFrameworkStore.ts index 5d56dcc..67b0a48 100644 --- a/apps/opencase/src/infrastructure/persistence/file/FileFrameworkStore.ts +++ b/apps/opencase/src/infrastructure/persistence/file/FileFrameworkStore.ts @@ -22,24 +22,29 @@ export interface DocumentMetadata { currentFile: string // relative to tenant/version root adoptionStatus?: string // CASE domain field — NOT used for server-level archive filtering licenseIdentifier?: string // UUID of the assigned CFLicense (for public-access checks) - /** URL this framework was imported from (set during import). */ - sourcePackageURI?: string - /** True when an imported framework has been locally modified after import. */ - isModifiedFromSource?: boolean /** Server-level archive flag — independent of CASE adoptionStatus */ archived?: boolean - /** Participant frameworks in an alignment document (extracted from ext:opencase.alignmentParticipants) */ - alignmentParticipants?: Array<{ identifier?: string; uri: string }> /** * The complete, untransformed `extensions['ext:opencase']` object as stored on the document, - * verbatim. Callers that request extensions (X-CASE-EDITOR) should see this whole object as-is — - * the other typed fields above (sourcePackageURI, isModifiedFromSource, alignmentParticipants) - * are a lossy subset kept separately because internal logic (fork-on-edit detection, alignment - * filtering) depends on them as structured values, not because they're the full extension data. + * verbatim. This is the single source of truth for anything derived from that extension — + * use the getOpenCase*() accessors below rather than re-deriving/duplicating individual fields + * onto DocumentMetadata itself, so there's exactly one place each fact lives. */ openCaseExtensions?: Record } +/** True when an imported framework has been locally modified after import (undefined if never imported/mirrored). */ +export function getOpenCaseIsModifiedFromSource (meta: DocumentMetadata | undefined | null): boolean | undefined { + const v = meta?.openCaseExtensions?.isModifiedFromSource + return typeof v === 'boolean' ? v : undefined +} + +/** Participant frameworks in an alignment document, if any. */ +export function getOpenCaseAlignmentParticipants (meta: DocumentMetadata | undefined | null): Array<{ identifier?: string; uri: string }> | undefined { + const v = meta?.openCaseExtensions?.alignmentParticipants + return Array.isArray(v) ? v as Array<{ identifier?: string; uri: string }> : undefined +} + export interface DocumentVersionInfo { file: string lastChangeDateTime: Date @@ -140,6 +145,42 @@ export class FileFrameworkStore { this.setTenantVersionMap(this.assocIndex, tenantId, version, assocMap) this.setTenantVersionMap(this.rubricsIndex, tenantId, version, rubricsMap) this.setTenantVersionMap(this.definitionsIndex, tenantId, version, defsMap) + + await this.backfillMissingOpenCaseExtensions(tenantId, version, versionDir, idxDir, docsMap) + } + + /** + * `openCaseExtensions` is only computed when a document is written (see + * updateInMemoryDocumentIndex) — a `documents.json` entry for a document that hasn't been saved + * since that field was introduced will never have it, even though the document's own bundle + * file on disk still has the full ext:opencase object. Self-heal once per stale entry by reading + * it straight off the bundle file, then persist so this never needs to run again for that + * document. + */ + private async backfillMissingOpenCaseExtensions ( + tenantId: TenantId, + version: CaseVersion, + rootDir: string, + idxDir: string, + docsMap: Map + ): Promise { + let changed = false + for (const meta of docsMap.values()) { + if (meta.openCaseExtensions !== undefined) continue + try { + const bundle = JSON.parse(await fs.readFile(path.join(rootDir, meta.currentFile), 'utf8')) + const extOpencase = bundle?.document?.extensions?.['ext:opencase'] + if (!extOpencase || typeof extOpencase !== 'object') continue + + meta.openCaseExtensions = extOpencase as Record + changed = true + } catch { + // Bundle file missing/unreadable — leave metadata as-is, nothing to backfill. + } + } + if (changed) { + await this.writeDocumentsIndex(idxDir, tenantId, version) + } } private setTenantVersionMap( @@ -182,10 +223,7 @@ export class FileFrameworkStore { currentFile: d.currentFile, adoptionStatus: d.adoptionStatus, licenseIdentifier: d.licenseIdentifier, - sourcePackageURI: d.sourcePackageURI, - isModifiedFromSource: d.isModifiedFromSource, archived: d.archived, - alignmentParticipants: d.alignmentParticipants, openCaseExtensions: d.openCaseExtensions, }) } @@ -438,23 +476,7 @@ export class FileFrameworkStore { licenseIdentifier = lic.identifier } - // Extract sourcePackageURI and isModifiedFromSource from ext:opencase extension - let sourcePackageURI: string | undefined - let isModifiedFromSource: boolean | undefined const extOpencase = doc.extensions?.['ext:opencase'] - if (extOpencase && typeof extOpencase === 'object') { - if (typeof (extOpencase as any).sourcePackageURI === 'string') { - sourcePackageURI = (extOpencase as any).sourcePackageURI - } - if (typeof (extOpencase as any).isModifiedFromSource === 'boolean') { - isModifiedFromSource = (extOpencase as any).isModifiedFromSource - } - } - - let alignmentParticipants: Array<{ identifier?: string; uri: string }> | undefined - if (extOpencase && typeof extOpencase === 'object' && Array.isArray((extOpencase as any).alignmentParticipants)) { - alignmentParticipants = (extOpencase as any).alignmentParticipants - } versionMap.set(storageKey, { sourcedId: currentIdentifier, @@ -469,9 +491,6 @@ export class FileFrameworkStore { currentFile: relativePath, adoptionStatus: doc.adoptionStatus as string | undefined, licenseIdentifier, - sourcePackageURI, - isModifiedFromSource, - alignmentParticipants, openCaseExtensions: extOpencase && typeof extOpencase === 'object' ? extOpencase as Record : undefined, }) } @@ -674,10 +693,7 @@ export class FileFrameworkStore { currentFile: meta.currentFile, adoptionStatus: meta.adoptionStatus, licenseIdentifier: meta.licenseIdentifier, - sourcePackageURI: meta.sourcePackageURI, - isModifiedFromSource: meta.isModifiedFromSource, archived: meta.archived, - alignmentParticipants: meta.alignmentParticipants, openCaseExtensions: meta.openCaseExtensions, })) diff --git a/apps/opencase/src/infrastructure/persistence/file/__tests__/FileFrameworkStore.restart.test.ts b/apps/opencase/src/infrastructure/persistence/file/__tests__/FileFrameworkStore.restart.test.ts index 7d8ee9b..015ec0b 100644 --- a/apps/opencase/src/infrastructure/persistence/file/__tests__/FileFrameworkStore.restart.test.ts +++ b/apps/opencase/src/infrastructure/persistence/file/__tests__/FileFrameworkStore.restart.test.ts @@ -106,4 +106,59 @@ describe('FileFrameworkStore — index survives a server restart', () => { await fs.rm(baseDataDir, { recursive: true, force: true }) }) + + it('backfills openCaseExtensions on load for a documents.json entry written before that field existed', async () => { + const baseDataDir = await fs.mkdtemp(path.join(os.tmpdir(), 'case-store-restart-backfill-')) + const storageKey = 'doc-1' + const versionDir = path.join(baseDataDir, 'tenants', tenantId, 'v1p1') + const idxDir = path.join(versionDir, 'indexes') + const frameworksDir = path.join(versionDir, 'frameworks', storageKey) + await fs.mkdir(idxDir, { recursive: true }) + await fs.mkdir(frameworksDir, { recursive: true }) + + const extOpencase = { + sourcePackageURI: 'https://standards.example.org/ims/case/v1p1/CFPackages/abc', + isModifiedFromSource: true, + importedAt: '2026-07-28T16:37:03.784Z' + } + const relativePath = 'frameworks/doc-1/doc-1_v0001.json' + await fs.writeFile( + path.join(versionDir, relativePath), + JSON.stringify({ + document: { sourcedId: storageKey, title: 'A framework', lastChangeDateTime: '2024-01-01T00:00:00Z', extensions: { 'ext:opencase': extOpencase } }, + items: [], + associations: [], + rubrics: [], + }), + 'utf8' + ) + + // Simulates a documents.json entry persisted before openCaseExtensions was introduced — + // no such field, and the older derived fields absent too. + await fs.writeFile( + path.join(idxDir, 'documents.json'), + JSON.stringify([ + { + storageKey, + sourcedId: storageKey, + title: 'A framework', + lastChangeDateTime: '2024-01-01T00:00:00Z', + currentFile: relativePath, + }, + ]), + 'utf8' + ) + + const store = new FileFrameworkStore({ baseDataDir }) + await store.loadAll() + + const meta = store.getDocumentMetadata(tenantId, version, storageKey) + expect(meta?.openCaseExtensions).toEqual(extOpencase) + + // The backfill should persist, so a subsequent restart doesn't need to re-read the bundle. + const persisted = JSON.parse(await fs.readFile(path.join(idxDir, 'documents.json'), 'utf8')) + expect(persisted[0].openCaseExtensions).toEqual(extOpencase) + + await fs.rm(baseDataDir, { recursive: true, force: true }) + }) })