|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * #7000 — attaching a `FileSystemRepository` must leave nothing on disk. |
| 5 | + * |
| 6 | + * This is the filesystem half of the property #6743 ruled on: a dry run |
| 7 | + * leaves nothing behind. #6743 / PR #6997 covered `.objectstack/data/` (the |
| 8 | + * sqlite file the driver brought into existence at open); the half left over |
| 9 | + * is the metadata repository, whose `start()` used to `mkdir` both the root |
| 10 | + * and `<root>/.objectstack/.log` unconditionally. `os migrate plan` on a |
| 11 | + * project that has never been started therefore left a |
| 12 | + * `.objectstack/metadata/.objectstack/.log` skeleton behind, and the presence |
| 13 | + * of `.objectstack/` stopped being a usable "this project has been started" |
| 14 | + * signal. |
| 15 | + * |
| 16 | + * The pin is written the same way #6743's is: assert the ABSENCE of the side |
| 17 | + * effect on a fresh fixture, and carry a control that proves the fixture is |
| 18 | + * genuine — here, that the very same repository is attachable, readable and |
| 19 | + * writable, so the absence is not the uninteresting kind produced by a boot |
| 20 | + * that failed early. |
| 21 | + */ |
| 22 | + |
| 23 | +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; |
| 24 | +import fs from 'node:fs/promises'; |
| 25 | +import { existsSync, readdirSync } from 'node:fs'; |
| 26 | +import path from 'node:path'; |
| 27 | +import os from 'node:os'; |
| 28 | +import type { MetaRef, MetadataEvent, MetadataItemHeader } from '@objectstack/metadata-core'; |
| 29 | +import { FileSystemRepository } from '../src/index.js'; |
| 30 | + |
| 31 | +const ref = (name: string): MetaRef => ({ org: 'system', type: 'view', name }); |
| 32 | +const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms)); |
| 33 | + |
| 34 | +describe('FileSystemRepository — attaching creates nothing on disk (#7000)', () => { |
| 35 | + /** Stands in for the project directory: nothing below it exists yet. */ |
| 36 | + let base: string; |
| 37 | + /** Stands in for `<project>/.objectstack/metadata` — deliberately absent. */ |
| 38 | + let root: string; |
| 39 | + let repo: FileSystemRepository | undefined; |
| 40 | + |
| 41 | + beforeEach(async () => { |
| 42 | + base = await fs.mkdtemp(path.join(os.tmpdir(), 'objectstack-fs7000-')); |
| 43 | + root = path.join(base, '.objectstack', 'metadata'); |
| 44 | + }); |
| 45 | + |
| 46 | + afterEach(async () => { |
| 47 | + if (repo) await repo.close().catch(() => undefined); |
| 48 | + repo = undefined; |
| 49 | + await fs.rm(base, { recursive: true, force: true }); |
| 50 | + }); |
| 51 | + |
| 52 | + /** |
| 53 | + * Every path anywhere below the project directory. Naming only the two |
| 54 | + * paths the old `mkdir`s created would miss a third one added later, so the |
| 55 | + * pin sweeps the whole fixture instead. |
| 56 | + */ |
| 57 | + function residueOnDisk(): string[] { |
| 58 | + const found: string[] = []; |
| 59 | + const walk = (dir: string, depth: number): void => { |
| 60 | + if (depth > 5) return; |
| 61 | + for (const entry of readdirSync(dir, { withFileTypes: true })) { |
| 62 | + const full = path.join(dir, entry.name); |
| 63 | + found.push(path.relative(base, full)); |
| 64 | + if (entry.isDirectory()) walk(full, depth + 1); |
| 65 | + } |
| 66 | + }; |
| 67 | + walk(base, 0); |
| 68 | + return found.sort(); |
| 69 | + } |
| 70 | + |
| 71 | + it('start() on a never-started project creates neither the root nor .objectstack/.log', async () => { |
| 72 | + expect(existsSync(root)).toBe(false); |
| 73 | + |
| 74 | + repo = new FileSystemRepository({ root, org: 'system', disableWatch: true }); |
| 75 | + await repo.start(); |
| 76 | + |
| 77 | + expect(existsSync(root)).toBe(false); |
| 78 | + expect(existsSync(path.join(root, '.objectstack', '.log'))).toBe(false); |
| 79 | + expect(residueOnDisk()).toEqual([]); |
| 80 | + |
| 81 | + // Detach is not a write either. |
| 82 | + await repo.close(); |
| 83 | + expect(residueOnDisk()).toEqual([]); |
| 84 | + }); |
| 85 | + |
| 86 | + it('an absent root is attachable and reads as an empty repository', async () => { |
| 87 | + repo = new FileSystemRepository({ root, org: 'system', disableWatch: true }); |
| 88 | + await repo.start(); |
| 89 | + |
| 90 | + expect(await repo.get(ref('case_grid'))).toBeNull(); |
| 91 | + expect(await repo.getByHash(ref('case_grid'), 'not-a-real-hash')).toBeNull(); |
| 92 | + |
| 93 | + const listed: MetadataItemHeader[] = []; |
| 94 | + for await (const header of repo.list({ org: 'system' })) listed.push(header); |
| 95 | + expect(listed).toEqual([]); |
| 96 | + |
| 97 | + const past: MetadataEvent[] = []; |
| 98 | + for await (const evt of repo.history(ref('case_grid'))) past.push(evt); |
| 99 | + expect(past).toEqual([]); |
| 100 | + |
| 101 | + // No read may be paid for with a directory. |
| 102 | + expect(residueOnDisk()).toEqual([]); |
| 103 | + }); |
| 104 | + |
| 105 | + it('the FIRST write materializes the root, the body file and the change log', async () => { |
| 106 | + repo = new FileSystemRepository({ root, org: 'system', disableWatch: true }); |
| 107 | + await repo.start(); |
| 108 | + expect(existsSync(root)).toBe(false); |
| 109 | + |
| 110 | + await repo.put(ref('case_grid'), { label: 'Cases' }, { parentVersion: null, actor: 'tester' }); |
| 111 | + |
| 112 | + expect(existsSync(root)).toBe(true); |
| 113 | + expect(existsSync(path.join(root, 'view', 'case_grid.json'))).toBe(true); |
| 114 | + expect(existsSync(path.join(root, '.objectstack', '.log', 'main.jsonl'))).toBe(true); |
| 115 | + |
| 116 | + // The deferred root is a real repository, not a half-built one: a fresh |
| 117 | + // attach over the same path reads the item and its log back. |
| 118 | + await repo.close(); |
| 119 | + repo = new FileSystemRepository({ root, org: 'system', disableWatch: true }); |
| 120 | + await repo.start(); |
| 121 | + const item = await repo.get(ref('case_grid')); |
| 122 | + expect(item?.body).toEqual({ label: 'Cases' }); |
| 123 | + expect(item?.seq).toBe(1); |
| 124 | + |
| 125 | + // And seq numbering continues from the log the deferred root wrote. |
| 126 | + const second = await repo.put( |
| 127 | + ref('case_grid'), |
| 128 | + { label: 'Cases (renamed)' }, |
| 129 | + { parentVersion: item!.hash, actor: 'tester' }, |
| 130 | + ); |
| 131 | + expect(second.seq).toBe(2); |
| 132 | + }); |
| 133 | + |
| 134 | + it('arms the watcher on the first write, so external edits are still detected', async () => { |
| 135 | + // The one property that dropping the `mkdir` could have silently killed: |
| 136 | + // chokidar cannot watch a path that does not exist yet, so a watcher armed |
| 137 | + // by `start()` against an absent root would never fire again. `ensureRoot` |
| 138 | + // arms it at the moment the root appears — this is that guard. |
| 139 | + // |
| 140 | + // The root here is deliberately NOT under a dot-directory. The watcher's |
| 141 | + // own `ignored: [/(^|[\\/])\../]` matches ANY dot segment of the path, |
| 142 | + // including segments of the watched root itself, so a root such as the |
| 143 | + // plugin's `<project>/.objectstack/metadata` is ignored wholesale and |
| 144 | + // chokidar watches nothing at all. That is pre-existing on `origin/main` |
| 145 | + // and out of scope here (filed separately); this case pins the arming, so |
| 146 | + // it uses a root the watcher can actually see. |
| 147 | + const plainRoot = path.join(base, 'project', 'metadata'); |
| 148 | + repo = new FileSystemRepository({ root: plainRoot, org: 'system' }); // watcher ENABLED |
| 149 | + await repo.start(); |
| 150 | + expect(existsSync(plainRoot)).toBe(false); |
| 151 | + |
| 152 | + await repo.put(ref('externally_edited'), { label: 'original' }, { parentVersion: null, actor: 'tester' }); |
| 153 | + expect(existsSync(plainRoot)).toBe(true); |
| 154 | + |
| 155 | + const iter = repo.watch({ org: 'system' }, 999)[Symbol.asyncIterator](); |
| 156 | + const collected: MetadataEvent[] = []; |
| 157 | + const collector = (async () => { |
| 158 | + const r = await iter.next(); |
| 159 | + if (!r.done) collected.push(r.value as MetadataEvent); |
| 160 | + })(); |
| 161 | + |
| 162 | + // Past the 200ms self-write suppression window of the put above. |
| 163 | + await sleep(400); |
| 164 | + await fs.writeFile( |
| 165 | + path.join(plainRoot, 'view', 'externally_edited.json'), |
| 166 | + JSON.stringify({ label: 'externally edited' }, null, 2), |
| 167 | + ); |
| 168 | + |
| 169 | + await Promise.race([collector, sleep(8000)]); |
| 170 | + await iter.return?.(undefined); |
| 171 | + |
| 172 | + expect(collected).toHaveLength(1); |
| 173 | + expect(collected[0]!.op).toBe('update'); |
| 174 | + expect(collected[0]!.source).toBe('fs'); |
| 175 | + expect(collected[0]!.actor).toBe('fs'); |
| 176 | + }, 20_000); |
| 177 | +}); |
0 commit comments