Merge main after Recordly 2.0 parts 1-8

This commit is contained in:
webadderall committed 2026-11-09 18:45:48 +11:00
commit a4b1597273
4 files changed
+113 -5

No files matched your search

+83
View File
@@ -22,6 +22,7 @@ describe("writeProjectFileAtomically", () => {
});
afterEach(async () => {
vi.useRealTimers();
vi.restoreAllMocks();
await fs.rm(tempDir, { recursive: true, force: true });
});
@@ -426,4 +427,86 @@ describe("writeProjectFileAtomically", () => {
await Promise.allSettled([first, second]);
}
});
it("releases unrelated admissions when a filesystem identity never resolves", async () => {
const other = path.join(tempDir, "other.recordly");
await writeProjectFileAtomically(projectPath, '{"revision":1}');
await writeProjectFileAtomically(other, '{"revision":1}');
const canonicalParent = await fs.realpath(tempDir);
const parentStats = await fs.stat(tempDir);
vi.useFakeTimers();
vi.spyOn(fs, "realpath")
.mockImplementationOnce(() => new Promise<string>(() => {}))
.mockResolvedValue(canonicalParent);
vi.spyOn(fs, "stat").mockResolvedValue(parentStats);
let firstError: unknown;
void writeProjectFileAtomically(projectPath, '{"revision":2}').catch((error) => {
firstError = error;
});
const second = writeProjectFileAtomically(other, '{"revision":9}');
void second.catch(() => undefined);
await vi.advanceTimersByTimeAsync(0);
expect(firstError).toBeUndefined();
await vi.advanceTimersByTimeAsync(5000);
expect(firstError).toBeInstanceOf(Error);
expect((firstError as Error).message).toContain("Project location did not respond");
await second;
expect(await fs.readFile(other, "utf-8")).toBe('{"revision":9}');
expect(await fs.readFile(projectPath, "utf-8")).toBe('{"revision":1}');
expect(vi.getTimerCount()).toBe(0);
});
it.each([
"resolve",
"reject",
])("never writes after a timed-out identity later %ss", async (completion) => {
const other = path.join(tempDir, "other.recordly");
await writeProjectFileAtomically(projectPath, '{"revision":1}');
await writeProjectFileAtomically(other, '{"revision":1}');
const backup = await getProjectBackupPath(projectPath);
const canonicalParent = await fs.realpath(tempDir);
const parentStats = await fs.stat(tempDir);
let resolveIdentity!: (value: string) => void;
let rejectIdentity!: (error: Error) => void;
const lateIdentity = new Promise<string>((resolve, reject) => {
resolveIdentity = resolve;
rejectIdentity = reject;
});
vi.useFakeTimers();
vi.spyOn(fs, "realpath")
.mockReturnValueOnce(lateIdentity)
.mockResolvedValue(canonicalParent);
vi.spyOn(fs, "stat").mockResolvedValue(parentStats);
let firstError: unknown;
void writeProjectFileAtomically(projectPath, '{"revision":2}').catch((error) => {
firstError = error;
});
const second = writeProjectFileAtomically(other, '{"revision":9}');
void second.catch(() => undefined);
await vi.advanceTimersByTimeAsync(5000);
expect(firstError).toBeInstanceOf(Error);
if (completion === "resolve") resolveIdentity(canonicalParent);
else rejectIdentity(new Error("Late filesystem failure"));
await vi.advanceTimersByTimeAsync(0);
await second;
// A same-key read joins after any wrongly admitted late write, so it also
// detects a timeout that rejected only the caller while leaving work queued.
const read = await readProjectFileWithRecovery(projectPath, JSON.parse);
expect(read.project).toEqual({ revision: 1 });
await expect(fs.access(backup)).rejects.toMatchObject({ code: "ENOENT" });
expect(await fs.readFile(other, "utf-8")).toBe('{"revision":9}');
expect(vi.getTimerCount()).toBe(0);
});
it("clears identity deadlines after successful resolution and immediate failure", async () => {
vi.useFakeTimers();
await writeProjectFileAtomically(projectPath, '{"revision":1}');
expect(vi.getTimerCount()).toBe(0);
const denied = Object.assign(new Error("Identity access denied"), { code: "EACCES" });
vi.spyOn(fs, "realpath").mockRejectedValueOnce(denied);
await expect(writeProjectFileAtomically(projectPath, '{"revision":2}')).rejects.toBe(
denied,
);
expect(vi.getTimerCount()).toBe(0);
expect(await fs.readFile(projectPath, "utf-8")).toBe('{"revision":1}');
});
});
+27 -3
View File
@@ -3,6 +3,7 @@ import fs from "node:fs/promises";
import path from "node:path";
const pendingWrites = new Map<string, Promise<unknown>>();
const PROJECT_IDENTITY_TIMEOUT_MS = 5000;
// Reserve admission before identity I/O so aliases retain call-invocation order.
let pendingIdentityAdmissions: Promise<unknown> = Promise.resolve();
let recoveryDirectory: string | undefined;
@@ -386,9 +387,32 @@ export async function updateProjectFileAtomically(
async function queueProjectWrite<T>(projectPath: string, write: () => Promise<T>): Promise<T> {
// Start identity resolution in parallel, and handle rejection immediately even
// when an earlier admission is still waiting for its filesystem result.
const identity = getProjectStorageKey(projectPath).then(
(key) => ({ key }),
(error: unknown) => ({ error }),
let identityTimeout: ReturnType<typeof setTimeout> | undefined;
// Race the identity itself, not just the returned save promise: a timed-out
// lookup must release admission and must never enqueue a late write.
const identity = Promise.race([
getProjectStorageKey(projectPath),
new Promise<never>((_, reject) => {
identityTimeout = setTimeout(
() =>
reject(
new Error(
"Project location did not respond while resolving its filesystem identity.",
),
),
PROJECT_IDENTITY_TIMEOUT_MS,
);
identityTimeout.unref?.();
}),
]).then(
(key) => {
clearTimeout(identityTimeout);
return { key };
},
(error: unknown) => {
clearTimeout(identityTimeout);
return { error };
},
);
const admission = pendingIdentityAdmissions.then(async () => {
const resolved = await identity;
@@ -41,7 +41,7 @@ import { legalDetails } from "../legal/config";
<p>We use access controls and authenticated server checks to protect accounts and uploads. No storage or transmission system is completely secure, and a sharing link or password can be redistributed by its recipient.</p>
<h2>How long information remains</h2>
<ul>
<li><strong>Local files:</strong> remain on your device until you remove them. Removing a cloud copy does not remove your local project or another person’s downloaded copy.</li>
<li><strong>Local files:</strong> remain on your device until you remove them. The app also keeps project recovery copies and preview thumbnails in its local application-data folder. Moving or deleting a project outside Recordly may leave these copies behind. Deleting a project in Recordly attempts to move the project and its associated recovery copies and preview thumbnails to the operating system’s Trash or Recycle Bin. These files may remain recoverable there until the Trash or Recycle Bin is emptied. Removing a cloud copy does not remove your local project or another person’s downloaded copy.</li>
<li><strong>Free hosted recordings:</strong> normally expire after 7 days and become unavailable. Cleanup removes expired media and associated recording records. Incomplete uploads may be cleaned up after one day.</li>
<li><strong>Paid retention:</strong> completed recordings granted Cloud retention have no scheduled expiry while Cloud is active. Upgrading also protects eligible, still-stored completed recordings. When Cloud access ends, those recordings expire after a seven-day grace period. Deleted media cannot be restored by upgrading.</li>
<li><strong>Deletion:</strong> deleting a cloud recording removes its media, thumbnail and associated comments, reactions and recording metadata through the service. Cached copies and provider recovery copies may take additional time to disappear; copies held by recipients are outside our control.</li>
+2 -1
View File
@@ -205,7 +205,8 @@
@media (forced-colors: active) {
:is(.input, .textarea):focus,
.input-group:has(.input-group__input:focus),
.share-link-field:has(> input:focus) {
.share-link-field:has(> input:focus),
[data-error-report] > button:focus-visible {
outline: 2px solid Highlight;
outline-offset: 2px;
}