@danypops/vehicle-core 0.3.0 → 0.4.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/dist/index.d.ts CHANGED
@@ -1,3 +1,4 @@
1
1
  export * from "./atomic-json.js";
2
2
  export * from "./vehicle-contract.js";
3
3
  export * from "./vehicle-errors.js";
4
+ export * from "./vehicle-jobs.js";
package/dist/index.js CHANGED
@@ -1,3 +1,4 @@
1
1
  export * from "./atomic-json.js";
2
2
  export * from "./vehicle-contract.js";
3
3
  export * from "./vehicle-errors.js";
4
+ export * from "./vehicle-jobs.js";
@@ -1,3 +1,4 @@
1
+ import type { VehicleJobWakeBudget } from "./vehicle-jobs.js";
1
2
  export type JsonPrimitive = string | number | boolean | null;
2
3
  export type JsonValue = JsonPrimitive | readonly JsonValue[] | {
3
4
  readonly [key: string]: JsonValue;
@@ -85,6 +86,12 @@ export interface VehicleFailureDescriptor {
85
86
  readonly code: string;
86
87
  readonly description: string;
87
88
  }
89
+ /** Declares an operation safe to run as a Vehicle Job (detached, polled/tailed/canceled by id). Absent means live-invoke only. */
90
+ export interface VehicleBackgroundCapability {
91
+ readonly supported: true;
92
+ readonly defaultWakeBudget: VehicleJobWakeBudget;
93
+ readonly maxWakeBudget: VehicleJobWakeBudget;
94
+ }
88
95
  export interface VehicleOperationDescriptor {
89
96
  readonly name: string;
90
97
  readonly version: number;
@@ -98,6 +105,7 @@ export interface VehicleOperationDescriptor {
98
105
  readonly longRunning: boolean;
99
106
  readonly limits: VehicleLimits;
100
107
  readonly errors: readonly VehicleFailureDescriptor[];
108
+ readonly background?: VehicleBackgroundCapability;
101
109
  }
102
110
  export interface VehicleOperation<Input, Output> {
103
111
  readonly descriptor: VehicleOperationDescriptor;
@@ -117,6 +125,7 @@ export interface DefineVehicleOperationOptions<Input, Output> {
117
125
  readonly longRunning?: boolean;
118
126
  readonly limits: VehicleLimits;
119
127
  readonly errors?: readonly VehicleFailureDescriptor[];
128
+ readonly background?: VehicleBackgroundCapability;
120
129
  }
121
130
  export interface VehiclePrincipal {
122
131
  readonly id: string;
@@ -84,6 +84,15 @@ export function defineVehicleOperation(options) {
84
84
  longRunning: options.longRunning ?? false,
85
85
  limits: Object.freeze({ ...options.limits }),
86
86
  errors: Object.freeze((options.errors ?? []).map((failure) => Object.freeze({ ...failure }))),
87
+ ...(options.background
88
+ ? {
89
+ background: Object.freeze({
90
+ supported: true,
91
+ defaultWakeBudget: Object.freeze({ ...options.background.defaultWakeBudget }),
92
+ maxWakeBudget: Object.freeze({ ...options.background.maxWakeBudget }),
93
+ }),
94
+ }
95
+ : {}),
87
96
  });
88
97
  return Object.freeze({ descriptor, input: options.input, output: options.output });
89
98
  }
@@ -114,6 +123,28 @@ function validateOperationMetadata(options) {
114
123
  (!Number.isSafeInteger(options.idempotency.retentionMs) || options.idempotency.retentionMs < 1)) {
115
124
  throw new Error("Vehicle keyed idempotency retentionMs must be a positive integer");
116
125
  }
126
+ if (options.background) {
127
+ if (!options.longRunning) {
128
+ throw new Error("Vehicle operation with a background capability must also set longRunning: true");
129
+ }
130
+ for (const [budgetName, budget] of [
131
+ ["defaultWakeBudget", options.background.defaultWakeBudget],
132
+ ["maxWakeBudget", options.background.maxWakeBudget],
133
+ ]) {
134
+ if (!Number.isSafeInteger(budget.maxCount) || budget.maxCount < 1) {
135
+ throw new Error(`Vehicle operation background.${budgetName}.maxCount must be a positive integer`);
136
+ }
137
+ if (!Number.isSafeInteger(budget.maxBytes) || budget.maxBytes < 1) {
138
+ throw new Error(`Vehicle operation background.${budgetName}.maxBytes must be a positive integer`);
139
+ }
140
+ }
141
+ if (options.background.defaultWakeBudget.maxCount > options.background.maxWakeBudget.maxCount) {
142
+ throw new Error("Vehicle operation background.defaultWakeBudget.maxCount must not exceed maxWakeBudget.maxCount");
143
+ }
144
+ if (options.background.defaultWakeBudget.maxBytes > options.background.maxWakeBudget.maxBytes) {
145
+ throw new Error("Vehicle operation background.defaultWakeBudget.maxBytes must not exceed maxWakeBudget.maxBytes");
146
+ }
147
+ }
117
148
  }
118
149
  function cloneJson(value) {
119
150
  const serialized = JSON.stringify(value);
@@ -1,6 +1,6 @@
1
1
  import type { JsonValue, VehicleSchemaIssue } from "./vehicle-contract.js";
2
2
  export type VehicleFailureCategory = "validation" | "not_found" | "conflict" | "authorization" | "capacity" | "timeout" | "cancelled" | "unavailable" | "internal";
3
- export type VehicleCoreErrorCode = "duplicate-owner" | "not-found" | "invalid-input" | "invalid-output" | "permission-denied" | "request-too-large" | "response-too-large" | "cancelled" | "deadline-exceeded" | "handler-failed" | "policy-failed" | "idempotency-key-required" | "client-closed" | "operation-unavailable";
3
+ export type VehicleCoreErrorCode = "duplicate-owner" | "not-found" | "invalid-input" | "invalid-output" | "permission-denied" | "request-too-large" | "response-too-large" | "cancelled" | "deadline-exceeded" | "handler-failed" | "policy-failed" | "idempotency-key-required" | "client-closed" | "operation-unavailable" | "background-not-supported" | "job-not-found";
4
4
  export interface VehicleRecovery {
5
5
  readonly operation?: string;
6
6
  readonly message: string;
@@ -0,0 +1,45 @@
1
+ /** Pure pieces of Vehicle Jobs: a termination-reason resolver and a bounded wake-log accumulator. Orchestration lives in vehicle-server's VehicleJobStore. */
2
+ export type VehicleJobStatus = "running" | "succeeded" | "failed" | "canceled";
3
+ /** Highest precedence first -- an explicit cancel always wins even if the handler also settled around the same time. */
4
+ export declare const VEHICLE_JOB_TERMINATION_PRECEDENCE: readonly ["canceled", "timeout", "failed", "succeeded"];
5
+ export type VehicleJobTerminationReason = (typeof VEHICLE_JOB_TERMINATION_PRECEDENCE)[number];
6
+ export declare function resolveVehicleJobTerminationReason(candidates: readonly VehicleJobTerminationReason[]): VehicleJobTerminationReason;
7
+ /** "always" keeps every notification; "transition" drops one identical to the last (hash dedup); "first-only" keeps just the first. */
8
+ export type VehicleJobNotifyMode = "always" | "transition" | "first-only";
9
+ export interface VehicleJobWakeBudget {
10
+ readonly maxCount: number;
11
+ readonly maxBytes: number;
12
+ }
13
+ export type VehicleJobWakeDropReason = "count-budget-exhausted" | "byte-budget-exhausted" | "deduplicated-transition" | "superseded-by-first-only";
14
+ export interface VehicleJobWakeEntry {
15
+ readonly seq: number;
16
+ readonly at: number;
17
+ readonly progress: unknown;
18
+ }
19
+ export interface VehicleJobWakeAppendResult {
20
+ readonly accepted: boolean;
21
+ readonly entry?: VehicleJobWakeEntry;
22
+ readonly dropReason?: VehicleJobWakeDropReason;
23
+ }
24
+ export interface VehicleJobWakeLogOptions {
25
+ readonly notifyMode: VehicleJobNotifyMode;
26
+ readonly budget: VehicleJobWakeBudget;
27
+ /** Defaults to Date.now. */
28
+ readonly now?: () => number;
29
+ }
30
+ /** Bounds a job's accumulated progress notifications by count+bytes, same discipline as enforcePayloadSize but across a job's whole lifetime. */
31
+ export declare class VehicleJobWakeLog {
32
+ private readonly options;
33
+ private readonly entries;
34
+ private usedBytes;
35
+ private nextSeq;
36
+ private lastHash;
37
+ private acceptedFirst;
38
+ private readonly now;
39
+ constructor(options: VehicleJobWakeLogOptions);
40
+ append(progress: unknown): VehicleJobWakeAppendResult;
41
+ /** Entries with seq strictly greater than `cursor`. */
42
+ since(cursor: number): readonly VehicleJobWakeEntry[];
43
+ /** Highest seq issued so far (0 if none accepted yet). */
44
+ get cursor(): number;
45
+ }
@@ -0,0 +1,77 @@
1
+ /** Pure pieces of Vehicle Jobs: a termination-reason resolver and a bounded wake-log accumulator. Orchestration lives in vehicle-server's VehicleJobStore. */
2
+ /** Highest precedence first -- an explicit cancel always wins even if the handler also settled around the same time. */
3
+ export const VEHICLE_JOB_TERMINATION_PRECEDENCE = ["canceled", "timeout", "failed", "succeeded"];
4
+ export function resolveVehicleJobTerminationReason(candidates) {
5
+ if (candidates.length === 0)
6
+ throw new Error("resolveVehicleJobTerminationReason requires at least one candidate");
7
+ for (const reason of VEHICLE_JOB_TERMINATION_PRECEDENCE) {
8
+ if (candidates.includes(reason))
9
+ return reason;
10
+ }
11
+ throw new Error(`Unrecognized Vehicle job termination candidate(s): ${candidates.join(", ")}`);
12
+ }
13
+ /** Bounds a job's accumulated progress notifications by count+bytes, same discipline as enforcePayloadSize but across a job's whole lifetime. */
14
+ export class VehicleJobWakeLog {
15
+ options;
16
+ entries = [];
17
+ usedBytes = 0;
18
+ nextSeq = 1;
19
+ lastHash;
20
+ acceptedFirst = false;
21
+ now;
22
+ constructor(options) {
23
+ this.options = options;
24
+ this.now = options.now ?? Date.now;
25
+ }
26
+ append(progress) {
27
+ if (this.options.notifyMode === "first-only" && this.acceptedFirst) {
28
+ return { accepted: false, dropReason: "superseded-by-first-only" };
29
+ }
30
+ const serialized = safeJsonStringify(progress);
31
+ if (this.options.notifyMode === "transition") {
32
+ const hash = fnv1aHash(serialized);
33
+ if (hash === this.lastHash)
34
+ return { accepted: false, dropReason: "deduplicated-transition" };
35
+ this.lastHash = hash;
36
+ }
37
+ const bytes = new TextEncoder().encode(serialized).byteLength;
38
+ if (this.entries.length >= this.options.budget.maxCount)
39
+ return { accepted: false, dropReason: "count-budget-exhausted" };
40
+ if (this.usedBytes + bytes > this.options.budget.maxBytes)
41
+ return { accepted: false, dropReason: "byte-budget-exhausted" };
42
+ const entry = { seq: this.nextSeq++, at: this.now(), progress };
43
+ this.entries.push(entry);
44
+ this.usedBytes += bytes;
45
+ this.acceptedFirst = true;
46
+ return { accepted: true, entry };
47
+ }
48
+ /** Entries with seq strictly greater than `cursor`. */
49
+ since(cursor) {
50
+ return this.entries.filter((entry) => entry.seq > cursor);
51
+ }
52
+ /** Highest seq issued so far (0 if none accepted yet). */
53
+ get cursor() {
54
+ return this.nextSeq - 1;
55
+ }
56
+ }
57
+ function safeJsonStringify(value) {
58
+ let serialized;
59
+ try {
60
+ serialized = JSON.stringify(value);
61
+ }
62
+ catch (error) {
63
+ throw new Error("Vehicle job progress value is not JSON-serializable", { cause: error });
64
+ }
65
+ if (serialized === undefined)
66
+ throw new Error("Vehicle job progress value is not JSON-serializable");
67
+ return serialized;
68
+ }
69
+ /** Non-cryptographic (FNV-1a) -- dedup only. */
70
+ function fnv1aHash(value) {
71
+ let hash = 0x811c9dc5;
72
+ for (let i = 0; i < value.length; i++) {
73
+ hash ^= value.charCodeAt(i);
74
+ hash = Math.imul(hash, 0x01000193);
75
+ }
76
+ return (hash >>> 0).toString(16);
77
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/vehicle-core",
3
- "version": "0.3.0",
3
+ "version": "0.4.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",
package/src/index.ts CHANGED
@@ -1,3 +1,4 @@
1
1
  export * from "./atomic-json.js";
2
2
  export * from "./vehicle-contract.js";
3
3
  export * from "./vehicle-errors.js";
4
+ export * from "./vehicle-jobs.js";
@@ -1,3 +1,5 @@
1
+ import type { VehicleJobWakeBudget } from "./vehicle-jobs.js";
2
+
1
3
  export type JsonPrimitive = string | number | boolean | null;
2
4
  export type JsonValue = JsonPrimitive | readonly JsonValue[] | { readonly [key: string]: JsonValue };
3
5
  export type JsonSchema = Readonly<Record<string, JsonValue>>;
@@ -137,6 +139,13 @@ export interface VehicleFailureDescriptor {
137
139
  readonly description: string;
138
140
  }
139
141
 
142
+ /** Declares an operation safe to run as a Vehicle Job (detached, polled/tailed/canceled by id). Absent means live-invoke only. */
143
+ export interface VehicleBackgroundCapability {
144
+ readonly supported: true;
145
+ readonly defaultWakeBudget: VehicleJobWakeBudget;
146
+ readonly maxWakeBudget: VehicleJobWakeBudget;
147
+ }
148
+
140
149
  export interface VehicleOperationDescriptor {
141
150
  readonly name: string;
142
151
  readonly version: number;
@@ -150,6 +159,7 @@ export interface VehicleOperationDescriptor {
150
159
  readonly longRunning: boolean;
151
160
  readonly limits: VehicleLimits;
152
161
  readonly errors: readonly VehicleFailureDescriptor[];
162
+ readonly background?: VehicleBackgroundCapability;
153
163
  }
154
164
 
155
165
  export interface VehicleOperation<Input, Output> {
@@ -171,6 +181,7 @@ export interface DefineVehicleOperationOptions<Input, Output> {
171
181
  readonly longRunning?: boolean;
172
182
  readonly limits: VehicleLimits;
173
183
  readonly errors?: readonly VehicleFailureDescriptor[];
184
+ readonly background?: VehicleBackgroundCapability;
174
185
  }
175
186
 
176
187
  export interface VehiclePrincipal {
@@ -260,6 +271,15 @@ export function defineVehicleOperation<Input, Output>(
260
271
  longRunning: options.longRunning ?? false,
261
272
  limits: Object.freeze({ ...options.limits }),
262
273
  errors: Object.freeze((options.errors ?? []).map((failure) => Object.freeze({ ...failure }))),
274
+ ...(options.background
275
+ ? {
276
+ background: Object.freeze({
277
+ supported: true as const,
278
+ defaultWakeBudget: Object.freeze({ ...options.background.defaultWakeBudget }),
279
+ maxWakeBudget: Object.freeze({ ...options.background.maxWakeBudget }),
280
+ }),
281
+ }
282
+ : {}),
263
283
  });
264
284
  return Object.freeze({ descriptor, input: options.input, output: options.output });
265
285
  }
@@ -293,6 +313,28 @@ function validateOperationMetadata<Input, Output>(options: DefineVehicleOperatio
293
313
  ) {
294
314
  throw new Error("Vehicle keyed idempotency retentionMs must be a positive integer");
295
315
  }
316
+ if (options.background) {
317
+ if (!options.longRunning) {
318
+ throw new Error("Vehicle operation with a background capability must also set longRunning: true");
319
+ }
320
+ for (const [budgetName, budget] of [
321
+ ["defaultWakeBudget", options.background.defaultWakeBudget],
322
+ ["maxWakeBudget", options.background.maxWakeBudget],
323
+ ] as const) {
324
+ if (!Number.isSafeInteger(budget.maxCount) || budget.maxCount < 1) {
325
+ throw new Error(`Vehicle operation background.${budgetName}.maxCount must be a positive integer`);
326
+ }
327
+ if (!Number.isSafeInteger(budget.maxBytes) || budget.maxBytes < 1) {
328
+ throw new Error(`Vehicle operation background.${budgetName}.maxBytes must be a positive integer`);
329
+ }
330
+ }
331
+ if (options.background.defaultWakeBudget.maxCount > options.background.maxWakeBudget.maxCount) {
332
+ throw new Error("Vehicle operation background.defaultWakeBudget.maxCount must not exceed maxWakeBudget.maxCount");
333
+ }
334
+ if (options.background.defaultWakeBudget.maxBytes > options.background.maxWakeBudget.maxBytes) {
335
+ throw new Error("Vehicle operation background.defaultWakeBudget.maxBytes must not exceed maxWakeBudget.maxBytes");
336
+ }
337
+ }
296
338
  }
297
339
 
298
340
  function cloneJson<T extends JsonValue>(value: T): T {
@@ -25,7 +25,9 @@ export type VehicleCoreErrorCode =
25
25
  | "policy-failed"
26
26
  | "idempotency-key-required"
27
27
  | "client-closed"
28
- | "operation-unavailable";
28
+ | "operation-unavailable"
29
+ | "background-not-supported"
30
+ | "job-not-found";
29
31
 
30
32
  export interface VehicleRecovery {
31
33
  readonly operation?: string;
@@ -0,0 +1,114 @@
1
+ /** Pure pieces of Vehicle Jobs: a termination-reason resolver and a bounded wake-log accumulator. Orchestration lives in vehicle-server's VehicleJobStore. */
2
+
3
+ export type VehicleJobStatus = "running" | "succeeded" | "failed" | "canceled";
4
+
5
+ /** Highest precedence first -- an explicit cancel always wins even if the handler also settled around the same time. */
6
+ export const VEHICLE_JOB_TERMINATION_PRECEDENCE = ["canceled", "timeout", "failed", "succeeded"] as const;
7
+ export type VehicleJobTerminationReason = (typeof VEHICLE_JOB_TERMINATION_PRECEDENCE)[number];
8
+
9
+ export function resolveVehicleJobTerminationReason(candidates: readonly VehicleJobTerminationReason[]): VehicleJobTerminationReason {
10
+ if (candidates.length === 0) throw new Error("resolveVehicleJobTerminationReason requires at least one candidate");
11
+ for (const reason of VEHICLE_JOB_TERMINATION_PRECEDENCE) {
12
+ if (candidates.includes(reason)) return reason;
13
+ }
14
+ throw new Error(`Unrecognized Vehicle job termination candidate(s): ${candidates.join(", ")}`);
15
+ }
16
+
17
+ /** "always" keeps every notification; "transition" drops one identical to the last (hash dedup); "first-only" keeps just the first. */
18
+ export type VehicleJobNotifyMode = "always" | "transition" | "first-only";
19
+
20
+ export interface VehicleJobWakeBudget {
21
+ readonly maxCount: number;
22
+ readonly maxBytes: number;
23
+ }
24
+
25
+ export type VehicleJobWakeDropReason =
26
+ | "count-budget-exhausted"
27
+ | "byte-budget-exhausted"
28
+ | "deduplicated-transition"
29
+ | "superseded-by-first-only";
30
+
31
+ export interface VehicleJobWakeEntry {
32
+ readonly seq: number;
33
+ readonly at: number;
34
+ readonly progress: unknown;
35
+ }
36
+
37
+ export interface VehicleJobWakeAppendResult {
38
+ readonly accepted: boolean;
39
+ readonly entry?: VehicleJobWakeEntry;
40
+ readonly dropReason?: VehicleJobWakeDropReason;
41
+ }
42
+
43
+ export interface VehicleJobWakeLogOptions {
44
+ readonly notifyMode: VehicleJobNotifyMode;
45
+ readonly budget: VehicleJobWakeBudget;
46
+ /** Defaults to Date.now. */
47
+ readonly now?: () => number;
48
+ }
49
+
50
+ /** Bounds a job's accumulated progress notifications by count+bytes, same discipline as enforcePayloadSize but across a job's whole lifetime. */
51
+ export class VehicleJobWakeLog {
52
+ private readonly entries: VehicleJobWakeEntry[] = [];
53
+ private usedBytes = 0;
54
+ private nextSeq = 1;
55
+ private lastHash: string | undefined;
56
+ private acceptedFirst = false;
57
+ private readonly now: () => number;
58
+
59
+ constructor(private readonly options: VehicleJobWakeLogOptions) {
60
+ this.now = options.now ?? Date.now;
61
+ }
62
+
63
+ append(progress: unknown): VehicleJobWakeAppendResult {
64
+ if (this.options.notifyMode === "first-only" && this.acceptedFirst) {
65
+ return { accepted: false, dropReason: "superseded-by-first-only" };
66
+ }
67
+ const serialized = safeJsonStringify(progress);
68
+ if (this.options.notifyMode === "transition") {
69
+ const hash = fnv1aHash(serialized);
70
+ if (hash === this.lastHash) return { accepted: false, dropReason: "deduplicated-transition" };
71
+ this.lastHash = hash;
72
+ }
73
+ const bytes = new TextEncoder().encode(serialized).byteLength;
74
+ if (this.entries.length >= this.options.budget.maxCount) return { accepted: false, dropReason: "count-budget-exhausted" };
75
+ if (this.usedBytes + bytes > this.options.budget.maxBytes) return { accepted: false, dropReason: "byte-budget-exhausted" };
76
+
77
+ const entry: VehicleJobWakeEntry = { seq: this.nextSeq++, at: this.now(), progress };
78
+ this.entries.push(entry);
79
+ this.usedBytes += bytes;
80
+ this.acceptedFirst = true;
81
+ return { accepted: true, entry };
82
+ }
83
+
84
+ /** Entries with seq strictly greater than `cursor`. */
85
+ since(cursor: number): readonly VehicleJobWakeEntry[] {
86
+ return this.entries.filter((entry) => entry.seq > cursor);
87
+ }
88
+
89
+ /** Highest seq issued so far (0 if none accepted yet). */
90
+ get cursor(): number {
91
+ return this.nextSeq - 1;
92
+ }
93
+ }
94
+
95
+ function safeJsonStringify(value: unknown): string {
96
+ let serialized: string | undefined;
97
+ try {
98
+ serialized = JSON.stringify(value);
99
+ } catch (error) {
100
+ throw new Error("Vehicle job progress value is not JSON-serializable", { cause: error });
101
+ }
102
+ if (serialized === undefined) throw new Error("Vehicle job progress value is not JSON-serializable");
103
+ return serialized;
104
+ }
105
+
106
+ /** Non-cryptographic (FNV-1a) -- dedup only. */
107
+ function fnv1aHash(value: string): string {
108
+ let hash = 0x811c9dc5;
109
+ for (let i = 0; i < value.length; i++) {
110
+ hash ^= value.charCodeAt(i);
111
+ hash = Math.imul(hash, 0x01000193);
112
+ }
113
+ return (hash >>> 0).toString(16);
114
+ }