@danypops/vehicle-core 0.17.0 → 0.18.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/README.md +12 -0
- package/dist/{vehicle-approvals.d.ts → approvals/approval.d.ts} +4 -11
- package/dist/{vehicle-approvals.js → approvals/approval.js} +33 -23
- package/dist/approvals/index.d.ts +1 -0
- package/dist/approvals/index.js +1 -0
- package/dist/client/client.d.ts +29 -0
- package/dist/client/client.js +1 -0
- package/dist/client/index.d.ts +1 -0
- package/dist/client/index.js +1 -0
- package/dist/content/content.d.ts +32 -0
- package/dist/content/content.js +25 -0
- package/dist/content/index.d.ts +1 -0
- package/dist/content/index.js +1 -0
- package/dist/{vehicle-errors.d.ts → errors/error.d.ts} +10 -9
- package/dist/errors/index.d.ts +1 -0
- package/dist/errors/index.js +1 -0
- package/dist/events/event.d.ts +42 -0
- package/dist/events/event.js +35 -0
- package/dist/events/index.d.ts +1 -0
- package/dist/events/index.js +1 -0
- package/dist/idempotency/index.d.ts +1 -0
- package/dist/idempotency/index.js +1 -0
- package/dist/{vehicle-idempotency.d.ts → idempotency/receipt.d.ts} +1 -1
- package/dist/index.d.ts +26 -8
- package/dist/index.js +26 -8
- package/dist/jobs/grant.d.ts +36 -0
- package/dist/jobs/grant.js +31 -0
- package/dist/jobs/identity.d.ts +11 -0
- package/dist/jobs/identity.js +13 -0
- package/dist/jobs/index.d.ts +8 -0
- package/dist/jobs/index.js +8 -0
- package/dist/jobs/replay.d.ts +8 -0
- package/dist/jobs/replay.js +9 -0
- package/dist/jobs/retention.d.ts +26 -0
- package/dist/jobs/retention.js +39 -0
- package/dist/jobs/steer.d.ts +23 -0
- package/dist/jobs/steer.js +41 -0
- package/dist/jobs/termination.d.ts +5 -0
- package/dist/jobs/termination.js +11 -0
- package/dist/jobs/wake-log.d.ts +39 -0
- package/dist/jobs/wake-log.js +65 -0
- package/dist/jobs/wire.d.ts +47 -0
- package/dist/jobs/wire.js +1 -0
- package/dist/manifest/index.d.ts +1 -0
- package/dist/manifest/index.js +1 -0
- package/dist/manifest/manifest.d.ts +49 -0
- package/dist/manifest/manifest.js +1 -0
- package/dist/operations/context.d.ts +52 -0
- package/dist/operations/context.js +1 -0
- package/dist/operations/effect.d.ts +3 -0
- package/dist/operations/effect.js +2 -0
- package/dist/operations/index.d.ts +3 -0
- package/dist/operations/index.js +3 -0
- package/dist/operations/operation.d.ts +98 -0
- package/dist/operations/operation.js +79 -0
- package/dist/{atomic-json.d.ts → persistence/atomic-json.d.ts} +1 -2
- package/dist/{atomic-json.js → persistence/atomic-json.js} +1 -2
- package/dist/persistence/index.d.ts +1 -0
- package/dist/persistence/index.js +1 -0
- package/dist/schedules/index.d.ts +1 -0
- package/dist/schedules/index.js +1 -0
- package/dist/{vehicle-scheduler.d.ts → schedules/schedule.d.ts} +6 -8
- package/dist/schemas/codec.d.ts +25 -0
- package/dist/schemas/codec.js +7 -0
- package/dist/schemas/index.d.ts +4 -0
- package/dist/schemas/index.js +4 -0
- package/dist/schemas/json.d.ts +7 -0
- package/dist/schemas/json.js +14 -0
- package/dist/schemas/loose-object.d.ts +20 -0
- package/dist/schemas/loose-object.js +73 -0
- package/dist/schemas/presentation.d.ts +11 -0
- package/dist/schemas/presentation.js +22 -0
- package/dist/watches/index.d.ts +1 -0
- package/dist/watches/index.js +1 -0
- package/dist/{vehicle-watchers.d.ts → watches/registry.d.ts} +2 -9
- package/dist/{vehicle-watchers.js → watches/registry.js} +2 -9
- package/package.json +5 -1
- package/src/{vehicle-approvals.ts → approvals/approval.ts} +27 -24
- package/src/approvals/index.ts +1 -0
- package/src/client/client.ts +31 -0
- package/src/client/index.ts +1 -0
- package/src/content/content.ts +46 -0
- package/src/content/index.ts +1 -0
- package/src/{vehicle-errors.ts → errors/error.ts} +10 -9
- package/src/errors/index.ts +1 -0
- package/src/events/event.ts +72 -0
- package/src/events/index.ts +1 -0
- package/src/idempotency/index.ts +1 -0
- package/src/{vehicle-idempotency.ts → idempotency/receipt.ts} +1 -1
- package/src/index.ts +26 -8
- package/src/jobs/grant.ts +53 -0
- package/src/jobs/identity.ts +13 -0
- package/src/jobs/index.ts +8 -0
- package/src/jobs/replay.ts +17 -0
- package/src/jobs/retention.ts +59 -0
- package/src/jobs/steer.ts +49 -0
- package/src/jobs/termination.ts +13 -0
- package/src/jobs/wake-log.ts +98 -0
- package/src/jobs/wire.ts +51 -0
- package/src/manifest/index.ts +1 -0
- package/src/manifest/manifest.ts +52 -0
- package/src/operations/context.ts +56 -0
- package/src/operations/effect.ts +3 -0
- package/src/operations/index.ts +3 -0
- package/src/operations/operation.ts +184 -0
- package/src/{atomic-json.ts → persistence/atomic-json.ts} +1 -2
- package/src/persistence/index.ts +1 -0
- package/src/schedules/index.ts +1 -0
- package/src/{vehicle-scheduler.ts → schedules/schedule.ts} +6 -8
- package/src/schemas/codec.ts +30 -0
- package/src/schemas/index.ts +4 -0
- package/src/schemas/json.ts +17 -0
- package/src/schemas/loose-object.ts +82 -0
- package/src/schemas/presentation.ts +28 -0
- package/src/watches/index.ts +1 -0
- package/src/{vehicle-watchers.ts → watches/registry.ts} +2 -9
- package/dist/vehicle-contract.d.ts +0 -353
- package/dist/vehicle-contract.js +0 -253
- package/dist/vehicle-jobs.d.ts +0 -159
- package/dist/vehicle-jobs.js +0 -182
- package/src/vehicle-contract.ts +0 -597
- package/src/vehicle-jobs.ts +0 -305
- /package/dist/{vehicle-errors.js → errors/error.js} +0 -0
- /package/dist/{vehicle-idempotency.js → idempotency/receipt.js} +0 -0
- /package/dist/{vehicle-scheduler.js → schedules/schedule.js} +0 -0
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import type { VehicleJobWakeBudget } from "../jobs/wake-log.js";
|
|
2
|
+
import type { VehicleSchemaCodec } from "../schemas/codec.js";
|
|
3
|
+
import { cloneJson, type JsonSchema } from "../schemas/json.js";
|
|
4
|
+
import type { VehicleOperationHandler } from "./context.js";
|
|
5
|
+
import type { VehicleEffect } from "./effect.js";
|
|
6
|
+
|
|
7
|
+
export type VehicleIdempotency =
|
|
8
|
+
| { readonly mode: "safe" }
|
|
9
|
+
| { readonly mode: "keyed"; readonly retentionMs: number }
|
|
10
|
+
| { readonly mode: "unsafe" };
|
|
11
|
+
|
|
12
|
+
export interface VehicleLimits {
|
|
13
|
+
readonly defaultTimeoutMs: number;
|
|
14
|
+
readonly maxTimeoutMs: number;
|
|
15
|
+
readonly maxRequestBytes: number;
|
|
16
|
+
readonly maxResponseBytes: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** One structured, documented failure mode a {@link VehicleOperationDescriptor} declares up front -- part of the operation's own serializable contract, not an ad hoc thrown Error a caller has to reverse-engineer from a message string. */
|
|
20
|
+
export interface VehicleFailureDescriptor {
|
|
21
|
+
readonly code: string;
|
|
22
|
+
readonly description: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Declares an operation safe to run as a Vehicle Job (detached, polled/tailed/canceled by id). Absent means live-invoke only. */
|
|
26
|
+
export interface VehicleBackgroundCapability {
|
|
27
|
+
readonly supported: true;
|
|
28
|
+
readonly defaultWakeBudget: VehicleJobWakeBudget;
|
|
29
|
+
readonly maxWakeBudget: VehicleJobWakeBudget;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The serializable half of a Vehicle operation -- name, version, schemas,
|
|
34
|
+
* ownership-implying permissions, effect classification, idempotency,
|
|
35
|
+
* streaming/long-running capability, request/response limits, and declared
|
|
36
|
+
* {@link VehicleFailureDescriptor} failure modes. Kept separate from the
|
|
37
|
+
* executable {@link VehicleOperationHandler} on purpose: a manifest, a Pi
|
|
38
|
+
* tool projection, or a client's own capability check can all inspect this
|
|
39
|
+
* shape without ever touching (or needing to trust) the implementation
|
|
40
|
+
* behind it.
|
|
41
|
+
*/
|
|
42
|
+
export interface VehicleOperationDescriptor {
|
|
43
|
+
readonly name: string;
|
|
44
|
+
readonly version: number;
|
|
45
|
+
readonly description: string;
|
|
46
|
+
readonly inputSchema: JsonSchema;
|
|
47
|
+
readonly outputSchema: JsonSchema;
|
|
48
|
+
readonly permissions: readonly string[];
|
|
49
|
+
readonly effect: VehicleEffect;
|
|
50
|
+
readonly idempotency: VehicleIdempotency;
|
|
51
|
+
readonly streaming: boolean;
|
|
52
|
+
readonly longRunning: boolean;
|
|
53
|
+
readonly limits: VehicleLimits;
|
|
54
|
+
readonly errors: readonly VehicleFailureDescriptor[];
|
|
55
|
+
readonly background?: VehicleBackgroundCapability;
|
|
56
|
+
/**
|
|
57
|
+
* Owner-declared override for whether this specific operation is ever a candidate for
|
|
58
|
+
* approval gating, independent of its `effect`. Undefined (the default) means "derive it
|
|
59
|
+
* from `effect` against the registry's own requireApprovalForEffects set instead" --
|
|
60
|
+
* VehicleRegistry.manifest()'s own resolution rule, unchanged for every existing
|
|
61
|
+
* operation that never sets this.
|
|
62
|
+
*
|
|
63
|
+
* Exists because VehicleEffect's five values are coarse enough that two operations a
|
|
64
|
+
* real owner classifies very differently (e.g. "restart an already-installed,
|
|
65
|
+
* already-vetted service" vs. "sync a read-only catalog mirror") can land in the same
|
|
66
|
+
* effect bucket (both external-write) -- no single requireApprovalForEffects set can
|
|
67
|
+
* gate one without also gating the other. The owner who registers the operation knows
|
|
68
|
+
* its real risk far better than a 5-value enum can; this lets them say so directly.
|
|
69
|
+
*/
|
|
70
|
+
readonly requiresApproval?: boolean;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface VehicleOperation<Input, Output> {
|
|
74
|
+
readonly descriptor: VehicleOperationDescriptor;
|
|
75
|
+
readonly input: VehicleSchemaCodec<Input>;
|
|
76
|
+
readonly output: VehicleSchemaCodec<Output>;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface DefineVehicleOperationOptions<Input, Output> {
|
|
80
|
+
readonly name: string;
|
|
81
|
+
readonly version: number;
|
|
82
|
+
readonly description: string;
|
|
83
|
+
readonly input: VehicleSchemaCodec<Input>;
|
|
84
|
+
readonly output: VehicleSchemaCodec<Output>;
|
|
85
|
+
readonly permissions?: readonly string[];
|
|
86
|
+
readonly effect: VehicleEffect;
|
|
87
|
+
readonly idempotency: VehicleIdempotency;
|
|
88
|
+
readonly streaming?: boolean;
|
|
89
|
+
readonly longRunning?: boolean;
|
|
90
|
+
readonly limits: VehicleLimits;
|
|
91
|
+
readonly errors?: readonly VehicleFailureDescriptor[];
|
|
92
|
+
readonly background?: VehicleBackgroundCapability;
|
|
93
|
+
/** See {@link VehicleOperationDescriptor.requiresApproval}. */
|
|
94
|
+
readonly requiresApproval?: boolean;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export interface VehicleOperationBinding<Input, Output> {
|
|
98
|
+
readonly operation: VehicleOperation<Input, Output>;
|
|
99
|
+
bind(): VehicleOperationHandler<Input, Output>;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function defineVehicleOperation<Input, Output>(
|
|
103
|
+
options: DefineVehicleOperationOptions<Input, Output>,
|
|
104
|
+
): VehicleOperation<Input, Output> {
|
|
105
|
+
validateOperationMetadata(options);
|
|
106
|
+
const descriptor: VehicleOperationDescriptor = Object.freeze({
|
|
107
|
+
name: options.name,
|
|
108
|
+
version: options.version,
|
|
109
|
+
description: options.description,
|
|
110
|
+
inputSchema: cloneJson(options.input.jsonSchema),
|
|
111
|
+
outputSchema: cloneJson(options.output.jsonSchema),
|
|
112
|
+
permissions: Object.freeze([...(options.permissions ?? [])]),
|
|
113
|
+
effect: options.effect,
|
|
114
|
+
idempotency: Object.freeze({ ...options.idempotency }),
|
|
115
|
+
streaming: options.streaming ?? false,
|
|
116
|
+
longRunning: options.longRunning ?? false,
|
|
117
|
+
limits: Object.freeze({ ...options.limits }),
|
|
118
|
+
errors: Object.freeze((options.errors ?? []).map((failure) => Object.freeze({ ...failure }))),
|
|
119
|
+
...(options.requiresApproval !== undefined ? { requiresApproval: options.requiresApproval } : {}),
|
|
120
|
+
...(options.background
|
|
121
|
+
? {
|
|
122
|
+
background: Object.freeze({
|
|
123
|
+
supported: true as const,
|
|
124
|
+
defaultWakeBudget: Object.freeze({ ...options.background.defaultWakeBudget }),
|
|
125
|
+
maxWakeBudget: Object.freeze({ ...options.background.maxWakeBudget }),
|
|
126
|
+
}),
|
|
127
|
+
}
|
|
128
|
+
: {}),
|
|
129
|
+
});
|
|
130
|
+
return Object.freeze({ descriptor, input: options.input, output: options.output });
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function bindVehicleOperation<Input, Output>(
|
|
134
|
+
operation: VehicleOperation<Input, Output>,
|
|
135
|
+
bind: () => VehicleOperationHandler<Input, Output>,
|
|
136
|
+
): VehicleOperationBinding<Input, Output> {
|
|
137
|
+
return Object.freeze({ operation, bind });
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function validateOperationMetadata<Input, Output>(options: DefineVehicleOperationOptions<Input, Output>): void {
|
|
141
|
+
if (!options.name.trim()) throw new Error("Vehicle operation name must not be empty");
|
|
142
|
+
if (!Number.isInteger(options.version) || options.version < 1) {
|
|
143
|
+
throw new Error("Vehicle operation version must be a positive integer");
|
|
144
|
+
}
|
|
145
|
+
if (!options.description.trim()) throw new Error("Vehicle operation description must not be empty");
|
|
146
|
+
for (const permission of options.permissions ?? []) {
|
|
147
|
+
if (!permission.trim()) throw new Error("Vehicle operation permissions must not contain an empty value");
|
|
148
|
+
}
|
|
149
|
+
const limits = options.limits;
|
|
150
|
+
for (const [name, value] of Object.entries(limits)) {
|
|
151
|
+
if (!Number.isSafeInteger(value) || value < 1) throw new Error(`Vehicle operation ${name} must be a positive integer`);
|
|
152
|
+
}
|
|
153
|
+
if (limits.defaultTimeoutMs > limits.maxTimeoutMs) {
|
|
154
|
+
throw new Error("Vehicle operation defaultTimeoutMs must not exceed maxTimeoutMs");
|
|
155
|
+
}
|
|
156
|
+
if (
|
|
157
|
+
options.idempotency.mode === "keyed" &&
|
|
158
|
+
(!Number.isSafeInteger(options.idempotency.retentionMs) || options.idempotency.retentionMs < 1)
|
|
159
|
+
) {
|
|
160
|
+
throw new Error("Vehicle keyed idempotency retentionMs must be a positive integer");
|
|
161
|
+
}
|
|
162
|
+
if (options.background) {
|
|
163
|
+
if (!options.longRunning) {
|
|
164
|
+
throw new Error("Vehicle operation with a background capability must also set longRunning: true");
|
|
165
|
+
}
|
|
166
|
+
for (const [budgetName, budget] of [
|
|
167
|
+
["defaultWakeBudget", options.background.defaultWakeBudget],
|
|
168
|
+
["maxWakeBudget", options.background.maxWakeBudget],
|
|
169
|
+
] as const) {
|
|
170
|
+
if (!Number.isSafeInteger(budget.maxCount) || budget.maxCount < 1) {
|
|
171
|
+
throw new Error(`Vehicle operation background.${budgetName}.maxCount must be a positive integer`);
|
|
172
|
+
}
|
|
173
|
+
if (!Number.isSafeInteger(budget.maxBytes) || budget.maxBytes < 1) {
|
|
174
|
+
throw new Error(`Vehicle operation background.${budgetName}.maxBytes must be a positive integer`);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
if (options.background.defaultWakeBudget.maxCount > options.background.maxWakeBudget.maxCount) {
|
|
178
|
+
throw new Error("Vehicle operation background.defaultWakeBudget.maxCount must not exceed maxWakeBudget.maxCount");
|
|
179
|
+
}
|
|
180
|
+
if (options.background.defaultWakeBudget.maxBytes > options.background.maxWakeBudget.maxBytes) {
|
|
181
|
+
throw new Error("Vehicle operation background.defaultWakeBudget.maxBytes must not exceed maxWakeBudget.maxBytes");
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
@@ -8,8 +8,7 @@
|
|
|
8
8
|
* the caller (vehicle-server, vehicle-client-pi) supplies real node:fs
|
|
9
9
|
* functions, this module only sequences them.
|
|
10
10
|
*
|
|
11
|
-
*
|
|
12
|
-
* a collision-safe temp filename, injectable fs/now/pid/random for
|
|
11
|
+
* A collision-safe temp filename, injectable fs/now/pid/random for
|
|
13
12
|
* deterministic tests, and explicit Windows-aware rename retry (a plain
|
|
14
13
|
* `fs.rename` onto an existing path can transiently fail on Windows if
|
|
15
14
|
* another process -- antivirus, search indexing -- has the destination
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./atomic-json.js";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./schedule.js";
|
|
@@ -1,18 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* "Trigger something at a future time or on a recurring interval,"
|
|
3
3
|
* independent of any specific job's own lifecycle -- a distinct shape from
|
|
4
|
-
* Vehicle Jobs (submit-then-track a unit of work already running).
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* bespoke callback closure, so it can be persisted and re-armed after a
|
|
9
|
-
* restart the way a closure never could.
|
|
4
|
+
* Vehicle Jobs (submit-then-track a unit of work already running). The
|
|
5
|
+
* fired action is a declarative Vehicle operation invocation or event
|
|
6
|
+
* emission -- never a bespoke callback closure, so it can be persisted and
|
|
7
|
+
* re-armed after a restart the way a closure never could.
|
|
10
8
|
*
|
|
11
9
|
* Pure pieces only: trigger/action shapes and the fire-time arithmetic.
|
|
12
10
|
* Real timers, persistence, and registry wiring live in vehicle-server's
|
|
13
|
-
* VehicleScheduler, the same core/server split
|
|
11
|
+
* VehicleScheduler, the same core/server split Vehicle Jobs uses.
|
|
14
12
|
*/
|
|
15
|
-
import type { JsonValue } from "
|
|
13
|
+
import type { JsonValue } from "../schemas/json.js";
|
|
16
14
|
|
|
17
15
|
export type VehicleScheduleTrigger = { readonly kind: "at"; readonly at: number } | { readonly kind: "every"; readonly intervalMs: number };
|
|
18
16
|
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { cloneJson, type JsonSchema } from "./json.js";
|
|
2
|
+
|
|
3
|
+
export interface VehicleSchemaIssue {
|
|
4
|
+
readonly path: readonly (string | number)[];
|
|
5
|
+
readonly message: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export type VehicleSchemaResult<T> =
|
|
9
|
+
| { readonly success: true; readonly value: T }
|
|
10
|
+
| { readonly success: false; readonly issues?: readonly VehicleSchemaIssue[] };
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* A serializable, descriptive `jsonSchema` (surfaced to a client or Pi tool
|
|
14
|
+
* projection) paired with a real `safeParse` that actually enforces it at
|
|
15
|
+
* runtime -- a Vehicle registry's own `invoke()` only ever calls
|
|
16
|
+
* `safeParse`; `jsonSchema` alone is never itself enforced, so a codec that
|
|
17
|
+
* only sets `jsonSchema` without a matching `safeParse` is a documentation
|
|
18
|
+
* gesture, not an honest contract.
|
|
19
|
+
*/
|
|
20
|
+
export interface VehicleSchemaCodec<T> {
|
|
21
|
+
readonly jsonSchema: JsonSchema;
|
|
22
|
+
safeParse(value: unknown): VehicleSchemaResult<T>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function defineVehicleSchema<T>(codec: VehicleSchemaCodec<T>): VehicleSchemaCodec<T> {
|
|
26
|
+
return Object.freeze({
|
|
27
|
+
jsonSchema: cloneJson(codec.jsonSchema),
|
|
28
|
+
safeParse: codec.safeParse,
|
|
29
|
+
});
|
|
30
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export type JsonPrimitive = string | number | boolean | null;
|
|
2
|
+
export type JsonValue = JsonPrimitive | readonly JsonValue[] | { readonly [key: string]: JsonValue };
|
|
3
|
+
export type JsonSchema = Readonly<Record<string, JsonValue>>;
|
|
4
|
+
|
|
5
|
+
export function cloneJson<T extends JsonValue>(value: T): T {
|
|
6
|
+
const serialized = JSON.stringify(value);
|
|
7
|
+
if (serialized === undefined) throw new Error("Vehicle JSON metadata must be serializable");
|
|
8
|
+
return freezeJson(JSON.parse(serialized) as JsonValue) as T;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function freezeJson(value: JsonValue): JsonValue {
|
|
12
|
+
if (Array.isArray(value)) return Object.freeze(value.map(freezeJson));
|
|
13
|
+
if (value !== null && typeof value === "object") {
|
|
14
|
+
return Object.freeze(Object.fromEntries(Object.entries(value).map(([key, child]) => [key, freezeJson(child)])));
|
|
15
|
+
}
|
|
16
|
+
return value;
|
|
17
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { defineVehicleSchema, type VehicleSchemaCodec } from "./codec.js";
|
|
2
|
+
import type { JsonValue } from "./json.js";
|
|
3
|
+
|
|
4
|
+
export interface LooseObjectProperty {
|
|
5
|
+
readonly type: string;
|
|
6
|
+
readonly enum?: readonly string[];
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** JSON Schema's own `type` keyword vocabulary -- checked for real below so a declared `type: "number"` (say) can't silently accept a string forever. `"integer"` additionally requires no fractional part, matching JSON Schema's own distinction from plain `"number"`. An unrecognized type name is treated as "anything goes" (matches passthroughVehicleSchema's own precedent for a shape not worth strictly enforcing) rather than rejecting every input outright for what would otherwise be a schema-authoring typo. */
|
|
10
|
+
function matchesLooseObjectPropertyType(type: string, value: unknown): boolean {
|
|
11
|
+
switch (type) {
|
|
12
|
+
case "string":
|
|
13
|
+
return typeof value === "string";
|
|
14
|
+
case "number":
|
|
15
|
+
return typeof value === "number" && Number.isFinite(value);
|
|
16
|
+
case "integer":
|
|
17
|
+
return typeof value === "number" && Number.isInteger(value);
|
|
18
|
+
case "boolean":
|
|
19
|
+
return typeof value === "boolean";
|
|
20
|
+
case "object":
|
|
21
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
22
|
+
case "array":
|
|
23
|
+
return Array.isArray(value);
|
|
24
|
+
case "null":
|
|
25
|
+
return value === null;
|
|
26
|
+
default:
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* A VehicleRegistry only ever calls a schema's own safeParse -- jsonSchema is
|
|
33
|
+
* descriptive metadata surfaced to a client/Pi projection, never itself
|
|
34
|
+
* enforced at runtime -- so a declared `type`/`enum`/`additionalProperties: false`
|
|
35
|
+
* has to be checked here for real, or it's a documentation gesture, not an
|
|
36
|
+
* honest contract (the exact drift this function's own jsonSchema metadata
|
|
37
|
+
* had before: it always advertised `additionalProperties: false` and a
|
|
38
|
+
* per-property `type`, while safeParse only ever checked `required` and
|
|
39
|
+
* `enum`). Every consumer projecting a plain-object input onto a
|
|
40
|
+
* VehicleOperation needs the same required/type/extra-key/enum checks; this
|
|
41
|
+
* is that check written once.
|
|
42
|
+
*/
|
|
43
|
+
export function defineLooseObjectSchema(
|
|
44
|
+
properties: Record<string, LooseObjectProperty>,
|
|
45
|
+
required: readonly string[] = [],
|
|
46
|
+
): VehicleSchemaCodec<Record<string, unknown>> {
|
|
47
|
+
return defineVehicleSchema<Record<string, unknown>>({
|
|
48
|
+
// LooseObjectProperty's named fields (type, enum) are all JSON-value-shaped
|
|
49
|
+
// at runtime, but TypeScript's structural check against the recursive
|
|
50
|
+
// JsonValue union doesn't see that through a plain interface -- the cast
|
|
51
|
+
// is a type-system limitation, not a runtime concern.
|
|
52
|
+
jsonSchema: { type: "object", properties: properties as unknown as JsonValue, required: [...required], additionalProperties: false },
|
|
53
|
+
safeParse(value) {
|
|
54
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
55
|
+
return { success: false, issues: [{ path: [], message: "input must be an object" }] };
|
|
56
|
+
}
|
|
57
|
+
const input = value as Record<string, unknown>;
|
|
58
|
+
for (const key of required) {
|
|
59
|
+
if (!(key in input)) return { success: false, issues: [{ path: [key], message: `${key} is required` }] };
|
|
60
|
+
}
|
|
61
|
+
for (const key of Object.keys(input)) {
|
|
62
|
+
if (!(key in properties)) return { success: false, issues: [{ path: [key], message: `${key} is not a recognized property` }] };
|
|
63
|
+
}
|
|
64
|
+
for (const [key, schema] of Object.entries(properties)) {
|
|
65
|
+
if (!(key in input)) continue;
|
|
66
|
+
if (!matchesLooseObjectPropertyType(schema.type, input[key])) {
|
|
67
|
+
return { success: false, issues: [{ path: [key], message: `${key} must be of type ${schema.type}` }] };
|
|
68
|
+
}
|
|
69
|
+
if (schema.enum && !schema.enum.includes(input[key] as string)) {
|
|
70
|
+
return { success: false, issues: [{ path: [key], message: `${key} must be one of ${schema.enum.join(", ")}` }] };
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return { success: true, value: input };
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Accepts any value unvalidated -- for an operation whose output shape isn't worth a dedicated schema (an internal/low-stakes result, or one already validated upstream by the domain logic it wraps). */
|
|
79
|
+
export const passthroughVehicleSchema: VehicleSchemaCodec<unknown> = defineVehicleSchema<unknown>({
|
|
80
|
+
jsonSchema: { type: "object" },
|
|
81
|
+
safeParse: (value) => ({ success: true, value }),
|
|
82
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/** Shared defense-in-depth vocabulary for credential-shaped fields at every Vehicle projection boundary. */
|
|
2
|
+
export const VEHICLE_CREDENTIAL_FIELD_NAMES = Object.freeze([
|
|
3
|
+
"password",
|
|
4
|
+
"token",
|
|
5
|
+
"accessToken",
|
|
6
|
+
"refreshToken",
|
|
7
|
+
"apiKey",
|
|
8
|
+
"secret",
|
|
9
|
+
"authorization",
|
|
10
|
+
"credential",
|
|
11
|
+
] as const);
|
|
12
|
+
|
|
13
|
+
const NORMALIZED_VEHICLE_CREDENTIAL_FIELD_NAMES = new Set(
|
|
14
|
+
VEHICLE_CREDENTIAL_FIELD_NAMES.map((name) => name.replace(/[^a-z0-9]/gi, "").toLowerCase()),
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
/** Case/separator-insensitive credential-name check used as a fallback when a schema annotation is missing. */
|
|
18
|
+
export function isVehicleCredentialFieldName(name: string): boolean {
|
|
19
|
+
return NORMALIZED_VEHICLE_CREDENTIAL_FIELD_NAMES.has(name.replace(/[^a-z0-9]/gi, "").toLowerCase());
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* JSON Schema property annotation consumed by human-facing Vehicle adapters.
|
|
24
|
+
* `omit` hides the field; `summarize` may show shape/size but never its value.
|
|
25
|
+
* Standard `writeOnly: true` and `format: "password"` always imply omission.
|
|
26
|
+
*/
|
|
27
|
+
export const VEHICLE_SCHEMA_PRESENTATION_EXTENSION = "x-vehicle-presentation" as const;
|
|
28
|
+
export type VehicleSchemaPresentation = "omit" | "summarize";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./registry.js";
|
|
@@ -1,13 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* "Watch a changing resource, get notified" --
|
|
3
|
-
*
|
|
4
|
-
* generalized past Lector's own workspace/pattern vocabulary (`workspaceId`
|
|
5
|
-
* -> `scope`, `pattern` -> `resource`) into a shared Vehicle primitive.
|
|
6
|
-
* Confirmed independently reinvented three-plus times across this house's
|
|
7
|
-
* own ecosystem before this existed: Lector's own registry, Lector's CLI
|
|
8
|
-
* (a second, non-resilient reimplementation of the same watch/subscribe
|
|
9
|
-
* shape), and Papyrus's TaskOverlay/NoteOverlay (each hand-rolling the same
|
|
10
|
-
* ensurePushChannel()+poll dance independently).
|
|
2
|
+
* "Watch a changing resource, get notified" -- a shared Vehicle primitive
|
|
3
|
+
* generalizing a (scope, resource) watch registration into a wire topic.
|
|
11
4
|
*
|
|
12
5
|
* Pure, in-memory bookkeeping only -- no filesystem, network, or PushChannel
|
|
13
6
|
* I/O here. Matching pattern/resource against a real changed resource is a
|