mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-08 15:57:01 +00:00
* feat(cli): add timeline editing verbs * chore: satisfy fallow complexity gate * test(cli): cover snap rounding at known fps * test(cli): cover snap rounding at known fps * test(cli): cover snap rounding at known fps * test(cli): cover snap rounding at known fps * fix(cli): derive split ids from stable refs * fix(cli): remove timeline gate blockers * fix(cli): close freshness and output gate gaps * fix(cli): close remaining timeline review findings * refactor(cli): simplify mutation decision flow * refactor(cli): split trim and apply paths * refactor(cli): isolate trim validation * fix(cli): satisfy timeline lint gate * style: format timeline mutation files * fix(cli): preserve mutation result types * fix(cli): close timeline receipt and nested bounds gaps * fix(studio): rollback writes that fail during write * fix(cli): bound moved clips by end time
85 lines
3.0 KiB
TypeScript
85 lines
3.0 KiB
TypeScript
import { createHash, randomUUID } from "node:crypto";
|
|
|
|
export interface FileWriteReceipt {
|
|
path: string;
|
|
version: string;
|
|
writeToken: string;
|
|
}
|
|
|
|
interface StoredReceipt extends FileWriteReceipt {
|
|
recordedAt: number;
|
|
}
|
|
|
|
const RECEIPT_TTL_MS = 10_000;
|
|
const receipts = new Map<string, StoredReceipt[]>();
|
|
|
|
/** Strong content version used as both the JSON version and HTTP ETag. */
|
|
export function fileContentVersion(content: string | Uint8Array): string {
|
|
return `"sha256:${createHash("sha256").update(content).digest("hex")}"`;
|
|
}
|
|
|
|
export function createWriteToken(requestToken?: string): string {
|
|
const token = requestToken?.trim();
|
|
return token && token.length <= 200 ? token : randomUUID();
|
|
}
|
|
|
|
export function recordFileWriteReceipt(absPath: string, receipt: FileWriteReceipt): void {
|
|
const now = Date.now();
|
|
const current = (receipts.get(absPath) ?? []).filter(
|
|
(entry) => now - entry.recordedAt < RECEIPT_TTL_MS,
|
|
);
|
|
current.push({ ...receipt, recordedAt: now });
|
|
receipts.set(absPath, current);
|
|
}
|
|
|
|
export function clearFileWriteReceipt(absPath: string, version: string, writeToken: string): void {
|
|
const current = (receipts.get(absPath) ?? []).filter(
|
|
(entry) => entry.version !== version || entry.writeToken !== writeToken,
|
|
);
|
|
if (current.length > 0) receipts.set(absPath, current);
|
|
else receipts.delete(absPath);
|
|
}
|
|
|
|
/**
|
|
* Attach one API write's identity to the watcher echo for its exact bytes.
|
|
*
|
|
* Reading is non-destructive: one watcher event fans out to every open SSE
|
|
* subscriber, and a receipt removed by the first reader leaves the rest seeing
|
|
* an unlabelled change and reloading the preview on Studio's own edit. Only the
|
|
* TTL removes a receipt.
|
|
*/
|
|
export function identifyFileWrite(
|
|
absPath: string,
|
|
expectedVersion: string,
|
|
): FileWriteReceipt | null {
|
|
const now = Date.now();
|
|
const current = (receipts.get(absPath) ?? []).filter(
|
|
(entry) => now - entry.recordedAt < RECEIPT_TTL_MS,
|
|
);
|
|
if (current.length > 0) receipts.set(absPath, current);
|
|
else receipts.delete(absPath);
|
|
// Newest match, not oldest: identical bytes written twice inside the TTL
|
|
// (undo, retyping a value) share a version, and the older token was already
|
|
// spent by the client on its own echo. The destructive read used to advance
|
|
// past it; scanning from the end keeps that cursor without the eviction.
|
|
let receipt: StoredReceipt | undefined;
|
|
for (let i = current.length - 1; i >= 0 && !receipt; i -= 1) {
|
|
if (current[i]?.version === expectedVersion) receipt = current[i];
|
|
}
|
|
if (!receipt) return null;
|
|
const { path, version, writeToken } = receipt;
|
|
return { path, version, writeToken };
|
|
}
|
|
|
|
/**
|
|
* @deprecated Renamed to {@link identifyFileWrite}, which despite this alias's
|
|
* name does NOT consume the receipt: one watcher event fans out to every SSE
|
|
* subscriber, so a destructive read left all but the first reloading on Studio's
|
|
* own write. Kept for one release; call `identifyFileWrite` instead.
|
|
*/
|
|
export const consumeFileWriteReceipt = identifyFileWrite;
|
|
|
|
export function resetFileWriteReceipts(): void {
|
|
receipts.clear();
|
|
}
|