@danypops/vehicle-core 0.2.0 → 0.3.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.
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Cross-platform atomic JSON persistence -- shared by every Vehicle
3
+ * primitive that needs durable state (Jobs' status file, Watchers'
4
+ * registry) so a crash or concurrent read never observes a half-written
5
+ * file. Lives in vehicle-core (not vehicle-server) but stays fs-free
6
+ * itself: every filesystem operation is injected via `AtomicJsonFsAdapter`,
7
+ * matching vehicle-core's own "zero runtime dependencies" invariant --
8
+ * the caller (vehicle-server, vehicle-client-pi) supplies real node:fs
9
+ * functions, this module only sequences them.
10
+ *
11
+ * Modeled on github.com/nicobailon/pi-subagents' `createAtomicJsonWriter`:
12
+ * a collision-safe temp filename, injectable fs/now/pid/random for
13
+ * deterministic tests, and explicit Windows-aware rename retry (a plain
14
+ * `fs.rename` onto an existing path can transiently fail on Windows if
15
+ * another process -- antivirus, search indexing -- has the destination
16
+ * briefly open; POSIX rename() has no such failure mode, so retrying by
17
+ * default there would only add latency for a class of error that never
18
+ * happens).
19
+ */
20
+ export interface AtomicJsonFsAdapter {
21
+ writeFile(path: string, data: string): Promise<void>;
22
+ rename(oldPath: string, newPath: string): Promise<void>;
23
+ unlink(path: string): Promise<void>;
24
+ readFile(path: string): Promise<string>;
25
+ }
26
+ export interface AtomicJsonWriterOptions {
27
+ readonly fs: AtomicJsonFsAdapter;
28
+ /** Defaults to Date.now. */
29
+ readonly now?: () => number;
30
+ /** Defaults to the current process's pid, or 0 outside Node/Bun. */
31
+ readonly pid?: () => number;
32
+ /** Defaults to a short random hex string. */
33
+ readonly random?: () => string;
34
+ /**
35
+ * Whether a failed rename onto the destination is retried at all.
36
+ * Defaults to `process.platform === "win32"` -- off on Linux/macOS,
37
+ * where a transient rename failure isn't a real failure mode.
38
+ */
39
+ readonly retryRename?: boolean;
40
+ /** Error codes on `rename` worth retrying. Defaults to ["EPERM", "EBUSY", "EACCES"] (the documented Windows file-lock codes). */
41
+ readonly retryRenameErrors?: readonly string[];
42
+ /** Delay before each retry attempt, in order. Defaults to [50, 100, 200]. */
43
+ readonly retryDelaysMs?: readonly number[];
44
+ /** Injectable so a test doesn't have to sleep for real. Defaults to setTimeout. */
45
+ readonly sleep?: (ms: number) => Promise<void>;
46
+ }
47
+ export interface AtomicJsonWriter {
48
+ /** Serializes `value` to JSON and writes it to `filePath` atomically (temp file + rename). */
49
+ write(filePath: string, value: unknown): Promise<void>;
50
+ /** Reads and JSON.parses `filePath`. Returns undefined if the file doesn't exist (fs.readFile throws ENOENT); rethrows any other error. */
51
+ read(filePath: string): Promise<unknown | undefined>;
52
+ }
53
+ export declare function createAtomicJsonWriter(options: AtomicJsonWriterOptions): AtomicJsonWriter;
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Cross-platform atomic JSON persistence -- shared by every Vehicle
3
+ * primitive that needs durable state (Jobs' status file, Watchers'
4
+ * registry) so a crash or concurrent read never observes a half-written
5
+ * file. Lives in vehicle-core (not vehicle-server) but stays fs-free
6
+ * itself: every filesystem operation is injected via `AtomicJsonFsAdapter`,
7
+ * matching vehicle-core's own "zero runtime dependencies" invariant --
8
+ * the caller (vehicle-server, vehicle-client-pi) supplies real node:fs
9
+ * functions, this module only sequences them.
10
+ *
11
+ * Modeled on github.com/nicobailon/pi-subagents' `createAtomicJsonWriter`:
12
+ * a collision-safe temp filename, injectable fs/now/pid/random for
13
+ * deterministic tests, and explicit Windows-aware rename retry (a plain
14
+ * `fs.rename` onto an existing path can transiently fail on Windows if
15
+ * another process -- antivirus, search indexing -- has the destination
16
+ * briefly open; POSIX rename() has no such failure mode, so retrying by
17
+ * default there would only add latency for a class of error that never
18
+ * happens).
19
+ */
20
+ function defaultPlatformIsWindows() {
21
+ return typeof process !== "undefined" && process.platform === "win32";
22
+ }
23
+ function defaultPid() {
24
+ return typeof process !== "undefined" ? process.pid : 0;
25
+ }
26
+ function defaultRandom() {
27
+ return Math.random().toString(36).slice(2, 10);
28
+ }
29
+ function isErrnoException(error) {
30
+ return error instanceof Error && "code" in error;
31
+ }
32
+ function dirAndBase(filePath) {
33
+ const separator = filePath.lastIndexOf("/");
34
+ if (separator === -1)
35
+ return { dir: ".", base: filePath };
36
+ return { dir: filePath.slice(0, separator) || "/", base: filePath.slice(separator + 1) };
37
+ }
38
+ export function createAtomicJsonWriter(options) {
39
+ const fsAdapter = options.fs;
40
+ const now = options.now ?? Date.now;
41
+ const pid = options.pid ?? defaultPid;
42
+ const random = options.random ?? defaultRandom;
43
+ const retryRename = options.retryRename ?? defaultPlatformIsWindows();
44
+ const retryRenameErrors = options.retryRenameErrors ?? ["EPERM", "EBUSY", "EACCES"];
45
+ const retryDelaysMs = options.retryDelaysMs ?? [50, 100, 200];
46
+ const sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
47
+ async function renameWithRetry(tempPath, filePath) {
48
+ let attempt = 0;
49
+ for (;;) {
50
+ try {
51
+ await fsAdapter.rename(tempPath, filePath);
52
+ return;
53
+ }
54
+ catch (error) {
55
+ const retryable = retryRename && isErrnoException(error) && !!error.code && retryRenameErrors.includes(error.code);
56
+ if (!retryable || attempt >= retryDelaysMs.length)
57
+ throw error;
58
+ await sleep(retryDelaysMs[attempt] ?? 0);
59
+ attempt++;
60
+ }
61
+ }
62
+ }
63
+ return {
64
+ async write(filePath, value) {
65
+ let serialized;
66
+ try {
67
+ serialized = JSON.stringify(value);
68
+ }
69
+ catch (error) {
70
+ throw new Error(`atomic-json: value for ${filePath} is not JSON-serializable`, { cause: error });
71
+ }
72
+ if (serialized === undefined)
73
+ throw new Error(`atomic-json: value for ${filePath} is not JSON-serializable`);
74
+ const { dir, base } = dirAndBase(filePath);
75
+ const tempPath = `${dir}/.${base}.${pid()}.${now()}.${random()}.tmp`;
76
+ await fsAdapter.writeFile(tempPath, serialized);
77
+ try {
78
+ await renameWithRetry(tempPath, filePath);
79
+ }
80
+ catch (error) {
81
+ try {
82
+ await fsAdapter.unlink(tempPath);
83
+ }
84
+ catch {
85
+ // Best-effort cleanup -- the rename failure itself is the real error to surface.
86
+ }
87
+ throw error;
88
+ }
89
+ },
90
+ async read(filePath) {
91
+ try {
92
+ const raw = await fsAdapter.readFile(filePath);
93
+ return JSON.parse(raw);
94
+ }
95
+ catch (error) {
96
+ if (isErrnoException(error) && error.code === "ENOENT")
97
+ return undefined;
98
+ throw error;
99
+ }
100
+ },
101
+ };
102
+ }
package/dist/index.d.ts CHANGED
@@ -1,2 +1,3 @@
1
+ export * from "./atomic-json.js";
1
2
  export * from "./vehicle-contract.js";
