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
107 changes: 107 additions & 0 deletions apps/web/src/assets/assetUrls.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { EnvironmentId, ThreadId, type AssetResource } from "@t3tools/contracts";
import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity";
import { describe, expect, it } from "vite-plus/test";

import { makeRevisionedAssetUrlFamily } from "./assetUrls";

const environmentId = EnvironmentId.make("environment-1");
const resource: AssetResource = {
_tag: "workspace-file",
threadId: ThreadId.make("thread-1"),
path: "docs/diagram.png",
};

type MintResult = AsyncResult.AsyncResult<{ relativeUrl: string; expiresAt: number }>;

const minted = (n: number): MintResult =>
AsyncResult.success({ relativeUrl: `/a/${n}`, expiresAt: 0 });

// A stand-in for the asset URL query: a refresh starts a mint (counted, shown as
// waiting) and `settle` completes the latest one.
function setup(initial: MintResult) {
let mints = 0;
// Node removal runs on the registry's scheduler; `unmount` flushes it so the
// next mount of the same revision builds a fresh node.
const scheduled: Array<() => void> = [];
const registry = AtomRegistry.make({
scheduleTask: (task) => {
scheduled.push(task);
return () => {};
},
});
const server = Atom.make(initial).pipe(Atom.keepAlive);
const query = Atom.readable(
(get) => get(server),
() => {
mints += 1;
registry.set(server, AsyncResult.waiting(registry.get(server)));
},
).pipe(Atom.keepAlive);
const revisioned = makeRevisionedAssetUrlFamily(() => query);
const mount = (revision: string | null) => {
const release = registry.mount(revisioned({ environmentId, input: { resource }, revision }));
return () => {
release();
for (const task of scheduled.splice(0)) task();
};
};
const settle = () => registry.set(server, minted(mints + 1));
return { mount, settle, mints: () => mints };
}

describe("makeRevisionedAssetUrlFamily", () => {
it("re-mints a settled URL on first read and on each new revision, not on remount", () => {
const { mount, settle, mints } = setup(minted(0));

mount("mutation-1")();
expect(mints()).toBe(1);
settle();

mount("mutation-1")();
expect(mints()).toBe(1);

mount("mutation-2")();
expect(mints()).toBe(2);
});

it("re-mints when a revert returns to an earlier revision", () => {
const { mount, settle, mints } = setup(minted(0));

mount("mutation-a")();
settle();
mount("mutation-b")();
settle();
expect(mints()).toBe(2);

const reverted = mount("mutation-a");
expect(mints()).toBe(3);
reverted();
});

it("shares a mint in flight on the first read", () => {
const { mount, mints } = setup(AsyncResult.initial(true));

mount("mutation-1")();
expect(mints()).toBe(0);
});

it("re-mints after a mint that was in flight when the revision changed", () => {
const { mount, settle, mints } = setup(minted(0));

const before = mount("mutation-1");
settle();
const edited = mount("mutation-2");
expect(mints()).toBe(2);
before();

const stillMinting = mount("mutation-3");
edited();
expect(mints()).toBe(2);

settle();
expect(mints()).toBe(3);
settle();
expect(mints()).toBe(3);
stillMinting();
});
});
82 changes: 79 additions & 3 deletions apps/web/src/assets/assetUrls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,9 @@ import {
resolveAssetUrl,
} from "@t3tools/client-runtime/state/assets";
import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
import type { AssetResource, EnvironmentId } from "@t3tools/contracts";
import { AsyncResult } from "effect/reactivity";
import { AssetResource, type AssetCreateUrlResult, EnvironmentId } from "@t3tools/contracts";
import * as Schema from "effect/Schema";
import { AsyncResult, Atom, type AtomRegistry } from "effect/reactivity";

import { assetEnvironment } from "~/state/assets";
import { useFilesystemReadAccess } from "~/state/filesystem";
Expand All @@ -19,16 +20,91 @@ export { resolveAssetUrl } from "@t3tools/client-runtime/state/assets";

export type AssetUrlState = RuntimeAssetUrlState & { readonly waiting?: boolean };

type AssetUrlTarget = {
readonly environmentId: EnvironmentId;
readonly input: { readonly resource: AssetResource };
};

const decodeRevisionedAssetUrlKey = Schema.decodeUnknownSync(
Schema.Tuple([EnvironmentId, AssetResource, Schema.NullOr(Schema.String)]),
);

const isSettled = (result: AsyncResult.AsyncResult<unknown, unknown>) =>
!AsyncResult.isInitial(result) && !result.waiting;

