mellos-mapping 0.24.0 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/docs/map-api.md CHANGED
@@ -64,20 +64,35 @@ a group does not silently move members: include their intended node moves in
64
64
  the same update or transaction. Normal single-field changes keep the old graph
65
65
  constraints; use a batch for changes that require a coordinated final graph.
66
66
 
67
- Every graph writer in the current MCP, HTTP viewer and watcher uses a cooperative
68
- cross-process project lock. MCP expectedRevision is compared inside that lock
69
- before loading the proposed changes into the saved map. `CONFLICT` requires a
70
- fresh read and reconsideration of the edit. `BUSY` means another transaction is
71
- active; retry after it completes. There is no background lock polling. Locks are
72
- released on normal completion/error; a confirmed dead PID can be recovered.
73
- An incomplete owner file after an abrupt crash is refused for manual inspection,
74
- not guessed stale from its age.
67
+ Every graph writer in the current MCP, HTTP viewer and watcher uses the same
68
+ `withStoreLock` boundary. It takes a non-blocking OS exclusive lock on one fixed
69
+ regular file, `.mellos/.write-lock`; named pages and the default page share it.
70
+ That file is separate from the map JSON files and is never removed or renamed
71
+ as part of locking. The read, revision check, change and save happen while the
72
+ lock is held.
73
+ Contention returns `BUSY`; retry after the other operation completes. There is
74
+ no background lock polling. `LOCK_MIGRATION_REQUIRED` means the lock path is
75
+ still a directory from the old protocol; `LOCK_UNAVAILABLE` means the required
76
+ native binding or supported platform is unavailable. Neither error permits an
77
+ unlocked write. Normal completion/error releases the lock, and the OS also
78
+ releases it when the owning process exits or crashes. A leftover lock
79
+ file is expected and does not indicate a live or stale owner.
80
+
81
+ MCP `expectedRevision` is optional. When supplied, it is compared inside the
82
+ lock; `CONFLICT` requires a fresh read and reconsideration of the edit. Omitting
83
+ it still serializes the operation against other writers, but does not check
84
+ whether its inputs came from an older read. A later update can overwrite the
85
+ same field or replace an entire `context` or `sources` value. Pass the revision
86
+ from the page read whenever an edit depends on that read.
75
87
 
76
88
  Low-level library saveMapFile, hand edits, and older running processes do not
