@mostlyrightmd/core 0.1.0-rc.7

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.
Files changed (95) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +3 -0
  3. package/dist/discovery/index.cjs +1646 -0
  4. package/dist/discovery/index.cjs.map +1 -0
  5. package/dist/discovery/index.d.cts +313 -0
  6. package/dist/discovery/index.d.ts +313 -0
  7. package/dist/discovery/index.mjs +1609 -0
  8. package/dist/discovery/index.mjs.map +1 -0
  9. package/dist/formats/index.cjs +498 -0
  10. package/dist/formats/index.cjs.map +1 -0
  11. package/dist/formats/index.d.cts +97 -0
  12. package/dist/formats/index.d.ts +97 -0
  13. package/dist/formats/index.mjs +465 -0
  14. package/dist/formats/index.mjs.map +1 -0
  15. package/dist/index.cjs +1624 -0
  16. package/dist/index.cjs.map +1 -0
  17. package/dist/index.d.cts +559 -0
  18. package/dist/index.d.ts +559 -0
  19. package/dist/index.global.js +1582 -0
  20. package/dist/index.global.js.map +1 -0
  21. package/dist/index.mjs +1557 -0
  22. package/dist/index.mjs.map +1 -0
  23. package/dist/internal/bounds.cjs +125 -0
  24. package/dist/internal/bounds.cjs.map +1 -0
  25. package/dist/internal/bounds.d.cts +36 -0
  26. package/dist/internal/bounds.d.ts +36 -0
  27. package/dist/internal/bounds.mjs +81 -0
  28. package/dist/internal/bounds.mjs.map +1 -0
  29. package/dist/internal/cache/fs.cjs +217 -0
  30. package/dist/internal/cache/fs.cjs.map +1 -0
  31. package/dist/internal/cache/fs.d.cts +57 -0
  32. package/dist/internal/cache/fs.d.ts +57 -0
  33. package/dist/internal/cache/fs.mjs +179 -0
  34. package/dist/internal/cache/fs.mjs.map +1 -0
  35. package/dist/internal/cache/index.browser.cjs +1184 -0
  36. package/dist/internal/cache/index.browser.cjs.map +1 -0
  37. package/dist/internal/cache/index.browser.d.cts +20 -0
  38. package/dist/internal/cache/index.browser.d.ts +20 -0
  39. package/dist/internal/cache/index.browser.mjs +36 -0
  40. package/dist/internal/cache/index.browser.mjs.map +1 -0
  41. package/dist/internal/cache/index.cjs +1389 -0
  42. package/dist/internal/cache/index.cjs.map +1 -0
  43. package/dist/internal/cache/index.d.cts +16 -0
  44. package/dist/internal/cache/index.d.ts +16 -0
  45. package/dist/internal/cache/index.mjs +40 -0
  46. package/dist/internal/cache/index.mjs.map +1 -0
  47. package/dist/internal/chunk-PKJXHY27.mjs +1137 -0
  48. package/dist/internal/chunk-PKJXHY27.mjs.map +1 -0
  49. package/dist/internal/convert.cjs +161 -0
  50. package/dist/internal/convert.cjs.map +1 -0
  51. package/dist/internal/convert.d.cts +44 -0
  52. package/dist/internal/convert.d.ts +44 -0
  53. package/dist/internal/convert.mjs +117 -0
  54. package/dist/internal/convert.mjs.map +1 -0
  55. package/dist/internal/fs-O6XR4WWW.mjs +183 -0
  56. package/dist/internal/fs-O6XR4WWW.mjs.map +1 -0
  57. package/dist/internal/keys-B7C8C88N.d.cts +191 -0
  58. package/dist/internal/keys-B7C8C88N.d.ts +191 -0
  59. package/dist/internal/merge/index.cjs +75 -0
  60. package/dist/internal/merge/index.cjs.map +1 -0
  61. package/dist/internal/merge/index.d.cts +74 -0
  62. package/dist/internal/merge/index.d.ts +74 -0
  63. package/dist/internal/merge/index.mjs +46 -0
  64. package/dist/internal/merge/index.mjs.map +1 -0
  65. package/dist/internal/pairs.cjs +328 -0
  66. package/dist/internal/pairs.cjs.map +1 -0
  67. package/dist/internal/pairs.d.cts +105 -0
  68. package/dist/internal/pairs.d.ts +105 -0
  69. package/dist/internal/pairs.mjs +298 -0
  70. package/dist/internal/pairs.mjs.map +1 -0
  71. package/dist/qc/index.cjs +247 -0
  72. package/dist/qc/index.cjs.map +1 -0
  73. package/dist/qc/index.d.cts +140 -0
  74. package/dist/qc/index.d.ts +140 -0
  75. package/dist/qc/index.mjs +212 -0
  76. package/dist/qc/index.mjs.map +1 -0
  77. package/dist/temporal/index.cjs +504 -0
  78. package/dist/temporal/index.cjs.map +1 -0
  79. package/dist/temporal/index.d.cts +121 -0
  80. package/dist/temporal/index.d.ts +121 -0
  81. package/dist/temporal/index.mjs +474 -0
  82. package/dist/temporal/index.mjs.map +1 -0
  83. package/dist/transforms/index.cjs +399 -0
  84. package/dist/transforms/index.cjs.map +1 -0
  85. package/dist/transforms/index.d.cts +193 -0
  86. package/dist/transforms/index.d.ts +193 -0
  87. package/dist/transforms/index.mjs +362 -0
  88. package/dist/transforms/index.mjs.map +1 -0
  89. package/dist/validator.cjs +1870 -0
  90. package/dist/validator.cjs.map +1 -0
  91. package/dist/validator.d.cts +30 -0
  92. package/dist/validator.d.ts +30 -0
  93. package/dist/validator.mjs +1843 -0
  94. package/dist/validator.mjs.map +1 -0
  95. package/package.json +115 -0