/**
* The URL is minted per resource and shared across revisions, so reading a
* different revision (even an earlier one after a revert) re-mints it. An
* in-flight mint is shared only on first read; after an edit the server must
* re-read the file, so the re-mint waits for that mint to settle.
*/
export function makeRevisionedAssetUrlFamily(
createUrl: (
target: AssetUrlTarget,
) => Atom.Atom<AsyncResult.AsyncResult<AssetCreateUrlResult, unknown>>,
) {
// Outside the nodes: a node is dropped once unmounted, and the next read of
// its revision must still see the revisions minted since. Never pruned on
// purpose: pruning would forget the revision and re-mint on the next mount.
// It holds one short entry per file shown, and goes with the registry.
const mintedUnder = new WeakMap<AtomRegistry.AtomRegistry, Map<string, string | null>>();
const family = Atom.family((key: string) => {
const [environmentId, resource, revision] = decodeRevisionedAssetUrlKey(JSON.parse(key));
const resourceKey = JSON.stringify([environmentId, resource]);
const query = createUrl({ environmentId, input: { resource } });
return Atom.transform(
query,
(get) => {
let minted = mintedUnder.get(get.registry);
if (minted === undefined) {
minted = new Map();
mintedUnder.set(get.registry, minted);
}
const firstRead = !minted.has(resourceKey);
let pending = minted.get(resourceKey) !== revision;
const current = get.once(query);
if (pending && (firstRead || isSettled(current))) {
pending = false;
minted.set(resourceKey, revision);
if (isSettled(current)) get.refresh(query);
}
get.subscribe(query, (value) => {
get.setSelf(value);
if (!pending || !isSettled(value)) return;
pending = false;
minted.set(resourceKey, revision);
get.refresh(query);
});
return get.once(query);
},
{ initialValueTarget: query },
// No idle TTL: each mount rebuilds the node, so a revision read again after
// another one is compared against the record instead of a cached node.
).pipe(Atom.setIdleTTL(0));
});
return (target: AssetUrlTarget & { readonly revision: string | null }) =>
family(JSON.stringify([target.environmentId, target.input.resource, target.revision]));
}

export const revisionedAssetUrl = makeRevisionedAssetUrlFamily(assetEnvironment.createUrl);

/**
* Pass `revision` for a workspace file whose bytes can change under the same
* path; each new revision gets a freshly minted URL.
*/
export function useAssetUrlState(
environmentId: EnvironmentId | null,
resource: AssetResource | null,
revision?: string | null,
): AssetUrlState {
const blocked = blockedAssetUrlState(resource, useFilesystemReadAccess(environmentId));
const preparedConnection = usePreparedConnection(environmentId);
const result = useAtomValue(
blocked !== null || environmentId === null || resource === null
? EMPTY_ASSET_URL_ATOM
: assetEnvironment.createUrl({ environmentId, input: { resource } }),
: revision === undefined
? assetEnvironment.createUrl({ environmentId, input: { resource } })
: revisionedAssetUrl({ environmentId, input: { resource }, revision }),
);
if (blocked !== null) return blocked;
return {
Expand Down
100 changes: 100 additions & 0 deletions apps/web/src/assets/longLivedAssetUrls.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { EnvironmentId, type AssetResource } from "@t3tools/contracts";
import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity";
import { describe, expect, it } from "vite-plus/test";

import { makeLongLivedAssetUrlFamily } from "./longLivedAssetUrls";

const environmentId = EnvironmentId.make("environment-1");
const resource: AssetResource = {
_tag: "attachment",
attachmentId: "attachment-1",
fileName: "page.html",
mimeType: "text/html",
disposition: "inline",
};

type MintResult = AsyncResult.AsyncResult<{ relativeUrl: string; expiresAt: number }>;

const url = (relativeUrl: string, expiresAt: number): MintResult =>
AsyncResult.success({ relativeUrl, expiresAt });

function setup(initial: MintResult) {
let mints = 0;
const tasks: Array<() => void> = [];
const registry = AtomRegistry.make({
scheduleTask: (task) => {
tasks.push(task);
return () => {};
},
});
const server = Atom.make(initial).pipe(Atom.keepAlive);
const query = Atom.readable(
(get) => get(server),
() => {
mints += 1;
registry.set(server, AsyncResult.waiting(registry.get(server)));
},
).pipe(Atom.keepAlive);
const family = makeLongLivedAssetUrlFamily(() => query, { minLifeMs: 100, now: () => 1_000 });
const atom = family({ environmentId, input: { resource } });
let unmount = registry.mount(atom);
// Readers adopt any Success, waiting or not, as `assetUrlStateFromResult` does.
const relativeUrl = () => {
const value = registry.get(atom);
return value._tag === "Success" ? value.value.relativeUrl : null;
};
return {
relativeUrl,
mints: () => mints,
serve: (result: MintResult) => registry.set(server, result),
remount: () => {
unmount();
// Runs the idle-TTL disposal, so the next mount builds a fresh node.
for (const task of tasks.splice(0)) task();
unmount = registry.mount(atom);
},
};
}

describe("makeLongLivedAssetUrlFamily", () => {
it("reuses a cached URL with enough life left", () => {
const { relativeUrl, mints } = setup(url("/cached", 2_000));
expect(relativeUrl()).toBe("/cached");
expect(mints()).toBe(0);
});

it("mints a URL near expiry and hides it until the new one arrives", () => {
const { relativeUrl, mints, serve } = setup(url("/stale", 1_050));
expect(mints()).toBe(1);
expect(relativeUrl()).toBeNull();

serve(url("/fresh", 5_000));
expect(relativeUrl()).toBe("/fresh");
});

it("does not hand a new reader the expiring URL while an earlier reader's mint is in flight", () => {
const { relativeUrl, mints, serve, remount } = setup(url("/stale", 1_050));
expect(mints()).toBe(1);
remount();
expect(relativeUrl()).toBeNull();

serve(url("/fresh", 5_000));
expect(relativeUrl()).toBe("/fresh");
expect(mints()).toBe(1);
});

it("mints at most once when the clock disagrees with the server", () => {
const { relativeUrl, mints, serve } = setup(url("/stale", 1_050));
serve(url("/still-short", 1_050));
expect(relativeUrl()).toBe("/still-short");
expect(mints()).toBe(1);
});

it("waits for a URL still loading", () => {
const { relativeUrl, mints, serve } = setup(AsyncResult.initial(true));
expect(relativeUrl()).toBeNull();
serve(url("/loaded", 5_000));
expect(relativeUrl()).toBe("/loaded");
expect(mints()).toBe(0);
});
});
Loading
Loading