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: 5 additions & 5 deletions apps/opencase/src/application/case/endpoints/CreateFramework.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 = {
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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 = (
Expand Down
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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' }] }
}
]
Expand All @@ -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
}
]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>
}

/** 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
Expand Down Expand Up @@ -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<string, DocumentMetadata>
): Promise<void> {
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<string, unknown>
changed = true
} catch {
// Bundle file missing/unreadable — leave metadata as-is, nothing to backfill.
}
}
if (changed) {
await this.writeDocumentsIndex(idxDir, tenantId, version)
}
}

private setTenantVersionMap<T>(
Expand Down Expand Up @@ -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,
})
}
Expand Down Expand Up @@ -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,
Expand All @@ -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<string, unknown> : undefined,
})
}
Expand Down Expand Up @@ -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,
}))

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
})
})
Loading