Skip to content

Commit 53eca2e

Browse files
os-helpclaude
andauthored
fix(metadata-fs): scope the watcher's dotfile ignore to paths relative to the root (#7150) (#7208)
`MetadataPlugin` attaches the FileSystemRepository at `<project>/.objectstack/metadata` (REPO_SUBDIR). The watcher's `ignored` matcher was a bare dotfile regex, and chokidar applies that matcher to the watched root path itself, not only to entries discovered underneath it — so the `.objectstack` segment of the root matched and the entire watch was inert. Measured on chokidar 5 with the repository's own watch options, two identical trees differing only in whether the root sits under a dot-directory: plain root getWatched: ['<root>', 'view'] events: add + change dot root getWatched: [] events: none The matcher is now a function evaluated against the path RELATIVE to the watch root, so dot segments belonging to the root itself are never considered while dotfiles under the root — including the repository's own `.objectstack/` bookkeeping subtree — stay ignored as before. Claude-Session: https://claude.ai/code/session_016R9de1FqP7NvwKvqXi92Gh Co-authored-by: Claude <noreply@anthropic.com>
1 parent 0caf122 commit 53eca2e

4 files changed

Lines changed: 295 additions & 2 deletions

File tree

.changeset/quiet-moons-search.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@objectstack/metadata-fs': patch
3+
---
4+
5+
fix(metadata-fs): the `FileSystemRepository` watcher now sees external edits in the production layout
6+
7+
`MetadataPlugin` attaches the repository at `<project>/.objectstack/metadata`, and the
8+
watcher's `ignored` matcher was a bare dotfile regex. chokidar applies that matcher to the
9+
watched root path itself, not only to entries found underneath it, so the `.objectstack`
10+
segment of the root matched and the entire watch was inert — `getWatched()` returned `{}`
11+
and no event ever fired. Hand edits, a `git checkout` that brings metadata JSON in, and any
12+
other out-of-process writer under `.objectstack/metadata/` were invisible until the next
13+
`start()`, even though `MetadataManager.setRepository()` is wired to those events and uses
14+
them to invalidate the registry and the `list()` cache.
15+
16+
The matcher is now evaluated against the path *relative* to the watch root, so dot segments
17+
belonging to the root itself are never considered while dotfiles under the root — including
18+
the repository's own `.objectstack/` bookkeeping subtree — stay ignored as before.

packages/metadata-fs/src/repository.ts

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -434,8 +434,12 @@ export class FileSystemRepository implements MetadataRepository {
434434
}
435435