2
3
  export * from "./vehicle-errors.js";
package/dist/index.js CHANGED
@@ -1,2 +1,3 @@
1
+ export * from "./atomic-json.js";
1
2
  export * from "./vehicle-contract.js";
2
3
  export * from "./vehicle-errors.js";
@@ -110,7 +110,8 @@ function validateOperationMetadata(options) {
110
110
  if (limits.defaultTimeoutMs > limits.maxTimeoutMs) {
111
111
  throw new Error("Vehicle operation defaultTimeoutMs must not exceed maxTimeoutMs");
112
112
  }
113
- if (options.idempotency.mode === "keyed" && (!Number.isSafeInteger(options.idempotency.retentionMs) || options.idempotency.retentionMs < 1)) {
113
+ if (options.idempotency.mode === "keyed" &&
114
+ (!Number.isSafeInteger(options.idempotency.retentionMs) || options.idempotency.retentionMs < 1)) {
114
115
  throw new Error("Vehicle keyed idempotency retentionMs must be a positive integer");
115
116
  }
116
117
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/vehicle-core",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Vehicle's runtime-neutral wire contract: operation descriptors, schema codecs, failure shapes. Zero runtime dependencies, zero Bun-specific code -- the one thing every Vehicle client and server package depends on.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -19,7 +19,7 @@
19
19
  },
20
20
  "devDependencies": {
21
21
  "@types/node": "^22.0.0",
22
- "typescript": "latest"
22
+ "typescript": "^5.9.2"
23
23
  },
24
24
  "repository": {
25
25
  "type": "git",
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Cross-platform atomic JSON persistence -- shared by every Vehicle
3
+ * primitive that needs durable state (Jobs' status file, Watchers'
4
+ * registry) so a crash or concurrent read never observes a half-written
5
+ * file. Lives in vehicle-core (not vehicle-server) but stays fs-free
6
+ * itself: every filesystem operation is injected via `AtomicJsonFsAdapter`,
7
+ * matching vehicle-core's own "zero runtime dependencies" invariant --
8
+ * the caller (vehicle-server, vehicle-client-pi) supplies real node:fs
9
+ * functions, this module only sequences them.
10
+ *
11
+ * Modeled on github.com/nicobailon/pi-subagents' `createAtomicJsonWriter`:
12
+ * a collision-safe temp filename, injectable fs/now/pid/random for
13
+ * deterministic tests, and explicit Windows-aware rename retry (a plain
14
+ * `fs.rename` onto an existing path can transiently fail on Windows if
15
+ * another process -- antivirus, search indexing -- has the destination
16
+ * briefly open; POSIX rename() has no such failure mode, so retrying by
17
+ * default there would only add latency for a class of error that never
18
+ * happens).
19
+ */
20
+
21
+ export interface AtomicJsonFsAdapter {
22
+ writeFile(path: string, data: string): Promise<void>;
23
+ rename(oldPath: string, newPath: string): Promise<void>;
24
+ unlink(path: string): Promise<void>;
25
+ readFile(path: string): Promise<string>;
26
+ }
27
+
28
+ export interface AtomicJsonWriterOptions {
29
+ readonly fs: AtomicJsonFsAdapter;
30
+ /** Defaults to Date.now. */
31
+ readonly now?: () => number;
32
+ /** Defaults to the current process's pid, or 0 outside Node/Bun. */
33
+ readonly pid?: () => number;
34
+ /** Defaults to a short random hex string. */
35
+ readonly random?: () => string;
36
+ /**
37
+ * Whether a failed rename onto the destination is retried at all.
38
+ * Defaults to `process.platform === "win32"` -- off on Linux/macOS,
39
+ * where a transient rename failure isn't a real failure mode.
40
+ */
41
+ readonly retryRename?: boolean;
42
+ /** Error codes on `rename` worth retrying. Defaults to ["EPERM", "EBUSY", "EACCES"] (the documented Windows file-lock codes). */
43
+ readonly retryRenameErrors?: readonly string[];
44
+ /** Delay before each retry attempt, in order. Defaults to [50, 100, 200]. */
45
+ readonly retryDelaysMs?: readonly number[];
46
+ /** Injectable so a test doesn't have to sleep for real. Defaults to setTimeout. */
47
+ readonly sleep?: (ms: number) => Promise<void>;
48
+ }
49
+
50
+ export interface AtomicJsonWriter {
51
+ /** Serializes `value` to JSON and writes it to `filePath` atomically (temp file + rename). */
52
+ write(filePath: string, value: unknown): Promise<void>;
53
+ /** Reads and JSON.parses `filePath`. Returns undefined if the file doesn't exist (fs.readFile throws ENOENT); rethrows any other error. */
54
+ read(filePath: string): Promise<unknown | undefined>;
55
+ }
56
+
57
+ function defaultPlatformIsWindows(): boolean {
58
+ return typeof process !== "undefined" && process.platform === "win32";
59
+ }
60
+
61
+ function defaultPid(): number {
62
+ return typeof process !== "undefined" ? process.pid : 0;
63
+ }
64
+
65
+ function defaultRandom(): string {
66
+ return Math.random().toString(36).slice(2, 10);
67
+ }
68
+
69
+ function isErrnoException(error: unknown): error is NodeJS.ErrnoException {
70
+ return error instanceof Error && "code" in error;
71
+ }
72
+
73
+ function dirAndBase(filePath: string): { readonly dir: string; readonly base: string } {
74
+ const separator = filePath.lastIndexOf("/");
75
+ if (separator === -1) return { dir: ".", base: filePath };
76
+ return { dir: filePath.slice(0, separator) || "/", base: filePath.slice(separator + 1) };
77
+ }
78
+
79
+ export function createAtomicJsonWriter(options: AtomicJsonWriterOptions): AtomicJsonWriter {
80
+ const fsAdapter = options.fs;
81
+ const now = options.now ?? Date.now;
82
+ const pid = options.pid ?? defaultPid;
83
+ const random = options.random ?? defaultRandom;
84
+ const retryRename = options.retryRename ?? defaultPlatformIsWindows();
85
+ const retryRenameErrors = options.retryRenameErrors ?? ["EPERM", "EBUSY", "EACCES"];
86
+ const retryDelaysMs = options.retryDelaysMs ?? [50, 100, 200];
87
+ const sleep = options.sleep ?? ((ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)));
88
+
89
+ async function renameWithRetry(tempPath: string, filePath: string): Promise<void> {
90
+ let attempt = 0;
91
+ for (;;) {
92
+ try {
93
+ await fsAdapter.rename(tempPath, filePath);
94
+ return;
95
+ } catch (error) {
96
+ const retryable = retryRename && isErrnoException(error) && !!error.code && retryRenameErrors.includes(error.code);
97
+ if (!retryable || attempt >= retryDelaysMs.length) throw error;
98
+ await sleep(retryDelaysMs[attempt] ?? 0);
99
+ attempt++;
100
+ }
101
+ }
102
+ }
103
+
104
+ return {
105
+ async write(filePath, value) {
106
+ let serialized: string | undefined;
107
+ try {
108
+ serialized = JSON.stringify(value);
109
+ } catch (error) {
110
+ throw new Error(`atomic-json: value for ${filePath} is not JSON-serializable`, { cause: error });
111
+ }
112
+ if (serialized === undefined) throw new Error(`atomic-json: value for ${filePath} is not JSON-serializable`);
113
+ const { dir, base } = dirAndBase(filePath);
114
+ const tempPath = `${dir}/.${base}.${pid()}.${now()}.${random()}.tmp`;
115
+ await fsAdapter.writeFile(tempPath, serialized);
116
+ try {
117
+ await renameWithRetry(tempPath, filePath);
118
+ } catch (error) {
119
+ try {
120
+ await fsAdapter.unlink(tempPath);
121
+ } catch {
122
+ // Best-effort cleanup -- the rename failure itself is the real error to surface.
123
+ }
124
+ throw error;
125
+ }
126
+ },
127
+ async read(filePath) {
128
+ try {
129
+ const raw = await fsAdapter.readFile(filePath);
130
+ return JSON.parse(raw) as unknown;
131
+ } catch (error) {
132
+ if (isErrnoException(error) && error.code === "ENOENT") return undefined;
133
+ throw error;
134
+ }
135
+ },
136
+ };
137
+ }
package/src/index.ts CHANGED
@@ -1,2 +1,3 @@
1
+ export * from "./atomic-json.js";
1
2
  export * from "./vehicle-contract.js";
2
3
  export * from "./vehicle-errors.js";
@@ -36,7 +36,10 @@ export interface LooseObjectProperty {
36
36
  * consumer projecting a plain-object input onto a VehicleOperation needs the
37
37
  * same required/enum checks; this is that check written once.
38
38
  */
39
- export function defineLooseObjectSchema(properties: Record<string, LooseObjectProperty>, required: readonly string[] = []): VehicleSchemaCodec<Record<string, unknown>> {
39
+ export function defineLooseObjectSchema(
40
+ properties: Record<string, LooseObjectProperty>,
41
+ required: readonly string[] = [],
42
+ ): VehicleSchemaCodec<Record<string, unknown>> {
40
43
  return defineVehicleSchema<Record<string, unknown>>({
41
44
  // LooseObjectProperty's named fields (type, enum) are all JSON-value-shaped
42
45
  // at runtime, but TypeScript's structural check against the recursive
@@ -236,12 +239,7 @@ export interface VehicleManifest extends VehicleManifestIdentity {
236
239
 
237
240
  export interface VehicleClient {
238
241
  manifest(): Promise<VehicleManifest>;
239
- invoke<Output = unknown>(
240
- name: string,
241
- version: number,
242
- input: unknown,
243
- options?: VehicleInvocationOptions,
244
- ): Promise<Output>;
242
+ invoke<Output = unknown>(name: string, version: number, input: unknown, options?: VehicleInvocationOptions): Promise<Output>;
245
243
  close(): Promise<void>;
246
244
  }
247
245
 
@@ -289,7 +287,10 @@ function validateOperationMetadata<Input, Output>(options: DefineVehicleOperatio
289
287
  if (limits.defaultTimeoutMs > limits.maxTimeoutMs) {
290
288
  throw new Error("Vehicle operation defaultTimeoutMs must not exceed maxTimeoutMs");
291
289
  }
292
- if (options.idempotency.mode === "keyed" && (!Number.isSafeInteger(options.idempotency.retentionMs) || options.idempotency.retentionMs < 1)) {
290
+ if (
291
+ options.idempotency.mode === "keyed" &&
292
+ (!Number.isSafeInteger(options.idempotency.retentionMs) || options.idempotency.retentionMs < 1)
293
+ ) {
293
294
  throw new Error("Vehicle keyed idempotency retentionMs must be a positive integer");
294
295
  }
295
296
  }