77
- participate in this contract. Restart MCP processes and native watchers after
78
- upgrading. Opening a Web surface upgrades services that lack format-2 support;
79
- existing tabs must reconnect with the newly returned URL. Atomic file
80
- replacement alone does not make a caller's stale read/modify/write safe.
89
+ participate in this contract. Atomic file replacement alone does not make a
90
+ caller's stale read/modify/write safe. Stop all old MCP, HTTP viewer and watcher
91
+ processes for the project before upgrading; restart the HTTP service as well
92
+ as reopening its tabs. A leftover old lock directory requires the manual
93
+ [migration steps](locking.md#upgrade-from-directory-locks). There is no hot
94
+ upgrade of the lock protocol, and supporting map format 2 alone does not
95
+ establish that all writers use the new lock.
81
96
 
82
97
  Legacy `mmap_remove {pages:[...]}` remains a separately documented batch of file
83
98
  deletions, with explicit partial success and historical reference behavior. It
@@ -106,7 +121,8 @@ and the page is saved once; a refusal preserves the original file bytes.
106
121
  ```
107
122
 
108
123
  Machine-readable error codes include NOT_FOUND, INVALID_STORE, REFUSED,
109
- CONFLICT, BUSY, INVALID_CURSOR, INVALID_ARGUMENT, REFERENCED and SAVE_FAILED.
124
+ CONFLICT, BUSY, LOCK_MIGRATION_REQUIRED, LOCK_UNAVAILABLE, INVALID_CURSOR,
125
+ INVALID_ARGUMENT, REFERENCED and SAVE_FAILED.
110
126
  Malformed schema inputs remain MCP invalid-argument errors. Read calls do not
111
127
  open panels or create page files.
112
128
 
@@ -1,7 +1,8 @@
1
- import { existsSync, mkdirSync, renameSync } from 'node:fs';
1
+ import { existsSync, renameSync } from 'node:fs';
2
2
  import { dirname, join } from 'node:path';
3
3
  import { PAGES_DIR_NAME } from './pages.js';
4
4
  import { configFilePath } from './policy.js';
5
+ import { withStoreLock } from './transaction.js';
5
6
  // ---------------------------------------------------------------------------
6
7
  // legacy migration — stores written under the old host-coupled location
7
8
  // ---------------------------------------------------------------------------
@@ -29,18 +30,24 @@ export function migrateLegacyStore(defaultFile) {
29
30
  const legacyDefault = join(projectRoot, LEGACY_STATE_FILE_RELATIVE_PATH);
30
31
  const legacyPages = join(dirname(legacyDefault), LEGACY_PAGES_DIR_NAME);
31
32
  const legacyConfig = join(dirname(legacyDefault), LEGACY_CONFIG_FILE_NAME);
32
- const hasLegacy = existsSync(legacyDefault) || existsSync(legacyPages) || existsSync(legacyConfig);
33
- const hasCurrent = existsSync(defaultFile)
33
+ const hasLegacy = () => existsSync(legacyDefault) || existsSync(legacyPages) || existsSync(legacyConfig);
34
+ const hasCurrent = () => existsSync(defaultFile)
34
35
  || existsSync(join(dirname(defaultFile), PAGES_DIR_NAME))
35
36
  || existsSync(configFilePath(defaultFile));
36
- if (!hasLegacy || hasCurrent)
37
+ if (!hasLegacy() || hasCurrent())
37
38
  return false;
38
- mkdirSync(dirname(defaultFile), { recursive: true });
39
- if (existsSync(legacyDefault))
40
- renameSync(legacyDefault, defaultFile);
41
- if (existsSync(legacyPages))
42
- renameSync(legacyPages, join(dirname(defaultFile), PAGES_DIR_NAME));
43
- if (existsSync(legacyConfig))
44
- renameSync(legacyConfig, configFilePath(defaultFile));
45
- return true;
39
+ return withStoreLock(defaultFile, () => {
40
+ // A writer or another startup may have committed after the fast checks.
41
+ // Recheck under the same lock used by all current project mutations;
42
+ // rename is allowed to replace its destination on every supported OS.
43
+ if (!hasLegacy() || hasCurrent())
44
+ return false;
45
+ if (existsSync(legacyDefault))
46
+ renameSync(legacyDefault, defaultFile);
47
+ if (existsSync(legacyPages))
48
+ renameSync(legacyPages, join(dirname(defaultFile), PAGES_DIR_NAME));
49
+ if (existsSync(legacyConfig))
50
+ renameSync(legacyConfig, configFilePath(defaultFile));
51
+ return true;
52
+ });
46
53
  }
@@ -0,0 +1,5 @@
1
+ export interface NativeLock {
2
+ tryLock(fd: number): boolean;
3
+ unlock(fd: number): void;
4
+ }
5
+ export declare function nativeLock(): NativeLock;
@@ -0,0 +1,27 @@
1
+ /** Load the shipped N-API implementation without a runtime npm dependency. */
2
+ import { existsSync } from 'node:fs';
3
+ import { createRequire } from 'node:module';
4
+ import { fileURLToPath } from 'node:url';
5
+ let loaded;
6
+ export function nativeLock() {
7
+ if (loaded)
8
+ return loaded;
9
+ if (Number(process.versions.napi ?? 0) < 9) {
10
+ throw new Error('Project locks require Node-API 9 (Node 18.17+ or 20.3+).');
11
+ }
12
+ // Bundled entry points live in dist/; source and npm library modules live
13
+ // two directories below the package root. Never resolve from the user's cwd.
14
+ const candidates = [
15
+ new URL('./native-lock.cjs', import.meta.url),
16
+ new URL('../../dist/native-lock.cjs', import.meta.url),
17
+ ];
18
+ const entry = candidates.find(candidate => existsSync(candidate));
19
+ if (!entry)
20
+ throw new Error('The installed package is missing dist/native-lock.cjs; reinstall the complete package.');
21
+ const backend = createRequire(import.meta.url)(fileURLToPath(entry));
22
+ if (typeof backend.tryLock !== 'function' || typeof backend.unlock !== 'function') {
23
+ throw new Error('The installed native lock backend is invalid.');
24
+ }
25
+ loaded = backend;
26
+ return backend;
27
+ }
@@ -1,4 +1,6 @@
1
1
  import type { MellosMap } from '../domain/types.js';
2
+ /** Viewer health must identify the writer protocol before a new client reuses it. */
3
+ export declare const STORE_LOCK_PROTOCOL = "os-file-v1";
2
4
  export declare class LedgerError extends Error {
3
5
  readonly code: string;
4
6
  readonly details: Record<string, unknown>;
@@ -8,5 +10,10 @@ export declare const revisionOf: (map: MellosMap) => string;
8
10
  export declare function assertRevision(actual: string, expected?: string): void;
9
11
  /** A named page and the default page share the same project lock. */
10
12
  export declare function storeDirectory(file: string): string;
11
- /** No timed polling: contention is an explicit retryable BUSY result. Never steal a live lock. */
12
- export declare function withStoreLock<T>(file: string, action: () => T): T;
13
+ /**
14
+ * A synchronous transaction on a permanent, independent OS lock file.
15
+ * The action must finish synchronously; all cooperating writers use this entry.
16
+ * Never unlink/rename this file: another opener must reach the same lock object.
17
+ * Contention is BUSY immediately, with no timer, stale lease or PID recovery.
18
+ */
19
+ export declare function withStoreLock<T>(file: string, action: () => T & (T extends PromiseLike<unknown> ? never : unknown)): T;
@@ -1,8 +1,11 @@
1
1
  /** Cooperative, cross-process transactions for MCP and HTTP writers. */
2
- import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
3
- import { dirname, join } from 'node:path';
4
- import { randomUUID, createHash } from 'node:crypto';
2
+ import { closeSync, fstatSync, lstatSync, mkdirSync, openSync, realpathSync } from 'node:fs';
3
+ import { basename, dirname, join } from 'node:path';
4
+ import { createHash } from 'node:crypto';
5
5
  import { serializeMap } from './format.js';
6
+ import { nativeLock } from './native-lock.js';
7
+ /** Viewer health must identify the writer protocol before a new client reuses it. */
8
+ export const STORE_LOCK_PROTOCOL = 'os-file-v1';
6
9
  export class LedgerError extends Error {
7
10
  code;
8
11
  details;
@@ -20,72 +23,98 @@ export function assertRevision(actual, expected) {
20
23
  /** A named page and the default page share the same project lock. */
21
24
  export function storeDirectory(file) {
22
25
  const dir = dirname(file);
23
- return dir.endsWith('/pages') || dir.endsWith('\\pages') ? dirname(dir) : dir;
24
- }
25
- function deadOwner(lock) {
26
+ // Keep a configured pages directory tied to its project even when that
27
+ // directory itself is a symlink. Case variants must share the lock on
28
+ // Windows and case-insensitive macOS volumes (and are harmless elsewhere).
29
+ if (basename(dir).toLowerCase() === 'pages')
30
+ return dirname(dir);
31
+ let canonical = dir;
26
32
  try {
27
- const owner = JSON.parse(readFileSync(join(lock, 'owner.json'), 'utf8'));
28
- if (!Number.isSafeInteger(owner.pid) || owner.pid <= 0)
29
- return false;
30
- try {
31
- process.kill(owner.pid, 0);
32
- return false;
33
- }
34
- catch (e) {
35
- return e.code === 'ESRCH';
36
- }
33
+ canonical = realpathSync.native(dir);
34
+ }
35
+ catch (error) {
36
+ if (error.code !== 'ENOENT')
37
+ throw error;
37
38
  }
38
- catch {
39
- return false;
39
+ // An explicit --file may reach pages through a differently named alias.
40
+ return basename(canonical).toLowerCase() === 'pages' ? dirname(canonical) : canonical;
41
+ }
42
+ function checkLockPath(lock) {
43
+ const entry = lstatSync(lock, { throwIfNoEntry: false });
44
+ if (entry?.isDirectory()) {
45
+ throw new LedgerError('LOCK_MIGRATION_REQUIRED', `Legacy directory lock at ${lock}. Stop all old MCP servers, viewers and watchers for this project, then move that directory aside for inspection and retry. Never remove the new regular lock file.`, { path: lock });
46
+ }
47
+ if (entry && !entry.isFile()) {
48
+ throw new LedgerError('LOCK_UNAVAILABLE', `Project lock must be a regular file, not a symlink or special file: ${lock}`, { path: lock });
40
49
  }
41
50
  }
42
- /** No timed polling: contention is an explicit retryable BUSY result. Never steal a live lock. */
51
+ /**
52
+ * A synchronous transaction on a permanent, independent OS lock file.
53
+ * The action must finish synchronously; all cooperating writers use this entry.
54
+ * Never unlink/rename this file: another opener must reach the same lock object.
55
+ * Contention is BUSY immediately, with no timer, stale lease or PID recovery.
56
+ */
43
57
  export function withStoreLock(file, action) {
58
+ let backend;
59
+ try {
60
+ backend = nativeLock();
61
+ }
62
+ catch (error) {
63
+ throw new LedgerError('LOCK_UNAVAILABLE', `Cannot load the operating-system lock backend: ${error instanceof Error ? error.message : String(error)}`);
64
+ }
44
65
  const dir = storeDirectory(file);
45
66
  mkdirSync(dir, { recursive: true });
46
67
  const lock = join(dir, '.write-lock');
47
- const owner = join(lock, 'owner.json');
48
- const token = randomUUID();
68
+ checkLockPath(lock);
69
+ let fd;
70
+ // a+ atomically opens/creates without truncation. At a legacy/new startup
71
+ // race, either old mkdir wins (we refuse its directory), or our file wins
72
+ // (old mkdir gets EEXIST and cannot find owner.json, so refuses to write).
49
73
  try {
50
- mkdirSync(lock);
74
+ fd = openSync(lock, 'a+', 0o600);
51
75
  }
52
76
  catch (error) {
53
- if (error.code !== 'EEXIST')
54
- throw error;
55
- // Reapers serialize inside the old directory and recheck its owner after acquiring.
56
- // Missing/invalid owners are deliberately not reclaimed on an age heuristic.
57
- if (!deadOwner(lock))
58
- throw new LedgerError('BUSY', `Another writer owns ${lock}; retry after it completes. An orphan without owner metadata needs manual inspection.`);
77
+ checkLockPath(lock);
78
+ throw new LedgerError('LOCK_UNAVAILABLE', `Cannot open project lock ${lock}: ${error instanceof Error ? error.message : String(error)}`, { path: lock });
79
+ }
80
+ let acquired = false;
81
+ try {
82
+ checkLockPath(lock);
83
+ if (!fstatSync(fd).isFile())
84
+ throw new LedgerError('LOCK_UNAVAILABLE', `Project lock is not a regular file: ${lock}`);
59
85
  try {
60
- mkdirSync(join(lock, '.reap'));
61
- }
62
- catch {
63
- throw new LedgerError('BUSY', 'Another process is recovering the writer lock.');
86
+ acquired = backend.tryLock(fd);
64
87
  }
65
- if (!deadOwner(lock)) {
66
- rmSync(join(lock, '.reap'), { recursive: true, force: true });
67
- throw new LedgerError('BUSY', 'Writer ownership changed.');
88
+ catch (error) {
89
+ throw new LedgerError('LOCK_UNAVAILABLE', `Cannot acquire project lock ${lock}: ${error instanceof Error ? error.message : String(error)}`, { path: lock });
68
90
  }
69
- rmSync(lock, { recursive: true });
91
+ if (!acquired)
92
+ throw new LedgerError('BUSY', `Another writer owns ${lock}; retry after it completes.`);
93
+ return action();
94
+ }
95
+ finally {
96
+ // Closing also releases this descriptor's lock if explicit unlock fails.
97
+ // Cleanup must not misreport an already committed map as unsaved.
70
98
  try {
71
- mkdirSync(lock);
99
+ if (acquired)
100
+ backend.unlock(fd);
72
101
  }
73
102
  catch {
74
- throw new LedgerError('BUSY', 'Another writer acquired the recovered lock.');
103
+ warnLockCleanup(`Could not explicitly unlock ${lock}; closing its file handle.`);
104
+ }
105
+ finally {
106
+ try {
107
+ closeSync(fd);
108
+ }
109
+ catch {
110
+ warnLockCleanup(`Could not close the project lock handle for ${lock}; restart this process before retrying writes.`);
111
+ }
75
112
  }
76
113
  }
77
- let initialized = false;
114
+ }
115
+ function warnLockCleanup(message) {
78
116
  try {
79
- writeFileSync(owner, JSON.stringify({ pid: process.pid, token }), { flag: 'wx' });
80
- initialized = true;
81
- return action();
82
- }
83
- finally {
84
- // Only remove the directory still owned by this invocation.
85
- try {
86
- if (!initialized || JSON.parse(readFileSync(owner, 'utf8')).token === token)
87
- rmSync(lock, { recursive: true });
88
- }
89
- catch { /* A cleanup error must not misreport a successfully committed map as unsaved. */ }
117
+ process.stderr.write(`mellos-mapping: ${message}\n`);
90
118
  }
119
+ catch { /* Preserve the action's result. */ }
91
120
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mellos-mapping",
3
- "version": "0.24.0",
3
+ "version": "0.25.0",
4
4
  "mcpName": "io.github.GuangminJu/mellos-mapping",
5
5
  "description": "A live layered dependency map for bottom-up development — MCP server + terminal pane. Ghost the design first, then light nodes up from the bottom as they are built and verified.",
6
6
  "type": "module",
@@ -71,6 +71,7 @@
71
71
  "README.zh-CN.md",
72
72
  "docs/codex.md",
73
73
  "docs/map-api.md",
74
+ "docs/locking.md",
74
75
  "scripts/codex-register.mjs",
75
76
  "scripts/codex-cli.mjs",
76
77
  "scripts/install-mmap-command.mjs",
@@ -82,7 +83,7 @@
82
83
  "scripts/watcher-command.mjs"
83
84
  ],
84
85
  "engines": {
85
- "node": ">=18"
86
+ "node": "^18.17.0 || >=20.3.0"
86
87
  },
87
88
  "scripts": {
88
89
  "test": "vitest run",
@@ -94,9 +95,10 @@
94
95
  "check:package": "node scripts/check-package-surface.mjs",
95
96
  "check:codex": "node scripts/check-codex-package.mjs",
96
97
  "check:reuse": "node scripts/check-reuse.mjs",
98
+ "check:locks": "node scripts/check-store-lock.mjs",
97
99
  "benchmark:render": "node scripts/benchmark-render.mjs",
98
100
  "prepack": "npm run build",
99
- "verify": "npm run typecheck && npm run test && npm run build && npm run check:reuse && npm run check:package && npm run check:codex && npm run check:release"
101
+ "verify": "npm run typecheck && npm run test && npm run build && npm run check:locks && npm run check:reuse && npm run check:package && npm run check:codex && npm run check:release"
100
102
  },
101
103
  "devDependencies": {
102
104
  "@modelcontextprotocol/sdk": "^1.12.0",
@@ -105,6 +107,7 @@
105
107
  "@xterm/addon-fit": "0.11.0",
106
108
  "@xterm/xterm": "6.0.0",
107
109
  "esbuild": "^0.25.0",
110
+ "fs-native-extensions": "1.5.1",
108
111
  "typescript": "^5.8.0",
109
112
  "vitest": "^4.1.11",
110
113
  "ws": "8.21.3",