73 lines
2.8 KiB
TypeScript
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`;
|
|
},
|
|
};
|
|
}
|