Files
openmaic/OpenMAIC/lib/courseware-repo/bundle-store.ts

73 lines
2.8 KiB
TypeScript

// Bundle byte store — where published ZIPs physically live.
//
// File-backed today (`data/courseware-bundles/<id>/v<version>.zip`); swap for
// object storage + CDN behind the same interface when configured. The record's
// `bundleUrl` is where the player downloads from — a CDN direct link once the
// object store is wired.
import { promises as fs } from 'fs';
import path from 'path';
import { assertCoursewareId, assertCoursewareVersion, resolveDirectChildPath } from './identity';
export const COURSEWARE_BUNDLES_DIR =
process.env.COURSEWARE_BUNDLE_DIR ??
path.join(process.env.LEARNING_DATA_DIR ?? path.join(process.cwd(), 'data'), 'courseware-bundles');
export interface BundleByteStore {
save(coursewareId: string, version: number, bytes: Uint8Array): Promise<string>;
read(coursewareId: string, version: number): Promise<Uint8Array | null>;
remove(coursewareId: string, version: number): Promise<void>;
storageKey(coursewareId: string, version: number): string;
}
export function createFileBundleByteStore(dir: string = COURSEWARE_BUNDLES_DIR): BundleByteStore {
const resolvePath = (coursewareId: string, version: number) => {
assertCoursewareId(coursewareId);
assertCoursewareVersion(version);
const coursewareDir = resolveDirectChildPath(dir, coursewareId);
return resolveDirectChildPath(coursewareDir, `v${version}.zip`);
};
return {
async save(coursewareId, version, bytes) {
const filePath = resolvePath(coursewareId, version);
await fs.mkdir(path.dirname(filePath), { recursive: true });
const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
await fs.writeFile(tempPath, bytes);
try {
// link() is an atomic create-if-absent operation. A published version
// can never be overwritten with different bytes.
await fs.link(tempPath, filePath);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error;
const existing = await fs.readFile(filePath);
if (!existing.equals(Buffer.from(bytes))) {
throw new Error(`Bundle ${coursewareId} v${version} already exists and is immutable`);
}
} finally {
await fs.rm(tempPath, { force: true }).catch(() => undefined);
}
return `${coursewareId}/v${version}.zip`;
},
async read(coursewareId, version) {
try {
return await fs.readFile(resolvePath(coursewareId, version));
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null;
throw error;
}
},
async remove(coursewareId, version) {
await fs.rm(resolvePath(coursewareId, version), { force: true });
},
storageKey(coursewareId, version) {
assertCoursewareId(coursewareId);
assertCoursewareVersion(version);
return `${coursewareId}/v${version}.zip`;
},
};
}