okf-catalog 0.1.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/CHANGELOG.md +24 -0
- package/LICENSE +202 -0
- package/NOTICE +10 -0
- package/README.md +87 -0
- package/dist/bundle/cat-file.js +94 -0
- package/dist/bundle/cat-file.js.map +1 -0
- package/dist/bundle/contract.js +60 -0
- package/dist/bundle/contract.js.map +1 -0
- package/dist/bundle/frontmatter.js +106 -0
- package/dist/bundle/frontmatter.js.map +1 -0
- package/dist/bundle/git-tree.js +109 -0
- package/dist/bundle/git-tree.js.map +1 -0
- package/dist/bundle/index-file.js +90 -0
- package/dist/bundle/index-file.js.map +1 -0
- package/dist/bundle/links.js +57 -0
- package/dist/bundle/links.js.map +1 -0
- package/dist/bundle/load.js +363 -0
- package/dist/bundle/load.js.map +1 -0
- package/dist/bundle/manifest.js +95 -0
- package/dist/bundle/manifest.js.map +1 -0
- package/dist/bundle/markdown.js +210 -0
- package/dist/bundle/markdown.js.map +1 -0
- package/dist/bundle/model.js +7 -0
- package/dist/bundle/model.js.map +1 -0
- package/dist/bundle/page.js +374 -0
- package/dist/bundle/page.js.map +1 -0
- package/dist/bundle/paths.js +36 -0
- package/dist/bundle/paths.js.map +1 -0
- package/dist/bundle/reserved.js +56 -0
- package/dist/bundle/reserved.js.map +1 -0
- package/dist/bundle/timestamp.js +49 -0
- package/dist/bundle/timestamp.js.map +1 -0
- package/dist/catalog/model.js +32 -0
- package/dist/catalog/model.js.map +1 -0
- package/dist/catalog/nearest.js +56 -0
- package/dist/catalog/nearest.js.map +1 -0
- package/dist/catalog/outputs.js +438 -0
- package/dist/catalog/outputs.js.map +1 -0
- package/dist/catalog/provenance.js +37 -0
- package/dist/catalog/provenance.js.map +1 -0
- package/dist/catalog/runtime.js +2 -0
- package/dist/catalog/runtime.js.map +1 -0
- package/dist/catalog/text.js +140 -0
- package/dist/catalog/text.js.map +1 -0
- package/dist/cli.js +81 -0
- package/dist/cli.js.map +1 -0
- package/dist/commands/check.js +155 -0
- package/dist/commands/check.js.map +1 -0
- package/dist/commands/pack.js +182 -0
- package/dist/commands/pack.js.map +1 -0
- package/dist/commands/serve.js +452 -0
- package/dist/commands/serve.js.map +1 -0
- package/dist/config/company-config.js +272 -0
- package/dist/config/company-config.js.map +1 -0
- package/dist/derive/derived-document.js +42 -0
- package/dist/derive/derived-document.js.map +1 -0
- package/dist/engine/qmd-render.js +54 -0
- package/dist/engine/qmd-render.js.map +1 -0
- package/dist/engine/qmd.js +241 -0
- package/dist/engine/qmd.js.map +1 -0
- package/dist/fs/cache-dir.js +140 -0
- package/dist/fs/cache-dir.js.map +1 -0
- package/dist/fs/company-lock.js +162 -0
- package/dist/fs/company-lock.js.map +1 -0
- package/dist/fs/walk.js +168 -0
- package/dist/fs/walk.js.map +1 -0
- package/dist/log.js +65 -0
- package/dist/log.js.map +1 -0
- package/dist/mcp/server.js +30 -0
- package/dist/mcp/server.js.map +1 -0
- package/dist/mcp/stdio.js +45 -0
- package/dist/mcp/stdio.js.map +1 -0
- package/dist/mcp/tools.js +249 -0
- package/dist/mcp/tools.js.map +1 -0
- package/dist/report/report.js +43 -0
- package/dist/report/report.js.map +1 -0
- package/dist/search/engine.js +2 -0
- package/dist/search/engine.js.map +1 -0
- package/dist/search/query.js +91 -0
- package/dist/search/query.js.map +1 -0
- package/dist/search/search.js +224 -0
- package/dist/search/search.js.map +1 -0
- package/dist/search/snippet.js +95 -0
- package/dist/search/snippet.js.map +1 -0
- package/dist/serve/poller.js +119 -0
- package/dist/serve/poller.js.map +1 -0
- package/dist/serve/runtime.js +390 -0
- package/dist/serve/runtime.js.map +1 -0
- package/dist/source/git-runner.js +232 -0
- package/dist/source/git-runner.js.map +1 -0
- package/dist/source/git.js +457 -0
- package/dist/source/git.js.map +1 -0
- package/dist/source/local.js +20 -0
- package/dist/source/local.js.map +1 -0
- package/dist/source/source.js +2 -0
- package/dist/source/source.js.map +1 -0
- package/npm-shrinkwrap.json +5482 -0
- package/package.json +80 -0
- package/plugin/claude-code/.claude-plugin/plugin.json +15 -0
- package/plugin/claude-code/.mcp.json +10 -0
- package/plugin/claude-code/skills/okf-catalog/SKILL.md +15 -0
- package/recipes/publish/README.md +32 -0
- package/recipes/publish/checkers.lock +435 -0
- package/recipes/publish/checkers.txt +5 -0
- package/recipes/publish/pack.sh +60 -0
- package/recipes/publish/publish.yml +88 -0
- package/recipes/publish/push.sh +63 -0
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import { lstatSync, mkdirSync, readdirSync, readlinkSync, renameSync, rmSync, symlinkSync, writeFileSync, } from "node:fs";
|
|
2
|
+
import { dirname, join, resolve } from "node:path";
|
|
3
|
+
import { createStore } from "@tobilu/qmd";
|
|
4
|
+
import { byCodeUnit, collisionKey, folderOf } from "../bundle/paths.js";
|
|
5
|
+
import { decodePath, encodePath, renderDocument } from "./qmd-render.js";
|
|
6
|
+
const LINK = "derived";
|
|
7
|
+
const DB = "index.sqlite";
|
|
8
|
+
const GENERATION = /^gen-\d+-\d+-\d+$/;
|
|
9
|
+
const TEMP_LINK = /^derived\.tmp-\d+$/;
|
|
10
|
+
/** The name two paths share once case and Unicode form are ignored, which is what a case-insensitive file system sees. */
|
|
11
|
+
/**
|
|
12
|
+
* The qmd adapter. Each `index` writes a new generation folder, flips the `derived` link to it (by base name,
|
|
13
|
+
* with `symlink` then `rename`, so there is never a moment without a live folder and the link holds wherever
|
|
14
|
+
* the directory is mounted), runs qmd's `update()` on the linked folder, compares qmd's count for this
|
|
15
|
+
* collection with what was written, names any gap, and only then removes the older generations. Paths go
|
|
16
|
+
* through the codec so a folder qmd would skip, or a name it mangles, still round-trips; two paths that would
|
|
17
|
+
* be one file on a case-insensitive disk are a collision everywhere, and only the first by path order is
|
|
18
|
+
* written. One process owns a directory: `open()` rebuilds a store that holds another collection's rows and
|
|
19
|
+
* clears what an earlier run left behind, and `index()` refuses to run twice at once; the per-company lock that
|
|
20
|
+
* keeps two processes apart is the composition layer's (decision D32).
|
|
21
|
+
*/
|
|
22
|
+
export class QmdEngine {
|
|
23
|
+
store;
|
|
24
|
+
options;
|
|
25
|
+
generation = 0;
|
|
26
|
+
indexing = false;
|
|
27
|
+
/** Set when `open()` found another collection's rows in the store and rebuilt it; for the caller's log. */
|
|
28
|
+
resetOnOpen;
|
|
29
|
+
constructor(store, options, resetOnOpen) {
|
|
30
|
+
this.store = store;
|
|
31
|
+
this.options = options;
|
|
32
|
+
this.resetOnOpen = resetOnOpen;
|
|
33
|
+
}
|
|
34
|
+
static async open(options) {
|
|
35
|
+
if (!/^[a-z0-9][a-z0-9-]{0,62}$/.test(options.company)) {
|
|
36
|
+
throw new Error(`company must be one lower-case path segment, got ${JSON.stringify(options.company)}`);
|
|
37
|
+
}
|
|
38
|
+
const dir = resolve(options.dir);
|
|
39
|
+
mkdirSync(dir, { recursive: true });
|
|
40
|
+
QmdEngine.clearLeftovers(dir);
|
|
41
|
+
const config = {
|
|
42
|
+
collections: { [options.company]: { path: join(dir, LINK), pattern: "**/*.md" } },
|
|
43
|
+
};
|
|
44
|
+
let reset;
|
|
45
|
+
let store;
|
|
46
|
+
let status;
|
|
47
|
+
try {
|
|
48
|
+
store = await createStore({ dbPath: join(dir, DB), config });
|
|
49
|
+
try {
|
|
50
|
+
status = await store.getStatus();
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
await store.close().catch(() => undefined);
|
|
54
|
+
throw error;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
catch (error) {
|
|
58
|
+
// A derived store that is not a database any more, at open or at its first read, is rebuilt once (D48);
|
|
59
|
+
// any other failure is a failure and the file stays as it was.
|
|
60
|
+
const code = String(error.code ?? "");
|
|
61
|
+
if (code !== "SQLITE_NOTADB" && !code.startsWith("SQLITE_CORRUPT"))
|
|
62
|
+
throw error;
|
|
63
|
+
QmdEngine.removeStore(dir);
|
|
64
|
+
reset = `the store could not be opened (${code}: ${error.message}); it was rebuilt`;
|
|
65
|
+
store = await createStore({ dbPath: join(dir, DB), config });
|
|
66
|
+
status = await store.getStatus();
|
|
67
|
+
}
|
|
68
|
+
const own = status.collections.find((c) => c.name === options.company)?.documents ?? 0;
|
|
69
|
+
const others = status.collections.filter((c) => c.name !== options.company).map((c) => c.name);
|
|
70
|
+
if (others.length > 0 || status.totalDocuments !== own) {
|
|
71
|
+
reset = `the store held ${status.totalDocuments - own} document(s) outside the ${options.company} collection (${others.join(", ") || "no collection"}); it was rebuilt`;
|
|
72
|
+
await store.close();
|
|
73
|
+
QmdEngine.removeStore(dir);
|
|
74
|
+
store = await createStore({ dbPath: join(dir, DB), config });
|
|
75
|
+
}
|
|
76
|
+
return new QmdEngine(store, { ...options, dir }, reset);
|
|
77
|
+
}
|
|
78
|
+
/** The store and its sidecars; the lock database in the same folder is never touched. */
|
|
79
|
+
static removeStore(dir) {
|
|
80
|
+
for (const suffix of ["", "-wal", "-shm", "-journal"]) {
|
|
81
|
+
rmSync(join(dir, `${DB}${suffix}`), { force: true });
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
/** Temporary links a crash left behind, and generation folders the live link does not name, are removed. */
|
|
85
|
+
static clearLeftovers(dir) {
|
|
86
|
+
let live;
|
|
87
|
+
try {
|
|
88
|
+
live = readlinkSync(join(dir, LINK));
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
live = undefined;
|
|
92
|
+
}
|
|
93
|
+
for (const name of readdirSync(dir)) {
|
|
94
|
+
if (TEMP_LINK.test(name) || (GENERATION.test(name) && name !== live)) {
|
|
95
|
+
rmSync(join(dir, name), { recursive: true, force: true });
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
async index(docs) {
|
|
100
|
+
if (this.indexing) {
|
|
101
|
+
throw new Error("index() is already running for this engine; refresh is single-flight in the composition layer (D28)");
|
|
102
|
+
}
|
|
103
|
+
this.indexing = true;
|
|
104
|
+
try {
|
|
105
|
+
// The loader has already refused unsafe paths; this guard keeps a caller that bypassed it from writing
|
|
106
|
+
// outside the generation folder. A backslash is allowed here: the codec percent-encodes it.
|
|
107
|
+
for (const doc of docs) {
|
|
108
|
+
const segments = doc.path.split("/");
|
|
109
|
+
if (doc.path.startsWith("/") || segments.some((s) => s === "" || s === "." || s === "..")) {
|
|
110
|
+
throw new Error(`${doc.path} is not a safe bundle path`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
this.generation += 1;
|
|
114
|
+
const genName = `gen-${Date.now()}-${process.pid}-${this.generation}`;
|
|
115
|
+
const gen = join(this.options.dir, genName);
|
|
116
|
+
mkdirSync(gen, { recursive: true });
|
|
117
|
+
const encodedFolders = new Set();
|
|
118
|
+
const written = new Set();
|
|
119
|
+
const notIndexed = new Set();
|
|
120
|
+
const collisions = [];
|
|
121
|
+
const seen = new Map();
|
|
122
|
+
for (const doc of [...docs].sort((a, b) => byCodeUnit(a.path, b.path))) {
|
|
123
|
+
const encoded = encodePath(doc.path);
|
|
124
|
+
const key = collisionKey(encoded);
|
|
125
|
+
const kept = seen.get(key);
|
|
126
|
+
if (kept !== undefined) {
|
|
127
|
+
collisions.push({ kept, dropped: doc.path });
|
|
128
|
+
notIndexed.add(doc.path);
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
seen.set(key, doc.path);
|
|
132
|
+
const folder = folderOf(doc.path);
|
|
133
|
+
if (folder !== "" && encodePath(folder) !== folder)
|
|
134
|
+
encodedFolders.add(folder);
|
|
135
|
+
const target = join(gen, encoded);
|
|
136
|
+
try {
|
|
137
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
138
|
+
writeFileSync(target, renderDocument(doc, { metadataBlock: this.options.renderMetadataBlock === true }));
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
// A name the file system cannot hold (too long once encoded, for one) costs that page, not the index.
|
|
142
|
+
notIndexed.add(doc.path);
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
written.add(doc.path);
|
|
146
|
+
}
|
|
147
|
+
// What is on disk is what qmd will see: count it before handing the folder over.
|
|
148
|
+
const onDisk = countFiles(gen);
|
|
149
|
+
if (onDisk !== written.size) {
|
|
150
|
+
throw new Error(`the generation folder holds ${onDisk} files for ${written.size} rendered documents`);
|
|
151
|
+
}
|
|
152
|
+
// Flip the link: a new link under a temporary name, then an atomic rename over the live one.
|
|
153
|
+
const tmp = join(this.options.dir, `${LINK}.tmp-${this.generation}`);
|
|
154
|
+
rmSync(tmp, { force: true });
|
|
155
|
+
symlinkSync(genName, tmp);
|
|
156
|
+
renameSync(tmp, join(this.options.dir, LINK));
|
|
157
|
+
const update = await this.store.update();
|
|
158
|
+
const documents = await this.ownCount();
|
|
159
|
+
if (documents !== written.size) {
|
|
160
|
+
const listed = await this.store.multiGet(`${this.options.company}/**`);
|
|
161
|
+
const indexed = new Set(listed.docs
|
|
162
|
+
.map((d) => this.decode(d.doc.displayPath))
|
|
163
|
+
.filter((p) => p !== undefined));
|
|
164
|
+
for (const path of written)
|
|
165
|
+
if (!indexed.has(path))
|
|
166
|
+
notIndexed.add(path);
|
|
167
|
+
}
|
|
168
|
+
// Only now, with the new index in place, remove the older generations; a failure above leaves them behind
|
|
169
|
+
// for the next run to clear, and the live link never names a folder that is gone.
|
|
170
|
+
for (const name of readdirSync(this.options.dir)) {
|
|
171
|
+
if (GENERATION.test(name) && name !== genName) {
|
|
172
|
+
rmSync(join(this.options.dir, name), { recursive: true, force: true });
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return {
|
|
176
|
+
documents,
|
|
177
|
+
indexed: update.indexed,
|
|
178
|
+
updated: update.updated,
|
|
179
|
+
unchanged: update.unchanged,
|
|
180
|
+
removed: update.removed,
|
|
181
|
+
skipped: update.skipped,
|
|
182
|
+
notIndexed: [...notIndexed].sort(byCodeUnit),
|
|
183
|
+
collisions: collisions.sort((a, b) => byCodeUnit(a.kept, b.kept)),
|
|
184
|
+
encodedFolders: [...encodedFolders].sort(byCodeUnit),
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
finally {
|
|
188
|
+
this.indexing = false;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
async lex(terms, limit) {
|
|
192
|
+
if (terms.length === 0)
|
|
193
|
+
return [];
|
|
194
|
+
const rows = await this.store.searchLex(terms.join(" "), { limit });
|
|
195
|
+
const hits = [];
|
|
196
|
+
for (const row of rows) {
|
|
197
|
+
const path = this.decode(row.displayPath);
|
|
198
|
+
if (path === undefined)
|
|
199
|
+
continue;
|
|
200
|
+
const score = row.score;
|
|
201
|
+
hits.push({ path, score, bm25: score >= 1 ? Number.MAX_SAFE_INTEGER : score / (1 - score) });
|
|
202
|
+
}
|
|
203
|
+
return hits;
|
|
204
|
+
}
|
|
205
|
+
async status() {
|
|
206
|
+
return { documents: await this.ownCount() };
|
|
207
|
+
}
|
|
208
|
+
async close() {
|
|
209
|
+
await this.store.close();
|
|
210
|
+
}
|
|
211
|
+
/** The active document count of this company's collection, not of the whole store. */
|
|
212
|
+
async ownCount() {
|
|
213
|
+
const status = await this.store.getStatus();
|
|
214
|
+
return status.collections.find((c) => c.name === this.options.company)?.documents ?? 0;
|
|
215
|
+
}
|
|
216
|
+
/** `<company>/<encoded path>` back to the bundle path; a hit from another collection is dropped. */
|
|
217
|
+
decode(displayPath) {
|
|
218
|
+
const slash = displayPath.indexOf("/");
|
|
219
|
+
if (slash === -1 || displayPath.slice(0, slash) !== this.options.company)
|
|
220
|
+
return undefined;
|
|
221
|
+
return decodePath(displayPath.slice(slash + 1));
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
/** Regular files under a folder, counted without following links. */
|
|
225
|
+
function countFiles(root) {
|
|
226
|
+
let count = 0;
|
|
227
|
+
const pending = [root];
|
|
228
|
+
while (pending.length > 0) {
|
|
229
|
+
const dir = pending.pop();
|
|
230
|
+
for (const entry of readdirSync(dir)) {
|
|
231
|
+
const path = join(dir, entry);
|
|
232
|
+
const stat = lstatSync(path);
|
|
233
|
+
if (stat.isDirectory())
|
|
234
|
+
pending.push(path);
|
|
235
|
+
else if (stat.isFile())
|
|
236
|
+
count += 1;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return count;
|
|
240
|
+
}
|
|
241
|
+
//# sourceMappingURL=qmd.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"qmd.js","sourceRoot":"","sources":["../../src/engine/qmd.ts"],"names":[],"mappings":"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","sourcesContent":["import {\n lstatSync,\n mkdirSync,\n readdirSync,\n readlinkSync,\n renameSync,\n rmSync,\n symlinkSync,\n writeFileSync,\n} from \"node:fs\";\nimport { dirname, join, resolve } from \"node:path\";\nimport { createStore, type QMDStore } from \"@tobilu/qmd\";\nimport { byCodeUnit, collisionKey, folderOf } from \"../bundle/paths.js\";\nimport type { DerivedDocument } from \"../derive/derived-document.js\";\nimport type { Engine, EngineHit, IndexResult } from \"../search/engine.js\";\nimport { decodePath, encodePath, renderDocument } from \"./qmd-render.js\";\n\nexport interface QmdEngineOptions {\n /** The company name: one qmd collection, one path segment. */\n company: string;\n /** The engine's working directory: generation folders, the `derived` link, and the SQLite store. */\n dir: string;\n /** Render the OKF metadata as a `qmd: metadata:` block (off until a qmd release reads it, decision D30). */\n renderMetadataBlock?: boolean;\n}\n\nconst LINK = \"derived\";\nconst DB = \"index.sqlite\";\nconst GENERATION = /^gen-\\d+-\\d+-\\d+$/;\nconst TEMP_LINK = /^derived\\.tmp-\\d+$/;\n\n/** The name two paths share once case and Unicode form are ignored, which is what a case-insensitive file system sees. */\n\n/**\n * The qmd adapter. Each `index` writes a new generation folder, flips the `derived` link to it (by base name,\n * with `symlink` then `rename`, so there is never a moment without a live folder and the link holds wherever\n * the directory is mounted), runs qmd's `update()` on the linked folder, compares qmd's count for this\n * collection with what was written, names any gap, and only then removes the older generations. Paths go\n * through the codec so a folder qmd would skip, or a name it mangles, still round-trips; two paths that would\n * be one file on a case-insensitive disk are a collision everywhere, and only the first by path order is\n * written. One process owns a directory: `open()` rebuilds a store that holds another collection's rows and\n * clears what an earlier run left behind, and `index()` refuses to run twice at once; the per-company lock that\n * keeps two processes apart is the composition layer's (decision D32).\n */\nexport class QmdEngine implements Engine {\n private generation = 0;\n private indexing = false;\n /** Set when `open()` found another collection's rows in the store and rebuilt it; for the caller's log. */\n readonly resetOnOpen: string | undefined;\n\n private constructor(\n private readonly store: QMDStore,\n private readonly options: QmdEngineOptions,\n resetOnOpen: string | undefined,\n ) {\n this.resetOnOpen = resetOnOpen;\n }\n\n static async open(options: QmdEngineOptions): Promise<QmdEngine> {\n if (!/^[a-z0-9][a-z0-9-]{0,62}$/.test(options.company)) {\n throw new Error(\n `company must be one lower-case path segment, got ${JSON.stringify(options.company)}`,\n );\n }\n const dir = resolve(options.dir);\n mkdirSync(dir, { recursive: true });\n QmdEngine.clearLeftovers(dir);\n const config = {\n collections: { [options.company]: { path: join(dir, LINK), pattern: \"**/*.md\" } },\n };\n let reset: string | undefined;\n let store: Awaited<ReturnType<typeof createStore>>;\n let status: Awaited<ReturnType<Awaited<ReturnType<typeof createStore>>[\"getStatus\"]>>;\n try {\n store = await createStore({ dbPath: join(dir, DB), config });\n try {\n status = await store.getStatus();\n } catch (error) {\n await store.close().catch(() => undefined);\n throw error;\n }\n } catch (error) {\n // A derived store that is not a database any more, at open or at its first read, is rebuilt once (D48);\n // any other failure is a failure and the file stays as it was.\n const code = String((error as { code?: string }).code ?? \"\");\n if (code !== \"SQLITE_NOTADB\" && !code.startsWith(\"SQLITE_CORRUPT\")) throw error;\n QmdEngine.removeStore(dir);\n reset = `the store could not be opened (${code}: ${(error as Error).message}); it was rebuilt`;\n store = await createStore({ dbPath: join(dir, DB), config });\n status = await store.getStatus();\n }\n const own = status.collections.find((c) => c.name === options.company)?.documents ?? 0;\n const others = status.collections.filter((c) => c.name !== options.company).map((c) => c.name);\n if (others.length > 0 || status.totalDocuments !== own) {\n reset = `the store held ${status.totalDocuments - own} document(s) outside the ${options.company} collection (${others.join(\", \") || \"no collection\"}); it was rebuilt`;\n await store.close();\n QmdEngine.removeStore(dir);\n store = await createStore({ dbPath: join(dir, DB), config });\n }\n return new QmdEngine(store, { ...options, dir }, reset);\n }\n\n /** The store and its sidecars; the lock database in the same folder is never touched. */\n private static removeStore(dir: string): void {\n for (const suffix of [\"\", \"-wal\", \"-shm\", \"-journal\"]) {\n rmSync(join(dir, `${DB}${suffix}`), { force: true });\n }\n }\n\n /** Temporary links a crash left behind, and generation folders the live link does not name, are removed. */\n private static clearLeftovers(dir: string): void {\n let live: string | undefined;\n try {\n live = readlinkSync(join(dir, LINK));\n } catch {\n live = undefined;\n }\n for (const name of readdirSync(dir)) {\n if (TEMP_LINK.test(name) || (GENERATION.test(name) && name !== live)) {\n rmSync(join(dir, name), { recursive: true, force: true });\n }\n }\n }\n\n async index(docs: readonly DerivedDocument[]): Promise<IndexResult> {\n if (this.indexing) {\n throw new Error(\n \"index() is already running for this engine; refresh is single-flight in the composition layer (D28)\",\n );\n }\n this.indexing = true;\n try {\n // The loader has already refused unsafe paths; this guard keeps a caller that bypassed it from writing\n // outside the generation folder. A backslash is allowed here: the codec percent-encodes it.\n for (const doc of docs) {\n const segments = doc.path.split(\"/\");\n if (doc.path.startsWith(\"/\") || segments.some((s) => s === \"\" || s === \".\" || s === \"..\")) {\n throw new Error(`${doc.path} is not a safe bundle path`);\n }\n }\n this.generation += 1;\n const genName = `gen-${Date.now()}-${process.pid}-${this.generation}`;\n const gen = join(this.options.dir, genName);\n mkdirSync(gen, { recursive: true });\n const encodedFolders = new Set<string>();\n const written = new Set<string>();\n const notIndexed = new Set<string>();\n const collisions: IndexResult[\"collisions\"] = [];\n const seen = new Map<string, string>();\n for (const doc of [...docs].sort((a, b) => byCodeUnit(a.path, b.path))) {\n const encoded = encodePath(doc.path);\n const key = collisionKey(encoded);\n const kept = seen.get(key);\n if (kept !== undefined) {\n collisions.push({ kept, dropped: doc.path });\n notIndexed.add(doc.path);\n continue;\n }\n seen.set(key, doc.path);\n const folder = folderOf(doc.path);\n if (folder !== \"\" && encodePath(folder) !== folder) encodedFolders.add(folder);\n const target = join(gen, encoded);\n try {\n mkdirSync(dirname(target), { recursive: true });\n writeFileSync(\n target,\n renderDocument(doc, { metadataBlock: this.options.renderMetadataBlock === true }),\n );\n } catch {\n // A name the file system cannot hold (too long once encoded, for one) costs that page, not the index.\n notIndexed.add(doc.path);\n continue;\n }\n written.add(doc.path);\n }\n // What is on disk is what qmd will see: count it before handing the folder over.\n const onDisk = countFiles(gen);\n if (onDisk !== written.size) {\n throw new Error(\n `the generation folder holds ${onDisk} files for ${written.size} rendered documents`,\n );\n }\n // Flip the link: a new link under a temporary name, then an atomic rename over the live one.\n const tmp = join(this.options.dir, `${LINK}.tmp-${this.generation}`);\n rmSync(tmp, { force: true });\n symlinkSync(genName, tmp);\n renameSync(tmp, join(this.options.dir, LINK));\n const update = await this.store.update();\n const documents = await this.ownCount();\n if (documents !== written.size) {\n const listed = await this.store.multiGet(`${this.options.company}/**`);\n const indexed = new Set(\n listed.docs\n .map((d) => this.decode(d.doc.displayPath))\n .filter((p): p is string => p !== undefined),\n );\n for (const path of written) if (!indexed.has(path)) notIndexed.add(path);\n }\n // Only now, with the new index in place, remove the older generations; a failure above leaves them behind\n // for the next run to clear, and the live link never names a folder that is gone.\n for (const name of readdirSync(this.options.dir)) {\n if (GENERATION.test(name) && name !== genName) {\n rmSync(join(this.options.dir, name), { recursive: true, force: true });\n }\n }\n return {\n documents,\n indexed: update.indexed,\n updated: update.updated,\n unchanged: update.unchanged,\n removed: update.removed,\n skipped: update.skipped,\n notIndexed: [...notIndexed].sort(byCodeUnit),\n collisions: collisions.sort((a, b) => byCodeUnit(a.kept, b.kept)),\n encodedFolders: [...encodedFolders].sort(byCodeUnit),\n };\n } finally {\n this.indexing = false;\n }\n }\n\n async lex(terms: readonly string[], limit: number): Promise<EngineHit[]> {\n if (terms.length === 0) return [];\n const rows = await this.store.searchLex(terms.join(\" \"), { limit });\n const hits: EngineHit[] = [];\n for (const row of rows) {\n const path = this.decode(row.displayPath);\n if (path === undefined) continue;\n const score = row.score;\n hits.push({ path, score, bm25: score >= 1 ? Number.MAX_SAFE_INTEGER : score / (1 - score) });\n }\n return hits;\n }\n\n async status(): Promise<{ documents: number }> {\n return { documents: await this.ownCount() };\n }\n\n async close(): Promise<void> {\n await this.store.close();\n }\n\n /** The active document count of this company's collection, not of the whole store. */\n private async ownCount(): Promise<number> {\n const status = await this.store.getStatus();\n return status.collections.find((c) => c.name === this.options.company)?.documents ?? 0;\n }\n\n /** `<company>/<encoded path>` back to the bundle path; a hit from another collection is dropped. */\n private decode(displayPath: string): string | undefined {\n const slash = displayPath.indexOf(\"/\");\n if (slash === -1 || displayPath.slice(0, slash) !== this.options.company) return undefined;\n return decodePath(displayPath.slice(slash + 1));\n }\n}\n\n/** Regular files under a folder, counted without following links. */\nfunction countFiles(root: string): number {\n let count = 0;\n const pending = [root];\n while (pending.length > 0) {\n const dir = pending.pop() as string;\n for (const entry of readdirSync(dir)) {\n const path = join(dir, entry);\n const stat = lstatSync(path);\n if (stat.isDirectory()) pending.push(path);\n else if (stat.isFile()) count += 1;\n }\n }\n return count;\n}\n"]}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { chmodSync, existsSync, lstatSync, mkdirSync, realpathSync } from "node:fs";
|
|
2
|
+
import { isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
3
|
+
/** The cache root: an absolute XDG_CACHE_HOME, else the platform's cache folder. A relative XDG value is ignored and noted. */
|
|
4
|
+
export function cacheRoot(input) {
|
|
5
|
+
const xdg = input.env.XDG_CACHE_HOME;
|
|
6
|
+
if (xdg !== undefined && xdg.length > 0) {
|
|
7
|
+
if (isAbsolute(xdg))
|
|
8
|
+
return { root: xdg };
|
|
9
|
+
return {
|
|
10
|
+
root: fallbackRoot(input.platform, input.home),
|
|
11
|
+
note: `XDG_CACHE_HOME is relative (${xdg}) and was ignored, as the specification requires`,
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
return { root: fallbackRoot(input.platform, input.home) };
|
|
15
|
+
}
|
|
16
|
+
const fallbackRoot = (platform, home) => platform === "darwin" ? join(home, "Library", "Caches") : join(home, ".cache");
|
|
17
|
+
export function companyDir(root, company) {
|
|
18
|
+
return join(root, "okf-catalog", company);
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Why a folder cannot hold the cache, or `undefined` when it can. A pure rule, so every platform tests it: the
|
|
22
|
+
* root may belong to anyone but must not be writable by everyone without the sticky bit; a folder the server
|
|
23
|
+
* created or owns must be this user's, not a link, and not writable by its group or by everyone.
|
|
24
|
+
*/
|
|
25
|
+
export function judgeFolder(stat, uid, isRoot) {
|
|
26
|
+
if (stat.isSymbolicLink)
|
|
27
|
+
return "is a symbolic link";
|
|
28
|
+
if (isRoot) {
|
|
29
|
+
if ((stat.mode & 0o002) !== 0 && (stat.mode & 0o1000) === 0) {
|
|
30
|
+
return "is writable by everyone and has no sticky bit";
|
|
31
|
+
}
|
|
32
|
+
if (stat.uid !== uid && (stat.mode & 0o020) !== 0 && (stat.mode & 0o1000) === 0) {
|
|
33
|
+
return "is owned by another user and writable by its group without the sticky bit";
|
|
34
|
+
}
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
37
|
+
if (stat.uid !== uid)
|
|
38
|
+
return "is owned by another user";
|
|
39
|
+
if ((stat.mode & 0o022) !== 0)
|
|
40
|
+
return "is writable by its group or by everyone";
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
/** Whether one folder lies inside the other: the cache must never sit inside the bundle, nor the bundle inside the cache. */
|
|
44
|
+
export function cacheOverlapsBundle(cacheDir, bundleDir) {
|
|
45
|
+
const inside = (inner, outer) => {
|
|
46
|
+
const rel = relative(resolve(outer), resolve(inner));
|
|
47
|
+
// A sibling named "..out" is outside; only ".." itself or a "../" prefix leaves the folder.
|
|
48
|
+
return rel === "" || (rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel));
|
|
49
|
+
};
|
|
50
|
+
return inside(cacheDir, bundleDir) || inside(bundleDir, cacheDir);
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Makes the company folder under the cache root with mode 0700, tightens the folders it owns between the root
|
|
54
|
+
* and the company folder, and refuses when any of them fails the rule above. Windows is not a version 0 host.
|
|
55
|
+
*/
|
|
56
|
+
export function ensureCache(dir, root, options) {
|
|
57
|
+
const refuse = (problem, detail) => ({
|
|
58
|
+
ok: false,
|
|
59
|
+
problem,
|
|
60
|
+
detail,
|
|
61
|
+
});
|
|
62
|
+
if (options.platform === "win32") {
|
|
63
|
+
const text = "Windows is not a version 0 host: the cache folder's ownership and mode checks assume POSIX";
|
|
64
|
+
return refuse(text, text);
|
|
65
|
+
}
|
|
66
|
+
// Judge what exists before anything is written: the root, then each existing folder down to the company's.
|
|
67
|
+
const judgeRoot = () => {
|
|
68
|
+
try {
|
|
69
|
+
const rootStat = lstatSync(realpathSync(root));
|
|
70
|
+
const fault = judgeFolder({ uid: rootStat.uid, mode: rootStat.mode, isSymbolicLink: rootStat.isSymbolicLink() }, options.uid, true);
|
|
71
|
+
if (fault !== undefined) {
|
|
72
|
+
return refuse(`the cache root ${fault}; set XDG_CACHE_HOME to a folder only you can write`, `the cache root ${root} ${fault}`);
|
|
73
|
+
}
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
catch (error) {
|
|
77
|
+
const code = error.code ?? "error";
|
|
78
|
+
return refuse(`the cache root cannot be read (${code}); set XDG_CACHE_HOME to a folder you own`, `the cache root ${root} cannot be read (${code})`);
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
const rootExists = existsSync(root);
|
|
82
|
+
if (rootExists) {
|
|
83
|
+
const fault = judgeRoot();
|
|
84
|
+
if (fault !== undefined)
|
|
85
|
+
return fault;
|
|
86
|
+
}
|
|
87
|
+
const below = relative(root, dir)
|
|
88
|
+
.split(sep)
|
|
89
|
+
.filter((s) => s.length > 0);
|
|
90
|
+
let current = root;
|
|
91
|
+
for (const segment of below) {
|
|
92
|
+
current = join(current, segment);
|
|
93
|
+
let stat;
|
|
94
|
+
try {
|
|
95
|
+
stat = lstatSync(current);
|
|
96
|
+
}
|
|
97
|
+
catch (error) {
|
|
98
|
+
if (error.code !== "ENOENT") {
|
|
99
|
+
const code = error.code ?? "error";
|
|
100
|
+
return refuse(`a cache folder cannot be read (${code}); check the cache root's permissions`, `the cache folder ${current} cannot be read (${code})`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
if (stat?.isSymbolicLink()) {
|
|
104
|
+
return refuse("a cache folder is a symbolic link; remove it so the server can create its own", `the cache folder ${current} is a symbolic link`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
try {
|
|
108
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
109
|
+
}
|
|
110
|
+
catch (error) {
|
|
111
|
+
const code = error.code ?? "error";
|
|
112
|
+
return refuse(`the cache folder could not be created (${code}); check the cache root's permissions`, `the cache folder ${dir} could not be created (${code})`);
|
|
113
|
+
}
|
|
114
|
+
if (!rootExists) {
|
|
115
|
+
const fault = judgeRoot();
|
|
116
|
+
if (fault !== undefined)
|
|
117
|
+
return fault;
|
|
118
|
+
}
|
|
119
|
+
current = root;
|
|
120
|
+
for (const segment of below) {
|
|
121
|
+
current = join(current, segment);
|
|
122
|
+
try {
|
|
123
|
+
const stat = lstatSync(current);
|
|
124
|
+
if (!stat.isSymbolicLink() && stat.uid === options.uid && (stat.mode & 0o777) !== 0o700) {
|
|
125
|
+
chmodSync(current, 0o700);
|
|
126
|
+
}
|
|
127
|
+
const after = lstatSync(current);
|
|
128
|
+
const fault = judgeFolder({ uid: after.uid, mode: after.mode, isSymbolicLink: after.isSymbolicLink() }, options.uid, false);
|
|
129
|
+
if (fault !== undefined) {
|
|
130
|
+
return refuse(`a cache folder ${fault}; remove it or fix it so the server can own it`, `the cache folder ${current} ${fault}`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
catch (error) {
|
|
134
|
+
const code = error.code ?? "error";
|
|
135
|
+
return refuse(`a cache folder cannot be read (${code})`, `the cache folder ${current} cannot be read (${code})`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return { ok: true };
|
|
139
|
+
}
|
|
140
|
+
//# sourceMappingURL=cache-dir.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cache-dir.js","sourceRoot":"","sources":["../../src/fs/cache-dir.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,UAAU,EAAE,SAAS,EAAE,SAAS,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AACpF,OAAO,EAAE,UAAU,EAAE,IAAI,EAAE,QAAQ,EAAE,OAAO,EAAE,GAAG,EAAE,MAAM,WAAW,CAAC;AAErE,+HAA+H;AAC/H,MAAM,UAAU,SAAS,CAAC,KAIzB;IACC,MAAM,GAAG,GAAG,KAAK,CAAC,GAAG,CAAC,cAAc,CAAC;IACrC,IAAI,GAAG,KAAK,SAAS,IAAI,GAAG,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;QACxC,IAAI,UAAU,CAAC,GAAG,CAAC;YAAE,OAAO,EAAE,IAAI,EAAE,GAAG,EAAE,CAAC;QAC1C,OAAO;YACL,IAAI,EAAE,YAAY,CAAC,KAAK,CAAC,QAAQ,EAAE,KAAK,CAAC,IAAI,CAAC;YAC9C,IAAI,EAAE,+BAA+B,GAAG,kDAAkD;SAC3F,CAAC;IACJ,CAAC;IACD,OAAO,EAAE,IAAI,EAAE,YAAY,CAAC,KAAK,CAAC,QAAQ,EAAE,KAAK,CAAC,IAAI,CAAC,EAAE,CAAC;AAC5D,CAAC;AAED,MAAM,YAAY,GAAG,CAAC,QAAyB,EAAE,IAAY,EAAU,EAAE,CACvE,QAAQ,KAAK,QAAQ,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,EAAE,SAAS,EAAE,QAAQ,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,EAAE,QAAQ,CAAC,CAAC;AAEjF,MAAM,UAAU,UAAU,CAAC,IAAY,EAAE,OAAe;IACtD,OAAO,IAAI,CAAC,IAAI,EAAE,aAAa,EAAE,OAAO,CAAC,CAAC;AAC5C,CAAC;AAQD;;;;GAIG;AACH,MAAM,UAAU,WAAW,CAAC,IAAgB,EAAE,GAAW,EAAE,MAAe;IACxE,IAAI,IAAI,CAAC,cAAc;QAAE,OAAO,oBAAoB,CAAC;IACrD,IAAI,MAAM,EAAE,CAAC;QACX,IAAI,CAAC,IAAI,CAAC,IAAI,GAAG,KAAK,CAAC,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,GAAG,MAAM,CAAC,KAAK,CAAC,EAAE,CAAC;YAC5D,OAAO,+CAA+C,CAAC;QACzD,CAAC;QACD,IAAI,IAAI,CAAC,GAAG,KAAK,GAAG,IAAI,CAAC,IAAI,CAAC,IAAI,GAAG,KAAK,CAAC,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,GAAG,MAAM,CAAC,KAAK,CAAC,EAAE,CAAC;YAChF,OAAO,2EAA2E,CAAC;QACrF,CAAC;QACD,OAAO,SAAS,CAAC;IACnB,CAAC;IACD,IAAI,IAAI,CAAC,GAAG,KAAK,GAAG;QAAE,OAAO,0BAA0B,CAAC;IACxD,IAAI,CAAC,IAAI,CAAC,IAAI,GAAG,KAAK,CAAC,KAAK,CAAC;QAAE,OAAO,yCAAyC,CAAC;IAChF,OAAO,SAAS,CAAC;AACnB,CAAC;AAED,6HAA6H;AAC7H,MAAM,UAAU,mBAAmB,CAAC,QAAgB,EAAE,SAAiB;IACrE,MAAM,MAAM,GAAG,CAAC,KAAa,EAAE,KAAa,EAAW,EAAE;QACvD,MAAM,GAAG,GAAG,QAAQ,CAAC,OAAO,CAAC,KAAK,CAAC,EAAE,OAAO,CAAC,KAAK,CAAC,CAAC,CAAC;QACrD,4FAA4F;QAC5F,OAAO,GAAG,KAAK,EAAE,IAAI,CAAC,GAAG,KAAK,IAAI,IAAI,CAAC,GAAG,CAAC,UAAU,CAAC,KAAK,GAAG,EAAE,CAAC,IAAI,CAAC,UAAU,CAAC,GAAG,CAAC,CAAC,CAAC;IACzF,CAAC,CAAC;IACF,OAAO,MAAM,CAAC,QAAQ,EAAE,SAAS,CAAC,IAAI,MAAM,CAAC,SAAS,EAAE,QAAQ,CAAC,CAAC;AACpE,CAAC;AAKD;;;GAGG;AACH,MAAM,UAAU,WAAW,CACzB,GAAW,EACX,IAAY,EACZ,OAAmD;IAEnD,MAAM,MAAM,GAAG,CAAC,OAAe,EAAE,MAAc,EAAgB,EAAE,CAAC,CAAC;QACjE,EAAE,EAAE,KAAK;QACT,OAAO;QACP,MAAM;KACP,CAAC,CAAC;IACH,IAAI,OAAO,CAAC,QAAQ,KAAK,OAAO,EAAE,CAAC;QACjC,MAAM,IAAI,GACR,4FAA4F,CAAC;QAC/F,OAAO,MAAM,CAAC,IAAI,EAAE,IAAI,CAAC,CAAC;IAC5B,CAAC;IACD,2GAA2G;IAC3G,MAAM,SAAS,GAAG,GAA6B,EAAE;QAC/C,IAAI,CAAC;YACH,MAAM,QAAQ,GAAG,SAAS,CAAC,YAAY,CAAC,IAAI,CAAC,CAAC,CAAC;YAC/C,MAAM,KAAK,GAAG,WAAW,CACvB,EAAE,GAAG,EAAE,QAAQ,CAAC,GAAG,EAAE,IAAI,EAAE,QAAQ,CAAC,IAAI,EAAE,cAAc,EAAE,QAAQ,CAAC,cAAc,EAAE,EAAE,EACrF,OAAO,CAAC,GAAG,EACX,IAAI,CACL,CAAC;YACF,IAAI,KAAK,KAAK,SAAS,EAAE,CAAC;gBACxB,OAAO,MAAM,CACX,kBAAkB,KAAK,qDAAqD,EAC5E,kBAAkB,IAAI,IAAI,KAAK,EAAE,CAClC,CAAC;YACJ,CAAC;YACD,OAAO,SAAS,CAAC;QACnB,CAAC;QAAC,OAAO,KAAK,EAAE,CAAC;YACf,MAAM,IAAI,GAAI,KAA+B,CAAC,IAAI,IAAI,OAAO,CAAC;YAC9D,OAAO,MAAM,CACX,kCAAkC,IAAI,2CAA2C,EACjF,kBAAkB,IAAI,oBAAoB,IAAI,GAAG,CAClD,CAAC;QACJ,CAAC;IACH,CAAC,CAAC;IACF,MAAM,UAAU,GAAG,UAAU,CAAC,IAAI,CAAC,CAAC;IACpC,IAAI,UAAU,EAAE,CAAC;QACf,MAAM,KAAK,GAAG,SAAS,EAAE,CAAC;QAC1B,IAAI,KAAK,KAAK,SAAS;YAAE,OAAO,KAAK,CAAC;IACxC,CAAC;IACD,MAAM,KAAK,GAAG,QAAQ,CAAC,IAAI,EAAE,GAAG,CAAC;SAC9B,KAAK,CAAC,GAAG,CAAC;SACV,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC;IAC/B,IAAI,OAAO,GAAG,IAAI,CAAC;IACnB,KAAK,MAAM,OAAO,IAAI,KAAK,EAAE,CAAC;QAC5B,OAAO,GAAG,IAAI,CAAC,OAAO,EAAE,OAAO,CAAC,CAAC;QACjC,IAAI,IAA8C,CAAC;QACnD,IAAI,CAAC;YACH,IAAI,GAAG,SAAS,CAAC,OAAO,CAAC,CAAC;QAC5B,CAAC;QAAC,OAAO,KAAK,EAAE,CAAC;YACf,IAAK,KAA+B,CAAC,IAAI,KAAK,QAAQ,EAAE,CAAC;gBACvD,MAAM,IAAI,GAAI,KAA+B,CAAC,IAAI,IAAI,OAAO,CAAC;gBAC9D,OAAO,MAAM,CACX,kCAAkC,IAAI,uCAAuC,EAC7E,oBAAoB,OAAO,oBAAoB,IAAI,GAAG,CACvD,CAAC;YACJ,CAAC;QACH,CAAC;QACD,IAAI,IAAI,EAAE,cAAc,EAAE,EAAE,CAAC;YAC3B,OAAO,MAAM,CACX,+EAA+E,EAC/E,oBAAoB,OAAO,qBAAqB,CACjD,CAAC;QACJ,CAAC;IACH,CAAC;IACD,IAAI,CAAC;QACH,SAAS,CAAC,GAAG,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,IAAI,EAAE,KAAK,EAAE,CAAC,CAAC;IACnD,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,MAAM,IAAI,GAAI,KAA+B,CAAC,IAAI,IAAI,OAAO,CAAC;QAC9D,OAAO,MAAM,CACX,0CAA0C,IAAI,uCAAuC,EACrF,oBAAoB,GAAG,0BAA0B,IAAI,GAAG,CACzD,CAAC;IACJ,CAAC;IACD,IAAI,CAAC,UAAU,EAAE,CAAC;QAChB,MAAM,KAAK,GAAG,SAAS,EAAE,CAAC;QAC1B,IAAI,KAAK,KAAK,SAAS;YAAE,OAAO,KAAK,CAAC;IACxC,CAAC;IACD,OAAO,GAAG,IAAI,CAAC;IACf,KAAK,MAAM,OAAO,IAAI,KAAK,EAAE,CAAC;QAC5B,OAAO,GAAG,IAAI,CAAC,OAAO,EAAE,OAAO,CAAC,CAAC;QACjC,IAAI,CAAC;YACH,MAAM,IAAI,GAAG,SAAS,CAAC,OAAO,CAAC,CAAC;YAChC,IAAI,CAAC,IAAI,CAAC,cAAc,EAAE,IAAI,IAAI,CAAC,GAAG,KAAK,OAAO,CAAC,GAAG,IAAI,CAAC,IAAI,CAAC,IAAI,GAAG,KAAK,CAAC,KAAK,KAAK,EAAE,CAAC;gBACxF,SAAS,CAAC,OAAO,EAAE,KAAK,CAAC,CAAC;YAC5B,CAAC;YACD,MAAM,KAAK,GAAG,SAAS,CAAC,OAAO,CAAC,CAAC;YACjC,MAAM,KAAK,GAAG,WAAW,CACvB,EAAE,GAAG,EAAE,KAAK,CAAC,GAAG,EAAE,IAAI,EAAE,KAAK,CAAC,IAAI,EAAE,cAAc,EAAE,KAAK,CAAC,cAAc,EAAE,EAAE,EAC5E,OAAO,CAAC,GAAG,EACX,KAAK,CACN,CAAC;YACF,IAAI,KAAK,KAAK,SAAS,EAAE,CAAC;gBACxB,OAAO,MAAM,CACX,kBAAkB,KAAK,gDAAgD,EACvE,oBAAoB,OAAO,IAAI,KAAK,EAAE,CACvC,CAAC;YACJ,CAAC;QACH,CAAC;QAAC,OAAO,KAAK,EAAE,CAAC;YACf,MAAM,IAAI,GAAI,KAA+B,CAAC,IAAI,IAAI,OAAO,CAAC;YAC9D,OAAO,MAAM,CACX,kCAAkC,IAAI,GAAG,EACzC,oBAAoB,OAAO,oBAAoB,IAAI,GAAG,CACvD,CAAC;QACJ,CAAC;IACH,CAAC;IACD,OAAO,EAAE,EAAE,EAAE,IAAI,EAAE,CAAC;AACtB,CAAC","sourcesContent":["import { chmodSync, existsSync, lstatSync, mkdirSync, realpathSync } from \"node:fs\";\nimport { isAbsolute, join, relative, resolve, sep } from \"node:path\";\n\n/** The cache root: an absolute XDG_CACHE_HOME, else the platform's cache folder. A relative XDG value is ignored and noted. */\nexport function cacheRoot(input: {\n env: NodeJS.ProcessEnv;\n platform: NodeJS.Platform;\n home: string;\n}): { root: string; note?: string } {\n const xdg = input.env.XDG_CACHE_HOME;\n if (xdg !== undefined && xdg.length > 0) {\n if (isAbsolute(xdg)) return { root: xdg };\n return {\n root: fallbackRoot(input.platform, input.home),\n note: `XDG_CACHE_HOME is relative (${xdg}) and was ignored, as the specification requires`,\n };\n }\n return { root: fallbackRoot(input.platform, input.home) };\n}\n\nconst fallbackRoot = (platform: NodeJS.Platform, home: string): string =>\n platform === \"darwin\" ? join(home, \"Library\", \"Caches\") : join(home, \".cache\");\n\nexport function companyDir(root: string, company: string): string {\n return join(root, \"okf-catalog\", company);\n}\n\nexport interface FolderStat {\n uid: number;\n mode: number;\n isSymbolicLink: boolean;\n}\n\n/**\n * Why a folder cannot hold the cache, or `undefined` when it can. A pure rule, so every platform tests it: the\n * root may belong to anyone but must not be writable by everyone without the sticky bit; a folder the server\n * created or owns must be this user's, not a link, and not writable by its group or by everyone.\n */\nexport function judgeFolder(stat: FolderStat, uid: number, isRoot: boolean): string | undefined {\n if (stat.isSymbolicLink) return \"is a symbolic link\";\n if (isRoot) {\n if ((stat.mode & 0o002) !== 0 && (stat.mode & 0o1000) === 0) {\n return \"is writable by everyone and has no sticky bit\";\n }\n if (stat.uid !== uid && (stat.mode & 0o020) !== 0 && (stat.mode & 0o1000) === 0) {\n return \"is owned by another user and writable by its group without the sticky bit\";\n }\n return undefined;\n }\n if (stat.uid !== uid) return \"is owned by another user\";\n if ((stat.mode & 0o022) !== 0) return \"is writable by its group or by everyone\";\n return undefined;\n}\n\n/** Whether one folder lies inside the other: the cache must never sit inside the bundle, nor the bundle inside the cache. */\nexport function cacheOverlapsBundle(cacheDir: string, bundleDir: string): boolean {\n const inside = (inner: string, outer: string): boolean => {\n const rel = relative(resolve(outer), resolve(inner));\n // A sibling named \"..out\" is outside; only \"..\" itself or a \"../\" prefix leaves the folder.\n return rel === \"\" || (rel !== \"..\" && !rel.startsWith(`..${sep}`) && !isAbsolute(rel));\n };\n return inside(cacheDir, bundleDir) || inside(bundleDir, cacheDir);\n}\n\n/** `problem` is for the model and names no path; `detail` is for the log and does. */\nexport type EnsureResult = { ok: true } | { ok: false; problem: string; detail: string };\n\n/**\n * Makes the company folder under the cache root with mode 0700, tightens the folders it owns between the root\n * and the company folder, and refuses when any of them fails the rule above. Windows is not a version 0 host.\n */\nexport function ensureCache(\n dir: string,\n root: string,\n options: { uid: number; platform: NodeJS.Platform },\n): EnsureResult {\n const refuse = (problem: string, detail: string): EnsureResult => ({\n ok: false,\n problem,\n detail,\n });\n if (options.platform === \"win32\") {\n const text =\n \"Windows is not a version 0 host: the cache folder's ownership and mode checks assume POSIX\";\n return refuse(text, text);\n }\n // Judge what exists before anything is written: the root, then each existing folder down to the company's.\n const judgeRoot = (): EnsureResult | undefined => {\n try {\n const rootStat = lstatSync(realpathSync(root));\n const fault = judgeFolder(\n { uid: rootStat.uid, mode: rootStat.mode, isSymbolicLink: rootStat.isSymbolicLink() },\n options.uid,\n true,\n );\n if (fault !== undefined) {\n return refuse(\n `the cache root ${fault}; set XDG_CACHE_HOME to a folder only you can write`,\n `the cache root ${root} ${fault}`,\n );\n }\n return undefined;\n } catch (error) {\n const code = (error as NodeJS.ErrnoException).code ?? \"error\";\n return refuse(\n `the cache root cannot be read (${code}); set XDG_CACHE_HOME to a folder you own`,\n `the cache root ${root} cannot be read (${code})`,\n );\n }\n };\n const rootExists = existsSync(root);\n if (rootExists) {\n const fault = judgeRoot();\n if (fault !== undefined) return fault;\n }\n const below = relative(root, dir)\n .split(sep)\n .filter((s) => s.length > 0);\n let current = root;\n for (const segment of below) {\n current = join(current, segment);\n let stat: ReturnType<typeof lstatSync> | undefined;\n try {\n stat = lstatSync(current);\n } catch (error) {\n if ((error as NodeJS.ErrnoException).code !== \"ENOENT\") {\n const code = (error as NodeJS.ErrnoException).code ?? \"error\";\n return refuse(\n `a cache folder cannot be read (${code}); check the cache root's permissions`,\n `the cache folder ${current} cannot be read (${code})`,\n );\n }\n }\n if (stat?.isSymbolicLink()) {\n return refuse(\n \"a cache folder is a symbolic link; remove it so the server can create its own\",\n `the cache folder ${current} is a symbolic link`,\n );\n }\n }\n try {\n mkdirSync(dir, { recursive: true, mode: 0o700 });\n } catch (error) {\n const code = (error as NodeJS.ErrnoException).code ?? \"error\";\n return refuse(\n `the cache folder could not be created (${code}); check the cache root's permissions`,\n `the cache folder ${dir} could not be created (${code})`,\n );\n }\n if (!rootExists) {\n const fault = judgeRoot();\n if (fault !== undefined) return fault;\n }\n current = root;\n for (const segment of below) {\n current = join(current, segment);\n try {\n const stat = lstatSync(current);\n if (!stat.isSymbolicLink() && stat.uid === options.uid && (stat.mode & 0o777) !== 0o700) {\n chmodSync(current, 0o700);\n }\n const after = lstatSync(current);\n const fault = judgeFolder(\n { uid: after.uid, mode: after.mode, isSymbolicLink: after.isSymbolicLink() },\n options.uid,\n false,\n );\n if (fault !== undefined) {\n return refuse(\n `a cache folder ${fault}; remove it or fix it so the server can own it`,\n `the cache folder ${current} ${fault}`,\n );\n }\n } catch (error) {\n const code = (error as NodeJS.ErrnoException).code ?? \"error\";\n return refuse(\n `a cache folder cannot be read (${code})`,\n `the cache folder ${current} cannot be read (${code})`,\n );\n }\n }\n return { ok: true };\n}\n"]}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync, } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import Database from "better-sqlite3";
|
|
4
|
+
const LOCK_DB = "lock.sqlite";
|
|
5
|
+
const OWNER = "owner.json";
|
|
6
|
+
/**
|
|
7
|
+
* Connections that hold a lock. A connection referenced only through a closure can be garbage-collected, and the
|
|
8
|
+
* binding then closes it, which releases the operating-system lock while the process still runs (observed: a
|
|
9
|
+
* fresh process acquired the lock a holder believed it held). The set keeps every holder alive until `close()`.
|
|
10
|
+
*/
|
|
11
|
+
const HELD = new Set();
|
|
12
|
+
/** How many times a busy lock is retried, and the pause between tries: about 200 ms before a folder counts as held. */
|
|
13
|
+
const BUSY_ATTEMPTS = 25;
|
|
14
|
+
const BUSY_PAUSE_MS = 8;
|
|
15
|
+
const pause = (ms) => {
|
|
16
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* One process per company folder, enforced by the operating system: an exclusive SQLite transaction on
|
|
20
|
+
* `lock.sqlite`, held for the life of the process and released the instant it dies, however it dies. There is
|
|
21
|
+
* no stale state to recover and no liveness test to get wrong. `owner.json` beside it is information only.
|
|
22
|
+
*
|
|
23
|
+
* A busy answer is retried briefly: on a cold folder two racers both create the database and each setup step
|
|
24
|
+
* takes a lock of its own, so without the retry both could see busy and neither would hold the lock (observed 4 in
|
|
25
|
+
* 100 cold races, 17 in 100 after a holder was killed). A folder still busy after the retries is held.
|
|
26
|
+
*/
|
|
27
|
+
export function acquireLock(companyDir, now) {
|
|
28
|
+
for (let attempt = 1;; attempt++) {
|
|
29
|
+
const result = tryAcquire(companyDir, now);
|
|
30
|
+
if (result !== "busy")
|
|
31
|
+
return result;
|
|
32
|
+
if (attempt >= BUSY_ATTEMPTS) {
|
|
33
|
+
const owner = readOwner(companyDir);
|
|
34
|
+
return owner === undefined ? { kind: "held" } : { kind: "held", owner };
|
|
35
|
+
}
|
|
36
|
+
// Jitter keeps two racers that failed together from retrying in lockstep.
|
|
37
|
+
pause(BUSY_PAUSE_MS / 2 + Math.random() * BUSY_PAUSE_MS);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
function tryAcquire(companyDir, now) {
|
|
41
|
+
const db = new Database(join(companyDir, LOCK_DB), { timeout: 0 });
|
|
42
|
+
try {
|
|
43
|
+
// A table and a row inside the open transaction: the exclusive pager lock exists only once a page is
|
|
44
|
+
// written, so an empty database would not keep a second process out.
|
|
45
|
+
// No `locking_mode = EXCLUSIVE`: in that mode a connection keeps its shared lock after a failed upgrade, so two
|
|
46
|
+
// racers each holding one would both be refused until both gave up. The open exclusive transaction below holds
|
|
47
|
+
// the lock for the life of the connection on its own.
|
|
48
|
+
db.pragma("journal_mode = DELETE");
|
|
49
|
+
db.exec("CREATE TABLE IF NOT EXISTS owner (pid INTEGER NOT NULL, started_at TEXT NOT NULL)");
|
|
50
|
+
db.exec("BEGIN EXCLUSIVE");
|
|
51
|
+
db.prepare("DELETE FROM owner").run();
|
|
52
|
+
db.prepare("INSERT INTO owner (pid, started_at) VALUES (?, ?)").run(process.pid, now.toISOString());
|
|
53
|
+
}
|
|
54
|
+
catch (error) {
|
|
55
|
+
db.close();
|
|
56
|
+
if (String(error.code ?? "").startsWith("SQLITE_BUSY"))
|
|
57
|
+
return "busy";
|
|
58
|
+
throw error;
|
|
59
|
+
}
|
|
60
|
+
HELD.add(db);
|
|
61
|
+
try {
|
|
62
|
+
const tmp = join(companyDir, `${OWNER}.${process.pid}.tmp`);
|
|
63
|
+
writeFileSync(tmp, `${JSON.stringify({ pid: process.pid, startedAt: now.toISOString() })}\n`);
|
|
64
|
+
renameSync(tmp, join(companyDir, OWNER));
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
// The lock must not outlive a failure to describe it.
|
|
68
|
+
try {
|
|
69
|
+
db.exec("ROLLBACK");
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
// closing releases the lock either way
|
|
73
|
+
}
|
|
74
|
+
db.close();
|
|
75
|
+
HELD.delete(db);
|
|
76
|
+
throw error;
|
|
77
|
+
}
|
|
78
|
+
let closed = false;
|
|
79
|
+
return {
|
|
80
|
+
kind: "exclusive",
|
|
81
|
+
close: () => {
|
|
82
|
+
if (closed)
|
|
83
|
+
return;
|
|
84
|
+
closed = true;
|
|
85
|
+
try {
|
|
86
|
+
db.exec("ROLLBACK");
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
// the transaction may already be gone; closing releases the lock either way
|
|
90
|
+
}
|
|
91
|
+
db.close();
|
|
92
|
+
HELD.delete(db);
|
|
93
|
+
rmSync(join(companyDir, OWNER), { force: true });
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
const OFFSET_DATETIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?(?:Z|[+-]\d{2}:\d{2})$/;
|
|
98
|
+
/**
|
|
99
|
+
* The holder named by `owner.json`, for `status` while this process runs in the private fallback: a positive
|
|
100
|
+
* integer pid and an offset date-time, else nothing, plus whether that process answers the signal-zero test now.
|
|
101
|
+
*/
|
|
102
|
+
export function readLockOwner(companyDir) {
|
|
103
|
+
const owner = readOwner(companyDir);
|
|
104
|
+
if (owner === undefined)
|
|
105
|
+
return undefined;
|
|
106
|
+
if (!Number.isInteger(owner.pid) || owner.pid <= 0 || !OFFSET_DATETIME.test(owner.startedAt))
|
|
107
|
+
return undefined;
|
|
108
|
+
return { pid: owner.pid, startedAt: owner.startedAt, alive: processAlive(owner.pid) };
|
|
109
|
+
}
|
|
110
|
+
function readOwner(companyDir) {
|
|
111
|
+
try {
|
|
112
|
+
const parsed = JSON.parse(readFileSync(join(companyDir, OWNER), "utf8"));
|
|
113
|
+
if (typeof parsed === "object" &&
|
|
114
|
+
parsed !== null &&
|
|
115
|
+
typeof parsed.pid === "number" &&
|
|
116
|
+
typeof parsed.startedAt === "string") {
|
|
117
|
+
return { pid: parsed.pid, startedAt: parsed.startedAt };
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
// no owner file, or not ours to read
|
|
122
|
+
}
|
|
123
|
+
return undefined;
|
|
124
|
+
}
|
|
125
|
+
/** The private working folder of a process that could not take the lock. */
|
|
126
|
+
export function privateDir(companyDir, pid) {
|
|
127
|
+
return join(companyDir, "private", String(pid));
|
|
128
|
+
}
|
|
129
|
+
/** Removes the private folders of processes that are gone, or that never were (a name that is not a positive integer). */
|
|
130
|
+
export function sweepPrivate(companyDir, isAlive) {
|
|
131
|
+
const folder = join(companyDir, "private");
|
|
132
|
+
if (!existsSync(folder))
|
|
133
|
+
return [];
|
|
134
|
+
const removed = [];
|
|
135
|
+
for (const name of readdirSync(folder)) {
|
|
136
|
+
const pid = /^[1-9]\d*$/.test(name) ? Number(name) : undefined;
|
|
137
|
+
if (pid !== undefined && Number.isSafeInteger(pid) && isAlive(pid))
|
|
138
|
+
continue;
|
|
139
|
+
rmSync(join(folder, name), { recursive: true, force: true });
|
|
140
|
+
removed.push(name);
|
|
141
|
+
}
|
|
142
|
+
return removed;
|
|
143
|
+
}
|
|
144
|
+
/** Whether a process exists, by the signal-zero test; a pid that is not a positive integer never does. */
|
|
145
|
+
export function processAlive(pid) {
|
|
146
|
+
if (!Number.isInteger(pid) || pid <= 0)
|
|
147
|
+
return false;
|
|
148
|
+
try {
|
|
149
|
+
process.kill(pid, 0);
|
|
150
|
+
return true;
|
|
151
|
+
}
|
|
152
|
+
catch (error) {
|
|
153
|
+
return error.code === "EPERM";
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
/** Makes the private folder for this process. */
|
|
157
|
+
export function makePrivateDir(companyDir, pid) {
|
|
158
|
+
const dir = privateDir(companyDir, pid);
|
|
159
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
160
|
+
return dir;
|
|
161
|
+
}
|
|
162
|
+
//# sourceMappingURL=company-lock.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"company-lock.js","sourceRoot":"","sources":["../../src/fs/company-lock.ts"],"names":[],"mappings":"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","sourcesContent":["import {\n existsSync,\n mkdirSync,\n readdirSync,\n readFileSync,\n renameSync,\n rmSync,\n writeFileSync,\n} from \"node:fs\";\nimport { join } from \"node:path\";\nimport Database from \"better-sqlite3\";\nimport type { LockOwnerStatus } from \"../catalog/runtime.js\";\n\nexport interface LockOwner {\n pid: number;\n startedAt: string;\n}\n\nexport type Lock = { kind: \"exclusive\"; close(): void } | { kind: \"held\"; owner?: LockOwner };\n\nconst LOCK_DB = \"lock.sqlite\";\nconst OWNER = \"owner.json\";\n\n/**\n * Connections that hold a lock. A connection referenced only through a closure can be garbage-collected, and the\n * binding then closes it, which releases the operating-system lock while the process still runs (observed: a\n * fresh process acquired the lock a holder believed it held). The set keeps every holder alive until `close()`.\n */\nconst HELD = new Set<Database.Database>();\n\n/** How many times a busy lock is retried, and the pause between tries: about 200 ms before a folder counts as held. */\nconst BUSY_ATTEMPTS = 25;\nconst BUSY_PAUSE_MS = 8;\n\nconst pause = (ms: number): void => {\n Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);\n};\n\n/**\n * One process per company folder, enforced by the operating system: an exclusive SQLite transaction on\n * `lock.sqlite`, held for the life of the process and released the instant it dies, however it dies. There is\n * no stale state to recover and no liveness test to get wrong. `owner.json` beside it is information only.\n *\n * A busy answer is retried briefly: on a cold folder two racers both create the database and each setup step\n * takes a lock of its own, so without the retry both could see busy and neither would hold the lock (observed 4 in\n * 100 cold races, 17 in 100 after a holder was killed). A folder still busy after the retries is held.\n */\nexport function acquireLock(companyDir: string, now: Date): Lock {\n for (let attempt = 1; ; attempt++) {\n const result = tryAcquire(companyDir, now);\n if (result !== \"busy\") return result;\n if (attempt >= BUSY_ATTEMPTS) {\n const owner = readOwner(companyDir);\n return owner === undefined ? { kind: \"held\" } : { kind: \"held\", owner };\n }\n // Jitter keeps two racers that failed together from retrying in lockstep.\n pause(BUSY_PAUSE_MS / 2 + Math.random() * BUSY_PAUSE_MS);\n }\n}\n\nfunction tryAcquire(companyDir: string, now: Date): Extract<Lock, { kind: \"exclusive\" }> | \"busy\" {\n const db = new Database(join(companyDir, LOCK_DB), { timeout: 0 });\n try {\n // A table and a row inside the open transaction: the exclusive pager lock exists only once a page is\n // written, so an empty database would not keep a second process out.\n // No `locking_mode = EXCLUSIVE`: in that mode a connection keeps its shared lock after a failed upgrade, so two\n // racers each holding one would both be refused until both gave up. The open exclusive transaction below holds\n // the lock for the life of the connection on its own.\n db.pragma(\"journal_mode = DELETE\");\n db.exec(\"CREATE TABLE IF NOT EXISTS owner (pid INTEGER NOT NULL, started_at TEXT NOT NULL)\");\n db.exec(\"BEGIN EXCLUSIVE\");\n db.prepare(\"DELETE FROM owner\").run();\n db.prepare(\"INSERT INTO owner (pid, started_at) VALUES (?, ?)\").run(\n process.pid,\n now.toISOString(),\n );\n } catch (error) {\n db.close();\n if (String((error as { code?: string }).code ?? \"\").startsWith(\"SQLITE_BUSY\")) return \"busy\";\n throw error;\n }\n HELD.add(db);\n try {\n const tmp = join(companyDir, `${OWNER}.${process.pid}.tmp`);\n writeFileSync(tmp, `${JSON.stringify({ pid: process.pid, startedAt: now.toISOString() })}\\n`);\n renameSync(tmp, join(companyDir, OWNER));\n } catch (error) {\n // The lock must not outlive a failure to describe it.\n try {\n db.exec(\"ROLLBACK\");\n } catch {\n // closing releases the lock either way\n }\n db.close();\n HELD.delete(db);\n throw error;\n }\n let closed = false;\n return {\n kind: \"exclusive\",\n close: () => {\n if (closed) return;\n closed = true;\n try {\n db.exec(\"ROLLBACK\");\n } catch {\n // the transaction may already be gone; closing releases the lock either way\n }\n db.close();\n HELD.delete(db);\n rmSync(join(companyDir, OWNER), { force: true });\n },\n };\n}\n\nconst OFFSET_DATETIME = /^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(?:\\.\\d{1,9})?(?:Z|[+-]\\d{2}:\\d{2})$/;\n\n/**\n * The holder named by `owner.json`, for `status` while this process runs in the private fallback: a positive\n * integer pid and an offset date-time, else nothing, plus whether that process answers the signal-zero test now.\n */\nexport function readLockOwner(companyDir: string): LockOwnerStatus | undefined {\n const owner = readOwner(companyDir);\n if (owner === undefined) return undefined;\n if (!Number.isInteger(owner.pid) || owner.pid <= 0 || !OFFSET_DATETIME.test(owner.startedAt))\n return undefined;\n return { pid: owner.pid, startedAt: owner.startedAt, alive: processAlive(owner.pid) };\n}\n\nfunction readOwner(companyDir: string): LockOwner | undefined {\n try {\n const parsed = JSON.parse(readFileSync(join(companyDir, OWNER), \"utf8\")) as unknown;\n if (\n typeof parsed === \"object\" &&\n parsed !== null &&\n typeof (parsed as LockOwner).pid === \"number\" &&\n typeof (parsed as LockOwner).startedAt === \"string\"\n ) {\n return { pid: (parsed as LockOwner).pid, startedAt: (parsed as LockOwner).startedAt };\n }\n } catch {\n // no owner file, or not ours to read\n }\n return undefined;\n}\n\n/** The private working folder of a process that could not take the lock. */\nexport function privateDir(companyDir: string, pid: number): string {\n return join(companyDir, \"private\", String(pid));\n}\n\n/** Removes the private folders of processes that are gone, or that never were (a name that is not a positive integer). */\nexport function sweepPrivate(companyDir: string, isAlive: (pid: number) => boolean): string[] {\n const folder = join(companyDir, \"private\");\n if (!existsSync(folder)) return [];\n const removed: string[] = [];\n for (const name of readdirSync(folder)) {\n const pid = /^[1-9]\\d*$/.test(name) ? Number(name) : undefined;\n if (pid !== undefined && Number.isSafeInteger(pid) && isAlive(pid)) continue;\n rmSync(join(folder, name), { recursive: true, force: true });\n removed.push(name);\n }\n return removed;\n}\n\n/** Whether a process exists, by the signal-zero test; a pid that is not a positive integer never does. */\nexport function processAlive(pid: number): boolean {\n if (!Number.isInteger(pid) || pid <= 0) return false;\n try {\n process.kill(pid, 0);\n return true;\n } catch (error) {\n return (error as NodeJS.ErrnoException).code === \"EPERM\";\n }\n}\n\n/** Makes the private folder for this process. */\nexport function makePrivateDir(companyDir: string, pid: number): string {\n const dir = privateDir(companyDir, pid);\n mkdirSync(dir, { recursive: true, mode: 0o700 });\n return dir;\n}\n"]}
|