@@ -0,0 +1,217 @@
1
+ "use strict";
2
+ var __create = Object.create;
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
7
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ var __export = (target, all) => {
9
+ for (var name in all)
10
+ __defProp(target, name, { get: all[name], enumerable: true });
11
+ };
12
+ var __copyProps = (to, from, except, desc) => {
13
+ if (from && typeof from === "object" || typeof from === "function") {
14
+ for (let key of __getOwnPropNames(from))
15
+ if (!__hasOwnProp.call(to, key) && key !== except)
16
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
17
+ }
18
+ return to;
19
+ };
20
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
21
+ // If the importer is in node compatibility mode or this is not an ESM
22
+ // file that has been converted to a CommonJS file using a Babel-
23
+ // compatible transform (i.e. "__esModule" has not been set), then set
24
+ // "default" to the CommonJS "module.exports" for node compatibility.
25
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
26
+ mod
27
+ ));
28
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
29
+
30
+ // src/internal/cache/fs-entry.ts
31
+ var fs_entry_exports = {};
32
+ __export(fs_entry_exports, {
33
+ FsStore: () => FsStore,
34
+ defaultFsRoot: () => defaultFsRoot
35
+ });
36
+ module.exports = __toCommonJS(fs_entry_exports);
37
+
38
+ // src/internal/cache/fs.ts
39
+ var import_node_crypto = require("crypto");
40
+ var import_promises = require("fs/promises");
41
+ var import_node_os = require("os");
42
+ var import_node_path = require("path");
43
+ var properLockfile = __toESM(require("proper-lockfile"), 1);
44
+ var _legacyCacheDirWarned = false;
45
+ function defaultFsRoot() {
46
+ const canonical = process.env.MOSTLYRIGHT_CACHE_DIR;
47
+ if (canonical !== void 0 && canonical.length > 0) return canonical;
48
+ const legacy = process.env.TRADEWINDS_CACHE_DIR;
49
+ if (legacy !== void 0 && legacy.length > 0) {
50
+ if (!_legacyCacheDirWarned) {
51
+ console.warn(
52
+ "TRADEWINDS_CACHE_DIR is deprecated; use MOSTLYRIGHT_CACHE_DIR. Support will be removed in vts-0.3. Run: mv ~/.tradewinds ~/.mostlyright"
53
+ );
54
+ _legacyCacheDirWarned = true;
55
+ }
56
+ return legacy;
57
+ }
58
+ return (0, import_node_path.join)((0, import_node_os.homedir)(), ".mostlyright", "cache-ts");
59
+ }
60
+ var FsStore = class {
61
+ #root;
62
+ // In-process per-key promise chain. proper-lockfile guarantees
63
+ // cross-process exclusion but its retry-based contention resolution
64
+ // is order-non-deterministic — two in-process callers racing on
65
+ // `lock()` may acquire in either order. Layering an in-process chain
66
+ // ensures strict FIFO for callers within the same Node process (and
67
+ // serves as a cheap fast-path: only one of N in-process callers ever
68
+ // actually contends with proper-lockfile).
69
+ #chain = /* @__PURE__ */ new Map();
70
+ constructor(opts = {}) {
71
+ this.#root = opts.root ?? defaultFsRoot();
72
+ }
73
+ /**
74
+ * Path resolver — `key` is sanitized via `encodeURIComponent` so that
75
+ *
76
+ * 1. `:` `/` `\` cannot escape the root (all percent-encoded), and
77
+ * 2. the key → file mapping is INJECTIVE: distinct keys always map to
78
+ * distinct files.
79
+ *
80
+ * Iter-13 C16 fix: the previous implementation collapsed `:` / `/` / `\`
81
+ * to the literal substring `"__"`, which is a lossy mapping — `"a:b"`,
82
+ * `"a/b"`, and the literal `"a__b"` all hashed to `a__b.json`, so one
83
+ * key's write would silently overwrite (and corrupt subsequent reads of)
84
+ * another key. `encodeURIComponent` is bijective on string inputs and
85
+ * filesystem-safe on every platform we ship (POSIX + Windows): the
86
+ * characters it leaves unescaped (alphanumerics, `-._~!*'()`) are all
87
+ * legal in NTFS, APFS, and ext4 filenames, and `%` is itself legal.
88
+ *
89
+ * BREAKING in v0.1.0: on-disk cache files written by any prior
90
+ * pre-release of this package use the old `__`-replacement scheme and
91
+ * are unreadable after upgrade. This is acceptable for a local-first
92
+ * cache: entries are regenerated on demand from live data, and the
93
+ * cache directory can be safely deleted by the user.
94
+ */
95
+ #pathFor(key) {
96
+ const safe = encodeURIComponent(key);
97
+ return (0, import_node_path.join)(this.#root, `${safe}.json`);
98
+ }
99
+ async get(key) {
100
+ const p = this.#pathFor(key);
101
+ let raw;
102
+ try {
103
+ raw = await (0, import_promises.readFile)(p, "utf8");
104
+ } catch (e) {
105
+ const code = e.code;
106
+ if (code === "ENOENT") return null;
107
+ throw e;
108
+ }
109
+ let entry;
110
+ try {
111
+ entry = JSON.parse(raw);
112
+ } catch {
113
+ return null;
114
+ }
115
+ if (entry.expiresAt !== void 0 && Date.now() >= entry.expiresAt) {
116
+ try {
117
+ await (0, import_promises.rm)(p, { force: true });
118
+ } catch {
119
+ }
120
+ return null;
121
+ }
122
+ return entry.value;
123
+ }
124
+ async set(key, value, opts) {
125
+ const p = this.#pathFor(key);
126
+ await (0, import_promises.mkdir)((0, import_node_path.dirname)(p), { recursive: true });
127
+ const entry = opts?.ttlMs !== void 0 ? { value, expiresAt: Date.now() + opts.ttlMs } : { value };
128
+ const tmp = `${p}.${(0, import_node_crypto.randomUUID)()}.tmp`;
129
+ try {
130
+ await (0, import_promises.writeFile)(tmp, JSON.stringify(entry), "utf8");
131
+ await (0, import_promises.rename)(tmp, p);
132
+ } catch (e) {
133
+ try {
134
+ await (0, import_promises.rm)(tmp, { force: true });
135
+ } catch {
136
+ }
137
+ throw e;
138
+ }
139
+ }
140
+ async delete(key) {
141
+ const p = this.#pathFor(key);
142
+ try {
143
+ await (0, import_promises.rm)(p, { force: true });
144
+ } catch (e) {
145
+ const code = e.code;
146
+ if (code === "ENOENT") return;
147
+ throw e;
148
+ }
149
+ }
150
+ /**
151
+ * Enumerate keys whose stored files exist under the cache root and whose
152
+ * decoded form starts with `prefix`.
153
+ *
154
+ * Returns an empty list if the root directory does not exist (cold cache).
155
+ *
156
+ * TS-W6 Wave 1: used by `availability()` to count observation months and
157
+ * climate years for a station. The file→key mapping is the inverse of
158
+ * `#pathFor` (encodeURIComponent → strip `.json` → decodeURIComponent).
159
+ */
160
+ async listKeys(prefix) {
161
+ let entries;
162
+ try {
163
+ entries = await (0, import_promises.readdir)(this.#root);
164
+ } catch (e) {
165
+ const code = e.code;
166
+ if (code === "ENOENT") return Object.freeze([]);
167
+ throw e;
168
+ }
169
+ const out = [];
170
+ for (const name of entries) {
171
+ if (!name.endsWith(".json")) continue;
172
+ const encoded = name.slice(0, -".json".length);
173
+ let decoded;
174
+ try {
175
+ decoded = decodeURIComponent(encoded);
176
+ } catch {
177
+ continue;
178
+ }
179
+ if (decoded.startsWith(prefix)) {
180
+ out.push(decoded);
181
+ }
182
+ }
183
+ return Object.freeze(out);
184
+ }
185
+ async withLock(key, fn) {
186
+ const p = this.#pathFor(key);
187
+ const prev = this.#chain.get(key) ?? Promise.resolve();
188
+ const run = async () => {
189
+ await (0, import_promises.mkdir)((0, import_node_path.dirname)(p), { recursive: true });
190
+ const release = await properLockfile.lock(p, {
191
+ realpath: false,
192
+ retries: { retries: 5, minTimeout: 20, maxTimeout: 200 }
193
+ });
194
+ try {
195
+ return await fn();
196
+ } finally {
197
+ await release();
198
+ }
199
+ };
200
+ const next = prev.then(run, run);
201
+ const absorbed = next.then(
202
+ () => void 0,
203
+ () => void 0
204
+ );
205
+ this.#chain.set(key, absorbed);
206
+ absorbed.finally(() => {
207
+ if (this.#chain.get(key) === absorbed) this.#chain.delete(key);
208
+ });
209
+ return next;
210
+ }
211
+ };
212
+ // Annotate the CommonJS export names for ESM import in node:
213
+ 0 && (module.exports = {
214
+ FsStore,
215
+ defaultFsRoot
216
+ });
217
+ //# sourceMappingURL=fs.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../src/internal/cache/fs-entry.ts","../../../src/internal/cache/fs.ts"],"sourcesContent":["// Node-only subpath entry for @mostlyrightmd/core/internal/cache/fs.\n//\n// Iter-2 H5: FsStore + defaultFsRoot were removed from the cache\n// barrel (`./internal/cache/index.ts`) because the re-export pulled\n// `node:fs/promises`, `node:os`, `node:path`, `node:crypto`,\n// `proper-lockfile` into the browser-facing cache subbundle (even\n// though `defaultCacheStore` uses dynamic `import('./fs.js')`).\n// This dedicated subpath exists so Node-side consumers (FsStore unit\n// tests + downstream callers who explicitly want a filesystem store)\n// can import FsStore without dragging the whole cache barrel into a\n// browser bundle.\n//\n// Package.json maps `@mostlyrightmd/core/internal/cache/fs` → this file.\n// tsup config emits it as a sibling dist entry (`dist/internal/cache/\n// fs.{mjs,cjs}`); the cache subbundle has no static import to this\n// file, so tree-shaking keeps Node imports out of MV3 bundles.\n\nexport { FsStore, defaultFsRoot } from \"./fs.js\";\nexport type { FsStoreOptions } from \"./fs.js\";\n","// FsStore — node:fs/promises + proper-lockfile CacheStore for Node runtimes.\n//\n// Path layout under the configured root:\n//\n// <root>/<sanitized-key>.json\n//\n// Where `<root>` defaults to\n// `$MOSTLYRIGHT_CACHE_DIR ?? $TRADEWINDS_CACHE_DIR (legacy + warn) ??\n// $HOME/.mostlyright/cache-ts` (per TS-CACHE-02 — distinct from Python's\n// `.mostlyright/cache` so the JSON envelopes here can't shadow Python's\n// parquet files). Phase 12 W4 + review-iter2: mirrors the Python back-compat\n// shim semantics — canonical → legacy + DeprecationWarning → default.\n//\n// Atomic write: payload is written to `<path>.tmp` then renamed onto\n// `<path>` (POSIX-atomic; Windows-safe via `fs.rename`).\n//\n// withLock uses proper-lockfile against a `<path>.lock` sidecar so two\n// concurrent writers serialize per-key. The lock is taken with\n// `realpath: false` so we can lock a path whose target may not yet\n// exist.\n//\n// Divergence from Python: the Python cache is parquet + per-month station\n// files keyed by (station, year, month); TS is JSON + per-key files keyed\n// by the caller's opaque string. Station-aware key generation lives in\n// `keys.ts` (plan 03).\n\nimport { randomUUID } from \"node:crypto\";\nimport { mkdir, readFile, readdir, rename, rm, writeFile } from \"node:fs/promises\";\nimport { homedir } from \"node:os\";\nimport { dirname, join } from \"node:path\";\n\nimport * as properLockfile from \"proper-lockfile\";\n\nimport type { CacheEntry, CacheSetOptions, CacheStore } from \"./types.js\";\n\n/**\n * Resolve the cache root on each call (not cached at module load) so tests\n * can `vi.stubEnv(\"MOSTLYRIGHT_CACHE_DIR\", ...)` between cases without a\n * module reload.\n *\n * Resolution order (Phase 12 W4 + review-iter2 — mirrors Python shim):\n * 1. `MOSTLYRIGHT_CACHE_DIR` env var (canonical, post-Phase-12).\n * 2. `TRADEWINDS_CACHE_DIR` env var (legacy; emits a one-time deprecation\n * `console.warn`; scheduled for removal in vts-0.3).\n * 3. `~/.mostlyright/cache-ts` (per TS-CACHE-02 — DISTINCT from Python's\n * `~/.mostlyright/cache` so JSON envelopes here can't shadow Python's\n * parquet files).\n */\nlet _legacyCacheDirWarned = false;\n\nexport function defaultFsRoot(): string {\n const canonical = process.env.MOSTLYRIGHT_CACHE_DIR;\n if (canonical !== undefined && canonical.length > 0) return canonical;\n const legacy = process.env.TRADEWINDS_CACHE_DIR;\n if (legacy !== undefined && legacy.length > 0) {\n if (!_legacyCacheDirWarned) {\n console.warn(\n \"TRADEWINDS_CACHE_DIR is deprecated; use MOSTLYRIGHT_CACHE_DIR. \" +\n \"Support will be removed in vts-0.3. \" +\n \"Run: mv ~/.tradewinds ~/.mostlyright\",\n );\n _legacyCacheDirWarned = true;\n }\n return legacy;\n }\n return join(homedir(), \".mostlyright\", \"cache-ts\");\n}\n\n/** Reset the one-time TRADEWINDS_CACHE_DIR deprecation latch — TEST USE ONLY. */\nexport function _resetLegacyCacheDirWarn(): void {\n _legacyCacheDirWarned = false;\n}\n\nexport interface FsStoreOptions {\n /** Override root directory. Defaults to {@link defaultFsRoot}. */\n readonly root?: string;\n}\n\n/**\n * Node-side CacheStore. Each key maps to one JSON file under the root.\n */\nexport class FsStore implements CacheStore {\n readonly #root: string;\n // In-process per-key promise chain. proper-lockfile guarantees\n // cross-process exclusion but its retry-based contention resolution\n // is order-non-deterministic — two in-process callers racing on\n // `lock()` may acquire in either order. Layering an in-process chain\n // ensures strict FIFO for callers within the same Node process (and\n // serves as a cheap fast-path: only one of N in-process callers ever\n // actually contends with proper-lockfile).\n readonly #chain = new Map<string, Promise<unknown>>();\n\n constructor(opts: FsStoreOptions = {}) {\n this.#root = opts.root ?? defaultFsRoot();\n }\n\n /**\n * Path resolver — `key` is sanitized via `encodeURIComponent` so that\n *\n * 1. `:` `/` `\\` cannot escape the root (all percent-encoded), and\n * 2. the key → file mapping is INJECTIVE: distinct keys always map to\n * distinct files.\n *\n * Iter-13 C16 fix: the previous implementation collapsed `:` / `/` / `\\`\n * to the literal substring `\"__\"`, which is a lossy mapping — `\"a:b\"`,\n * `\"a/b\"`, and the literal `\"a__b\"` all hashed to `a__b.json`, so one\n * key's write would silently overwrite (and corrupt subsequent reads of)\n * another key. `encodeURIComponent` is bijective on string inputs and\n * filesystem-safe on every platform we ship (POSIX + Windows): the\n * characters it leaves unescaped (alphanumerics, `-._~!*'()`) are all\n * legal in NTFS, APFS, and ext4 filenames, and `%` is itself legal.\n *\n * BREAKING in v0.1.0: on-disk cache files written by any prior\n * pre-release of this package use the old `__`-replacement scheme and\n * are unreadable after upgrade. This is acceptable for a local-first\n * cache: entries are regenerated on demand from live data, and the\n * cache directory can be safely deleted by the user.\n */\n #pathFor(key: string): string {\n const safe = encodeURIComponent(key);\n return join(this.#root, `${safe}.json`);\n }\n\n async get<T = unknown>(key: string): Promise<T | null> {\n const p = this.#pathFor(key);\n let raw: string;\n try {\n raw = await readFile(p, \"utf8\");\n } catch (e: unknown) {\n const code = (e as { code?: string }).code;\n if (code === \"ENOENT\") return null;\n throw e;\n }\n let entry: CacheEntry<T>;\n try {\n entry = JSON.parse(raw) as CacheEntry<T>;\n } catch {\n // Corrupt cache entry — treat as miss; do NOT throw. Caller\n // re-fetches and overwrites.\n return null;\n }\n if (entry.expiresAt !== undefined && Date.now() >= entry.expiresAt) {\n // Lazy-evict: best-effort unlink, ignore failures.\n try {\n await rm(p, { force: true });\n } catch {\n // ignore\n }\n return null;\n }\n return entry.value as T;\n }\n\n async set<T = unknown>(key: string, value: T, opts?: CacheSetOptions): Promise<void> {\n const p = this.#pathFor(key);\n await mkdir(dirname(p), { recursive: true });\n const entry: CacheEntry<T> =\n opts?.ttlMs !== undefined ? { value, expiresAt: Date.now() + opts.ttlMs } : { value };\n // Codex iter-2 C6: use a UNIQUE temp filename per write. Two concurrent\n // `set(\"same-key\", ...)` calls would otherwise race on the shared\n // `<path>.tmp`: writer A creates `<path>.tmp` and renames it to\n // `<path>`; writer B's subsequent rename then fails with ENOENT\n // because A's rename moved B's-in-progress temp away. With a unique\n // per-write suffix, each writer owns its own temp file; rename-into-\n // place stays atomic on POSIX (last-rename-wins semantics — any of\n // the N concurrent writers' value will be the final cache contents,\n // documented at the test that covers this).\n const tmp = `${p}.${randomUUID()}.tmp`;\n try {\n await writeFile(tmp, JSON.stringify(entry), \"utf8\");\n await rename(tmp, p);\n } catch (e) {\n // Best-effort cleanup if rename failed (e.g. permissions). Don't\n // let a stale unique-temp file leak.\n try {\n await rm(tmp, { force: true });\n } catch {\n // ignore\n }\n throw e;\n }\n }\n\n async delete(key: string): Promise<void> {\n const p = this.#pathFor(key);\n try {\n await rm(p, { force: true });\n } catch (e: unknown) {\n const code = (e as { code?: string }).code;\n if (code === \"ENOENT\") return;\n throw e;\n }\n }\n\n /**\n * Enumerate keys whose stored files exist under the cache root and whose\n * decoded form starts with `prefix`.\n *\n * Returns an empty list if the root directory does not exist (cold cache).\n *\n * TS-W6 Wave 1: used by `availability()` to count observation months and\n * climate years for a station. The file→key mapping is the inverse of\n * `#pathFor` (encodeURIComponent → strip `.json` → decodeURIComponent).\n */\n async listKeys(prefix: string): Promise<ReadonlyArray<string>> {\n let entries: string[];\n try {\n entries = await readdir(this.#root);\n } catch (e: unknown) {\n const code = (e as { code?: string }).code;\n if (code === \"ENOENT\") return Object.freeze([]);\n throw e;\n }\n const out: string[] = [];\n for (const name of entries) {\n if (!name.endsWith(\".json\")) continue;\n // Ignore the proper-lockfile lock sidecars and our own in-flight\n // unique-temp files (`<key>.json.<uuid>.tmp`) — they end with `.tmp`\n // not `.json`, so the suffix filter above already excludes them.\n // The lock directories proper-lockfile creates end with `.json.lock`\n // (a directory entry), also excluded by the `.json` suffix check.\n const encoded = name.slice(0, -\".json\".length);\n let decoded: string;\n try {\n decoded = decodeURIComponent(encoded);\n } catch {\n // Defensive: skip files whose names don't decode (manual placements,\n // partial writes, etc).\n continue;\n }\n if (decoded.startsWith(prefix)) {\n out.push(decoded);\n }\n }\n return Object.freeze(out);\n }\n\n async withLock<T>(key: string, fn: () => Promise<T>): Promise<T> {\n const p = this.#pathFor(key);\n // Chain in-process callers FIFO. Cross-process exclusion is layered\n // on top via proper-lockfile inside `run()`.\n const prev = this.#chain.get(key) ?? Promise.resolve();\n const run = async (): Promise<T> => {\n await mkdir(dirname(p), { recursive: true });\n const release = await properLockfile.lock(p, {\n realpath: false,\n retries: { retries: 5, minTimeout: 20, maxTimeout: 200 },\n });\n try {\n return await fn();\n } finally {\n await release();\n }\n };\n const next = prev.then(run, run);\n // Absorber tail so the chain doesn't leak unhandled rejections.\n const absorbed = next.then(\n () => undefined,\n () => undefined,\n );\n this.#chain.set(key, absorbed);\n absorbed.finally(() => {\n if (this.#chain.get(key) === absorbed) this.#chain.delete(key);\n });\n return next;\n }\n}\n"],"mappings":"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","names":[]}
@@ -0,0 +1,57 @@
1
+ /** Optional setters for cache writes. */
2
+ interface CacheSetOptions {
3
+ /** Time-to-live in milliseconds. Implementations may honor or ignore. */
4
+ readonly ttlMs?: number;
5
+ }
6
+ /**
7
+ * Pluggable key/value cache contract used throughout the SDK.
8
+ *
9
+ * All methods are async — concrete implementations may resolve immediately
10
+ * (MemoryStore) or do I/O (FsStore / IndexedDBStore).
11
+ *
12
+ * Semantic contract:
13
+ * - `get<T>(key)` returns the stored value or `null` on miss. NEVER throws
14
+ * on miss.
15
+ * - `set<T>(key, value, opts?)` overwrites. ttlMs is implementation-honored
16
+ * (MemoryStore + IndexedDBStore honor it; FsStore ignores in v0.1).
17
+ * - `delete(key)` is a no-op on miss; returns void.
18
+ * - `withLock<T>(key, fn)` runs `fn` under a key-scoped exclusive lock and
19
+ * releases on settle (resolve OR throw). Nested calls to the same key
20
+ * serialize; calls to different keys MAY run in parallel.
21
+ */
22
+ interface CacheStore {
23
+ get<T = unknown>(key: string): Promise<T | null>;
24
+ set<T = unknown>(key: string, value: T, opts?: CacheSetOptions): Promise<void>;
25
+ delete(key: string): Promise<void>;
26
+ withLock<T>(key: string, fn: () => Promise<T>): Promise<T>;
27
+ }
28
+
29
+ declare function defaultFsRoot(): string;
30
+ interface FsStoreOptions {
31
+ /** Override root directory. Defaults to {@link defaultFsRoot}. */
32
+ readonly root?: string;
33
+ }
34
+ /**
35
+ * Node-side CacheStore. Each key maps to one JSON file under the root.
36
+ */
37
+ declare class FsStore implements CacheStore {
38
+ #private;
39
+ constructor(opts?: FsStoreOptions);
40
+ get<T = unknown>(key: string): Promise<T | null>;
41
+ set<T = unknown>(key: string, value: T, opts?: CacheSetOptions): Promise<void>;
42
+ delete(key: string): Promise<void>;
43
+ /**
44
+ * Enumerate keys whose stored files exist under the cache root and whose
45
+ * decoded form starts with `prefix`.
46
+ *
47
+ * Returns an empty list if the root directory does not exist (cold cache).
48
+ *
49
+ * TS-W6 Wave 1: used by `availability()` to count observation months and
50
+ * climate years for a station. The file→key mapping is the inverse of
51
+ * `#pathFor` (encodeURIComponent → strip `.json` → decodeURIComponent).
52
+ */
53
+ listKeys(prefix: string): Promise<ReadonlyArray<string>>;
54
+ withLock<T>(key: string, fn: () => Promise<T>): Promise<T>;
55
+ }
56
+
57
+ export { FsStore, type FsStoreOptions, defaultFsRoot };
@@ -0,0 +1,57 @@
1
+ /** Optional setters for cache writes. */
2
+ interface CacheSetOptions {
3
+ /** Time-to-live in milliseconds. Implementations may honor or ignore. */
4
+ readonly ttlMs?: number;
5
+ }
6
+ /**
7
+ * Pluggable key/value cache contract used throughout the SDK.
8
+ *
9
+ * All methods are async — concrete implementations may resolve immediately
10
+ * (MemoryStore) or do I/O (FsStore / IndexedDBStore).
11
+ *
12
+ * Semantic contract:
13
+ * - `get<T>(key)` returns the stored value or `null` on miss. NEVER throws
14
+ * on miss.
15
+ * - `set<T>(key, value, opts?)` overwrites. ttlMs is implementation-honored
16
+ * (MemoryStore + IndexedDBStore honor it; FsStore ignores in v0.1).
17
+ * - `delete(key)` is a no-op on miss; returns void.
18
+ * - `withLock<T>(key, fn)` runs `fn` under a key-scoped exclusive lock and
19
+ * releases on settle (resolve OR throw). Nested calls to the same key
20
+ * serialize; calls to different keys MAY run in parallel.
21
+ */
22
+ interface CacheStore {
23
+ get<T = unknown>(key: string): Promise<T | null>;
24
+ set<T = unknown>(key: string, value: T, opts?: CacheSetOptions): Promise<void>;
25
+ delete(key: string): Promise<void>;
26
+ withLock<T>(key: string, fn: () => Promise<T>): Promise<T>;
27
+ }
28
+
29
+ declare function defaultFsRoot(): string;
30
+ interface FsStoreOptions {
31
+ /** Override root directory. Defaults to {@link defaultFsRoot}. */
32
+ readonly root?: string;
33
+ }
34
+ /**
35
+ * Node-side CacheStore. Each key maps to one JSON file under the root.
36
+ */
37
+ declare class FsStore implements CacheStore {
38
+ #private;
39
+ constructor(opts?: FsStoreOptions);
40
+ get<T = unknown>(key: string): Promise<T | null>;
41
+ set<T = unknown>(key: string, value: T, opts?: CacheSetOptions): Promise<void>;
42
+ delete(key: string): Promise<void>;
43
+ /**
44
+ * Enumerate keys whose stored files exist under the cache root and whose
45
+ * decoded form starts with `prefix`.
46
+ *
47
+ * Returns an empty list if the root directory does not exist (cold cache).
48
+ *
49
+ * TS-W6 Wave 1: used by `availability()` to count observation months and
50
+ * climate years for a station. The file→key mapping is the inverse of
51
+ * `#pathFor` (encodeURIComponent → strip `.json` → decodeURIComponent).
52
+ */
53
+ listKeys(prefix: string): Promise<ReadonlyArray<string>>;
54
+ withLock<T>(key: string, fn: () => Promise<T>): Promise<T>;
55
+ }
56
+
57
+ export { FsStore, type FsStoreOptions, defaultFsRoot };
@@ -0,0 +1,179 @@
1
+ // src/internal/cache/fs.ts
2
+ import { randomUUID } from "node:crypto";
3
+ import { mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
4
+ import { homedir } from "node:os";
5
+ import { dirname, join } from "node:path";
6
+ import * as properLockfile from "proper-lockfile";
7
+ var _legacyCacheDirWarned = false;
8
+ function defaultFsRoot() {
9
+ const canonical = process.env.MOSTLYRIGHT_CACHE_DIR;
10
+ if (canonical !== void 0 && canonical.length > 0) return canonical;
11
+ const legacy = process.env.TRADEWINDS_CACHE_DIR;
12
+ if (legacy !== void 0 && legacy.length > 0) {
13
+ if (!_legacyCacheDirWarned) {
14
+ console.warn(
15
+ "TRADEWINDS_CACHE_DIR is deprecated; use MOSTLYRIGHT_CACHE_DIR. Support will be removed in vts-0.3. Run: mv ~/.tradewinds ~/.mostlyright"
16
+ );
17
+ _legacyCacheDirWarned = true;
18
+ }
19
+ return legacy;
20
+ }
21
+ return join(homedir(), ".mostlyright", "cache-ts");
22
+ }
23
+ var FsStore = class {
24
+ #root;
25
+ // In-process per-key promise chain. proper-lockfile guarantees
26
+ // cross-process exclusion but its retry-based contention resolution
27
+ // is order-non-deterministic — two in-process callers racing on
28
+ // `lock()` may acquire in either order. Layering an in-process chain
29
+ // ensures strict FIFO for callers within the same Node process (and
30
+ // serves as a cheap fast-path: only one of N in-process callers ever
31
+ // actually contends with proper-lockfile).
32
+ #chain = /* @__PURE__ */ new Map();
33
+ constructor(opts = {}) {
34
+ this.#root = opts.root ?? defaultFsRoot();
35
+ }
36
+ /**
37
+ * Path resolver — `key` is sanitized via `encodeURIComponent` so that
38
+ *
39
+ * 1. `:` `/` `\` cannot escape the root (all percent-encoded), and
40
+ * 2. the key → file mapping is INJECTIVE: distinct keys always map to
41
+ * distinct files.
42
+ *
43
+ * Iter-13 C16 fix: the previous implementation collapsed `:` / `/` / `\`
44
+ * to the literal substring `"__"`, which is a lossy mapping — `"a:b"`,
45
+ * `"a/b"`, and the literal `"a__b"` all hashed to `a__b.json`, so one
46
+ * key's write would silently overwrite (and corrupt subsequent reads of)
47
+ * another key. `encodeURIComponent` is bijective on string inputs and
48
+ * filesystem-safe on every platform we ship (POSIX + Windows): the
49
+ * characters it leaves unescaped (alphanumerics, `-._~!*'()`) are all
50
+ * legal in NTFS, APFS, and ext4 filenames, and `%` is itself legal.
51
+ *
52
+ * BREAKING in v0.1.0: on-disk cache files written by any prior
53
+ * pre-release of this package use the old `__`-replacement scheme and
54
+ * are unreadable after upgrade. This is acceptable for a local-first
55
+ * cache: entries are regenerated on demand from live data, and the
56
+ * cache directory can be safely deleted by the user.
57
+ */
58
+ #pathFor(key) {
59
+ const safe = encodeURIComponent(key);
60
+ return join(this.#root, `${safe}.json`);
61
+ }
62
+ async get(key) {
63
+ const p = this.#pathFor(key);
64
+ let raw;
65
+ try {
66
+ raw = await readFile(p, "utf8");
67
+ } catch (e) {
68
+ const code = e.code;
69
+ if (code === "ENOENT") return null;
70
+ throw e;
71
+ }
72
+ let entry;
73
+ try {
74
+ entry = JSON.parse(raw);
75
+ } catch {
76
+ return null;
77
+ }
78
+ if (entry.expiresAt !== void 0 && Date.now() >= entry.expiresAt) {
79
+ try {
80
+ await rm(p, { force: true });
81
+ } catch {
82
+ }
83
+ return null;
84
+ }
85
+ return entry.value;
86
+ }
87
+ async set(key, value, opts) {
88
+ const p = this.#pathFor(key);
89
+ await mkdir(dirname(p), { recursive: true });
90
+ const entry = opts?.ttlMs !== void 0 ? { value, expiresAt: Date.now() + opts.ttlMs } : { value };
91
+ const tmp = `${p}.${randomUUID()}.tmp`;
92
+ try {
93
+ await writeFile(tmp, JSON.stringify(entry), "utf8");
94
+ await rename(tmp, p);
95
+ } catch (e) {
96
+ try {
97
+ await rm(tmp, { force: true });
98
+ } catch {
99
+ }
100
+ throw e;
101
+ }
102
+ }
103
+ async delete(key) {
104
+ const p = this.#pathFor(key);
105
+ try {
106
+ await rm(p, { force: true });
107
+ } catch (e) {
108
+ const code = e.code;
109
+ if (code === "ENOENT") return;
110
+ throw e;
111
+ }
112
+ }
113
+ /**
114
+ * Enumerate keys whose stored files exist under the cache root and whose
115
+ * decoded form starts with `prefix`.
116
+ *
117
+ * Returns an empty list if the root directory does not exist (cold cache).
118
+ *
119
+ * TS-W6 Wave 1: used by `availability()` to count observation months and
120
+ * climate years for a station. The file→key mapping is the inverse of
121
+ * `#pathFor` (encodeURIComponent → strip `.json` → decodeURIComponent).
122
+ */
123
+ async listKeys(prefix) {
124
+ let entries;
125
+ try {
126
+ entries = await readdir(this.#root);
127
+ } catch (e) {
128
+ const code = e.code;
129
+ if (code === "ENOENT") return Object.freeze([]);
130
+ throw e;
131
+ }
132
+ const out = [];
133
+ for (const name of entries) {
134
+ if (!name.endsWith(".json")) continue;
135
+ const encoded = name.slice(0, -".json".length);
136
+ let decoded;
137
+ try {
138
+ decoded = decodeURIComponent(encoded);
139
+ } catch {
140
+ continue;
141
+ }
142
+ if (decoded.startsWith(prefix)) {
143
+ out.push(decoded);
144
+ }
145
+ }
146
+ return Object.freeze(out);
147
+ }
148
+ async withLock(key, fn) {
149
+ const p = this.#pathFor(key);
150
+ const prev = this.#chain.get(key) ?? Promise.resolve();
151
+ const run = async () => {
152
+ await mkdir(dirname(p), { recursive: true });
153
+ const release = await properLockfile.lock(p, {
154
+ realpath: false,
155
+ retries: { retries: 5, minTimeout: 20, maxTimeout: 200 }
156
+ });
157
+ try {
158
+ return await fn();
159
+ } finally {
160
+ await release();
161
+ }
162
+ };
163
+ const next = prev.then(run, run);
164
+ const absorbed = next.then(
165
+ () => void 0,
166
+ () => void 0
167
+ );
168
+ this.#chain.set(key, absorbed);
169
+ absorbed.finally(() => {
170
+ if (this.#chain.get(key) === absorbed) this.#chain.delete(key);
171
+ });
172
+ return next;
173
+ }
174
+ };
175
+ export {
176
+ FsStore,
177
+ defaultFsRoot
178
+ };
179
+ //# sourceMappingURL=fs.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../src/internal/cache/fs.ts"],"sourcesContent":["// FsStore — node:fs/promises + proper-lockfile CacheStore for Node runtimes.\n//\n// Path layout under the configured root:\n//\n// <root>/<sanitized-key>.json\n//\n// Where `<root>` defaults to\n// `$MOSTLYRIGHT_CACHE_DIR ?? $TRADEWINDS_CACHE_DIR (legacy + warn) ??\n// $HOME/.mostlyright/cache-ts` (per TS-CACHE-02 — distinct from Python's\n// `.mostlyright/cache` so the JSON envelopes here can't shadow Python's\n// parquet files). Phase 12 W4 + review-iter2: mirrors the Python back-compat\n// shim semantics — canonical → legacy + DeprecationWarning → default.\n//\n// Atomic write: payload is written to `<path>.tmp` then renamed onto\n// `<path>` (POSIX-atomic; Windows-safe via `fs.rename`).\n//\n// withLock uses proper-lockfile against a `<path>.lock` sidecar so two\n// concurrent writers serialize per-key. The lock is taken with\n// `realpath: false` so we can lock a path whose target may not yet\n// exist.\n//\n// Divergence from Python: the Python cache is parquet + per-month station\n// files keyed by (station, year, month); TS is JSON + per-key files keyed\n// by the caller's opaque string. Station-aware key generation lives in\n// `keys.ts` (plan 03).\n\nimport { randomUUID } from \"node:crypto\";\nimport { mkdir, readFile, readdir, rename, rm, writeFile } from \"node:fs/promises\";\nimport { homedir } from \"node:os\";\nimport { dirname, join } from \"node:path\";\n\nimport * as properLockfile from \"proper-lockfile\";\n\nimport type { CacheEntry, CacheSetOptions, CacheStore } from \"./types.js\";\n\n/**\n * Resolve the cache root on each call (not cached at module load) so tests\n * can `vi.stubEnv(\"MOSTLYRIGHT_CACHE_DIR\", ...)` between cases without a\n * module reload.\n *\n * Resolution order (Phase 12 W4 + review-iter2 — mirrors Python shim):\n * 1. `MOSTLYRIGHT_CACHE_DIR` env var (canonical, post-Phase-12).\n * 2. `TRADEWINDS_CACHE_DIR` env var (legacy; emits a one-time deprecation\n * `console.warn`; scheduled for removal in vts-0.3).\n * 3. `~/.mostlyright/cache-ts` (per TS-CACHE-02 — DISTINCT from Python's\n * `~/.mostlyright/cache` so JSON envelopes here can't shadow Python's\n * parquet files).\n */\nlet _legacyCacheDirWarned = false;\n\nexport function defaultFsRoot(): string {\n const canonical = process.env.MOSTLYRIGHT_CACHE_DIR;\n if (canonical !== undefined && canonical.length > 0) return canonical;\n const legacy = process.env.TRADEWINDS_CACHE_DIR;\n if (legacy !== undefined && legacy.length > 0) {\n if (!_legacyCacheDirWarned) {\n console.warn(\n \"TRADEWINDS_CACHE_DIR is deprecated; use MOSTLYRIGHT_CACHE_DIR. \" +\n \"Support will be removed in vts-0.3. \" +\n \"Run: mv ~/.tradewinds ~/.mostlyright\",\n );\n _legacyCacheDirWarned = true;\n }\n return legacy;\n }\n return join(homedir(), \".mostlyright\", \"cache-ts\");\n}\n\n/** Reset the one-time TRADEWINDS_CACHE_DIR deprecation latch — TEST USE ONLY. */\nexport function _resetLegacyCacheDirWarn(): void {\n _legacyCacheDirWarned = false;\n}\n\nexport interface FsStoreOptions {\n /** Override root directory. Defaults to {@link defaultFsRoot}. */\n readonly root?: string;\n}\n\n/**\n * Node-side CacheStore. Each key maps to one JSON file under the root.\n */\nexport class FsStore implements CacheStore {\n readonly #root: string;\n // In-process per-key promise chain. proper-lockfile guarantees\n // cross-process exclusion but its retry-based contention resolution\n // is order-non-deterministic — two in-process callers racing on\n // `lock()` may acquire in either order. Layering an in-process chain\n // ensures strict FIFO for callers within the same Node process (and\n // serves as a cheap fast-path: only one of N in-process callers ever\n // actually contends with proper-lockfile).\n readonly #chain = new Map<string, Promise<unknown>>();\n\n constructor(opts: FsStoreOptions = {}) {\n this.#root = opts.root ?? defaultFsRoot();\n }\n\n /**\n * Path resolver — `key` is sanitized via `encodeURIComponent` so that\n *\n * 1. `:` `/` `\\` cannot escape the root (all percent-encoded), and\n * 2. the key → file mapping is INJECTIVE: distinct keys always map to\n * distinct files.\n *\n * Iter-13 C16 fix: the previous implementation collapsed `:` / `/` / `\\`\n * to the literal substring `\"__\"`, which is a lossy mapping — `\"a:b\"`,\n * `\"a/b\"`, and the literal `\"a__b\"` all hashed to `a__b.json`, so one\n * key's write would silently overwrite (and corrupt subsequent reads of)\n * another key. `encodeURIComponent` is bijective on string inputs and\n * filesystem-safe on every platform we ship (POSIX + Windows): the\n * characters it leaves unescaped (alphanumerics, `-._~!*'()`) are all\n * legal in NTFS, APFS, and ext4 filenames, and `%` is itself legal.\n *\n * BREAKING in v0.1.0: on-disk cache files written by any prior\n * pre-release of this package use the old `__`-replacement scheme and\n * are unreadable after upgrade. This is acceptable for a local-first\n * cache: entries are regenerated on demand from live data, and the\n * cache directory can be safely deleted by the user.\n */\n #pathFor(key: string): string {\n const safe = encodeURIComponent(key);\n return join(this.#root, `${safe}.json`);\n }\n\n async get<T = unknown>(key: string): Promise<T | null> {\n const p = this.#pathFor(key);\n let raw: string;\n try {\n raw = await readFile(p, \"utf8\");\n } catch (e: unknown) {\n const code = (e as { code?: string }).code;\n if (code === \"ENOENT\") return null;\n throw e;\n }\n let entry: CacheEntry<T>;\n try {\n entry = JSON.parse(raw) as CacheEntry<T>;\n } catch {\n // Corrupt cache entry — treat as miss; do NOT throw. Caller\n // re-fetches and overwrites.\n return null;\n }\n if (entry.expiresAt !== undefined && Date.now() >= entry.expiresAt) {\n // Lazy-evict: best-effort unlink, ignore failures.\n try {\n await rm(p, { force: true });\n } catch {\n // ignore\n }\n return null;\n }\n return entry.value as T;\n }\n\n async set<T = unknown>(key: string, value: T, opts?: CacheSetOptions): Promise<void> {\n const p = this.#pathFor(key);\n await mkdir(dirname(p), { recursive: true });\n const entry: CacheEntry<T> =\n opts?.ttlMs !== undefined ? { value, expiresAt: Date.now() + opts.ttlMs } : { value };\n // Codex iter-2 C6: use a UNIQUE temp filename per write. Two concurrent\n // `set(\"same-key\", ...)` calls would otherwise race on the shared\n // `<path>.tmp`: writer A creates `<path>.tmp` and renames it to\n // `<path>`; writer B's subsequent rename then fails with ENOENT\n // because A's rename moved B's-in-progress temp away. With a unique\n // per-write suffix, each writer owns its own temp file; rename-into-\n // place stays atomic on POSIX (last-rename-wins semantics — any of\n // the N concurrent writers' value will be the final cache contents,\n // documented at the test that covers this).\n const tmp = `${p}.${randomUUID()}.tmp`;\n try {\n await writeFile(tmp, JSON.stringify(entry), \"utf8\");\n await rename(tmp, p);\n } catch (e) {\n // Best-effort cleanup if rename failed (e.g. permissions). Don't\n // let a stale unique-temp file leak.\n try {\n await rm(tmp, { force: true });\n } catch {\n // ignore\n }\n throw e;\n }\n }\n\n async delete(key: string): Promise<void> {\n const p = this.#pathFor(key);\n try {\n await rm(p, { force: true });\n } catch (e: unknown) {\n const code = (e as { code?: string }).code;\n if (code === \"ENOENT\") return;\n throw e;\n }\n }\n\n /**\n * Enumerate keys whose stored files exist under the cache root and whose\n * decoded form starts with `prefix`.\n *\n * Returns an empty list if the root directory does not exist (cold cache).\n *\n * TS-W6 Wave 1: used by `availability()` to count observation months and\n * climate years for a station. The file→key mapping is the inverse of\n * `#pathFor` (encodeURIComponent → strip `.json` → decodeURIComponent).\n */\n async listKeys(prefix: string): Promise<ReadonlyArray<string>> {\n let entries: string[];\n try {\n entries = await readdir(this.#root);\n } catch (e: unknown) {\n const code = (e as { code?: string }).code;\n if (code === \"ENOENT\") return Object.freeze([]);\n throw e;\n }\n const out: string[] = [];\n for (const name of entries) {\n if (!name.endsWith(\".json\")) continue;\n // Ignore the proper-lockfile lock sidecars and our own in-flight\n // unique-temp files (`<key>.json.<uuid>.tmp`) — they end with `.tmp`\n // not `.json`, so the suffix filter above already excludes them.\n // The lock directories proper-lockfile creates end with `.json.lock`\n // (a directory entry), also excluded by the `.json` suffix check.\n const encoded = name.slice(0, -\".json\".length);\n let decoded: string;\n try {\n decoded = decodeURIComponent(encoded);\n } catch {\n // Defensive: skip files whose names don't decode (manual placements,\n // partial writes, etc).\n continue;\n }\n if (decoded.startsWith(prefix)) {\n out.push(decoded);\n }\n }\n return Object.freeze(out);\n }\n\n async withLock<T>(key: string, fn: () => Promise<T>): Promise<T> {\n const p = this.#pathFor(key);\n // Chain in-process callers FIFO. Cross-process exclusion is layered\n // on top via proper-lockfile inside `run()`.\n const prev = this.#chain.get(key) ?? Promise.resolve();\n const run = async (): Promise<T> => {\n await mkdir(dirname(p), { recursive: true });\n const release = await properLockfile.lock(p, {\n realpath: false,\n retries: { retries: 5, minTimeout: 20, maxTimeout: 200 },\n });\n try {\n return await fn();\n } finally {\n await release();\n }\n };\n const next = prev.then(run, run);\n // Absorber tail so the chain doesn't leak unhandled rejections.\n const absorbed = next.then(\n () => undefined,\n () => undefined,\n );\n this.#chain.set(key, absorbed);\n absorbed.finally(() => {\n if (this.#chain.get(key) === absorbed) this.#chain.delete(key);\n });\n return next;\n }\n}\n"],"mappings":"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","names":[]}