@jini-ai/platform 0.3.0 → 0.5.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 +146 -0
- package/README.md +121 -2
- package/dist/asset-cache.d.ts +0 -22
- package/dist/asset-cache.js +3 -138
- package/dist/download.js +2 -2
- package/dist/fs/atomic-write.d.ts +103 -0
- package/dist/fs/atomic-write.js +201 -0
- package/dist/fs/durable-json.d.ts +89 -0
- package/dist/fs/durable-json.js +166 -0
- package/dist/fs/env-text.d.ts +11 -0
- package/dist/fs/env-text.js +22 -0
- package/dist/fs/file-lock.d.ts +126 -0
- package/dist/fs/file-lock.js +306 -0
- package/dist/fs/guarded-reader.d.ts +60 -0
- package/dist/fs/guarded-reader.js +310 -0
- package/dist/fs/index.d.ts +8 -0
- package/dist/fs/index.js +9 -0
- package/dist/fs/strict-containment.d.ts +5 -0
- package/dist/fs/strict-containment.js +13 -0
- package/dist/fs.js +2 -3
- package/dist/http/guarded/client.d.ts +13 -0
- package/dist/http/guarded/client.js +240 -0
- package/dist/http/guarded/errors.d.ts +21 -0
- package/dist/http/guarded/errors.js +20 -0
- package/dist/http/guarded/fetch-adapter.d.ts +23 -0
- package/dist/http/guarded/fetch-adapter.js +30 -0
- package/dist/http/guarded/index.d.ts +12 -0
- package/dist/http/guarded/index.js +7 -0
- package/dist/http/guarded/node-ports.d.ts +8 -0
- package/dist/http/guarded/node-ports.js +11 -0
- package/dist/http/guarded/ports.d.ts +51 -0
- package/dist/http/guarded/ports.js +2 -0
- package/dist/http/guarded/transport.fetch.d.ts +11 -0
- package/dist/http/guarded/transport.fetch.js +109 -0
- package/dist/http/guarded/types.d.ts +8 -0
- package/dist/http/guarded/types.js +2 -0
- package/dist/index.d.ts +61 -1
- package/dist/index.js +61 -1
- package/dist/mail/adapter-module.d.ts +20 -0
- package/dist/mail/adapter-module.js +2 -0
- package/dist/mail/delivery-availability.d.ts +7 -0
- package/dist/mail/delivery-availability.js +6 -0
- package/dist/mail/index.d.ts +7 -0
- package/dist/mail/index.js +3 -0
- package/dist/mail/ports.d.ts +43 -0
- package/dist/mail/ports.js +2 -0
- package/dist/mail/purpose-scoped-mailer.d.ts +18 -0
- package/dist/mail/purpose-scoped-mailer.js +50 -0
- package/dist/mail/smtp.d.ts +79 -0
- package/dist/mail/smtp.js +102 -0
- package/dist/mail/types.d.ts +83 -0
- package/dist/mail/types.js +2 -0
- package/dist/messages.d.ts +22 -0
- package/dist/messages.js +15 -0
- package/dist/net/address.d.ts +38 -0
- package/dist/net/address.js +241 -0
- package/dist/net/index.d.ts +2 -0
- package/dist/net/index.js +2 -0
- package/dist/process.d.ts +2 -1
- package/dist/process.js +2 -1
- package/dist/proxy-env.js +1 -1
- package/dist/resource-paths.js +1 -1
- package/dist/sandbox-env.d.ts +1 -1
- package/dist/sandbox-env.js +1 -1
- package/dist/secrets/aad.d.ts +7 -0
- package/dist/secrets/aad.js +5 -0
- package/dist/secrets/credential-sets/aad.d.ts +7 -0
- package/dist/secrets/credential-sets/aad.js +7 -0
- package/dist/secrets/credential-sets/index.d.ts +4 -0
- package/dist/secrets/credential-sets/index.js +3 -0
- package/dist/secrets/credential-sets/repo.memory.d.ts +36 -0
- package/dist/secrets/credential-sets/repo.memory.js +72 -0
- package/dist/secrets/credential-sets/types.d.ts +67 -0
- package/dist/secrets/credential-sets/types.js +6 -0
- package/dist/secrets/index.d.ts +9 -0
- package/dist/secrets/index.js +5 -0
- package/dist/secrets/keyring.env.d.ts +176 -0
- package/dist/secrets/keyring.env.js +271 -0
- package/dist/secrets/keyring.memory.d.ts +10 -0
- package/dist/secrets/keyring.memory.js +9 -0
- package/dist/secrets/ports.d.ts +33 -0
- package/dist/secrets/ports.js +2 -0
- package/dist/secrets/secret-sealer.aesgcm.d.ts +22 -0
- package/dist/secrets/secret-sealer.aesgcm.js +64 -0
- package/dist/secrets/testing.d.ts +4 -0
- package/dist/secrets/testing.js +4 -0
- package/dist/secrets/types.d.ts +10 -0
- package/dist/secrets/types.js +2 -0
- package/dist/shell.d.ts +10 -6
- package/dist/shell.js +10 -6
- package/dist/terminal.d.ts +2 -2
- package/dist/terminal.js +9 -5
- package/package.json +79 -4
- package/dist/asset-cache.d.ts.map +0 -1
- package/dist/asset-cache.js.map +0 -1
- package/dist/aws-sigv4.d.ts.map +0 -1
- package/dist/aws-sigv4.js.map +0 -1
- package/dist/blob-storage.d.ts.map +0 -1
- package/dist/blob-storage.js.map +0 -1
- package/dist/command.d.ts.map +0 -1
- package/dist/command.js.map +0 -1
- package/dist/download.d.ts.map +0 -1
- package/dist/download.js.map +0 -1
- package/dist/fetch-with-timeout.d.ts.map +0 -1
- package/dist/fetch-with-timeout.js.map +0 -1
- package/dist/fs.d.ts.map +0 -1
- package/dist/fs.js.map +0 -1
- package/dist/home-expansion.d.ts.map +0 -1
- package/dist/home-expansion.js.map +0 -1
- package/dist/http.d.ts.map +0 -1
- package/dist/http.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/process.d.ts.map +0 -1
- package/dist/process.js.map +0 -1
- package/dist/proxy-env.d.ts.map +0 -1
- package/dist/proxy-env.js.map +0 -1
- package/dist/resource-paths.d.ts.map +0 -1
- package/dist/resource-paths.js.map +0 -1
- package/dist/sandbox-env.d.ts.map +0 -1
- package/dist/sandbox-env.js.map +0 -1
- package/dist/shell.d.ts.map +0 -1
- package/dist/shell.js.map +0 -1
- package/dist/terminal.d.ts.map +0 -1
- package/dist/terminal.js.map +0 -1
- package/dist/toolchain.d.ts.map +0 -1
- package/dist/toolchain.js.map +0 -1
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import * as fsPromises from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { randomUUID } from "node:crypto";
|
|
5
|
+
import { defaultPlatformMessages } from "../messages.js";
|
|
6
|
+
/** Optional adapter wiring: no layout, env key, or application name is selected here. */
|
|
7
|
+
export function createNodeAtomicFilesystem(_required, { filesystem = fs } = {}) {
|
|
8
|
+
return {
|
|
9
|
+
stat: ({ path }) => filesystem.statSync(path),
|
|
10
|
+
lstat: ({ path }) => filesystem.lstatSync(path),
|
|
11
|
+
open: ({ path, flags, mode }) => filesystem.openSync(path, flags, mode),
|
|
12
|
+
write: ({ descriptor, content }) => filesystem.writeFileSync(descriptor, content),
|
|
13
|
+
sync: ({ descriptor }) => filesystem.fsyncSync(descriptor),
|
|
14
|
+
close: ({ descriptor }) => filesystem.closeSync(descriptor),
|
|
15
|
+
chmod: ({ path, mode }) => filesystem.chmodSync(path, mode),
|
|
16
|
+
rename: ({ from, to }) => filesystem.renameSync(from, to),
|
|
17
|
+
remove: ({ path }) => filesystem.rmSync(path, { force: true }),
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
function isMissing(error) {
|
|
21
|
+
return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
|
|
22
|
+
}
|
|
23
|
+
function validateMode(mode, options) {
|
|
24
|
+
if (!Number.isInteger(mode) || mode < 0 || mode > 0o777)
|
|
25
|
+
throw new RangeError((options.messages ?? defaultPlatformMessages).atomicModeInvalid());
|
|
26
|
+
return mode;
|
|
27
|
+
}
|
|
28
|
+
function temporaryPath({ filePath }, options) {
|
|
29
|
+
const { tempName = () => `${process.pid}.${randomUUID()}` } = options;
|
|
30
|
+
if (options.tempPath) {
|
|
31
|
+
const temporary = options.tempPath({ filePath });
|
|
32
|
+
if (path.resolve(path.dirname(temporary)) !== path.resolve(path.dirname(filePath)) || path.resolve(temporary) === path.resolve(filePath) || temporary.includes("\0"))
|
|
33
|
+
throw new RangeError((options.messages ?? defaultPlatformMessages).atomicTempPathInvalid());
|
|
34
|
+
return temporary;
|
|
35
|
+
}
|
|
36
|
+
const token = tempName({ filePath });
|
|
37
|
+
if (!token || token === "." || token === ".." || /[/\\\x00]/.test(token))
|
|
38
|
+
throw new RangeError((options.messages ?? defaultPlatformMessages).atomicTempNameInvalid());
|
|
39
|
+
return path.join(path.dirname(filePath), `.${path.basename(filePath)}.${token}.tmp`);
|
|
40
|
+
}
|
|
41
|
+
function assertNotSymlink(info, options) {
|
|
42
|
+
if (info.isSymbolicLink())
|
|
43
|
+
throw new Error((options.messages ?? defaultPlatformMessages).atomicSymlinkRefused());
|
|
44
|
+
}
|
|
45
|
+
function assertOwnerOnly(mode, options) {
|
|
46
|
+
// Windows has no POSIX group/other bits; secret stores skip this verification there.
|
|
47
|
+
if (options.verifyOwnerOnly && (options.platform ?? process.platform) !== "win32" && (mode & 0o077) !== 0)
|
|
48
|
+
throw new Error((options.messages ?? defaultPlatformMessages).atomicPermissionsRefused());
|
|
49
|
+
}
|
|
50
|
+
/** Same-directory rename; preserve existing mode exactly, default new files to 0600.
|
|
51
|
+
* Only ENOENT means absent. Stat/write/chmod failures propagate before any rename.
|
|
52
|
+
* Flush file bytes before rename so metadata cannot precede content; then flush the parent dir.
|
|
53
|
+
* A directory-sync failure is reported after replacement, because rename cannot be rolled back safely.
|
|
54
|
+
*/
|
|
55
|
+
export function writeFileAtomic(required, options = {}) {
|
|
56
|
+
const { filePath, content, fs: filesystem } = required;
|
|
57
|
+
let existingMode;
|
|
58
|
+
try {
|
|
59
|
+
if (options.refuseSymlink) {
|
|
60
|
+
const info = filesystem.lstat({ path: filePath });
|
|
61
|
+
assertNotSymlink(info, options);
|
|
62
|
+
existingMode = info.mode & 0o777;
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
existingMode = filesystem.stat({ path: filePath }).mode & 0o777;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
catch (error) {
|
|
69
|
+
if (!isMissing(error))
|
|
70
|
+
throw error;
|
|
71
|
+
}
|
|
72
|
+
const mode = validateMode(options.mode ?? existingMode ?? options.defaultMode ?? 0o600, options);
|
|
73
|
+
const temporary = temporaryPath(required, options);
|
|
74
|
+
// A failed exclusive create does not grant ownership of an existing temporary file.
|
|
75
|
+
let descriptor = filesystem.open({ path: temporary, flags: "wx", mode });
|
|
76
|
+
let renamed = false;
|
|
77
|
+
try {
|
|
78
|
+
filesystem.write({ descriptor, content });
|
|
79
|
+
filesystem.chmod({ path: temporary, mode });
|
|
80
|
+
assertOwnerOnly(filesystem.stat({ path: temporary }).mode, options);
|
|
81
|
+
filesystem.sync({ descriptor });
|
|
82
|
+
const fileDescriptor = descriptor;
|
|
83
|
+
descriptor = undefined;
|
|
84
|
+
filesystem.close({ descriptor: fileDescriptor });
|
|
85
|
+
if (options.refuseSymlink) {
|
|
86
|
+
try {
|
|
87
|
+
assertNotSymlink(filesystem.lstat({ path: filePath }), options);
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
if (!isMissing(error))
|
|
91
|
+
throw error;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
filesystem.rename({ from: temporary, to: filePath });
|
|
95
|
+
renamed = true;
|
|
96
|
+
const directory = filesystem.open({ path: path.dirname(filePath), flags: "r" });
|
|
97
|
+
try {
|
|
98
|
+
filesystem.sync({ descriptor: directory });
|
|
99
|
+
}
|
|
100
|
+
finally {
|
|
101
|
+
filesystem.close({ descriptor: directory });
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
catch (error) {
|
|
105
|
+
if (descriptor !== undefined) {
|
|
106
|
+
try {
|
|
107
|
+
filesystem.close({ descriptor });
|
|
108
|
+
}
|
|
109
|
+
catch { /* preserve the original failure */ }
|
|
110
|
+
}
|
|
111
|
+
if (!renamed) {
|
|
112
|
+
try {
|
|
113
|
+
filesystem.remove({ path: temporary });
|
|
114
|
+
}
|
|
115
|
+
catch { /* preserve the original failure */ }
|
|
116
|
+
}
|
|
117
|
+
throw error;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
/** Pretty JSON delegates to the same mode-preserving atomic primitive. */
|
|
121
|
+
export function writeJsonFileAtomic(required, options = {}) {
|
|
122
|
+
const { data, ...writer } = required;
|
|
123
|
+
const content = JSON.stringify(data, null, 2);
|
|
124
|
+
if (content === undefined)
|
|
125
|
+
throw new TypeError((options.messages ?? defaultPlatformMessages).atomicJsonUnserializable());
|
|
126
|
+
writeFileAtomic({ ...writer, content }, options);
|
|
127
|
+
}
|
|
128
|
+
/** Async counterpart with the same ownership, permission and durability guarantees.
|
|
129
|
+
* Parent creation supports runtime-state and secret stores without another writer implementation. */
|
|
130
|
+
export async function writeFileAtomicAsync(required, options = {}) {
|
|
131
|
+
const { filePath, content } = required;
|
|
132
|
+
const filesystem = options.fs ?? fsPromises;
|
|
133
|
+
if (options.createParent)
|
|
134
|
+
await filesystem.mkdir(path.dirname(filePath), { recursive: true });
|
|
135
|
+
let existingMode;
|
|
136
|
+
try {
|
|
137
|
+
const info = options.refuseSymlink ? await filesystem.lstat(filePath) : await filesystem.stat(filePath);
|
|
138
|
+
if (options.refuseSymlink)
|
|
139
|
+
assertNotSymlink(info, options);
|
|
140
|
+
existingMode = info.mode & 0o777;
|
|
141
|
+
}
|
|
142
|
+
catch (error) {
|
|
143
|
+
if (!isMissing(error))
|
|
144
|
+
throw error;
|
|
145
|
+
}
|
|
146
|
+
const mode = validateMode(options.mode ?? existingMode ?? options.defaultMode ?? 0o600, options);
|
|
147
|
+
const temporary = temporaryPath(required, options);
|
|
148
|
+
// Keep the exclusive create outside cleanup: EEXIST belongs to a different writer.
|
|
149
|
+
const handle = await filesystem.open(temporary, "wx", mode);
|
|
150
|
+
let closed = false, renamed = false;
|
|
151
|
+
try {
|
|
152
|
+
await handle.writeFile(content, "utf8");
|
|
153
|
+
await handle.chmod(mode);
|
|
154
|
+
assertOwnerOnly((await handle.stat()).mode, options);
|
|
155
|
+
await handle.sync();
|
|
156
|
+
await handle.close();
|
|
157
|
+
closed = true;
|
|
158
|
+
if (options.refuseSymlink) {
|
|
159
|
+
try {
|
|
160
|
+
assertNotSymlink(await filesystem.lstat(filePath), options);
|
|
161
|
+
}
|
|
162
|
+
catch (error) {
|
|
163
|
+
if (!isMissing(error))
|
|
164
|
+
throw error;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
await filesystem.rename(temporary, filePath);
|
|
168
|
+
renamed = true;
|
|
169
|
+
const directory = await filesystem.open(path.dirname(filePath), "r");
|
|
170
|
+
try {
|
|
171
|
+
await directory.sync();
|
|
172
|
+
}
|
|
173
|
+
finally {
|
|
174
|
+
await directory.close();
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
catch (error) {
|
|
178
|
+
if (!closed) {
|
|
179
|
+
try {
|
|
180
|
+
await handle.close();
|
|
181
|
+
}
|
|
182
|
+
catch { /* preserve the original failure */ }
|
|
183
|
+
}
|
|
184
|
+
if (!renamed) {
|
|
185
|
+
try {
|
|
186
|
+
await filesystem.rm(temporary, { force: true });
|
|
187
|
+
}
|
|
188
|
+
catch { /* preserve the original failure */ }
|
|
189
|
+
}
|
|
190
|
+
throw error;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
/** Pretty JSON can retain a state-store's trailing newline without changing its serialization. */
|
|
194
|
+
export async function writeJsonFileAtomicAsync(required, options = {}) {
|
|
195
|
+
const { data, ...writer } = required;
|
|
196
|
+
const content = JSON.stringify(data, null, 2);
|
|
197
|
+
if (content === undefined)
|
|
198
|
+
throw new TypeError((options.messages ?? defaultPlatformMessages).atomicJsonUnserializable());
|
|
199
|
+
await writeFileAtomicAsync({ ...writer, content: content + (options.trailingNewline ? "\n" : "") }, options);
|
|
200
|
+
}
|
|
201
|
+
//# sourceMappingURL=atomic-write.js.map
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared stores once wrote JSON directly, treated torn reads as empty, then persisted the empty
|
|
3
|
+
* state on the next write, erasing recoverable records. One mechanism keeps stores from drifting:
|
|
4
|
+
* atomic replacement, explicit unreadable state, quarantine, and locks for shared read-modify-write.
|
|
5
|
+
* Recovery policy stays with each store: salvage, restart, or leave untouched is not decided here.
|
|
6
|
+
* Native effects are Electron-free so desktop, CLI, and bridge processes can share the mechanism.
|
|
7
|
+
*/
|
|
8
|
+
import nodeFs from "node:fs";
|
|
9
|
+
import type { Clock } from "@jini-ai/core/primitives";
|
|
10
|
+
export type JsonFileRead = {
|
|
11
|
+
state: "ok";
|
|
12
|
+
value: unknown;
|
|
13
|
+
} | {
|
|
14
|
+
state: "missing";
|
|
15
|
+
} | {
|
|
16
|
+
state: "unreadable";
|
|
17
|
+
text?: string;
|
|
18
|
+
};
|
|
19
|
+
export interface QuarantineNotice {
|
|
20
|
+
label: string;
|
|
21
|
+
consequence: string;
|
|
22
|
+
}
|
|
23
|
+
export interface FileLockOptions {
|
|
24
|
+
waitMs?: number;
|
|
25
|
+
}
|
|
26
|
+
export interface DurableJsonPorts {
|
|
27
|
+
filesystem: Pick<typeof nodeFs, "readFileSync" | "mkdirSync" | "openSync" | "writeFileSync" | "fsyncSync" | "closeSync" | "renameSync" | "rmSync" | "writeSync" | "statSync" | "fstatSync" | "lstatSync" | "chmodSync">;
|
|
28
|
+
clock: Clock;
|
|
29
|
+
sleep: {
|
|
30
|
+
sleep(required: {
|
|
31
|
+
durationMs: number;
|
|
32
|
+
}): void;
|
|
33
|
+
};
|
|
34
|
+
process: {
|
|
35
|
+
pid: number;
|
|
36
|
+
isAlive(required: {
|
|
37
|
+
pid: number;
|
|
38
|
+
}): boolean;
|
|
39
|
+
};
|
|
40
|
+
random: {
|
|
41
|
+
token(_required: Record<string, never>): string;
|
|
42
|
+
};
|
|
43
|
+
report: {
|
|
44
|
+
error(required: {
|
|
45
|
+
message: string;
|
|
46
|
+
}): void;
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
export interface DurableJsonNotices {
|
|
50
|
+
prefix: string;
|
|
51
|
+
lockTimeout(required: {
|
|
52
|
+
lockPath: string;
|
|
53
|
+
waitMs: number;
|
|
54
|
+
}): string;
|
|
55
|
+
}
|
|
56
|
+
/** Native synchronous adapters. Liveness probes treat permission errors as alive. */
|
|
57
|
+
export declare function createNodeDurableJsonPorts(_required: Record<string, never>): DurableJsonPorts;
|
|
58
|
+
export interface DurableJsonFile {
|
|
59
|
+
read(required: Record<string, never>): JsonFileRead;
|
|
60
|
+
tempPath(required: Record<string, never>, optional?: {
|
|
61
|
+
pid?: number;
|
|
62
|
+
}): string;
|
|
63
|
+
write(required: {
|
|
64
|
+
value: unknown;
|
|
65
|
+
}): void;
|
|
66
|
+
quarantine(required: {
|
|
67
|
+
notice: QuarantineNotice;
|
|
68
|
+
}): boolean;
|
|
69
|
+
withLock<T>(required: {
|
|
70
|
+
critical: () => T;
|
|
71
|
+
}, optional?: FileLockOptions): T;
|
|
72
|
+
}
|
|
73
|
+
/** Atomic replacement, distinct corrupt reads, quarantine, and token-owned synchronous locks.
|
|
74
|
+
* Lock stealing rechecks inode, bytes and mtime; conditional unlink is not available on all hosts.
|
|
75
|
+
* File fsync precedes rename; directory fsync follows it. Persistence remains filesystem-dependent.
|
|
76
|
+
*/
|
|
77
|
+
export declare function createDurableJsonFile({ filePath, filesystem: fs, clock, sleep, process, random, report, notices }: DurableJsonPorts & {
|
|
78
|
+
filePath: string;
|
|
79
|
+
notices: DurableJsonNotices;
|
|
80
|
+
}, { pollMs, staleMs }?: {
|
|
81
|
+
pollMs?: number;
|
|
82
|
+
staleMs?: number;
|
|
83
|
+
}): DurableJsonFile;
|
|
84
|
+
export declare function salvageJsonPrefix({ text }: {
|
|
85
|
+
text: string;
|
|
86
|
+
}, { maxAttempts }?: {
|
|
87
|
+
maxAttempts?: number;
|
|
88
|
+
}): unknown;
|
|
89
|
+
//# sourceMappingURL=durable-json.d.ts.map
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared stores once wrote JSON directly, treated torn reads as empty, then persisted the empty
|
|
3
|
+
* state on the next write, erasing recoverable records. One mechanism keeps stores from drifting:
|
|
4
|
+
* atomic replacement, explicit unreadable state, quarantine, and locks for shared read-modify-write.
|
|
5
|
+
* Recovery policy stays with each store: salvage, restart, or leave untouched is not decided here.
|
|
6
|
+
* Native effects are Electron-free so desktop, CLI, and bridge processes can share the mechanism.
|
|
7
|
+
*/
|
|
8
|
+
import nodeFs from "node:fs";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import { randomBytes } from "node:crypto";
|
|
11
|
+
import { createNodeAtomicFilesystem, writeJsonFileAtomic } from "./atomic-write.js";
|
|
12
|
+
import { withFileLockSync } from "./file-lock.js";
|
|
13
|
+
/** Native synchronous adapters. Liveness probes treat permission errors as alive. */
|
|
14
|
+
export function createNodeDurableJsonPorts(_required) {
|
|
15
|
+
const cell = new Int32Array(new SharedArrayBuffer(4));
|
|
16
|
+
return {
|
|
17
|
+
filesystem: nodeFs,
|
|
18
|
+
clock: { nowMs: () => Date.now() },
|
|
19
|
+
// Synchronous store/IPC writers cannot await lock polling without changing all callers' ABI.
|
|
20
|
+
sleep: { sleep: ({ durationMs }) => { Atomics.wait(cell, 0, 0, durationMs); } },
|
|
21
|
+
process: { pid: process.pid, isAlive: ({ pid }) => {
|
|
22
|
+
// Signal zero sends nothing. Only ESRCH proves death; permission failure must count as alive
|
|
23
|
+
// because a false "dead" authorizes breaking a lock still owned by another process.
|
|
24
|
+
try {
|
|
25
|
+
process.kill(pid, 0);
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
28
|
+
catch (error) {
|
|
29
|
+
return error.code !== "ESRCH";
|
|
30
|
+
}
|
|
31
|
+
} },
|
|
32
|
+
random: { token: (_required) => randomBytes(4).toString("hex") },
|
|
33
|
+
report: { error: ({ message }) => console.error(message) },
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
/** Atomic replacement, distinct corrupt reads, quarantine, and token-owned synchronous locks.
|
|
37
|
+
* Lock stealing rechecks inode, bytes and mtime; conditional unlink is not available on all hosts.
|
|
38
|
+
* File fsync precedes rename; directory fsync follows it. Persistence remains filesystem-dependent.
|
|
39
|
+
*/
|
|
40
|
+
export function createDurableJsonFile({ filePath, filesystem: fs, clock, sleep, process, random, report, notices }, { pollMs = 10, staleMs = 30000 } = {}) {
|
|
41
|
+
if (!filePath)
|
|
42
|
+
throw new TypeError("filePath is required");
|
|
43
|
+
for (const value of [pollMs, staleMs])
|
|
44
|
+
if (!Number.isSafeInteger(value) || value <= 0)
|
|
45
|
+
throw new TypeError("lock intervals must be positive safe integers");
|
|
46
|
+
// A synchronous read-modify-write normally takes milliseconds; five seconds allows contention
|
|
47
|
+
// while refusing a wedged change. Short ten-millisecond polling fits those short critical sections.
|
|
48
|
+
// Thirty-second staleness also handles pid reuse after a crash:
|
|
49
|
+
// an unrelated live process must not hold the abandoned file forever merely by inheriting its pid.
|
|
50
|
+
const LOCK_WAIT_MS = 5000, LOCK_POLL_MS = pollMs, LOCK_STALE_MS = staleMs;
|
|
51
|
+
function readJsonFile(filePath) {
|
|
52
|
+
let text;
|
|
53
|
+
try {
|
|
54
|
+
text = fs.readFileSync(filePath, "utf8");
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
return error.code === "ENOENT" ? { state: "missing" } : { state: "unreadable" };
|
|
58
|
+
}
|
|
59
|
+
try {
|
|
60
|
+
return { state: "ok", value: JSON.parse(text) };
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return { state: "unreadable", text };
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
// Per-process sibling temps prevent one writer truncating another's pending replacement. Writes
|
|
67
|
+
// within one process are synchronous, so it cannot collide with itself; its next write reuses the temp.
|
|
68
|
+
function tempPathFor(filePath, pid = process.pid) {
|
|
69
|
+
return `${filePath}.${pid}.tmp`;
|
|
70
|
+
}
|
|
71
|
+
// Same-directory rename gives readers complete old/new versions rather than a torn intermediate.
|
|
72
|
+
// Flush bytes before rename so metadata cannot precede content. The canonical writer also syncs
|
|
73
|
+
// the parent directory; platform/filesystem support determines persistence after that sync.
|
|
74
|
+
// Keep the legacy temp path, pretty JSON bytes and new-file create mode for these shared stores.
|
|
75
|
+
function writeJson(filePath, value) {
|
|
76
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
77
|
+
writeJsonFileAtomic({ filePath, data: value, fs: createNodeAtomicFilesystem({}, { filesystem: fs }) }, {
|
|
78
|
+
defaultMode: 0o666 & ~globalThis.process.umask(), tempPath: () => tempPathFor(filePath),
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
// Preserve damaged bytes for inspection instead of deleting/overwriting them. False means callers
|
|
82
|
+
// must leave the original alone; an already-moved file is safe because another process cleared it.
|
|
83
|
+
function quarantineUnreadableFile(filePath, notice) {
|
|
84
|
+
const asidePath = `${filePath}.corrupt-${clock.nowMs()}`;
|
|
85
|
+
try {
|
|
86
|
+
fs.renameSync(filePath, asidePath);
|
|
87
|
+
}
|
|
88
|
+
catch (error) {
|
|
89
|
+
if (error.code === "ENOENT")
|
|
90
|
+
return true;
|
|
91
|
+
report.error({ message: `${notices.prefix}: ${notice.label} ${filePath} is unreadable and could not be moved aside (${String(error)}). Left untouched; nothing was written over it.` });
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
report.error({ message: `${notices.prefix}: ${notice.label} ${filePath} was unreadable (torn or corrupt). Moved it aside to ${asidePath}; ${notice.consequence}` });
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
// Atomic replacement prevents tears; this lock serializes shared read-modify-write operations.
|
|
98
|
+
// Deliberately non-reentrant. Token-owned release must not evict a later owner after a stale break.
|
|
99
|
+
// The canonical lock rechecks the inode at steal time; its documented check-to-unlink race remains.
|
|
100
|
+
return {
|
|
101
|
+
read: (_required) => readJsonFile(filePath),
|
|
102
|
+
tempPath: (_required, { pid = process.pid } = {}) => tempPathFor(filePath, pid),
|
|
103
|
+
write: ({ value }) => writeJson(filePath, value),
|
|
104
|
+
quarantine: ({ notice }) => quarantineUnreadableFile(filePath, notice),
|
|
105
|
+
withLock: ({ critical }, optional = {}) => {
|
|
106
|
+
if (optional.waitMs !== undefined && (!Number.isSafeInteger(optional.waitMs) || optional.waitMs < 0))
|
|
107
|
+
throw new TypeError("waitMs must be a nonnegative safe integer");
|
|
108
|
+
return withFileLockSync({ lockPath: `${filePath}.lock`, run: critical }, {
|
|
109
|
+
timeoutMs: optional.waitMs ?? LOCK_WAIT_MS, staleMs: LOCK_STALE_MS, pollMs: LOCK_POLL_MS,
|
|
110
|
+
filesystem: fs, clock, sleep: ({ durationMs }) => sleep.sleep({ durationMs }), process,
|
|
111
|
+
token: () => random.token({}), format: "token", createParent: true,
|
|
112
|
+
timeoutMessage: notices.lockTimeout, suppressReleaseErrors: true,
|
|
113
|
+
});
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
// Bound recovery to the latest 1,000 parse attempts by default, rather than parsing once per quoted
|
|
118
|
+
// character in a large damaged file. Stores must validate recovered values normally: partial objects
|
|
119
|
+
// are not trustworthy merely because their brackets can be closed into syntactically valid JSON.
|
|
120
|
+
export function salvageJsonPrefix({ text }, { maxAttempts = 1000 } = {}) {
|
|
121
|
+
if (!Number.isSafeInteger(maxAttempts) || maxAttempts <= 0)
|
|
122
|
+
throw new TypeError("maxAttempts must be a positive safe integer");
|
|
123
|
+
const cuts = elementEnds(text);
|
|
124
|
+
const floor = Math.max(0, cuts.length - maxAttempts);
|
|
125
|
+
for (let index = cuts.length - 1; index >= floor; index -= 1) {
|
|
126
|
+
const cut = cuts[index];
|
|
127
|
+
try {
|
|
128
|
+
return JSON.parse(text.slice(0, cut.end) + cut.closers);
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
// A cut after an object key cannot close validly; try an earlier completed value.
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return undefined;
|
|
135
|
+
}
|
|
136
|
+
// Cut only at completed strings/objects/arrays, sufficient for string-valued lists. Damage inside a
|
|
137
|
+
// bare number/boolean falls back to the previous cut and loses that element rather than guessing it.
|
|
138
|
+
function elementEnds(text) {
|
|
139
|
+
const open = [];
|
|
140
|
+
const cuts = [];
|
|
141
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
142
|
+
const char = text[index];
|
|
143
|
+
if (char === '"')
|
|
144
|
+
index = endOfString(text, index);
|
|
145
|
+
else if (char === "{" || char === "[") {
|
|
146
|
+
open.push(char === "{" ? "}" : "]");
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
else if (char === "}" || char === "]")
|
|
150
|
+
open.pop();
|
|
151
|
+
else
|
|
152
|
+
continue;
|
|
153
|
+
cuts.push({ end: index + 1, closers: [...open].reverse().join("") });
|
|
154
|
+
}
|
|
155
|
+
return cuts;
|
|
156
|
+
}
|
|
157
|
+
function endOfString(text, start) {
|
|
158
|
+
for (let index = start + 1; index < text.length; index += 1) {
|
|
159
|
+
if (text[index] === "\\")
|
|
160
|
+
index += 1;
|
|
161
|
+
else if (text[index] === '"')
|
|
162
|
+
return index;
|
|
163
|
+
}
|
|
164
|
+
return text.length;
|
|
165
|
+
}
|
|
166
|
+
//# sourceMappingURL=durable-json.js.map
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** Narrow env-text transforms: first exact KEY= match, preserve unrelated lines, one final newline. */
|
|
2
|
+
export declare function upsertEnvLine({ source, key, value }: {
|
|
3
|
+
source: string;
|
|
4
|
+
key: string;
|
|
5
|
+
value: string;
|
|
6
|
+
}): string;
|
|
7
|
+
export declare function readEnvLine({ source, key }: {
|
|
8
|
+
source: string;
|
|
9
|
+
key: string;
|
|
10
|
+
}): string | null;
|
|
11
|
+
//# sourceMappingURL=env-text.d.ts.map
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/** Narrow env-text transforms: first exact KEY= match, preserve unrelated lines, one final newline. */
|
|
2
|
+
export function upsertEnvLine({ source, key, value }) {
|
|
3
|
+
const lines = source.length === 0 ? [] : source.split("\n");
|
|
4
|
+
if (lines.length > 0 && lines[lines.length - 1] === "")
|
|
5
|
+
lines.pop();
|
|
6
|
+
const prefix = `${key}=`;
|
|
7
|
+
const index = lines.findIndex((line) => line.startsWith(prefix));
|
|
8
|
+
const newLine = `${prefix}${value}`;
|
|
9
|
+
if (index === -1) {
|
|
10
|
+
lines.push(newLine);
|
|
11
|
+
}
|
|
12
|
+
else {
|
|
13
|
+
lines[index] = newLine;
|
|
14
|
+
}
|
|
15
|
+
return `${lines.join("\n")}\n`;
|
|
16
|
+
}
|
|
17
|
+
export function readEnvLine({ source, key }) {
|
|
18
|
+
const prefix = `${key}=`;
|
|
19
|
+
const match = source.split("\n").find((line) => line.startsWith(prefix));
|
|
20
|
+
return match === undefined ? null : match.slice(prefix.length).trim();
|
|
21
|
+
}
|
|
22
|
+
//# sourceMappingURL=env-text.js.map
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Atomic writes prevent tears but not lost updates between shared read-modify-writes. Plain readers
|
|
3
|
+
* need no lock. Deliberately non-reentrant: nesting times out rather than silently sharing an
|
|
4
|
+
* exclusive section; compose store operations from unlocked internals instead.
|
|
5
|
+
* Kernel exclusive create chooses the winner. A unique token AND inode identify that owner;
|
|
6
|
+
* pid alone cannot distinguish a later owner after a stale break. Signal-zero liveness probes
|
|
7
|
+
* treat only ESRCH as death: permission failure must never authorize evicting a live process.
|
|
8
|
+
* Stale removal rechecks inode, bytes and mtime immediately before unlink. There is still a residual
|
|
9
|
+
* check-to-unlink race on filesystems without conditional unlink; rename/restore chains create wider
|
|
10
|
+
* multi-process races and are deliberately avoided. Atomic file replacement independently prevents
|
|
11
|
+
* torn content. Hosts whose operations can legitimately exceed the stale budget use staleMs: Infinity.
|
|
12
|
+
*/
|
|
13
|
+
import fs from "node:fs";
|
|
14
|
+
import * as asyncFs from "node:fs/promises";
|
|
15
|
+
import { type Clock } from "@jini-ai/core/primitives";
|
|
16
|
+
import { type PlatformMessages } from "../messages.js";
|
|
17
|
+
export declare const DEFAULT_LOCK_TIMEOUT_MS = 15000;
|
|
18
|
+
export declare const DEFAULT_LOCK_STALE_AFTER_MS = 10000;
|
|
19
|
+
export interface FileLockHolder {
|
|
20
|
+
readonly pid: number;
|
|
21
|
+
readonly hostname: string;
|
|
22
|
+
readonly token: string;
|
|
23
|
+
readonly acquiredAt: string;
|
|
24
|
+
}
|
|
25
|
+
export interface HeldFileLock {
|
|
26
|
+
readonly lockPath: string;
|
|
27
|
+
assertHeld(required: Record<string, never>): Promise<void>;
|
|
28
|
+
}
|
|
29
|
+
export interface HeldFileLockSync {
|
|
30
|
+
readonly lockPath: string;
|
|
31
|
+
assertHeld(required: Record<string, never>): void;
|
|
32
|
+
}
|
|
33
|
+
export type FileLockSyncFilesystem = Pick<typeof fs, "mkdirSync" | "openSync" | "writeSync" | "fstatSync" | "readFileSync" | "statSync" | "closeSync" | "rmSync">;
|
|
34
|
+
export type FileLockAsyncFilesystem = Pick<typeof asyncFs, "mkdir" | "open" | "stat" | "rm">;
|
|
35
|
+
export interface FileLockOptions {
|
|
36
|
+
timeoutMs?: number;
|
|
37
|
+
/** Infinity disables age stealing; dead local owners remain reclaimable. */
|
|
38
|
+
staleMs?: number;
|
|
39
|
+
pollMs?: number;
|
|
40
|
+
clock?: Clock;
|
|
41
|
+
/** Times the wait budget (start and timeout check). Defaults to `clock.nowMs`; hosts with a
|
|
42
|
+
* monotonic source pass it so a wall-clock jump can neither end nor stretch the wait. Stale age
|
|
43
|
+
* stays on `clock.nowMs`, because lock-file mtimes are wall-clock times. */
|
|
44
|
+
monotonicMs?: () => number;
|
|
45
|
+
sleep?: (required: {
|
|
46
|
+
durationMs: number;
|
|
47
|
+
}) => void | Promise<void>;
|
|
48
|
+
process?: {
|
|
49
|
+
pid: number;
|
|
50
|
+
isAlive(required: {
|
|
51
|
+
pid: number;
|
|
52
|
+
}): boolean;
|
|
53
|
+
};
|
|
54
|
+
token?: () => string;
|
|
55
|
+
hostname?: () => string;
|
|
56
|
+
onStaleLockRemoved?: (required: {
|
|
57
|
+
holder: FileLockHolder | undefined;
|
|
58
|
+
}) => void;
|
|
59
|
+
createParent?: boolean;
|
|
60
|
+
format?: "json" | "token";
|
|
61
|
+
/** Host stores keep their existing user-facing timeout copy. */
|
|
62
|
+
timeoutMessage?: (required: {
|
|
63
|
+
lockPath: string;
|
|
64
|
+
waitMs: number;
|
|
65
|
+
}) => string;
|
|
66
|
+
messages?: PlatformMessages;
|
|
67
|
+
/** Durable stores historically ignored release failures to avoid masking their critical section. */
|
|
68
|
+
suppressReleaseErrors?: boolean;
|
|
69
|
+
}
|
|
70
|
+
export declare class FileLockTimeoutError extends Error {
|
|
71
|
+
readonly lockPath: string;
|
|
72
|
+
readonly holder: FileLockHolder | undefined;
|
|
73
|
+
readonly waitedMs: number;
|
|
74
|
+
constructor(required: {
|
|
75
|
+
lockPath: string;
|
|
76
|
+
holder: FileLockHolder | undefined;
|
|
77
|
+
waitedMs: number;
|
|
78
|
+
}, optional?: {
|
|
79
|
+
message?: string;
|
|
80
|
+
messages?: PlatformMessages;
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
export declare class FileLockLostError extends Error {
|
|
84
|
+
readonly lockPath: string;
|
|
85
|
+
constructor({ lockPath }: {
|
|
86
|
+
lockPath: string;
|
|
87
|
+
}, { messages }?: {
|
|
88
|
+
messages?: PlatformMessages;
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
/** Platform-specific contention classification retains the Windows exclusive-open behavior. */
|
|
92
|
+
export declare function isContendedLockError({ code, platform }: {
|
|
93
|
+
code: string | undefined;
|
|
94
|
+
platform: NodeJS.Platform;
|
|
95
|
+
}): boolean;
|
|
96
|
+
/** One pure stale decision serves both I/O adapters. Invalid/foreign pids have unknown liveness,
|
|
97
|
+
* never false liveness; otherwise kill(0/negative, 0) would address a process group. */
|
|
98
|
+
export declare function isLockStale(required: {
|
|
99
|
+
nowMs: number;
|
|
100
|
+
mtimeMs: number;
|
|
101
|
+
staleMs: number;
|
|
102
|
+
ownerAlive: boolean | undefined;
|
|
103
|
+
observedInode: number;
|
|
104
|
+
currentInode: number;
|
|
105
|
+
observedDevice: number;
|
|
106
|
+
currentDevice: number;
|
|
107
|
+
}): boolean;
|
|
108
|
+
/** Synchronous read-modify-write guard. Durable stores pass their former 5s/30s/10ms values.
|
|
109
|
+
* Release never evicts a later owner, even when the old owner reaches finally after a steal. */
|
|
110
|
+
export declare function withFileLockSync<T>({ lockPath, run }: {
|
|
111
|
+
lockPath: string;
|
|
112
|
+
run: (lock: HeldFileLockSync) => T;
|
|
113
|
+
}, options?: Omit<FileLockOptions, "sleep"> & {
|
|
114
|
+
sleep?: (required: {
|
|
115
|
+
durationMs: number;
|
|
116
|
+
}) => void;
|
|
117
|
+
filesystem?: FileLockSyncFilesystem;
|
|
118
|
+
}): T;
|
|
119
|
+
/** Async adapter of the same stale and ownership policy; polling yields to other writers. */
|
|
120
|
+
export declare function withFileLock<T>({ lockPath, run }: {
|
|
121
|
+
lockPath: string;
|
|
122
|
+
run: (lock: HeldFileLock) => Promise<T> | T;
|
|
123
|
+
}, options?: FileLockOptions & {
|
|
124
|
+
filesystem?: FileLockAsyncFilesystem;
|
|
125
|
+
}): Promise<T>;
|
|
126
|
+
//# sourceMappingURL=file-lock.d.ts.map
|