mellos-mapping 0.23.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/README.md +48 -10
- package/README.zh-CN.md +38 -9
- package/dist/hook-session-start.mjs +1 -1
- package/dist/native/LICENSES.txt +443 -0
- package/dist/native/darwin-arm64/fs-native-extensions.node +0 -0
- package/dist/native/darwin-x64/fs-native-extensions.node +0 -0
- package/dist/native/linux-arm64/fs-native-extensions.node +0 -0
- package/dist/native/linux-x64/fs-native-extensions.node +0 -0
- package/dist/native/win32-arm64/fs-native-extensions.node +0 -0
- package/dist/native/win32-x64/fs-native-extensions.node +0 -0
- package/dist/native-lock.cjs +346 -0
- package/dist/server.mjs +198 -138
- package/dist/terminal-worker.mjs +117 -74
- package/dist/watch.mjs +223 -100
- package/dist/web/app.css +106 -2
- package/dist/web/app.js +78 -0
- package/dist/web/index.html +1 -1
- package/dist/web/terminal.css +103 -0
- package/dist/web/terminal.html +1 -1
- package/dist/web/terminal.js +78 -0
- package/dist/web.mjs +305 -112
- package/docs/codex.md +4 -1
- package/docs/locking.md +115 -0
- package/docs/map-api.md +29 -13
- package/lib/store/migration.js +19 -12
- package/lib/store/native-lock.d.ts +5 -0
- package/lib/store/native-lock.js +27 -0
- package/lib/store/transaction.d.ts +9 -2
- package/lib/store/transaction.js +79 -50
- package/package.json +6 -3
package/docs/locking.md
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# Project locking
|
|
2
|
+
|
|
3
|
+
Map writes have three separate guarantees: atomic file replacement keeps a
|
|
4
|
+
reader from seeing half a saved map, a project lock serializes cooperating
|
|
5
|
+
writers, and an optional revision check rejects edits computed from stale
|
|
6
|
+
data. None of these replaces the others.
|
|
7
|
+
|
|
8
|
+
## One stable lock for every page
|
|
9
|
+
|
|
10
|
+
The default page and all named pages use the same fixed regular file,
|
|
11
|
+
`.mellos/.write-lock`, for their project.
|
|
12
|
+
`withStoreLock` acquires an OS exclusive lock without waiting before reading
|
|
13
|
+
the current graph. MCP graph mutations, HTTP viewer mutations and watcher
|
|
14
|
+
mutations all use this boundary. A competing writer receives `BUSY` and can
|
|
15
|
+
retry after the active operation completes; acquisition does not start a
|
|
16
|
+
background poll or queue.
|
|
17
|
+
|
|
18
|
+
The lock file is independent of the map JSON files. A successful map save
|
|
19
|
+
replaces its target through a private sibling temporary file, while the lock
|
|
20
|
+
file keeps the same identity. Do not delete, rename or replace the lock file:
|
|
21
|
+
doing so can leave processes locking different files for the same project.
|
|
22
|
+
Keeping the file after an operation is deliberate; its presence is not proof
|
|
23
|
+
that a writer is active.
|
|
24
|
+
|
|
25
|
+
Normal completion and errors release the lock. When the owning process exits,
|
|
26
|
+
including a crash, the OS releases its lock. The new protocol does not use
|
|
27
|
+
directory creation, an `owner.json` PID, a `.reap` directory, or elapsed time
|
|
28
|
+
to decide ownership or reclaim a lock. Failure to acquire the required OS lock
|
|
29
|
+
must not cause a write to continue without it.
|
|
30
|
+
|
|
31
|
+
## A lock does not validate an earlier read
|
|
32
|
+
|
|
33
|
+
Each operation loads the current map, checks a supplied `expectedRevision`,
|
|
34
|
+
applies its changes and saves while holding the project lock. This prevents
|
|
35
|
+
two cooperating writers from both saving independently loaded copies at once.
|
|
36
|
+
The lock does not remain held across separate MCP read and write calls.
|
|
37
|
+
|
|
38
|
+
When an edit depends on data returned by `mmap_read`, include that page's
|
|
39
|
+
revision in the write. If another operation changed the map in between, the
|
|
40
|
+
write returns `CONFLICT` without applying the edit. Read again, reconsider the
|
|
41
|
+
change, and submit it with the new revision. Use `expectedRevision: "absent"`
|
|
42
|
+
when declaring a page that must not already exist.
|
|
43
|
+
|
|
44
|
+
Omitting `expectedRevision` preserves compatibility, but skips that comparison.
|
|
45
|
+
For example, if two sessions read the same `context`, revise different parts,
|
|
46
|
+
and then write their complete objects without a revision, the later write
|
|
47
|
+
replaces the earlier one. The project lock serializes both writes; it cannot
|
|
48
|
+
infer which fields the caller intended to preserve.
|
|
49
|
+
|
|
50
|
+
## Scope
|
|
51
|
+
|
|
52
|
+
The lock is a contract among participating writers. Hand edits, direct calls
|
|
53
|
+
to low-level `saveMapFile`, and older running processes do not acquire this
|
|
54
|
+
new lock. A filesystem must support the OS lock used by the runtime; file
|
|
55
|
+
replacement alone is not an alternative locking protocol.
|
|
56
|
+
|
|
57
|
+
The bundled native bindings require Node.js `^18.17.0 || >=20.3.0` (N-API 9).
|
|
58
|
+
Releases include bindings for Windows 10+ (Server 2016+ on x64), macOS 13.0+
|
|
59
|
+
and Linux with glibc 2.28+, each on x64 and arm64; users do not compile them
|
|
60
|
+
during installation. A missing binding or unsupported platform returns
|
|
61
|
+
`LOCK_UNAVAILABLE` instead of falling back to directory locks or continuing
|
|
62
|
+
without a lock.
|
|
63
|
+
|
|
64
|
+
macOS 13.0 is the deployment target recorded in both shipped Mach-O addons.
|
|
65
|
+
The OS must also meet the selected Node.js version's requirements: for example,
|
|
66
|
+
[Node.js 24 requires macOS 13.5+](https://github.com/nodejs/node/blob/v24.0.0/BUILDING.md#platform-list).
|
|
67
|
+
The Linux addons require only `libc.so.6`, with maximum symbol versions
|
|
68
|
+
`GLIBC_2.14` on x64 and `GLIBC_2.17` on arm64; they have no `GLIBCXX` or `CXXABI`
|
|
69
|
+
dependency. The higher glibc 2.28 baseline comes from the supported official
|
|
70
|
+
Node.js binaries, which also require kernel 4.18+ and libstdc++ providing
|
|
71
|
+
`GLIBCXX_3.4.25`; see the [Node.js 18.17 platform requirements](https://github.com/nodejs/node/blob/v18.17.0/BUILDING.md#platform-list).
|
|
72
|
+
The Windows addons import system `KERNEL32.dll` and `ntdll.dll`; the supported
|
|
73
|
+
Windows floor follows Node.js, rather than the older PE header version.
|
|
74
|
+
|
|
75
|
+
Pages organize separate efforts, but share the project lock. This does not
|
|
76
|
+
turn legacy `mmap_remove {pages:[...]}` into a multi-page transaction: that API
|
|
77
|
+
still reports partial deletion and does not accept `expectedRevision`. Use
|
|
78
|
+
per-page `deletePage` for revision-checked deletion. See the
|
|
79
|
+
[persistent-map API guide](map-api.md) for the data contract.
|
|
80
|
+
|
|
81
|
+
## Upgrade from directory locks
|
|
82
|
+
|
|
83
|
+
Earlier runtimes used a directory named `.mellos/.write-lock`, containing
|
|
84
|
+
`owner.json` and sometimes `.reap`. New writers use a regular file at that same
|
|
85
|
+
path. If they find a directory there, they return `LOCK_MIGRATION_REQUIRED`.
|
|
86
|
+
They do not reclaim it automatically, even when the recorded PID appears dead
|
|
87
|
+
or its timestamp is old.
|
|
88
|
+
|
|
89
|
+
1. Stop every old MCP server, HTTP viewer service and native watcher using this
|
|
90
|
+
project. Updating files or opening a new browser tab does not stop those
|
|
91
|
+
processes. Stop and restart existing HTTP services explicitly; there is no
|
|
92
|
+
hot upgrade of the locking protocol.
|
|
93
|
+
New clients check the viewer's health response for `lockProtocol:
|
|
94
|
+
"os-file-v1"`; an older service returns a migration error on reuse rather
|
|
95
|
+
than silently providing a viewer with different write semantics. Use
|
|
96
|
+
`mellos-mapping-web <project-directory> --stop` before reopening it.
|
|
97
|
+
2. Inspect `.mellos/.write-lock`. If it is a **directory**, and all old writers
|
|
98
|
+
have stopped, move that directory to a separate backup location. Preserve
|
|
99
|
+
it for diagnosis. If it is already a **regular file**, leave it in place:
|
|
100
|
+
it belongs to the new protocol and must not be deleted or renamed. If the
|
|
101
|
+
path is absent, no old directory needs moving.
|
|
102
|
+
3. Start the updated runtime and retry the operation. It creates the permanent
|
|
103
|
+
regular file if absent, acquires the OS lock and then performs the write.
|
|
104
|
+
Reconnect viewer tabs using the URL returned by the restarted service.
|
|
105
|
+
|
|
106
|
+
Using the same path prevents old and new writers from silently choosing
|
|
107
|
+
different locks. The new runtime's atomic file creation competes with the old
|
|
108
|
+
runtime's directory creation. If the old directory wins, the new runtime
|
|
109
|
+
requires migration. If the regular file wins, an old writer cannot create its
|
|
110
|
+
directory or read `owner.json` beneath the file and returns `BUSY` instead of
|
|
111
|
+
writing. Do not remove the new file to make an old client proceed; upgrade and
|
|
112
|
+
restart that client.
|
|
113
|
+
|
|
114
|
+
Do not automate this migration by deleting everything named `.write-lock`.
|
|
115
|
+
The old directory and the new permanent file require different treatment.
|
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
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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.
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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,
|
|
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
|
|
package/lib/store/migration.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { existsSync,
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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,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
|
-
/**
|
|
12
|
-
|
|
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;
|
package/lib/store/transaction.js
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
/** Cooperative, cross-process transactions for MCP and HTTP writers. */
|
|
2
|
-
import {
|
|
3
|
-
import { dirname, join } from 'node:path';
|
|
4
|
-
import {
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
39
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
48
|
-
|
|
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
|
-
|
|
74
|
+
fd = openSync(lock, 'a+', 0o600);
|
|
51
75
|
}
|
|
52
76
|
catch (error) {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
61
|
-
}
|
|
62
|
-
catch {
|
|
63
|
-
throw new LedgerError('BUSY', 'Another process is recovering the writer lock.');
|
|
86
|
+
acquired = backend.tryLock(fd);
|
|
64
87
|
}
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
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
|
-
|
|
99
|
+
if (acquired)
|
|
100
|
+
backend.unlock(fd);
|
|
72
101
|
}
|
|
73
102
|
catch {
|
|
74
|
-
|
|
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
|
-
|
|
114
|
+
}
|
|
115
|
+
function warnLockCleanup(message) {
|
|
78
116
|
try {
|
|
79
|
-
|
|
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.
|
|
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": ">=
|
|
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",
|