@danypops/vehicle-core 0.16.0 → 0.17.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 +1 -0
- package/dist/index.js +1 -0
- package/dist/vehicle-approvals.js +36 -5
- package/dist/vehicle-contract.d.ts +11 -5
- package/dist/vehicle-contract.js +40 -6
- package/dist/vehicle-errors.d.ts +12 -1
- package/dist/vehicle-errors.js +20 -0
- package/dist/vehicle-idempotency.d.ts +66 -0
- package/dist/vehicle-idempotency.js +42 -0
- package/dist/vehicle-scheduler.d.ts +17 -1
- package/dist/vehicle-scheduler.js +29 -1
- package/package.json +1 -1
- package/src/index.ts +1 -0
- package/src/vehicle-approvals.ts +36 -5
- package/src/vehicle-contract.ts +41 -7
- package/src/vehicle-errors.ts +22 -0
- package/src/vehicle-idempotency.ts +97 -0
- package/src/vehicle-scheduler.ts +26 -1
package/dist/index.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ export * from "./atomic-json.js";
|
|
|
2
2
|
export * from "./vehicle-approvals.js";
|
|
3
3
|
export * from "./vehicle-contract.js";
|
|
4
4
|
export * from "./vehicle-errors.js";
|
|
5
|
+
export * from "./vehicle-idempotency.js";
|
|
5
6
|
export * from "./vehicle-jobs.js";
|
|
6
7
|
export * from "./vehicle-scheduler.js";
|
|
7
8
|
export * from "./vehicle-watchers.js";
|
package/dist/index.js
CHANGED
|
@@ -2,6 +2,7 @@ export * from "./atomic-json.js";
|
|
|
2
2
|
export * from "./vehicle-approvals.js";
|
|
3
3
|
export * from "./vehicle-contract.js";
|
|
4
4
|
export * from "./vehicle-errors.js";
|
|
5
|
+
export * from "./vehicle-idempotency.js";
|
|
5
6
|
export * from "./vehicle-jobs.js";
|
|
6
7
|
export * from "./vehicle-scheduler.js";
|
|
7
8
|
export * from "./vehicle-watchers.js";
|
|
@@ -1,4 +1,19 @@
|
|
|
1
|
-
import { defineVehicleEvent, defineVehicleSchema } from "./vehicle-contract.js";
|
|
1
|
+
import { defineVehicleEvent, defineVehicleSchema, VEHICLE_EFFECTS } from "./vehicle-contract.js";
|
|
2
|
+
/** A sha256 hex digest: exactly 64 lowercase hex characters -- matches hashApprovalInput's own (vehicle-server) output shape. */
|
|
3
|
+
const SHA256_HEX_PATTERN = /^[0-9a-f]{64}$/;
|
|
4
|
+
function isVehicleEffect(value) {
|
|
5
|
+
return typeof value === "string" && VEHICLE_EFFECTS.includes(value);
|
|
6
|
+
}
|
|
7
|
+
function isVehiclePrincipal(value) {
|
|
8
|
+
if (typeof value !== "object" || value === null)
|
|
9
|
+
return false;
|
|
10
|
+
const candidate = value;
|
|
11
|
+
if (typeof candidate.id !== "string")
|
|
12
|
+
return false;
|
|
13
|
+
if (candidate.claims === undefined)
|
|
14
|
+
return true;
|
|
15
|
+
return typeof candidate.claims === "object" && candidate.claims !== null && !Array.isArray(candidate.claims);
|
|
16
|
+
}
|
|
2
17
|
/** Set once at registry-configuration time (VehicleRegistry.configureApprovals()); never on a per-invoke basis. */
|
|
3
18
|
export const DEFAULT_APPROVAL_EFFECTS = ["destructive", "open-world"];
|
|
4
19
|
/**
|
|
@@ -21,7 +36,8 @@ const requestedPayloadSchema = defineVehicleSchema({
|
|
|
21
36
|
requestId: { type: "string" },
|
|
22
37
|
operationName: { type: "string" },
|
|
23
38
|
operationVersion: { type: "number" },
|
|
24
|
-
effect: { type: "string" },
|
|
39
|
+
effect: { type: "string", enum: [...VEHICLE_EFFECTS] },
|
|
40
|
+
principal: { type: "object" },
|
|
25
41
|
requestedAt: { type: "number" },
|
|
26
42
|
expiresAt: { type: "number" },
|
|
27
43
|
inputHash: { type: "string" },
|
|
@@ -36,12 +52,20 @@ const requestedPayloadSchema = defineVehicleSchema({
|
|
|
36
52
|
if (typeof row.requestId !== "string" ||
|
|
37
53
|
typeof row.operationName !== "string" ||
|
|
38
54
|
typeof row.operationVersion !== "number" ||
|
|
39
|
-
|
|
55
|
+
!Number.isInteger(row.operationVersion) ||
|
|
56
|
+
row.operationVersion < 1 ||
|
|
57
|
+
!isVehicleEffect(row.effect) ||
|
|
40
58
|
typeof row.requestedAt !== "number" ||
|
|
59
|
+
!Number.isFinite(row.requestedAt) ||
|
|
41
60
|
typeof row.expiresAt !== "number" ||
|
|
42
|
-
|
|
61
|
+
!Number.isFinite(row.expiresAt) ||
|
|
62
|
+
typeof row.inputHash !== "string" ||
|
|
63
|
+
!SHA256_HEX_PATTERN.test(row.inputHash)) {
|
|
43
64
|
return { success: false, issues: [{ path: [], message: "invalid approval request payload" }] };
|
|
44
65
|
}
|
|
66
|
+
if (row.principal !== undefined && !isVehiclePrincipal(row.principal)) {
|
|
67
|
+
return { success: false, issues: [{ path: ["principal"], message: "invalid approval request principal" }] };
|
|
68
|
+
}
|
|
45
69
|
return { success: true, value: row };
|
|
46
70
|
},
|
|
47
71
|
});
|
|
@@ -64,9 +88,16 @@ const resolvedPayloadSchema = defineVehicleSchema({
|
|
|
64
88
|
const row = value;
|
|
65
89
|
if (typeof row.requestId !== "string" ||
|
|
66
90
|
(row.decision !== "granted" && row.decision !== "denied") ||
|
|
67
|
-
typeof row.decidedAt !== "number"
|
|
91
|
+
typeof row.decidedAt !== "number" ||
|
|
92
|
+
!Number.isFinite(row.decidedAt)) {
|
|
68
93
|
return { success: false, issues: [{ path: [], message: "invalid approval outcome payload" }] };
|
|
69
94
|
}
|
|
95
|
+
if (row.decidedBy !== undefined && typeof row.decidedBy !== "string") {
|
|
96
|
+
return { success: false, issues: [{ path: ["decidedBy"], message: "decidedBy must be a string" }] };
|
|
97
|
+
}
|
|
98
|
+
if (row.comment !== undefined && typeof row.comment !== "string") {
|
|
99
|
+
return { success: false, issues: [{ path: ["comment"], message: "comment must be a string" }] };
|
|
100
|
+
}
|
|
70
101
|
return { success: true, value: row };
|
|
71
102
|
},
|
|
72
103
|
});
|
|
@@ -46,10 +46,14 @@ export interface LooseObjectProperty {
|
|
|
46
46
|
/**
|
|
47
47
|
* A VehicleRegistry only ever calls a schema's own safeParse -- jsonSchema is
|
|
48
48
|
* descriptive metadata surfaced to a client/Pi projection, never itself
|
|
49
|
-
* enforced at runtime -- so a declared `enum`
|
|
50
|
-
* real, or it's a documentation gesture, not an
|
|
51
|
-
*
|
|
52
|
-
*
|
|
49
|
+
* enforced at runtime -- so a declared `type`/`enum`/`additionalProperties: false`
|
|
50
|
+
* has to be checked here for real, or it's a documentation gesture, not an
|
|
51
|
+
* honest contract (the exact drift this function's own jsonSchema metadata
|
|
52
|
+
* had before: it always advertised `additionalProperties: false` and a
|
|
53
|
+
* per-property `type`, while safeParse only ever checked `required` and
|
|
54
|
+
* `enum`). Every consumer projecting a plain-object input onto a
|
|
55
|
+
* VehicleOperation needs the same required/type/extra-key/enum checks; this
|
|
56
|
+
* is that check written once.
|
|
53
57
|
*/
|
|
54
58
|
export declare function defineLooseObjectSchema(properties: Record<string, LooseObjectProperty>, required?: readonly string[]): VehicleSchemaCodec<Record<string, unknown>>;
|
|
55
59
|
/** 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). */
|
|
@@ -86,7 +90,9 @@ export interface WithVehicleContent {
|
|
|
86
90
|
* risk forwarding partial/garbled blocks.
|
|
87
91
|
*/
|
|
88
92
|
export declare function extractVehicleContent(output: unknown): readonly VehicleContentBlock[] | undefined;
|
|
89
|
-
|
|
93
|
+
/** Canonical list VehicleEffect is itself derived from, so a runtime discriminator check (e.g. a persisted/wire VehicleApprovalRequest's own `effect` field) has one real source to check against instead of a second, driftable hardcoded list. */
|
|
94
|
+
export declare const VEHICLE_EFFECTS: readonly ["read", "local-write", "external-write", "destructive", "open-world"];
|
|
95
|
+
export type VehicleEffect = (typeof VEHICLE_EFFECTS)[number];
|
|
90
96
|
export type VehicleIdempotency = {
|
|
91
97
|
readonly mode: "safe";
|
|
92
98
|
} | {
|
package/dist/vehicle-contract.js
CHANGED
|
@@ -26,13 +26,38 @@ export function defineVehicleSchema(codec) {
|
|
|
26
26
|
safeParse: codec.safeParse,
|
|
27
27
|
});
|
|
28
28
|
}
|
|
29
|
+
/** 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. */
|
|
30
|
+
function matchesLooseObjectPropertyType(type, value) {
|
|
31
|
+
switch (type) {
|
|
32
|
+
case "string":
|
|
33
|
+
return typeof value === "string";
|
|
34
|
+
case "number":
|
|
35
|
+
return typeof value === "number" && Number.isFinite(value);
|
|
36
|
+
case "integer":
|
|
37
|
+
return typeof value === "number" && Number.isInteger(value);
|
|
38
|
+
case "boolean":
|
|
39
|
+
return typeof value === "boolean";
|
|
40
|
+
case "object":
|
|
41
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
42
|
+
case "array":
|
|
43
|
+
return Array.isArray(value);
|
|
44
|
+
case "null":
|
|
45
|
+
return value === null;
|
|
46
|
+
default:
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
29
50
|
/**
|
|
30
51
|
* A VehicleRegistry only ever calls a schema's own safeParse -- jsonSchema is
|
|
31
52
|
* descriptive metadata surfaced to a client/Pi projection, never itself
|
|
32
|
-
* enforced at runtime -- so a declared `enum`
|
|
33
|
-
* real, or it's a documentation gesture, not an
|
|
34
|
-
*
|
|
35
|
-
*
|
|
53
|
+
* enforced at runtime -- so a declared `type`/`enum`/`additionalProperties: false`
|
|
54
|
+
* has to be checked here for real, or it's a documentation gesture, not an
|
|
55
|
+
* honest contract (the exact drift this function's own jsonSchema metadata
|
|
56
|
+
* had before: it always advertised `additionalProperties: false` and a
|
|
57
|
+
* per-property `type`, while safeParse only ever checked `required` and
|
|
58
|
+
* `enum`). Every consumer projecting a plain-object input onto a
|
|
59
|
+
* VehicleOperation needs the same required/type/extra-key/enum checks; this
|
|
60
|
+
* is that check written once.
|
|
36
61
|
*/
|
|
37
62
|
export function defineLooseObjectSchema(properties, required = []) {
|
|
38
63
|
return defineVehicleSchema({
|
|
@@ -50,10 +75,17 @@ export function defineLooseObjectSchema(properties, required = []) {
|
|
|
50
75
|
if (!(key in input))
|
|
51
76
|
return { success: false, issues: [{ path: [key], message: `${key} is required` }] };
|
|
52
77
|
}
|
|
78
|
+
for (const key of Object.keys(input)) {
|
|
79
|
+
if (!(key in properties))
|
|
80
|
+
return { success: false, issues: [{ path: [key], message: `${key} is not a recognized property` }] };
|
|
81
|
+
}
|
|
53
82
|
for (const [key, schema] of Object.entries(properties)) {
|
|
54
|
-
if (!
|
|
83
|
+
if (!(key in input))
|
|
55
84
|
continue;
|
|
56
|
-
if (!schema.
|
|
85
|
+
if (!matchesLooseObjectPropertyType(schema.type, input[key])) {
|
|
86
|
+
return { success: false, issues: [{ path: [key], message: `${key} must be of type ${schema.type}` }] };
|
|
87
|
+
}
|
|
88
|
+
if (schema.enum && !schema.enum.includes(input[key])) {
|
|
57
89
|
return { success: false, issues: [{ path: [key], message: `${key} must be one of ${schema.enum.join(", ")}` }] };
|
|
58
90
|
}
|
|
59
91
|
}
|
|
@@ -91,6 +123,8 @@ export function extractVehicleContent(output) {
|
|
|
91
123
|
}
|
|
92
124
|
return blocks;
|
|
93
125
|
}
|
|
126
|
+
/** Canonical list VehicleEffect is itself derived from, so a runtime discriminator check (e.g. a persisted/wire VehicleApprovalRequest's own `effect` field) has one real source to check against instead of a second, driftable hardcoded list. */
|
|
127
|
+
export const VEHICLE_EFFECTS = ["read", "local-write", "external-write", "destructive", "open-world"];
|
|
94
128
|
function validateEventMetadata(options) {
|
|
95
129
|
if (!options.name.trim())
|
|
96
130
|
throw new Error("Vehicle event name must not be empty");
|
package/dist/vehicle-errors.d.ts
CHANGED
|
@@ -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" | "background-not-supported" | "job-not-found" | "job-not-steerable" | "job-steer-queue-full";
|
|
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" | "idempotency-conflict" | "client-closed" | "operation-unavailable" | "background-not-supported" | "job-not-found" | "job-not-steerable" | "job-steer-queue-full";
|
|
4
4
|
export interface VehicleRecovery {
|
|
5
5
|
readonly operation?: string;
|
|
6
6
|
readonly message: string;
|
|
@@ -61,6 +61,17 @@ export declare class VehicleError extends Error {
|
|
|
61
61
|
}
|
|
62
62
|
/** Recognizes VehicleError instances across duplicated package installations in one process. */
|
|
63
63
|
export declare function isVehicleError(value: unknown): value is VehicleError;
|
|
64
|
+
/**
|
|
65
|
+
* Reconstructs a throwable VehicleError from a previously-serialized VehicleFailure -- the inverse
|
|
66
|
+
* of VehicleError.prototype.toFailure(), needed anywhere a wire-safe failure gets replayed as a
|
|
67
|
+
* real rejection later (e.g. VehicleIdempotencyPolicy replaying a settled failed receipt to a
|
|
68
|
+
* second caller reusing the same idempotency key). Lossy on purpose: a VehicleFailure never
|
|
69
|
+
* carries the original `cause` (toFailure() already reduced it to an optional bounded
|
|
70
|
+
* causeMessage per the throw site's own exposeCause choice), so the reconstructed error has no
|
|
71
|
+
* cause at all rather than fabricating one -- a replayed failure only needs to match the original
|
|
72
|
+
* code/category/message/details a caller would react to, not its internal cause chain.
|
|
73
|
+
*/
|
|
74
|
+
export declare function vehicleErrorFromFailure(failure: VehicleFailure): VehicleError;
|
|
64
75
|
/** Extracts a bounded, wire-safe message from an unknown cause -- never the full stack trace, never an unbounded payload. */
|
|
65
76
|
export declare function boundedCauseMessage(cause: unknown): string | undefined;
|
|
66
77
|
export declare function boundedValidationDetails(issues: readonly VehicleSchemaIssue[] | undefined): JsonValue | undefined;
|
package/dist/vehicle-errors.js
CHANGED
|
@@ -60,6 +60,26 @@ export class VehicleError extends Error {
|
|
|
60
60
|
export function isVehicleError(value) {
|
|
61
61
|
return value instanceof Error && Reflect.get(value, VEHICLE_ERROR_BRAND) === true;
|
|
62
62
|
}
|
|
63
|
+
/**
|
|
64
|
+
* Reconstructs a throwable VehicleError from a previously-serialized VehicleFailure -- the inverse
|
|
65
|
+
* of VehicleError.prototype.toFailure(), needed anywhere a wire-safe failure gets replayed as a
|
|
66
|
+
* real rejection later (e.g. VehicleIdempotencyPolicy replaying a settled failed receipt to a
|
|
67
|
+
* second caller reusing the same idempotency key). Lossy on purpose: a VehicleFailure never
|
|
68
|
+
* carries the original `cause` (toFailure() already reduced it to an optional bounded
|
|
69
|
+
* causeMessage per the throw site's own exposeCause choice), so the reconstructed error has no
|
|
70
|
+
* cause at all rather than fabricating one -- a replayed failure only needs to match the original
|
|
71
|
+
* code/category/message/details a caller would react to, not its internal cause chain.
|
|
72
|
+
*/
|
|
73
|
+
export function vehicleErrorFromFailure(failure) {
|
|
74
|
+
return new VehicleError(failure.code, failure.message, {
|
|
75
|
+
category: failure.category,
|
|
76
|
+
retryable: failure.retryable,
|
|
77
|
+
retryAfterMs: failure.retryAfterMs,
|
|
78
|
+
recovery: failure.recovery,
|
|
79
|
+
details: failure.details,
|
|
80
|
+
operationId: failure.operationId,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
63
83
|
const MAX_CAUSE_MESSAGE_LENGTH = 500;
|
|
64
84
|
/** Extracts a bounded, wire-safe message from an unknown cause -- never the full stack trace, never an unbounded payload. */
|
|
65
85
|
export function boundedCauseMessage(cause) {
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure pieces of Vehicle's bounded keyed-idempotency replay policy: the settled-receipt shape and
|
|
3
|
+
* its eviction-selection rule. Orchestration (in-flight dedup, persistence, fail-closed conflict
|
|
4
|
+
* detection) lives in vehicle-server's VehicleIdempotencyPolicy -- mirrors the vehicle-jobs.js /
|
|
5
|
+
* VehicleJobStore split (a pure, independently-testable bounded-retention rule here; the stateful
|
|
6
|
+
* store that calls it lives in vehicle-server).
|
|
7
|
+
*/
|
|
8
|
+
import type { VehicleFailure } from "./vehicle-errors.js";
|
|
9
|
+
/** A settled keyed-idempotency outcome -- exactly what gets replayed to a caller reusing the same key. Never carries the original request's raw input (only its hash is ever retained, see VehicleIdempotencyReceipt) and never a credential: `output`/`failure` are already what the operation would hand back to any caller, the same wire-safe boundary VehicleJobPersistedRecord's own `output`/`error` fields already cross. */
|
|
10
|
+
export type VehicleIdempotencyResult = {
|
|
11
|
+
readonly ok: true;
|
|
12
|
+
readonly output: unknown;
|
|
13
|
+
} | {
|
|
14
|
+
readonly ok: false;
|
|
15
|
+
readonly failure: VehicleFailure;
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* One settled keyed-idempotency receipt. Deliberately excludes the original request's raw input --
|
|
19
|
+
* `inputHash` is the only trace of it retained, so a persisted receipt can never leak whatever the
|
|
20
|
+
* caller originally sent (which may carry sensitive arguments the operation's own output does not).
|
|
21
|
+
* A still-in-flight (pending) request has no receipt yet -- that bookkeeping is transient,
|
|
22
|
+
* in-memory-only state in VehicleIdempotencyPolicy, deliberately never a candidate for persistence
|
|
23
|
+
* or eviction (mirrors "a running job is never a candidate" in vehicle-jobs.js's own job eviction).
|
|
24
|
+
*/
|
|
25
|
+
export interface VehicleIdempotencyReceipt {
|
|
26
|
+
readonly key: string;
|
|
27
|
+
readonly operationName: string;
|
|
28
|
+
readonly operationVersion: number;
|
|
29
|
+
readonly inputHash: string;
|
|
30
|
+
readonly settledAt: number;
|
|
31
|
+
/** settledAt + the descriptor's own keyed retentionMs at the time this receipt settled. A receipt past this is no longer a valid replay -- see selectVehicleIdempotencyReceiptsForEviction. */
|
|
32
|
+
readonly expiresAt: number;
|
|
33
|
+
readonly result: VehicleIdempotencyResult;
|
|
34
|
+
/** Approximate serialized size of `result`, used only to enforce maxTotalBytes -- never exact byte-for-byte, matching every other Vehicle capacity bound's own "good enough to stay bounded" precedent (e.g. enforcePayloadSize). */
|
|
35
|
+
readonly sizeBytes: number;
|
|
36
|
+
}
|
|
37
|
+
/** Minimal shape selectVehicleIdempotencyReceiptsForEviction needs -- kept separate from VehicleIdempotencyReceipt's own `result` so a sweep never has to touch (or risk logging) the actual settled output/failure it's merely deciding whether to keep. */
|
|
38
|
+
export interface VehicleIdempotencyEvictionCandidate {
|
|
39
|
+
readonly key: string;
|
|
40
|
+
readonly settledAt: number;
|
|
41
|
+
readonly expiresAt: number;
|
|
42
|
+
readonly sizeBytes: number;
|
|
43
|
+
}
|
|
44
|
+
export interface VehicleIdempotencyRetentionOptions {
|
|
45
|
+
/** Hard cap on total retained settled receipts. */
|
|
46
|
+
readonly maxEntries: number;
|
|
47
|
+
/** Hard cap on the sum of every retained receipt's own sizeBytes. */
|
|
48
|
+
readonly maxTotalBytes: number;
|
|
49
|
+
readonly now: number;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Pure eviction-selection policy for settled keyed-idempotency receipts, independently testable
|
|
53
|
+
* from VehicleIdempotencyPolicy's own bookkeeping -- mirrors selectVehicleJobsForEviction's own
|
|
54
|
+
* three-phase shape:
|
|
55
|
+
*
|
|
56
|
+
* 1. Any receipt already past its own `expiresAt` (a real per-operation retentionMs elapsed --
|
|
57
|
+
* replaying it would no longer be correct, keeping it around would only be wasted memory).
|
|
58
|
+
* 2. If still over maxEntries once (1) is applied, the oldest remaining receipts by settledAt,
|
|
59
|
+
* until back within budget.
|
|
60
|
+
* 3. If still over maxTotalBytes once (1)+(2) are applied, the oldest remaining receipts by
|
|
61
|
+
* settledAt, until back within budget.
|
|
62
|
+
*
|
|
63
|
+
* A pending (still in-flight) request is never a candidate -- it has no receipt yet, so it can
|
|
64
|
+
* never appear in `candidates` at all; this function only ever sees settled ones.
|
|
65
|
+
*/
|
|
66
|
+
export declare function selectVehicleIdempotencyReceiptsForEviction(candidates: readonly VehicleIdempotencyEvictionCandidate[], options: VehicleIdempotencyRetentionOptions): readonly string[];
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure eviction-selection policy for settled keyed-idempotency receipts, independently testable
|
|
3
|
+
* from VehicleIdempotencyPolicy's own bookkeeping -- mirrors selectVehicleJobsForEviction's own
|
|
4
|
+
* three-phase shape:
|
|
5
|
+
*
|
|
6
|
+
* 1. Any receipt already past its own `expiresAt` (a real per-operation retentionMs elapsed --
|
|
7
|
+
* replaying it would no longer be correct, keeping it around would only be wasted memory).
|
|
8
|
+
* 2. If still over maxEntries once (1) is applied, the oldest remaining receipts by settledAt,
|
|
9
|
+
* until back within budget.
|
|
10
|
+
* 3. If still over maxTotalBytes once (1)+(2) are applied, the oldest remaining receipts by
|
|
11
|
+
* settledAt, until back within budget.
|
|
12
|
+
*
|
|
13
|
+
* A pending (still in-flight) request is never a candidate -- it has no receipt yet, so it can
|
|
14
|
+
* never appear in `candidates` at all; this function only ever sees settled ones.
|
|
15
|
+
*/
|
|
16
|
+
export function selectVehicleIdempotencyReceiptsForEviction(candidates, options) {
|
|
17
|
+
const byAgeAscending = (a, b) => a.settledAt - b.settledAt;
|
|
18
|
+
const evicted = new Set();
|
|
19
|
+
for (const candidate of candidates) {
|
|
20
|
+
if (options.now >= candidate.expiresAt)
|
|
21
|
+
evicted.add(candidate.key);
|
|
22
|
+
}
|
|
23
|
+
const remaining = () => candidates.filter((candidate) => !evicted.has(candidate.key));
|
|
24
|
+
if (remaining().length > options.maxEntries) {
|
|
25
|
+
const oldestFirst = remaining().sort(byAgeAscending);
|
|
26
|
+
for (const candidate of oldestFirst) {
|
|
27
|
+
if (remaining().length <= options.maxEntries)
|
|
28
|
+
break;
|
|
29
|
+
evicted.add(candidate.key);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
const totalBytes = () => remaining().reduce((sum, candidate) => sum + candidate.sizeBytes, 0);
|
|
33
|
+
if (totalBytes() > options.maxTotalBytes) {
|
|
34
|
+
const oldestFirst = remaining().sort(byAgeAscending);
|
|
35
|
+
for (const candidate of oldestFirst) {
|
|
36
|
+
if (totalBytes() <= options.maxTotalBytes)
|
|
37
|
+
break;
|
|
38
|
+
evicted.add(candidate.key);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return [...evicted];
|
|
42
|
+
}
|
|
@@ -49,7 +49,23 @@ export declare class VehicleScheduleLimitExceeded extends Error {
|
|
|
49
49
|
readonly max: number;
|
|
50
50
|
constructor(owner: string, max: number);
|
|
51
51
|
}
|
|
52
|
-
/**
|
|
52
|
+
/** Raised for a trigger whose own numeric field is non-finite, zero, or negative -- see isValidVehicleScheduleTrigger. A typed failure a caller can recognize by class, the same discoverability VehicleScheduleLimitExceeded already gives the capacity case. */
|
|
53
|
+
export declare class VehicleScheduleInvalidTriggerError extends Error {
|
|
54
|
+
readonly trigger: VehicleScheduleTrigger;
|
|
55
|
+
constructor(trigger: VehicleScheduleTrigger);
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Real validation for a VehicleScheduleTrigger's own numeric field -- `kind`/`at`/`intervalMs` are
|
|
59
|
+
* plain TypeScript types, never runtime-checked before this, so a wire or persisted trigger with a
|
|
60
|
+
* non-finite or non-positive value silently corrupted every arithmetic function below it:
|
|
61
|
+
* `now + NaN` poisons nextFireAt forever, `now + 0` or a negative intervalMs fires an "every"
|
|
62
|
+
* schedule again immediately on every tick (a respawn-storm-shaped bug), and a non-finite `at`
|
|
63
|
+
* breaks every `> now` comparison nextFireAtAfterRestore relies on. Both `at` and `intervalMs` are
|
|
64
|
+
* required to be a real, positive, finite number -- a wall-clock fire time or interval of zero,
|
|
65
|
+
* negative, NaN, or Infinity is never a legitimate schedule, only ever a wire/persistence defect.
|
|
66
|
+
*/
|
|
67
|
+
export declare function isValidVehicleScheduleTrigger(trigger: VehicleScheduleTrigger): boolean;
|
|
68
|
+
/** The first fire time for a freshly created schedule. Throws VehicleScheduleInvalidTriggerError if `trigger` isn't valid -- see isValidVehicleScheduleTrigger; callers at a real wire/persistence boundary should validate (and reject/discard) before ever reaching here, this is a last-resort guard against a caller that skipped that. */
|
|
53
69
|
export declare function initialFireAt(trigger: VehicleScheduleTrigger, now: number): number;
|
|
54
70
|
/** The next fire time after a successful fire, or undefined if the entry (a one-shot "at") should be removed instead of re-armed. */
|
|
55
71
|
export declare function nextFireAtAfterFire(trigger: VehicleScheduleTrigger, now: number): number | undefined;
|
|
@@ -11,8 +11,36 @@ export class VehicleScheduleLimitExceeded extends Error {
|
|
|
11
11
|
this.name = "VehicleScheduleLimitExceeded";
|
|
12
12
|
}
|
|
13
13
|
}
|
|
14
|
-
/**
|
|
14
|
+
/** Raised for a trigger whose own numeric field is non-finite, zero, or negative -- see isValidVehicleScheduleTrigger. A typed failure a caller can recognize by class, the same discoverability VehicleScheduleLimitExceeded already gives the capacity case. */
|
|
15
|
+
export class VehicleScheduleInvalidTriggerError extends Error {
|
|
16
|
+
trigger;
|
|
17
|
+
constructor(trigger) {
|
|
18
|
+
super(`Invalid Vehicle schedule trigger: ${JSON.stringify(trigger)}`);
|
|
19
|
+
this.trigger = trigger;
|
|
20
|
+
this.name = "VehicleScheduleInvalidTriggerError";
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Real validation for a VehicleScheduleTrigger's own numeric field -- `kind`/`at`/`intervalMs` are
|
|
25
|
+
* plain TypeScript types, never runtime-checked before this, so a wire or persisted trigger with a
|
|
26
|
+
* non-finite or non-positive value silently corrupted every arithmetic function below it:
|
|
27
|
+
* `now + NaN` poisons nextFireAt forever, `now + 0` or a negative intervalMs fires an "every"
|
|
28
|
+
* schedule again immediately on every tick (a respawn-storm-shaped bug), and a non-finite `at`
|
|
29
|
+
* breaks every `> now` comparison nextFireAtAfterRestore relies on. Both `at` and `intervalMs` are
|
|
30
|
+
* required to be a real, positive, finite number -- a wall-clock fire time or interval of zero,
|
|
31
|
+
* negative, NaN, or Infinity is never a legitimate schedule, only ever a wire/persistence defect.
|
|
32
|
+
*/
|
|
33
|
+
export function isValidVehicleScheduleTrigger(trigger) {
|
|
34
|
+
if (trigger.kind === "at")
|
|
35
|
+
return Number.isFinite(trigger.at) && trigger.at > 0;
|
|
36
|
+
if (trigger.kind === "every")
|
|
37
|
+
return Number.isFinite(trigger.intervalMs) && trigger.intervalMs > 0;
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
/** The first fire time for a freshly created schedule. Throws VehicleScheduleInvalidTriggerError if `trigger` isn't valid -- see isValidVehicleScheduleTrigger; callers at a real wire/persistence boundary should validate (and reject/discard) before ever reaching here, this is a last-resort guard against a caller that skipped that. */
|
|
15
41
|
export function initialFireAt(trigger, now) {
|
|
42
|
+
if (!isValidVehicleScheduleTrigger(trigger))
|
|
43
|
+
throw new VehicleScheduleInvalidTriggerError(trigger);
|
|
16
44
|
return trigger.kind === "at" ? trigger.at : now + trigger.intervalMs;
|
|
17
45
|
}
|
|
18
46
|
/** The next fire time after a successful fire, or undefined if the entry (a one-shot "at") should be removed instead of re-armed. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@danypops/vehicle-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.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
|
@@ -2,6 +2,7 @@ export * from "./atomic-json.js";
|
|
|
2
2
|
export * from "./vehicle-approvals.js";
|
|
3
3
|
export * from "./vehicle-contract.js";
|
|
4
4
|
export * from "./vehicle-errors.js";
|
|
5
|
+
export * from "./vehicle-idempotency.js";
|
|
5
6
|
export * from "./vehicle-jobs.js";
|
|
6
7
|
export * from "./vehicle-scheduler.js";
|
|
7
8
|
export * from "./vehicle-watchers.js";
|
package/src/vehicle-approvals.ts
CHANGED
|
@@ -7,7 +7,22 @@
|
|
|
7
7
|
* atomic-json.ts already uses for fs access.
|
|
8
8
|
*/
|
|
9
9
|
import type { VehicleEffect, VehiclePrincipal } from "./vehicle-contract.js";
|
|
10
|
-
import { defineVehicleEvent, defineVehicleSchema } from "./vehicle-contract.js";
|
|
10
|
+
import { defineVehicleEvent, defineVehicleSchema, VEHICLE_EFFECTS } from "./vehicle-contract.js";
|
|
11
|
+
|
|
12
|
+
/** A sha256 hex digest: exactly 64 lowercase hex characters -- matches hashApprovalInput's own (vehicle-server) output shape. */
|
|
13
|
+
const SHA256_HEX_PATTERN = /^[0-9a-f]{64}$/;
|
|
14
|
+
|
|
15
|
+
function isVehicleEffect(value: unknown): value is VehicleEffect {
|
|
16
|
+
return typeof value === "string" && (VEHICLE_EFFECTS as readonly string[]).includes(value);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function isVehiclePrincipal(value: unknown): value is VehiclePrincipal {
|
|
20
|
+
if (typeof value !== "object" || value === null) return false;
|
|
21
|
+
const candidate = value as Record<string, unknown>;
|
|
22
|
+
if (typeof candidate.id !== "string") return false;
|
|
23
|
+
if (candidate.claims === undefined) return true;
|
|
24
|
+
return typeof candidate.claims === "object" && candidate.claims !== null && !Array.isArray(candidate.claims);
|
|
25
|
+
}
|
|
11
26
|
|
|
12
27
|
/** Set once at registry-configuration time (VehicleRegistry.configureApprovals()); never on a per-invoke basis. */
|
|
13
28
|
export const DEFAULT_APPROVAL_EFFECTS: readonly VehicleEffect[] = ["destructive", "open-world"];
|
|
@@ -74,7 +89,8 @@ const requestedPayloadSchema = defineVehicleSchema<VehicleApprovalRequest>({
|
|
|
74
89
|
requestId: { type: "string" },
|
|
75
90
|
operationName: { type: "string" },
|
|
76
91
|
operationVersion: { type: "number" },
|
|
77
|
-
effect: { type: "string" },
|
|
92
|
+
effect: { type: "string", enum: [...VEHICLE_EFFECTS] },
|
|
93
|
+
principal: { type: "object" },
|
|
78
94
|
requestedAt: { type: "number" },
|
|
79
95
|
expiresAt: { type: "number" },
|
|
80
96
|
inputHash: { type: "string" },
|
|
@@ -89,13 +105,21 @@ const requestedPayloadSchema = defineVehicleSchema<VehicleApprovalRequest>({
|
|
|
89
105
|
typeof row.requestId !== "string" ||
|
|
90
106
|
typeof row.operationName !== "string" ||
|
|
91
107
|
typeof row.operationVersion !== "number" ||
|
|
92
|
-
|
|
108
|
+
!Number.isInteger(row.operationVersion) ||
|
|
109
|
+
row.operationVersion < 1 ||
|
|
110
|
+
!isVehicleEffect(row.effect) ||
|
|
93
111
|
typeof row.requestedAt !== "number" ||
|
|
112
|
+
!Number.isFinite(row.requestedAt) ||
|
|
94
113
|
typeof row.expiresAt !== "number" ||
|
|
95
|
-
|
|
114
|
+
!Number.isFinite(row.expiresAt) ||
|
|
115
|
+
typeof row.inputHash !== "string" ||
|
|
116
|
+
!SHA256_HEX_PATTERN.test(row.inputHash)
|
|
96
117
|
) {
|
|
97
118
|
return { success: false, issues: [{ path: [], message: "invalid approval request payload" }] };
|
|
98
119
|
}
|
|
120
|
+
if (row.principal !== undefined && !isVehiclePrincipal(row.principal)) {
|
|
121
|
+
return { success: false, issues: [{ path: ["principal"], message: "invalid approval request principal" }] };
|
|
122
|
+
}
|
|
99
123
|
return { success: true, value: row as unknown as VehicleApprovalRequest };
|
|
100
124
|
},
|
|
101
125
|
});
|
|
@@ -119,10 +143,17 @@ const resolvedPayloadSchema = defineVehicleSchema<VehicleApprovalOutcome>({
|
|
|
119
143
|
if (
|
|
120
144
|
typeof row.requestId !== "string" ||
|
|
121
145
|
(row.decision !== "granted" && row.decision !== "denied") ||
|
|
122
|
-
typeof row.decidedAt !== "number"
|
|
146
|
+
typeof row.decidedAt !== "number" ||
|
|
147
|
+
!Number.isFinite(row.decidedAt)
|
|
123
148
|
) {
|
|
124
149
|
return { success: false, issues: [{ path: [], message: "invalid approval outcome payload" }] };
|
|
125
150
|
}
|
|
151
|
+
if (row.decidedBy !== undefined && typeof row.decidedBy !== "string") {
|
|
152
|
+
return { success: false, issues: [{ path: ["decidedBy"], message: "decidedBy must be a string" }] };
|
|
153
|
+
}
|
|
154
|
+
if (row.comment !== undefined && typeof row.comment !== "string") {
|
|
155
|
+
return { success: false, issues: [{ path: ["comment"], message: "comment must be a string" }] };
|
|
156
|
+
}
|
|
126
157
|
return { success: true, value: row as unknown as VehicleApprovalOutcome };
|
|
127
158
|
},
|
|
128
159
|
});
|
package/src/vehicle-contract.ts
CHANGED
|
@@ -73,13 +73,39 @@ export interface LooseObjectProperty {
|
|
|
73
73
|
readonly enum?: readonly string[];
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
+
/** 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. */
|
|
77
|
+
function matchesLooseObjectPropertyType(type: string, value: unknown): boolean {
|
|
78
|
+
switch (type) {
|
|
79
|
+
case "string":
|
|
80
|
+
return typeof value === "string";
|
|
81
|
+
case "number":
|
|
82
|
+
return typeof value === "number" && Number.isFinite(value);
|
|
83
|
+
case "integer":
|
|
84
|
+
return typeof value === "number" && Number.isInteger(value);
|
|
85
|
+
case "boolean":
|
|
86
|
+
return typeof value === "boolean";
|
|
87
|
+
case "object":
|
|
88
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
89
|
+
case "array":
|
|
90
|
+
return Array.isArray(value);
|
|
91
|
+
case "null":
|
|
92
|
+
return value === null;
|
|
93
|
+
default:
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
76
98
|
/**
|
|
77
99
|
* A VehicleRegistry only ever calls a schema's own safeParse -- jsonSchema is
|
|
78
100
|
* descriptive metadata surfaced to a client/Pi projection, never itself
|
|
79
|
-
* enforced at runtime -- so a declared `enum`
|
|
80
|
-
* real, or it's a documentation gesture, not an
|
|
81
|
-
*
|
|
82
|
-
*
|
|
101
|
+
* enforced at runtime -- so a declared `type`/`enum`/`additionalProperties: false`
|
|
102
|
+
* has to be checked here for real, or it's a documentation gesture, not an
|
|
103
|
+
* honest contract (the exact drift this function's own jsonSchema metadata
|
|
104
|
+
* had before: it always advertised `additionalProperties: false` and a
|
|
105
|
+
* per-property `type`, while safeParse only ever checked `required` and
|
|
106
|
+
* `enum`). Every consumer projecting a plain-object input onto a
|
|
107
|
+
* VehicleOperation needs the same required/type/extra-key/enum checks; this
|
|
108
|
+
* is that check written once.
|
|
83
109
|
*/
|
|
84
110
|
export function defineLooseObjectSchema(
|
|
85
111
|
properties: Record<string, LooseObjectProperty>,
|
|
@@ -99,9 +125,15 @@ export function defineLooseObjectSchema(
|
|
|
99
125
|
for (const key of required) {
|
|
100
126
|
if (!(key in input)) return { success: false, issues: [{ path: [key], message: `${key} is required` }] };
|
|
101
127
|
}
|
|
128
|
+
for (const key of Object.keys(input)) {
|
|
129
|
+
if (!(key in properties)) return { success: false, issues: [{ path: [key], message: `${key} is not a recognized property` }] };
|
|
130
|
+
}
|
|
102
131
|
for (const [key, schema] of Object.entries(properties)) {
|
|
103
|
-
if (!
|
|
104
|
-
if (!schema.
|
|
132
|
+
if (!(key in input)) continue;
|
|
133
|
+
if (!matchesLooseObjectPropertyType(schema.type, input[key])) {
|
|
134
|
+
return { success: false, issues: [{ path: [key], message: `${key} must be of type ${schema.type}` }] };
|
|
135
|
+
}
|
|
136
|
+
if (schema.enum && !schema.enum.includes(input[key] as string)) {
|
|
105
137
|
return { success: false, issues: [{ path: [key], message: `${key} must be one of ${schema.enum.join(", ")}` }] };
|
|
106
138
|
}
|
|
107
139
|
}
|
|
@@ -163,7 +195,9 @@ export function extractVehicleContent(output: unknown): readonly VehicleContentB
|
|
|
163
195
|
return blocks;
|
|
164
196
|
}
|
|
165
197
|
|
|
166
|
-
|
|
198
|
+
/** Canonical list VehicleEffect is itself derived from, so a runtime discriminator check (e.g. a persisted/wire VehicleApprovalRequest's own `effect` field) has one real source to check against instead of a second, driftable hardcoded list. */
|
|
199
|
+
export const VEHICLE_EFFECTS = ["read", "local-write", "external-write", "destructive", "open-world"] as const;
|
|
200
|
+
export type VehicleEffect = (typeof VEHICLE_EFFECTS)[number];
|
|
167
201
|
|
|
168
202
|
export type VehicleIdempotency =
|
|
169
203
|
| { readonly mode: "safe" }
|
package/src/vehicle-errors.ts
CHANGED
|
@@ -26,6 +26,7 @@ export type VehicleCoreErrorCode =
|
|
|
26
26
|
| "handler-failed"
|
|
27
27
|
| "policy-failed"
|
|
28
28
|
| "idempotency-key-required"
|
|
29
|
+
| "idempotency-conflict"
|
|
29
30
|
| "client-closed"
|
|
30
31
|
| "operation-unavailable"
|
|
31
32
|
| "background-not-supported"
|
|
@@ -156,6 +157,27 @@ export function isVehicleError(value: unknown): value is VehicleError {
|
|
|
156
157
|
return value instanceof Error && Reflect.get(value, VEHICLE_ERROR_BRAND) === true;
|
|
157
158
|
}
|
|
158
159
|
|
|
160
|
+
/**
|
|
161
|
+
* Reconstructs a throwable VehicleError from a previously-serialized VehicleFailure -- the inverse
|
|
162
|
+
* of VehicleError.prototype.toFailure(), needed anywhere a wire-safe failure gets replayed as a
|
|
163
|
+
* real rejection later (e.g. VehicleIdempotencyPolicy replaying a settled failed receipt to a
|
|
164
|
+
* second caller reusing the same idempotency key). Lossy on purpose: a VehicleFailure never
|
|
165
|
+
* carries the original `cause` (toFailure() already reduced it to an optional bounded
|
|
166
|
+
* causeMessage per the throw site's own exposeCause choice), so the reconstructed error has no
|
|
167
|
+
* cause at all rather than fabricating one -- a replayed failure only needs to match the original
|
|
168
|
+
* code/category/message/details a caller would react to, not its internal cause chain.
|
|
169
|
+
*/
|
|
170
|
+
export function vehicleErrorFromFailure(failure: VehicleFailure): VehicleError {
|
|
171
|
+
return new VehicleError(failure.code, failure.message, {
|
|
172
|
+
category: failure.category,
|
|
173
|
+
retryable: failure.retryable,
|
|
174
|
+
retryAfterMs: failure.retryAfterMs,
|
|
175
|
+
recovery: failure.recovery,
|
|
176
|
+
details: failure.details,
|
|
177
|
+
operationId: failure.operationId,
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
159
181
|
const MAX_CAUSE_MESSAGE_LENGTH = 500;
|
|
160
182
|
|
|
161
183
|
/** Extracts a bounded, wire-safe message from an unknown cause -- never the full stack trace, never an unbounded payload. */
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure pieces of Vehicle's bounded keyed-idempotency replay policy: the settled-receipt shape and
|
|
3
|
+
* its eviction-selection rule. Orchestration (in-flight dedup, persistence, fail-closed conflict
|
|
4
|
+
* detection) lives in vehicle-server's VehicleIdempotencyPolicy -- mirrors the vehicle-jobs.js /
|
|
5
|
+
* VehicleJobStore split (a pure, independently-testable bounded-retention rule here; the stateful
|
|
6
|
+
* store that calls it lives in vehicle-server).
|
|
7
|
+
*/
|
|
8
|
+
import type { VehicleFailure } from "./vehicle-errors.js";
|
|
9
|
+
|
|
10
|
+
/** A settled keyed-idempotency outcome -- exactly what gets replayed to a caller reusing the same key. Never carries the original request's raw input (only its hash is ever retained, see VehicleIdempotencyReceipt) and never a credential: `output`/`failure` are already what the operation would hand back to any caller, the same wire-safe boundary VehicleJobPersistedRecord's own `output`/`error` fields already cross. */
|
|
11
|
+
export type VehicleIdempotencyResult =
|
|
12
|
+
| { readonly ok: true; readonly output: unknown }
|
|
13
|
+
| { readonly ok: false; readonly failure: VehicleFailure };
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* One settled keyed-idempotency receipt. Deliberately excludes the original request's raw input --
|
|
17
|
+
* `inputHash` is the only trace of it retained, so a persisted receipt can never leak whatever the
|
|
18
|
+
* caller originally sent (which may carry sensitive arguments the operation's own output does not).
|
|
19
|
+
* A still-in-flight (pending) request has no receipt yet -- that bookkeeping is transient,
|
|
20
|
+
* in-memory-only state in VehicleIdempotencyPolicy, deliberately never a candidate for persistence
|
|
21
|
+
* or eviction (mirrors "a running job is never a candidate" in vehicle-jobs.js's own job eviction).
|
|
22
|
+
*/
|
|
23
|
+
export interface VehicleIdempotencyReceipt {
|
|
24
|
+
readonly key: string;
|
|
25
|
+
readonly operationName: string;
|
|
26
|
+
readonly operationVersion: number;
|
|
27
|
+
readonly inputHash: string;
|
|
28
|
+
readonly settledAt: number;
|
|
29
|
+
/** settledAt + the descriptor's own keyed retentionMs at the time this receipt settled. A receipt past this is no longer a valid replay -- see selectVehicleIdempotencyReceiptsForEviction. */
|
|
30
|
+
readonly expiresAt: number;
|
|
31
|
+
readonly result: VehicleIdempotencyResult;
|
|
32
|
+
/** Approximate serialized size of `result`, used only to enforce maxTotalBytes -- never exact byte-for-byte, matching every other Vehicle capacity bound's own "good enough to stay bounded" precedent (e.g. enforcePayloadSize). */
|
|
33
|
+
readonly sizeBytes: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Minimal shape selectVehicleIdempotencyReceiptsForEviction needs -- kept separate from VehicleIdempotencyReceipt's own `result` so a sweep never has to touch (or risk logging) the actual settled output/failure it's merely deciding whether to keep. */
|
|
37
|
+
export interface VehicleIdempotencyEvictionCandidate {
|
|
38
|
+
readonly key: string;
|
|
39
|
+
readonly settledAt: number;
|
|
40
|
+
readonly expiresAt: number;
|
|
41
|
+
readonly sizeBytes: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface VehicleIdempotencyRetentionOptions {
|
|
45
|
+
/** Hard cap on total retained settled receipts. */
|
|
46
|
+
readonly maxEntries: number;
|
|
47
|
+
/** Hard cap on the sum of every retained receipt's own sizeBytes. */
|
|
48
|
+
readonly maxTotalBytes: number;
|
|
49
|
+
readonly now: number;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Pure eviction-selection policy for settled keyed-idempotency receipts, independently testable
|
|
54
|
+
* from VehicleIdempotencyPolicy's own bookkeeping -- mirrors selectVehicleJobsForEviction's own
|
|
55
|
+
* three-phase shape:
|
|
56
|
+
*
|
|
57
|
+
* 1. Any receipt already past its own `expiresAt` (a real per-operation retentionMs elapsed --
|
|
58
|
+
* replaying it would no longer be correct, keeping it around would only be wasted memory).
|
|
59
|
+
* 2. If still over maxEntries once (1) is applied, the oldest remaining receipts by settledAt,
|
|
60
|
+
* until back within budget.
|
|
61
|
+
* 3. If still over maxTotalBytes once (1)+(2) are applied, the oldest remaining receipts by
|
|
62
|
+
* settledAt, until back within budget.
|
|
63
|
+
*
|
|
64
|
+
* A pending (still in-flight) request is never a candidate -- it has no receipt yet, so it can
|
|
65
|
+
* never appear in `candidates` at all; this function only ever sees settled ones.
|
|
66
|
+
*/
|
|
67
|
+
export function selectVehicleIdempotencyReceiptsForEviction(
|
|
68
|
+
candidates: readonly VehicleIdempotencyEvictionCandidate[],
|
|
69
|
+
options: VehicleIdempotencyRetentionOptions,
|
|
70
|
+
): readonly string[] {
|
|
71
|
+
const byAgeAscending = (a: VehicleIdempotencyEvictionCandidate, b: VehicleIdempotencyEvictionCandidate) => a.settledAt - b.settledAt;
|
|
72
|
+
|
|
73
|
+
const evicted = new Set<string>();
|
|
74
|
+
for (const candidate of candidates) {
|
|
75
|
+
if (options.now >= candidate.expiresAt) evicted.add(candidate.key);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const remaining = () => candidates.filter((candidate) => !evicted.has(candidate.key));
|
|
79
|
+
if (remaining().length > options.maxEntries) {
|
|
80
|
+
const oldestFirst = remaining().sort(byAgeAscending);
|
|
81
|
+
for (const candidate of oldestFirst) {
|
|
82
|
+
if (remaining().length <= options.maxEntries) break;
|
|
83
|
+
evicted.add(candidate.key);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const totalBytes = () => remaining().reduce((sum, candidate) => sum + candidate.sizeBytes, 0);
|
|
88
|
+
if (totalBytes() > options.maxTotalBytes) {
|
|
89
|
+
const oldestFirst = remaining().sort(byAgeAscending);
|
|
90
|
+
for (const candidate of oldestFirst) {
|
|
91
|
+
if (totalBytes() <= options.maxTotalBytes) break;
|
|
92
|
+
evicted.add(candidate.key);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return [...evicted];
|
|
97
|
+
}
|
package/src/vehicle-scheduler.ts
CHANGED
|
@@ -50,8 +50,33 @@ export class VehicleScheduleLimitExceeded extends Error {
|
|
|
50
50
|
}
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
-
/**
|
|
53
|
+
/** Raised for a trigger whose own numeric field is non-finite, zero, or negative -- see isValidVehicleScheduleTrigger. A typed failure a caller can recognize by class, the same discoverability VehicleScheduleLimitExceeded already gives the capacity case. */
|
|
54
|
+
export class VehicleScheduleInvalidTriggerError extends Error {
|
|
55
|
+
constructor(readonly trigger: VehicleScheduleTrigger) {
|
|
56
|
+
super(`Invalid Vehicle schedule trigger: ${JSON.stringify(trigger)}`);
|
|
57
|
+
this.name = "VehicleScheduleInvalidTriggerError";
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Real validation for a VehicleScheduleTrigger's own numeric field -- `kind`/`at`/`intervalMs` are
|
|
63
|
+
* plain TypeScript types, never runtime-checked before this, so a wire or persisted trigger with a
|
|
64
|
+
* non-finite or non-positive value silently corrupted every arithmetic function below it:
|
|
65
|
+
* `now + NaN` poisons nextFireAt forever, `now + 0` or a negative intervalMs fires an "every"
|
|
66
|
+
* schedule again immediately on every tick (a respawn-storm-shaped bug), and a non-finite `at`
|
|
67
|
+
* breaks every `> now` comparison nextFireAtAfterRestore relies on. Both `at` and `intervalMs` are
|
|
68
|
+
* required to be a real, positive, finite number -- a wall-clock fire time or interval of zero,
|
|
69
|
+
* negative, NaN, or Infinity is never a legitimate schedule, only ever a wire/persistence defect.
|
|
70
|
+
*/
|
|
71
|
+
export function isValidVehicleScheduleTrigger(trigger: VehicleScheduleTrigger): boolean {
|
|
72
|
+
if (trigger.kind === "at") return Number.isFinite(trigger.at) && trigger.at > 0;
|
|
73
|
+
if (trigger.kind === "every") return Number.isFinite(trigger.intervalMs) && trigger.intervalMs > 0;
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The first fire time for a freshly created schedule. Throws VehicleScheduleInvalidTriggerError if `trigger` isn't valid -- see isValidVehicleScheduleTrigger; callers at a real wire/persistence boundary should validate (and reject/discard) before ever reaching here, this is a last-resort guard against a caller that skipped that. */
|
|
54
78
|
export function initialFireAt(trigger: VehicleScheduleTrigger, now: number): number {
|
|
79
|
+
if (!isValidVehicleScheduleTrigger(trigger)) throw new VehicleScheduleInvalidTriggerError(trigger);
|
|
55
80
|
return trigger.kind === "at" ? trigger.at : now + trigger.intervalMs;
|
|
56
81
|
}
|
|
57
82
|
|