436436
private startWatcher(): void {
437-
const w = chokidar.watch(this.layout.root, {
438-
ignored: [/(^|[\\/])\../], // skip dotfiles incl. .objectstack
437+
const root = this.layout.root;
438+
const w = chokidar.watch(root, {
439+
// Skip dotfiles under the root — including the repository's own
440+
// `.objectstack/` bookkeeping subtree — matched on the path RELATIVE
441+
// to the watch root (#7150). See `isIgnoredWatchPath`.
442+
ignored: (p: string) => isIgnoredWatchPath(root, p),
439443
ignoreInitial: true,
440444
depth: 2,
441445
awaitWriteFinish: { stabilityThreshold: 50, pollInterval: 20 },
@@ -507,6 +511,43 @@ export class FileSystemRepository implements MetadataRepository {
507511

508512
// ── Utilities ─────────────────────────────────────────────────────────
509513

514+
/**
515+
* Watcher ignore matcher — "everything under the root, except the
516+
* repository's own bookkeeping" (#7150).
517+
*
518+
* chokidar hands its matcher **absolute** paths, and applies it to the
519+
* watched root itself as well as to entries discovered underneath it. The
520+
* previous matcher was a bare dotfile regex (`/(^|[\\/])\../`), which
521+
* therefore matched the `.objectstack` segment of the root path the plugin
522+
* actually uses (`<project>/.objectstack/metadata`, `REPO_SUBDIR` in
523+
* `packages/metadata/src/plugin.ts`) and ignored the whole watch. Measured on
524+
* chokidar 5 with this repository's own options, two identical trees
525+
* differing only in whether the root sits under a dot-directory:
526+
*
527+
* plain root getWatched: ['<root>', 'view'] events: add+change
528+
* dot-rooted getWatched: [] events: none
529+
*
530+
* So the intent is kept and only the *frame of reference* is fixed: judge the
531+
* path relative to the root, so dot segments belonging to the root itself are
532+
* never considered.
533+
*
534+
* Why not drop the matcher entirely and lean on `parseItemPath`, which already
535+
* rejects `.objectstack`? Measured: `parseItemPath` rejects that ONE name, so
536+
* a dot-directory at the type level leaks — `<root>/.cache/x.json` parses as
537+
* type `.cache`, and `<root>/view/.scratch.json` as an item named `.scratch`.
538+
* Both would be published as `MetadataEvent`s while `scanHeads` skips every
539+
* dot entry on boot, leaving the boot scan and the watcher disagreeing about
540+
* what the repository contains. Dropping it also puts `.objectstack/.log/` in
541+
* the poll set, so every one of the repository's own log appends wakes
542+
* `handleFsChange` only to be discarded.
543+
*/
544+
function isIgnoredWatchPath(root: string, absPath: string): boolean {
545+
const rel = path.relative(root, absPath);
546+
// The watched root itself, and anything outside it, are not ours to judge.
547+
if (rel === '' || rel.startsWith('..')) return false;
548+
return rel.split(/[\\/]/).some((segment) => segment.startsWith('.'));
549+
}
550+
510551
async function readJson(file: string): Promise<unknown | null> {
511552
try {
512553
const text = await fs.readFile(file, 'utf8');
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #7150 — the watcher must see changes under a **dot-rooted** watch root.
5+
*
6+
* `MetadataPlugin` attaches the repository at `REPO_SUBDIR =
7+
* '.objectstack/metadata'` under the project root, so in the layout the
8+
* product actually ships the watched root path contains a dot segment.
9+
* chokidar applies its `ignored` matcher to the watched root itself, not only
10+
* to entries found underneath it, so the old bare dotfile regex
11+
* (`/(^|[\\/])\../`) matched that segment and the whole watch was inert:
12+
* `getWatched()` returned `{}` and no event ever fired. The consumer side is
13+
* live code — `MetadataManager.setRepository()` subscribes to `repo.watch({})`
14+
* and invalidates the registry and `list()` cache per event — so the source
15+
* could never fire for the layout that is in use.
16+
*
17+
* The two cases below are the two halves of the watcher's promise, which the
18+
* fix has to satisfy together: **see everything under the root** (case 1) and
19+
* **ignore the repository's own bookkeeping** (case 2). A fix that only
20+
* widened the matcher would pass case 1 and fail case 2.
21+
*
22+
* Sibling pin: `no-root-on-attach.test.ts` deliberately uses a root that is
23+
* NOT under a dot-directory, because it measures watcher *arming* (#7000).
24+
* Do not repurpose it — it has to keep measuring what it measures.
25+
*/
26+
27+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
28+
import fs from 'node:fs/promises';
29+
import path from 'node:path';
30+
import os from 'node:os';
31+
import type { MetaRef, MetadataEvent } from '@objectstack/metadata-core';
32+
import { FileSystemRepository } from '../src/index.js';
33+
34+
const ref = (name: string): MetaRef => ({ org: 'system', type: 'view', name });
35+
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
36+
37+
describe('FileSystemRepository watcher — dot-rooted watch root (#7150)', () => {
38+
/** Stands in for the project directory. */
39+
let base: string;
40+
/** Stands in for `<project>/.objectstack/metadata` — the production layout. */
41+
let root: string;
42+
let repo: FileSystemRepository | undefined;
43+
44+
beforeEach(async () => {
45+
base = await fs.mkdtemp(path.join(os.tmpdir(), 'objectstack-fs7150-'));
46+
root = path.join(base, '.objectstack', 'metadata');
47+
});
48+
49+
afterEach(async () => {
50+
if (repo) await repo.close().catch(() => undefined);
51+
repo = undefined;
52+
await fs.rm(base, { recursive: true, force: true });
53+
});
54+
55+
/**
56+
* Drain `repo.watch()` into an array for the life of the case. `since: 999`
57+
* skips log replay, so everything collected here came from the watcher.
58+
*/
59+
function collectEvents(r: FileSystemRepository): {
60+
events: MetadataEvent[];
61+
first: Promise<void>;
62+
stop: () => Promise<void>;
63+
} {
64+
const iter = r.watch({ org: 'system' }, 999)[Symbol.asyncIterator]();
65+
const events: MetadataEvent[] = [];
66+
let resolveFirst!: () => void;
67+
const first = new Promise<void>((res) => { resolveFirst = res; });
68+
let stopped = false;
69+
void (async () => {
70+
while (!stopped) {
71+
const next = await iter.next();
72+
if (next.done) return;
73+
events.push(next.value as MetadataEvent);
74+
resolveFirst();
75+
}
76+
})();
77+
return {
78+
events,
79+
first,
80+
stop: async () => { stopped = true; await iter.return?.(undefined); },
81+
};
82+
}
83+
84+
it('sees an external edit when the root is under a dot-directory', async () => {
85+
repo = new FileSystemRepository({ root, org: 'system' }); // watcher ENABLED
86+
await repo.start();
87+
88+
// The first write materializes the root and arms the watcher (#7000).
89+
await repo.put(ref('case_grid'), { label: 'original' }, {
90+
parentVersion: null,
91+
actor: 'tester',
92+
});
93+
94+
const sink = collectEvents(repo);
95+
// Past the 200ms self-write suppression window of the put above.
96+
await sleep(400);
97+
98+
// An out-of-process writer: a hand edit, or a `git checkout` bringing
99+
// metadata JSON in. Before the fix this produced nothing, ever.
100+
await fs.writeFile(
101+
path.join(root, 'view', 'case_grid.json'),
102+
JSON.stringify({ label: 'externally edited' }, null, 2),
103+
);
104+
105+
await Promise.race([sink.first, sleep(8000)]);
106+
await sink.stop();
107+
108+
expect(sink.events).toHaveLength(1);
109+
expect(sink.events[0]!.op).toBe('update');
110+
expect(sink.events[0]!.ref.name).toBe('case_grid');
111+
expect(sink.events[0]!.source).toBe('fs');
112+
expect(sink.events[0]!.actor).toBe('fs');
113+
}, 30_000);
114+
115+
it('still ignores dot entries UNDER the root, including its own bookkeeping', async () => {
116+
repo = new FileSystemRepository({ root, org: 'system' }); // watcher ENABLED
117+
await repo.start();
118+
await repo.put(ref('seed'), { label: 'seed' }, { parentVersion: null, actor: 'tester' });
119+
120+
const sink = collectEvents(repo);
121+
await sleep(400);
122+
123+
// Noise that must stay invisible. `.cache/x.json` and `view/.scratch.json`
124+
// are the measured leaks of "drop the matcher and let `parseItemPath`
125+
// decide": that guard rejects the single name `.objectstack`, so these two
126+
// parse as type `.cache` and as an item named `.scratch` respectively —
127+
// while `scanHeads` skips every dot entry on boot. `.objectstack/.log/`
128+
// is the repository's own change log.
129+
await fs.mkdir(path.join(root, '.cache'), { recursive: true });
130+
await fs.writeFile(path.join(root, '.cache', 'x.json'), '{"junk":true}');
131+
await fs.writeFile(path.join(root, 'view', '.scratch.json'), '{"junk":true}');
132+
await fs.appendFile(
133+
path.join(root, '.objectstack', '.log', 'main.jsonl'),
134+
'{"not":"an event"}\n',
135+
);
136+
137+
// Give the 1s poll several turns to deliver anything it was going to.
138+
await sleep(4000);
139+
expect(sink.events).toEqual([]);
140+
141+
// Control: the watcher is genuinely alive, so the emptiness above is the
142+
// interesting kind and not a watcher that never armed.
143+
await fs.writeFile(
144+
path.join(root, 'view', 'seed.json'),
145+
JSON.stringify({ label: 'really edited' }, null, 2),
146+
);
147+
await Promise.race([sink.first, sleep(8000)]);
148+
await sink.stop();
149+
150+
expect(sink.events).toHaveLength(1);
151+
expect(sink.events[0]!.ref.name).toBe('seed');
152+
expect(sink.events[0]!.op).toBe('update');
153+
}, 30_000);
154+
});
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #7150, consumer half — an external edit under the **production** metadata
5+
* layout must reach `MetadataManager` and drop its caches.
6+
*
7+
* `MetadataPlugin` attaches a `FileSystemRepository` rooted at
8+
* `<project>/.objectstack/metadata` (`REPO_SUBDIR`), and `setRepository()`
9+
* subscribes to `repo.watch({})`, re-emits each event through
10+
* `notifyWatchers`, and invalidates the registry entry plus the `list()`
11+
* cache for the affected type. That whole chain was dead in this layout: the
12+
* repository's chokidar `ignored` matcher was a bare dotfile regex, chokidar
13+
* applies it to the watched root path itself, and the root has a
14+
* `.objectstack` segment — so the source end never fired.
15+
*
16+
* `metadata-fs`'s own `watch-dot-root.test.ts` pins the repository end. This
17+
* case is the end-to-end one: a real `FileSystemRepository` on a real
18+
* dot-rooted temp dir, a real out-of-process write, and the assertion made at
19+
* the consumer — the seam that a reader of `metadata-fs` alone cannot see is
20+
* load-bearing.
21+
*/
22+
23+
import { describe, it, expect } from 'vitest';
24+
import fs from 'node:fs/promises';
25+
import path from 'node:path';
26+
import os from 'node:os';
27+
import type { MetaRef } from '@objectstack/metadata-core';
28+
import type { MetadataWatchEvent } from '@objectstack/spec/system';
29+
import { FileSystemRepository } from '@objectstack/metadata-fs';
30+
import { MetadataManager } from './metadata-manager.js';
31+
32+
const ref = (name: string): MetaRef => ({ org: 'system', type: 'view' as never, name });
33+
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
34+
35+
describe('MetadataManager ← FileSystemRepository under `.objectstack/metadata` (#7150)', () => {
36+
it('an out-of-process write reaches subscribe() in the dot-rooted layout', async () => {
37+
const base = await fs.mkdtemp(path.join(os.tmpdir(), 'objectstack-md7150-'));
38+
// Exactly what `MetadataPlugin` builds: `path.join(rootDir, REPO_SUBDIR)`.
39+
const root = path.join(base, '.objectstack', 'metadata');
40+
41+
const repo = new FileSystemRepository({ root, org: 'system' }); // watcher ENABLED
42+
const mgr = new MetadataManager({ formats: ['json'] });
43+
try {
44+
await repo.start();
45+
// The first write materializes the root and arms the watcher (#7000).
46+
await repo.put(ref('case_grid'), { name: 'case_grid', label: 'Cases' }, {
47+
parentVersion: null,
48+
actor: 'tester',
49+
});
50+
51+
const seen: MetadataWatchEvent[] = [];
52+
mgr.setRepository(repo);
53+
mgr.subscribe('view', (evt) => { seen.push(evt); });
54+
55+
// Past the repository's 200ms self-write suppression window.
56+
await sleep(400);
57+
const before = seen.length;
58+
59+
// The out-of-process writer: a hand edit, or a `git checkout` bringing
60+
// metadata JSON in. This is what was invisible until the next start().
61+
await fs.writeFile(
62+
path.join(root, 'view', 'case_grid.json'),
63+
JSON.stringify({ name: 'case_grid', label: 'Cases (edited on disk)' }, null, 2),
64+
);
65+
66+
const deadline = Date.now() + 15_000;
67+
while (seen.length === before && Date.now() < deadline) await sleep(100);
68+
69+
const fresh = seen.slice(before);
70+
expect(fresh).toHaveLength(1);
71+
expect(fresh[0]!.type).toBe('changed');
72+
expect(fresh[0]!.metadataType).toBe('view');
73+
expect(fresh[0]!.name).toBe('case_grid');
74+
} finally {
75+
await mgr.dispose().catch(() => undefined);
76+
await repo.close().catch(() => undefined);
77+
await fs.rm(base, { recursive: true, force: true });
78+
}
79+
}, 40_000);
80+
});

0 commit comments

Comments
 (0)