Skip to content

Commit 23dae89

Browse files
fix(metadata): FilesystemLoader remembers what findFile resolved, so a repeated read makes no fs.access probe (#24621)
Fixes #24537 Clause-②: no ## What changed `FilesystemLoader` (`packages/metadata/src/loaders/filesystem-loader.ts`) now remembers what `findFile` resolved for each `(type, name)`. The answer is either the path or "no file". The memo belongs to one loader instance, so each kernel has its own. A repeated read of the same name makes no `fs.access` probe, whether a file holds the name or not. - The caller is `plugin-security`'s `catalogSetLoader` (`security-plugin.ts`). It does not change. - The fix lands where the claim expected. The loader is the producer of the cost, and no other package changes. - Nothing public is added: no member, export or option. In the emitted `dist/node.d.ts`, `FilesystemLoader` gains only `private` members, and its public members are byte-identical. ## Premise, measured before any change (`origin/main` 2667e24) The probe counted calls to `node:fs/promises.access` along the real chain: `createSecurityCatalogReader().resolve('permission', name)` → `NodeMetadataManager.getDiagnosed` → `FilesystemLoader.load` → `findFile` It used the plugin's format set (yaml, json, typescript, javascript), which makes five resolvable extensions. | name | `fs.access` per resolution, over 4 resolutions | |---|---| | no file holds it | 5, 5, 5, 5 | | `permission/hit_set.json` holds it | 2, 2, 2, 2 (`load` and `stat` each resolved it) | One narrowing of the card's reach: only names that the engine registry does not serve fall through to the metadata service. A set the registry holds never reaches this loader. That covers platform sets, the sets in package manifests, and environment-authored sets hydrated from `sys_metadata`. The same probe after this change reads `5, 0, 0, 0, 0` for the miss and `1, 0, 0, 0, 0` for the hit. ## What can change the answer, and what retires it I enumerated every in-process path that touches `ROOT/TYPE/`: | path | exists? | how the memo is retired | pinned by | |---|---|---|---| | `FilesystemLoader.save()`, the only in-process writer. It is reached through `MetadataManager.save()`, which no production code in this repo calls. | yes | Every entry is retired, in a `finally`, because a direct write can fail after creating the file. | "a save through the loader retires a remembered miss" | | `FilesystemLoader.delete()` | does not exist | n/a | n/a | | The file watcher on `added` / `changed`. This is `NodeMetadataManager.handleFileEvent` (chokidar), which runs only with `watch: true`. | yes | The handler already re-reads with `useCache: false`. That read now resolves the name again from disk and keeps the answer. | "an `added` file event ..." | | The file watcher on `unlink` | yes | The handler reads nothing. The next read finds the remembered path gone: `stat` reports ENOENT/ENOTDIR, or the probe that `exists()` makes to confirm the path fails. That read resolves the name once more from disk and logs no stat failure. | "an `unlink` event ..." | | The loader's own walk (`list`, `loadMany`, `loadManyKeyed`, all through `resolvableNames`) | yes | that type's entries | the RECORD case | | Artifact reload (HMR POST or the artifact watcher), and `register()` | yes | Not needed. Both write the manager's registry, which `get` / `getDiagnosed` ask before any loader. | the two "writing a permission set" cases | | The ADR-0008 `FileSystemRepository` | It writes under `ROOT/.objectstack/metadata`, never under `ROOT/TYPE/`. | n/a | n/a | Two more guards: - **Race.** A probe can still be on disk when a retirement happens. An epoch counter stops it from writing its older answer back. Pinned by "a probe that was on disk ...". - **Bound.** Callers supply the names, so the memo is capped at 10,000 entries and starts over past that. Pinned by "is bounded ...". ## The gap no event reaches The gap is named here and deliberately not closed with a clock. The scenario: 1. A name has already been answered "no file". 2. Another process then creates `ROOT/TYPE/NAME.ext`. 3. No watcher is running. No watcher running is the normal case: every shipped composition passes `watch: false`. That covers `createStandaloneStack`, both of `os serve`'s doors (the dev artifact door and the host-config door), and the verify harness. - **On main today.** `get()` sees the file on the next read, because `findFile` probes every time. `list()` sees it once the manager's 30 s list cache expires and the next `list()` walks. - **After this PR, `list()`.** Unchanged. - **After this PR, `get()`.** It keeps the remembered miss until one of three things happens: the next walk of that type (the next `list()` after the TTL is one), a `save()` through the loader, or a restart. A walk retires the type, so `get()` is never staler than `list()`. A name that `list()` reports is never answered `null`. - **Edits to a file that already resolves.** These are still read on the next request, because the content cache revalidates by etag. Pinned by the CONTROL case. - **Deletions.** These are covered by the `unlink` row above. The gap is pinned as a `RECORD:` case, so any change to it shows up in a diff. ## "Activating or writing a permission set is seen on the next request" Neither of these goes through this loader: - **Activation.** `POST /security/_activation/:type/:name` (`packages/runtime/src/domains/catalog-activation.ts`) writes a `sys_metadata_activation` row. The resolver reads that ledger (`readDisabledCatalogNames`), not the catalog body, so the memo never sees it. - **Writing a definition.** `saveMetaItem` (the clone door, for example) puts the set in the engine registry through `registerItem`, and `catalog.resolve` asks that registry first. `MetadataManager.register()` puts it in the manager's registry, which `getDiagnosed` asks before any loader. Two cases pin that a remembered miss hides neither write. ## Tests `packages/metadata/src/loaders/filesystem-loader-resolution-memo.test.ts` has 13 cases. They run real files in a tmpdir through the real `NodeMetadataManager` and the real `createSecurityCatalogReader`, and count probes at `node:fs/promises.access`. - probe count, miss: `[5, 0, 0, 0, 0]` - probe count, hit: `[1, 0, 0, 0, 0]`, and the body is still served - CONTROL: an edit to a resolved file is read on the next resolution - two managers do not share what they remember - a set written to the engine registry is seen on the next resolution - a set registered with the metadata service is seen on the next resolution - a save through the loader retires a remembered miss - an `added` file event announces the body (not `null`), and the next resolution sees the file - an `unlink` event: `load()` and `exists()`, each asked first, answer the miss, and no stat failure is logged - a deletion no event reports is re-resolved from disk (json removed, yaml added → the yaml body) - RECORD: the gap above, closed by the next walk - a probe still on disk during a save does not write its miss back - the memo is bounded Results on the final head (HEAD dff1e3f): - `pnpm --filter @objectstack/metadata test`: 64 files, 905 tests, all passed. - `pnpm --filter @objectstack/metadata typecheck`: exit 0. `tsc --listFiles` includes the new test file. ## Ablation There is one leg per mechanism. Each leg ran through `scripts/ablation-replace.mjs` in WRAP mode: the anchor hit once, the mutation was proven on disk by blob hash, and the restore was proven (blob equals HEAD, `git diff HEAD` empty). The test imports `./filesystem-loader.js` relatively, so it reads source and no `dist/` step applies. | leg | mutation | went red | |---|---|---| | memo | `if (remembered?.has(name))` → `if (false && …)` | both probe counts, the RECORD case, the bound (4) | | save retire | `this.retireResolutions();` → `void 0;` | the save case, the late-probe case (2) | | walk retire | `this.retireResolutions(type);` → `void type;` | the RECORD case (1) | | fresh read | `resolveStats(type, name, !useCache)` → `false` | the `added` event case (1) | | self-heal | the ENOENT/ENOTDIR `continue` → `if (false)` | the `unlink` case, the unreported-deletion case (2) | | `exists()` confirmation | the fresh fallback → `return true` | the `unlink` case (1) | | epoch | `if (epoch === this.resolutionEpoch)` → `if (true)` | the late-probe case (1) | | bound | the cap test → `if (false)` | the bound case (1) | ## Gates The local subset was derived with `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` from the real diff, on the final head dff1e3f (merge base 5a871e4). It produced 64 commands, and I ran all of them. - Each exit code was captured before any pipe. **All 64 exited 0.** - `--ran` reconciliation: `64 derived, 64 run, 0 NOT-MEASURED, 0 UNRUN` ("a DERIVED zero — all 64 recorded an exit code and none of them is 3"). - An earlier run, before merging main (6fb0a2d), hit three prerequisite refusals (exit 3), all since cleared: - the plugin-teardown self-test could not reach its pinned fixture commit from the shallow clone; - `check:lean-entry-closure` needed `@objectstack/objectql`'s built dist; - `check:dual-build-cjs-loads` needed built dist for every package. All three exit 0 on the rerun at dff1e3f. Its last line reads: 111 require entry points across 69 packages load. - After the merge I rebuilt `@objectstack/spec`. `check:generated` reported all 14 generated artifacts up to date. - Left to CI and read from this PR's check-runs, not re-run locally: the required contexts (`Lint & Repo Gates`, `TypeScript Type Check`, `Test Core`, `Build Core`, `Governed Surface Queue Guard`). `test:repo` was not run locally either: the diff touches no file in that project. ## Acceptance notes - **Not filed, no owner yet.** Artifact-mode runtimes still register this `FilesystemLoader` at `rootDir`. `NodeMetadataManager` installs it whenever no loaders are passed, and `MetadataPlugin` never passes any. Two pieces of documentation disagree with that: `MetadataPluginOptions.watch` says the source-file scan is redundant in artifact mode, and the `bootstrap: 'artifact-only'` branch says "Never touch the filesystem". The per-request probe this PR memoises exists only because that loader is registered. Removing it from artifact modes would be a composition change with its own blast radius. I took no public-door reading, so it is noted here rather than filed. - **Observation.** A name that a file does hold still costs one `fs.stat` and one `readFile` per read. That is the content cache's etag revalidation, and it is what keeps edits visible without a watcher. It only applies when a file exists under the root, so it is not the miss path this card measured. - **Observation.** `FilesystemLoader.contract` declares `supportsWatch: true`, but the loader has no watch of its own: `NodeMetadataManager` owns the chokidar watcher. That is why the watch retirement rides on the handler's existing `useCache: false` re-read and the read-time repair, and not on a new loader member. --- _Generated by [Claude Code](https://claude.ai/code/session_01AHeUBbe6S3JRawcpbLejXD)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3566c08 commit 23dae89

3 files changed

Lines changed: 615 additions & 51 deletions

File tree

‎.changeset/24537-findfile-memo.md‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
'@objectstack/metadata': patch
3+
---
4+
5+
fix(metadata): `FilesystemLoader` remembers which file a metadata name resolves to, so a repeated read stops probing the filesystem (#24537)
6+
7+
Clause-②: no
8+
9+
`FilesystemLoader` used to make one `fs.access` call per resolvable extension (five under `MetadataPlugin`'s format set) on every `load()`, `exists()` and `stat()`, for a name no file holds as well as for one a file holds. Every authenticated request paid this cost: the security catalog asks the metadata service for each held permission set the engine registry does not serve, and in an artifact-mode runtime no such file exists.
10+
11+
Each loader instance now remembers the answer for each `(type, name)`: the path, or no file. Only something that can change the answer retires it:
12+
13+
- a `save()` through the loader;
14+
- a walk of the type's directory by the loader (`list()`, `loadMany()`, `loadManyKeyed()`), so a name `list()` reports is not answered `null` by `get()`;
15+
- a read with `useCache: false`, which re-resolves from disk. The file watcher's re-read on an `added` or `changed` event is such a read;
16+
- a remembered path that the next read finds deleted. That read resolves the name again from disk and logs no stat failure. This covers an `unlink` event, and a deletion that no event reports.
17+
18+
The memo holds at most 10,000 names and then starts over.
19+
20+
One change is not seen at once. Suppose a name has already been answered "no file", and then another process creates that file under the loader's root while no watcher runs (`watch: false`). The loader does not see the file until the next walk of that type, the next `save()` through the loader, or a restart. Content edits to a file that already resolves are still read on the next request. Nothing in the public API changes.
Lines changed: 378 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,378 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #24537 — `FilesystemLoader.findFile` remembers what it resolved, so a
5+
* request stops probing the filesystem.
6+
*
7+
* ---------------------------------------------------------------------------
8+
* The cost (measured on `origin/main` @ 2667e2402, the first case below)
9+
* ---------------------------------------------------------------------------
10+
* `plugin-security`'s catalog read (`createSecurityCatalogReader().resolve`)
11+
* asks the metadata service for every held permission set the engine registry
12+
* does not serve, on every authenticated request. That falls through
13+
* `MetadataManager.getDiagnosed` to `FilesystemLoader.load`, and `findFile`
14+
* probed one `fs.access` per resolvable extension, every time:
15+
*
16+
* a name no file holds 5 probes per resolution, every resolution
17+
* a name a file holds 2 probes per resolution (`load` and `stat` each
18+
* resolved it), every resolution
19+
*
20+
* The miss is the common case: an artifact-mode runtime has no
21+
* `ROOT/permission/*.json` at all.
22+
*
23+
* ---------------------------------------------------------------------------
24+
* What retires a remembered answer, and the case that pins each
25+
* ---------------------------------------------------------------------------
26+
* the loader's own write (`save`) "a save through the loader …"
27+
* the watcher's re-read (`added` event, "an `added` file event …"
28+
* `useCache: false`)
29+
* a remembered path found gone on read "an `unlink` event …",
30+
* (`unlink` event, or no event at all) "a deletion nothing reported …"
31+
* the loader's own walk of the type "RECORD: a file created with no
32+
* watcher …"
33+
* a probe already on disk when it retires "a probe that was on disk …"
34+
*
35+
* `RECORD:` pins the one change no event reaches — a file another process
36+
* creates under the root while no watcher runs (`watch: false`, every shipped
37+
* composition) — so the gap is visible in a diff rather than implicit. It is
38+
* closed by the next walk of that type, which is also what keeps a name
39+
* `list()` reports from being answered `null` by `get()`.
40+
*
41+
* Writing a permission set never reaches this memo at all: a definition saved
42+
* through the metadata protocol lands in the engine registry, which the catalog
43+
* read asks first, and `register()` lands in the manager's registry, which
44+
* `getDiagnosed` asks before any loader. The two "writing a permission set"
45+
* cases pin that a remembered miss cannot hide either.
46+
*
47+
* Probes are counted at `node:fs/promises.access`, the call `findFile` makes,
48+
* for paths under this test's root only.
49+
*/
50+
51+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
52+
import * as os from 'node:os';
53+
import * as path from 'node:path';
54+
55+
const { probes, logger } = vi.hoisted(() => ({
56+
probes: {
57+
paths: [] as string[],
58+
/** Holds ONE probe of `path` after it has read the disk, until released. */
59+
hold: undefined as undefined | { path: string; reached: () => void; release: Promise<void> },
60+
},
61+
logger: {
62+
info: vi.fn(),
63+
warn: vi.fn(),
64+
error: vi.fn(),
65+
debug: vi.fn(),
66+
},
67+
}));
68+
69+
vi.mock('node:fs/promises', async (importOriginal) => {
70+
const actual = await importOriginal<typeof import('node:fs/promises')>();
71+
return {
72+
...actual,
73+
access: async (target: Parameters<typeof actual.access>[0], mode?: number) => {
74+
const probed = String(target);
75+
probes.paths.push(probed);
76+
77+
let failure: unknown;
78+
let failed = false;
79+
try {
80+
await actual.access(target, mode);
81+
} catch (error) {
82+
failed = true;
83+
failure = error;
84+
}
85+
86+
const hold = probes.hold;
87+
if (hold && probed === hold.path) {
88+
probes.hold = undefined;
89+
hold.reached();
90+
await hold.release;
91+
}
92+
93+
if (failed) throw failure;
94+
},
95+
};
96+
});
97+
98+
vi.mock('@objectstack/core', async (orig) => ({
99+
// Spread the REAL module: the catalog reader under test comes from it.
100+
...((await orig()) as object),
101+
createLogger: () => logger,
102+
}));
103+
104+
import * as fs from 'node:fs/promises';
105+
import type { MetadataFormat } from '@objectstack/spec/system';
106+
import { createSecurityCatalogReader, type SecurityCatalogReader } from '@objectstack/core';
107+
import { NodeMetadataManager } from '../node-metadata-manager.js';
108+
import { FilesystemLoader } from './filesystem-loader.js';
109+
import { JSONSerializer } from '../serializers/json-serializer.js';
110+
import type { MetadataSerializer } from '../serializers/serializer-interface.js';
111+
112+
/** The format set `MetadataPlugin` constructs its manager with. */
113+
const PLUGIN_FORMATS: MetadataFormat[] = ['yaml', 'json', 'typescript', 'javascript'];
114+
/** …and the extensions `findFile` probes under it, in precedence order. */
115+
const PLUGIN_EXTENSIONS = ['.json', '.yaml', '.yml', '.ts', '.js'];
116+
117+
let rootDir: string;
118+
const managers: NodeMetadataManager[] = [];
119+
120+
beforeEach(async () => {
121+
vi.clearAllMocks();
122+
probes.paths.length = 0;
123+
probes.hold = undefined;
124+
rootDir = await fs.mkdtemp(path.join(os.tmpdir(), 'os-24537-'));
125+
});
126+
127+
afterEach(async () => {
128+
for (const mgr of managers.splice(0)) await mgr.stopWatching();
129+
await fs.rm(rootDir, { recursive: true, force: true });
130+
});
131+
132+
const permissionSet = (name: string, label = name) => ({ name, label, objects: {} });
133+
134+
async function writeSetFile(name: string, body: object = permissionSet(name), ext = '.json'): Promise<string> {
135+
const dir = path.join(rootDir, 'permission');
136+
await fs.mkdir(dir, { recursive: true });
137+
const filePath = path.join(dir, `${name}${ext}`);
138+
await fs.writeFile(filePath, ext === '.json' ? JSON.stringify(body) : `name: ${name}\nlabel: ${(body as { label?: string }).label ?? name}\n`, 'utf-8');
139+
return filePath;
140+
}
141+
142+
/** The plugin's manager over `rootDir`: no loaders given, so the default FilesystemLoader. */
143+
function makeManager(): NodeMetadataManager {
144+
const mgr = new NodeMetadataManager({ rootDir, watch: false, formats: PLUGIN_FORMATS });
145+
managers.push(mgr);
146+
return mgr;
147+
}
148+
149+
/** An engine registry that serves `held` only — empty unless a case writes to it. */
150+
function makeRegistry(held: Map<string, Record<string, unknown>> = new Map()) {
151+
return {
152+
getItem: (_type: string, name: string) => held.get(name),
153+
listItems: () => [...held.values()],
154+
isPackageDisabled: () => false,
155+
};
156+
}
157+
158+
function makeCatalog(mgr: NodeMetadataManager, held?: Map<string, Record<string, unknown>>): SecurityCatalogReader {
159+
return createSecurityCatalogReader({ registry: makeRegistry(held), metadata: mgr as never });
160+
}
161+
162+
/** `fs.access` probes made under this test's root since the last call. */
163+
function probesSince(mark: { at: number }): number {
164+
const count = probes.paths.slice(mark.at).filter((p) => p.startsWith(rootDir)).length;
165+
mark.at = probes.paths.length;
166+
return count;
167+
}
168+
169+
/** Probes per resolution, over `times` resolutions of `name` by the catalog read. */
170+
async function probesPerResolution(catalog: SecurityCatalogReader, name: string, times = 5): Promise<number[]> {
171+
const mark = { at: probes.paths.length };
172+
const counts: number[] = [];
173+
for (let i = 0; i < times; i++) {
174+
await catalog.resolve('permission', name);
175+
counts.push(probesSince(mark));
176+
}
177+
return counts;
178+
}
179+
180+
function fireFileEvent(mgr: NodeMetadataManager, eventType: 'added' | 'changed' | 'deleted', filePath: string): Promise<void> {
181+
return (
182+
mgr as unknown as { handleFileEvent(t: 'added' | 'changed' | 'deleted', p: string): Promise<void> }
183+
).handleFileEvent(eventType, filePath);
184+
}
185+
186+
const statFailureLogs = () => logger.error.mock.calls.filter((call) => call[0] === 'Failed to stat file');
187+
188+
describe('#24537 — repeated resolutions of one name probe the filesystem once', () => {
189+
it('a name no file holds: one probe per extension on the first resolution, none after', async () => {
190+
const catalog = makeCatalog(makeManager());
191+
192+
// On main @ 2667e2402: 5 probes on every resolution (measured over four).
193+
expect(await probesPerResolution(catalog, 'miss_set')).toEqual([PLUGIN_EXTENSIONS.length, 0, 0, 0, 0]);
194+
expect(await catalog.resolve('permission', 'miss_set')).toBeUndefined();
195+
});
196+
197+
it('a name a file holds: one probe on the first resolution, none after — and the body is still served', async () => {
198+
await writeSetFile('hit_set');
199+
const catalog = makeCatalog(makeManager());
200+
201+
// On main @ 2667e2402: 2 probes on every resolution (measured over four).
202+
expect(await probesPerResolution(catalog, 'hit_set')).toEqual([1, 0, 0, 0, 0]);
203+
expect(await catalog.resolve('permission', 'hit_set')).toMatchObject({
204+
name: 'hit_set',
205+
source: 'metadata',
206+
});
207+
});
208+
209+
it('CONTROL: a remembered path is not remembered CONTENT — an edit to the file is read on the next resolution', async () => {
210+
const filePath = await writeSetFile('edited_set', permissionSet('edited_set', 'before'));
211+
const catalog = makeCatalog(makeManager());
212+
expect((await catalog.resolve('permission', 'edited_set'))?.definition).toMatchObject({ label: 'before' });
213+
214+
await fs.writeFile(filePath, JSON.stringify(permissionSet('edited_set', 'after')), 'utf-8');
215+
216+
expect((await catalog.resolve('permission', 'edited_set'))?.definition).toMatchObject({ label: 'after' });
217+
});
218+
219+
it('two managers do not share what they remember', async () => {
220+
const first = makeCatalog(makeManager());
221+
const second = makeCatalog(makeManager());
222+
223+
await first.resolve('permission', 'per_kernel_set');
224+
expect(await probesPerResolution(second, 'per_kernel_set', 1)).toEqual([PLUGIN_EXTENSIONS.length]);
225+
});
226+
});
227+
228+
describe('#24537 — writing a permission set is seen on the next resolution', () => {
229+
it('a set written to the engine registry: the registry answers before any loader', async () => {
230+
const held = new Map<string, Record<string, unknown>>();
231+
const catalog = makeCatalog(makeManager(), held);
232+
expect(await catalog.resolve('permission', 'authored_set')).toBeUndefined();
233+
234+
held.set('authored_set', permissionSet('authored_set'));
235+
236+
expect(await catalog.resolve('permission', 'authored_set')).toMatchObject({ name: 'authored_set', source: 'registry' });
237+
});
238+
239+
it('a set registered with the metadata service: its registry answers before any loader', async () => {
240+
const mgr = makeManager();
241+
const catalog = makeCatalog(mgr);
242+
expect(await catalog.resolve('permission', 'registered_set')).toBeUndefined();
243+
244+
await mgr.register('permission', 'registered_set', permissionSet('registered_set'));
245+
246+
expect(await catalog.resolve('permission', 'registered_set')).toMatchObject({
247+
name: 'registered_set',
248+
source: 'metadata',
249+
});
250+
});
251+
252+
it('a save through the loader retires a remembered miss', async () => {
253+
const mgr = makeManager();
254+
const catalog = makeCatalog(mgr);
255+
expect(await catalog.resolve('permission', 'saved_set')).toBeUndefined();
256+
257+
await mgr.save('permission', 'saved_set', permissionSet('saved_set'), { format: 'json' });
258+
259+
expect(await catalog.resolve('permission', 'saved_set')).toMatchObject({ name: 'saved_set', source: 'metadata' });
260+
});
261+
});
262+
263+
describe('#24537 — the file watcher retires what it reports', () => {
264+
it('an `added` file event: its re-read resolves from disk, announces the body, and the next resolution sees it', async () => {
265+
const mgr = makeManager();
266+
const catalog = makeCatalog(mgr);
267+
const announced: unknown[] = [];
268+
mgr.subscribe('permission', (evt) => { announced.push(evt.data); });
269+
expect(await catalog.resolve('permission', 'watched_set')).toBeUndefined();
270+
271+
const filePath = await writeSetFile('watched_set');
272+
await fireFileEvent(mgr, 'added', filePath);
273+
274+
// Without the fresh re-read the event would announce `null` for a file
275+
// that was just added, and the remembered miss would stand.
276+
expect(announced).toEqual([permissionSet('watched_set')]);
277+
expect(await catalog.resolve('permission', 'watched_set')).toMatchObject({ name: 'watched_set' });
278+
});
279+
280+
it('an `unlink` event: the next read finds the remembered path gone and answers the miss — no stat failure logged', async () => {
281+
const mgr = makeManager();
282+
const catalog = makeCatalog(mgr);
283+
const paths: string[] = [];
284+
for (const name of ['unlinked_set', 'unlinked_too']) {
285+
paths.push(await writeSetFile(name));
286+
expect(await catalog.resolve('permission', name)).toMatchObject({ name });
287+
}
288+
289+
for (const filePath of paths) {
290+
await fs.rm(filePath);
291+
await fireFileEvent(mgr, 'deleted', filePath);
292+
}
293+
294+
// Each door asked FIRST, while the memo still holds its path: `load()`
295+
// (through the catalog read) for one name, `exists()` for the other.
296+
expect(await catalog.resolve('permission', 'unlinked_set')).toBeUndefined();
297+
expect(await mgr.exists('permission', 'unlinked_too')).toBe(false);
298+
expect(await catalog.resolve('permission', 'unlinked_set')).toBeUndefined();
299+
expect(statFailureLogs()).toEqual([]);
300+
});
301+
});
302+
303+
describe('#24537 — changes no event reports', () => {
304+
it('a deletion nothing reported: the remembered path is re-resolved from disk, whatever it holds now', async () => {
305+
const jsonPath = await writeSetFile('moved_set', permissionSet('moved_set', 'from json'));
306+
const mgr = makeManager();
307+
const catalog = makeCatalog(mgr);
308+
expect((await catalog.resolve('permission', 'moved_set'))?.definition).toMatchObject({ label: 'from json' });
309+
310+
await fs.rm(jsonPath);
311+
await writeSetFile('moved_set', permissionSet('moved_set', 'from yaml'), '.yaml');
312+
313+
expect((await catalog.resolve('permission', 'moved_set'))?.definition).toMatchObject({ label: 'from yaml' });
314+
expect(await mgr.exists('permission', 'moved_set')).toBe(true);
315+
expect(statFailureLogs()).toEqual([]);
316+
});
317+
318+
it('RECORD: a file created with no watcher is unseen by a remembered miss until the next walk of its type', async () => {
319+
const mgr = makeManager();
320+
const catalog = makeCatalog(mgr);
321+
expect(await catalog.resolve('permission', 'dropped_set')).toBeUndefined();
322+
323+
await writeSetFile('dropped_set');
324+
325+
// The named gap: nothing reported the file, so the remembered miss stands.
326+
expect(await catalog.resolve('permission', 'dropped_set')).toBeUndefined();
327+
328+
// The walk behind `list()` sees the file — and retires the type, so the
329+
// name it lists is not answered `null` by the door.
330+
expect((await mgr.list('permission')).map((item) => (item as { name: string }).name)).toEqual(['dropped_set']);
331+
expect(await catalog.resolve('permission', 'dropped_set')).toMatchObject({ name: 'dropped_set' });
332+
});
333+
});
334+
335+
describe('#24537 — what the memo holds', () => {
336+
const jsonOnly = () =>
337+
new FilesystemLoader(rootDir, new Map<MetadataFormat, MetadataSerializer>([['json', new JSONSerializer()]]), logger as never);
338+
339+
it('a probe that was on disk when a save retired the memo does not write its older miss back', async () => {
340+
const loader = jsonOnly();
341+
const target = path.join(rootDir, 'permission', 'late_set.json');
342+
let reached!: () => void;
343+
let release!: () => void;
344+
const onDisk = new Promise<void>((resolve) => { reached = resolve; });
345+
probes.hold = { path: target, reached, release: new Promise<void>((resolve) => { release = resolve; }) };
346+
347+
// This load's probe has already read "absent" when the save lands.
348+
const pending = loader.load('permission', 'late_set');
349+
await onDisk;
350+
await loader.save('permission', 'late_set', permissionSet('late_set'), { format: 'json' });
351+
release();
352+
expect((await pending).data).toBeNull();
353+
354+
expect((await loader.load('permission', 'late_set')).data).toEqual(permissionSet('late_set'));
355+
});
356+
357+
it('is bounded: past the cap it starts over rather than growing with every name a caller asks for', async () => {
358+
const loader = jsonOnly();
359+
const cap = 'RESOLUTION_MEMO_CAP';
360+
const original = Reflect.get(FilesystemLoader, cap);
361+
Reflect.set(FilesystemLoader, cap, 3);
362+
try {
363+
const mark = { at: probes.paths.length };
364+
for (const name of ['a', 'b', 'c']) await loader.exists('permission', name);
365+
probesSince(mark);
366+
367+
await loader.exists('permission', 'a');
368+
expect(probesSince(mark)).toBe(0); // three remembered, `a` among them
369+
370+
await loader.exists('permission', 'd'); // the fourth: starts over
371+
probesSince(mark);
372+
await loader.exists('permission', 'a');
373+
expect(probesSince(mark)).toBe(1); // `a` was dropped with the rest
374+
} finally {
375+
Reflect.set(FilesystemLoader, cap, original);
376+
}
377+
});
378+
});

0 commit comments

Comments
 (0)