@danypops/vehicle-core 0.18.0 → 0.18.2
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/concurrency/debounced-scheduler.d.ts +46 -0
- package/dist/concurrency/debounced-scheduler.js +92 -0
- package/dist/concurrency/index.d.ts +1 -0
- package/dist/concurrency/index.js +1 -0
- package/dist/index.d.ts +5 -3
- package/dist/index.js +5 -3
- package/dist/schemas/index.d.ts +1 -0
- package/dist/schemas/index.js +1 -0
- package/dist/schemas/primitives.d.ts +37 -0
- package/dist/schemas/primitives.js +47 -0
- package/package.json +1 -1
- package/src/concurrency/debounced-scheduler.ts +106 -0
- package/src/concurrency/index.ts +1 -0
- package/src/index.ts +5 -3
- package/src/schemas/index.ts +1 -0
- package/src/schemas/primitives.ts +62 -0
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Coalesces a burst of calls for the same key into exactly one callback fire,
|
|
3
|
+
* delayMs after the last call for that key -- the classic debounce shape,
|
|
4
|
+
* useful anywhere a flurry of raw upstream events for one logical unit of
|
|
5
|
+
* work (a filesystem save that fires more than once via temp-file write +
|
|
6
|
+
* atomic rename, a burst of webhook deliveries for the same resource) needs
|
|
7
|
+
* to collapse into a single downstream action. Different keys are fully
|
|
8
|
+
* independent. Pure timer bookkeeping, no I/O -- the callback itself does
|
|
9
|
+
* whatever real work is needed.
|
|
10
|
+
*/
|
|
11
|
+
/** The minimal logging surface this module needs -- any real Logger (e.g. Vehicle's own daemon Logger) satisfies this structurally, no adapter required. */
|
|
12
|
+
export interface MinimalLogger {
|
|
13
|
+
debug(msg: string, fields?: Record<string, unknown>): void;
|
|
14
|
+
warn(msg: string, fields?: Record<string, unknown>): void;
|
|
15
|
+
}
|
|
16
|
+
export declare class DebounceCapacityExceeded extends Error {
|
|
17
|
+
readonly key: string;
|
|
18
|
+
readonly max: number;
|
|
19
|
+
constructor(key: string, max: number);
|
|
20
|
+
}
|
|
21
|
+
export interface DebouncedSchedulerOptions {
|
|
22
|
+
/** Maximum distinct keys with a pending fire at once. Default 4096. */
|
|
23
|
+
readonly maxKeys?: number;
|
|
24
|
+
readonly logger?: MinimalLogger;
|
|
25
|
+
}
|
|
26
|
+
export declare class DebouncedScheduler {
|
|
27
|
+
private readonly timers;
|
|
28
|
+
private readonly delayMs;
|
|
29
|
+
private readonly maxKeys;
|
|
30
|
+
private readonly logger;
|
|
31
|
+
constructor(delayMs: number, options?: DebouncedSchedulerOptions);
|
|
32
|
+
/**
|
|
33
|
+
* Schedules `callback` to run delayMs after this call, resetting any pending fire already
|
|
34
|
+
* scheduled for `key`. A callback that throws or rejects is caught and dropped -- there is
|
|
35
|
+
* no request awaiting this fire to report the error, and an unhandled timer failure would
|
|
36
|
+
* otherwise crash the whole process rather than just this one key's work.
|
|
37
|
+
* A caller that cares about its own errors should catch and log inside `callback` itself.
|
|
38
|
+
*/
|
|
39
|
+
schedule(key: string, callback: () => unknown): void;
|
|
40
|
+
/** Cancels `key`'s pending fire, if any. Idempotent -- an unknown or already-fired key is a safe no-op. */
|
|
41
|
+
cancel(key: string): void;
|
|
42
|
+
/** True while `key` has a fire pending. */
|
|
43
|
+
has(key: string): boolean;
|
|
44
|
+
/** Cancels every pending key at once -- for clean shutdown. */
|
|
45
|
+
clear(): void;
|
|
46
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Coalesces a burst of calls for the same key into exactly one callback fire,
|
|
3
|
+
* delayMs after the last call for that key -- the classic debounce shape,
|
|
4
|
+
* useful anywhere a flurry of raw upstream events for one logical unit of
|
|
5
|
+
* work (a filesystem save that fires more than once via temp-file write +
|
|
6
|
+
* atomic rename, a burst of webhook deliveries for the same resource) needs
|
|
7
|
+
* to collapse into a single downstream action. Different keys are fully
|
|
8
|
+
* independent. Pure timer bookkeeping, no I/O -- the callback itself does
|
|
9
|
+
* whatever real work is needed.
|
|
10
|
+
*/
|
|
11
|
+
export class DebounceCapacityExceeded extends Error {
|
|
12
|
+
key;
|
|
13
|
+
max;
|
|
14
|
+
constructor(key, max) {
|
|
15
|
+
super(`debounced scheduler distinct-key bound exceeded (${max}) scheduling key "${key}"`);
|
|
16
|
+
this.key = key;
|
|
17
|
+
this.max = max;
|
|
18
|
+
this.name = "DebounceCapacityExceeded";
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
const DEFAULT_MAX_KEYS = 4096;
|
|
22
|
+
const NOOP_LOGGER = { debug() { }, warn() { } };
|
|
23
|
+
export class DebouncedScheduler {
|
|
24
|
+
timers = new Map();
|
|
25
|
+
delayMs;
|
|
26
|
+
maxKeys;
|
|
27
|
+
logger;
|
|
28
|
+
constructor(delayMs, options = {}) {
|
|
29
|
+
if (!Number.isSafeInteger(delayMs) || delayMs < 0)
|
|
30
|
+
throw new TypeError("delayMs must be a non-negative safe integer");
|
|
31
|
+
this.delayMs = delayMs;
|
|
32
|
+
this.maxKeys = options.maxKeys ?? DEFAULT_MAX_KEYS;
|
|
33
|
+
this.logger = options.logger ?? NOOP_LOGGER;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Schedules `callback` to run delayMs after this call, resetting any pending fire already
|
|
37
|
+
* scheduled for `key`. A callback that throws or rejects is caught and dropped -- there is
|
|
38
|
+
* no request awaiting this fire to report the error, and an unhandled timer failure would
|
|
39
|
+
* otherwise crash the whole process rather than just this one key's work.
|
|
40
|
+
* A caller that cares about its own errors should catch and log inside `callback` itself.
|
|
41
|
+
*/
|
|
42
|
+
schedule(key, callback) {
|
|
43
|
+
const existing = this.timers.get(key);
|
|
44
|
+
if (existing) {
|
|
45
|
+
clearTimeout(existing);
|
|
46
|
+
this.logger.debug("debounced schedule coalesced", { component: "debounced-scheduler", operation: "schedule" });
|
|
47
|
+
}
|
|
48
|
+
else if (this.timers.size >= this.maxKeys) {
|
|
49
|
+
this.logger.warn("debounced schedule rejected", {
|
|
50
|
+
component: "debounced-scheduler",
|
|
51
|
+
operation: "schedule",
|
|
52
|
+
code: "DebounceCapacityExceeded",
|
|
53
|
+
});
|
|
54
|
+
throw new DebounceCapacityExceeded(key, this.maxKeys);
|
|
55
|
+
}
|
|
56
|
+
const reportFailure = (error) => {
|
|
57
|
+
this.logger.warn("debounced callback failed", {
|
|
58
|
+
component: "debounced-scheduler",
|
|
59
|
+
operation: "fire",
|
|
60
|
+
code: error instanceof Error ? error.name || "Error" : "Error",
|
|
61
|
+
});
|
|
62
|
+
};
|
|
63
|
+
const timer = setTimeout(() => {
|
|
64
|
+
this.timers.delete(key);
|
|
65
|
+
try {
|
|
66
|
+
Promise.resolve(callback()).catch(reportFailure);
|
|
67
|
+
}
|
|
68
|
+
catch (error) {
|
|
69
|
+
reportFailure(error);
|
|
70
|
+
}
|
|
71
|
+
}, this.delayMs);
|
|
72
|
+
this.timers.set(key, timer);
|
|
73
|
+
}
|
|
74
|
+
/** Cancels `key`'s pending fire, if any. Idempotent -- an unknown or already-fired key is a safe no-op. */
|
|
75
|
+
cancel(key) {
|
|
76
|
+
const existing = this.timers.get(key);
|
|
77
|
+
if (!existing)
|
|
78
|
+
return;
|
|
79
|
+
clearTimeout(existing);
|
|
80
|
+
this.timers.delete(key);
|
|
81
|
+
}
|
|
82
|
+
/** True while `key` has a fire pending. */
|
|
83
|
+
has(key) {
|
|
84
|
+
return this.timers.has(key);
|
|
85
|
+
}
|
|
86
|
+
/** Cancels every pending key at once -- for clean shutdown. */
|
|
87
|
+
clear() {
|
|
88
|
+
for (const timer of this.timers.values())
|
|
89
|
+
clearTimeout(timer);
|
|
90
|
+
this.timers.clear();
|
|
91
|
+
}
|
|
92
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./debounced-scheduler.js";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./debounced-scheduler.js";
|
package/dist/index.d.ts
CHANGED
|
@@ -5,14 +5,16 @@
|
|
|
5
5
|
* (model-facing narrative blocks), operations (descriptors, effect
|
|
6
6
|
* classification, invocation context), events, manifest, client (the port a
|
|
7
7
|
* caller programs against), approvals (the Approval Gate's wire shapes), jobs
|
|
8
|
-
* (Vehicle Jobs' pure pieces), schedules, watches,
|
|
9
|
-
* JSON
|
|
10
|
-
*
|
|
8
|
+
* (Vehicle Jobs' pure pieces), schedules, watches, persistence (atomic
|
|
9
|
+
* JSON), and concurrency (timer-based scheduling primitives) -- the latter
|
|
10
|
+
* two are technical utilities, not Vehicle protocol capabilities, kept
|
|
11
|
+
* distinct for that reason. Every symbol below is re-exported unchanged
|
|
11
12
|
* from its historical flat-file home, so root-level `import { X } from
|
|
12
13
|
* "@danypops/vehicle-core"` usage is completely unaffected by this layout.
|
|
13
14
|
*/
|
|
14
15
|
export * from "./approvals/index.js";
|
|
15
16
|
export * from "./client/index.js";
|
|
17
|
+
export * from "./concurrency/index.js";
|
|
16
18
|
export * from "./content/index.js";
|
|
17
19
|
export * from "./errors/index.js";
|
|
18
20
|
export * from "./events/index.js";
|
package/dist/index.js
CHANGED
|
@@ -5,14 +5,16 @@
|
|
|
5
5
|
* (model-facing narrative blocks), operations (descriptors, effect
|
|
6
6
|
* classification, invocation context), events, manifest, client (the port a
|
|
7
7
|
* caller programs against), approvals (the Approval Gate's wire shapes), jobs
|
|
8
|
-
* (Vehicle Jobs' pure pieces), schedules, watches,
|
|
9
|
-
* JSON
|
|
10
|
-
*
|
|
8
|
+
* (Vehicle Jobs' pure pieces), schedules, watches, persistence (atomic
|
|
9
|
+
* JSON), and concurrency (timer-based scheduling primitives) -- the latter
|
|
10
|
+
* two are technical utilities, not Vehicle protocol capabilities, kept
|
|
11
|
+
* distinct for that reason. Every symbol below is re-exported unchanged
|
|
11
12
|
* from its historical flat-file home, so root-level `import { X } from
|
|
12
13
|
* "@danypops/vehicle-core"` usage is completely unaffected by this layout.
|
|
13
14
|
*/
|
|
14
15
|
export * from "./approvals/index.js";
|
|
15
16
|
export * from "./client/index.js";
|
|
17
|
+
export * from "./concurrency/index.js";
|
|
16
18
|
export * from "./content/index.js";
|
|
17
19
|
export * from "./errors/index.js";
|
|
18
20
|
export * from "./events/index.js";
|
package/dist/schemas/index.d.ts
CHANGED
package/dist/schemas/index.js
CHANGED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { VehicleSchemaIssue } from "./codec.js";
|
|
2
|
+
/** The `{ success: false, issues }` half of VehicleSchemaResult -- named on its own since every hand-written safeParse's failure branch is this exact shape, never a bare Error. */
|
|
3
|
+
export interface VehicleSchemaFailure {
|
|
4
|
+
readonly success: false;
|
|
5
|
+
readonly issues: readonly VehicleSchemaIssue[];
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* A safeParse's own first, universal check: the value wasn't even an object. Every hand-written
|
|
9
|
+
* object-shaped VehicleSchemaCodec across the ecosystem re-derived this exact literal before this
|
|
10
|
+
* existed as a shared primitive -- one canonical wording now, not five near-identical copies.
|
|
11
|
+
*/
|
|
12
|
+
export declare function notAnObjectIssue(): VehicleSchemaFailure;
|
|
13
|
+
/**
|
|
14
|
+
* One field-scoped failure. `path` accepts a single key (the common case: a top-level field) or
|
|
15
|
+
* a full path segment array (for a nested/array-indexed field, matching VehicleSchemaIssue.path's
|
|
16
|
+
* own `readonly (string | number)[]` shape directly) -- both real shapes existing hand-written
|
|
17
|
+
* safeParse implementations across the ecosystem already needed.
|
|
18
|
+
*/
|
|
19
|
+
export declare function schemaIssue(path: string | number | readonly (string | number)[], message: string): VehicleSchemaFailure;
|
|
20
|
+
/** True for a real object value -- not null, not an array (JSON Schema's own object/array distinction; `typeof [] === "object"` is not what a caller checking "is this a plain object" means). */
|
|
21
|
+
export declare function isPlainObject(value: unknown): value is Record<string, unknown>;
|
|
22
|
+
/** A non-empty string -- the shape every identifier-like field (workspaceId, ref, path, ...) across the ecosystem actually requires; an empty string is a real, distinct failure from "not a string at all". */
|
|
23
|
+
export declare function isNonEmptyString(value: unknown): value is string;
|
|
24
|
+
/** A real integer safely representable in a double -- the base every bounded-count/size field below builds on. */
|
|
25
|
+
export declare function isSafeInteger(value: unknown): value is number;
|
|
26
|
+
/** A safe integer that is zero or more -- e.g. an offset/cursor field where zero is a real, valid value, unlike a positive-only count. */
|
|
27
|
+
export declare function isNonNegativeSafeInteger(value: unknown): value is number;
|
|
28
|
+
/**
|
|
29
|
+
* A safe integer that is at least 1 -- the shape every bounded-count/size field (maxBytes,
|
|
30
|
+
* maxCount, maxResults, maxMatches, ...) across the ecosystem actually requires. `maximum`, when
|
|
31
|
+
* given, additionally caps the accepted value inclusively -- a field that must be positive AND
|
|
32
|
+
* never exceed some hard ceiling (e.g. a page size capped well below an unbounded read) is a real,
|
|
33
|
+
* recurring combination, not a hypothetical one.
|
|
34
|
+
*/
|
|
35
|
+
export declare function isPositiveSafeInteger(value: unknown, maximum?: number): value is number;
|
|
36
|
+
/** An array whose every element is a string -- the shape every string-list field (labels, tags, pathspecs, ...) across the ecosystem actually requires; a mixed-type array is a real, distinct failure from "not an array at all". */
|
|
37
|
+
export declare function isStringArray(value: unknown): value is string[];
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A safeParse's own first, universal check: the value wasn't even an object. Every hand-written
|
|
3
|
+
* object-shaped VehicleSchemaCodec across the ecosystem re-derived this exact literal before this
|
|
4
|
+
* existed as a shared primitive -- one canonical wording now, not five near-identical copies.
|
|
5
|
+
*/
|
|
6
|
+
export function notAnObjectIssue() {
|
|
7
|
+
return { success: false, issues: [{ path: [], message: "input must be an object" }] };
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* One field-scoped failure. `path` accepts a single key (the common case: a top-level field) or
|
|
11
|
+
* a full path segment array (for a nested/array-indexed field, matching VehicleSchemaIssue.path's
|
|
12
|
+
* own `readonly (string | number)[]` shape directly) -- both real shapes existing hand-written
|
|
13
|
+
* safeParse implementations across the ecosystem already needed.
|
|
14
|
+
*/
|
|
15
|
+
export function schemaIssue(path, message) {
|
|
16
|
+
return { success: false, issues: [{ path: Array.isArray(path) ? path : [path], message }] };
|
|
17
|
+
}
|
|
18
|
+
/** True for a real object value -- not null, not an array (JSON Schema's own object/array distinction; `typeof [] === "object"` is not what a caller checking "is this a plain object" means). */
|
|
19
|
+
export function isPlainObject(value) {
|
|
20
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
21
|
+
}
|
|
22
|
+
/** A non-empty string -- the shape every identifier-like field (workspaceId, ref, path, ...) across the ecosystem actually requires; an empty string is a real, distinct failure from "not a string at all". */
|
|
23
|
+
export function isNonEmptyString(value) {
|
|
24
|
+
return typeof value === "string" && value.length > 0;
|
|
25
|
+
}
|
|
26
|
+
/** A real integer safely representable in a double -- the base every bounded-count/size field below builds on. */
|
|
27
|
+
export function isSafeInteger(value) {
|
|
28
|
+
return typeof value === "number" && Number.isSafeInteger(value);
|
|
29
|
+
}
|
|
30
|
+
/** A safe integer that is zero or more -- e.g. an offset/cursor field where zero is a real, valid value, unlike a positive-only count. */
|
|
31
|
+
export function isNonNegativeSafeInteger(value) {
|
|
32
|
+
return isSafeInteger(value) && value >= 0;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* A safe integer that is at least 1 -- the shape every bounded-count/size field (maxBytes,
|
|
36
|
+
* maxCount, maxResults, maxMatches, ...) across the ecosystem actually requires. `maximum`, when
|
|
37
|
+
* given, additionally caps the accepted value inclusively -- a field that must be positive AND
|
|
38
|
+
* never exceed some hard ceiling (e.g. a page size capped well below an unbounded read) is a real,
|
|
39
|
+
* recurring combination, not a hypothetical one.
|
|
40
|
+
*/
|
|
41
|
+
export function isPositiveSafeInteger(value, maximum) {
|
|
42
|
+
return isSafeInteger(value) && value >= 1 && (maximum === undefined || value <= maximum);
|
|
43
|
+
}
|
|
44
|
+
/** An array whose every element is a string -- the shape every string-list field (labels, tags, pathspecs, ...) across the ecosystem actually requires; a mixed-type array is a real, distinct failure from "not an array at all". */
|
|
45
|
+
export function isStringArray(value) {
|
|
46
|
+
return Array.isArray(value) && value.every((item) => typeof item === "string");
|
|
47
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@danypops/vehicle-core",
|
|
3
|
-
"version": "0.18.
|
|
3
|
+
"version": "0.18.2",
|
|
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",
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Coalesces a burst of calls for the same key into exactly one callback fire,
|
|
3
|
+
* delayMs after the last call for that key -- the classic debounce shape,
|
|
4
|
+
* useful anywhere a flurry of raw upstream events for one logical unit of
|
|
5
|
+
* work (a filesystem save that fires more than once via temp-file write +
|
|
6
|
+
* atomic rename, a burst of webhook deliveries for the same resource) needs
|
|
7
|
+
* to collapse into a single downstream action. Different keys are fully
|
|
8
|
+
* independent. Pure timer bookkeeping, no I/O -- the callback itself does
|
|
9
|
+
* whatever real work is needed.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** The minimal logging surface this module needs -- any real Logger (e.g. Vehicle's own daemon Logger) satisfies this structurally, no adapter required. */
|
|
13
|
+
export interface MinimalLogger {
|
|
14
|
+
debug(msg: string, fields?: Record<string, unknown>): void;
|
|
15
|
+
warn(msg: string, fields?: Record<string, unknown>): void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export class DebounceCapacityExceeded extends Error {
|
|
19
|
+
constructor(
|
|
20
|
+
readonly key: string,
|
|
21
|
+
readonly max: number,
|
|
22
|
+
) {
|
|
23
|
+
super(`debounced scheduler distinct-key bound exceeded (${max}) scheduling key "${key}"`);
|
|
24
|
+
this.name = "DebounceCapacityExceeded";
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface DebouncedSchedulerOptions {
|
|
29
|
+
/** Maximum distinct keys with a pending fire at once. Default 4096. */
|
|
30
|
+
readonly maxKeys?: number;
|
|
31
|
+
readonly logger?: MinimalLogger;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const DEFAULT_MAX_KEYS = 4096;
|
|
35
|
+
const NOOP_LOGGER: MinimalLogger = { debug() {}, warn() {} };
|
|
36
|
+
|
|
37
|
+
export class DebouncedScheduler {
|
|
38
|
+
private readonly timers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
39
|
+
private readonly delayMs: number;
|
|
40
|
+
private readonly maxKeys: number;
|
|
41
|
+
private readonly logger: MinimalLogger;
|
|
42
|
+
|
|
43
|
+
constructor(delayMs: number, options: DebouncedSchedulerOptions = {}) {
|
|
44
|
+
if (!Number.isSafeInteger(delayMs) || delayMs < 0) throw new TypeError("delayMs must be a non-negative safe integer");
|
|
45
|
+
this.delayMs = delayMs;
|
|
46
|
+
this.maxKeys = options.maxKeys ?? DEFAULT_MAX_KEYS;
|
|
47
|
+
this.logger = options.logger ?? NOOP_LOGGER;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Schedules `callback` to run delayMs after this call, resetting any pending fire already
|
|
52
|
+
* scheduled for `key`. A callback that throws or rejects is caught and dropped -- there is
|
|
53
|
+
* no request awaiting this fire to report the error, and an unhandled timer failure would
|
|
54
|
+
* otherwise crash the whole process rather than just this one key's work.
|
|
55
|
+
* A caller that cares about its own errors should catch and log inside `callback` itself.
|
|
56
|
+
*/
|
|
57
|
+
schedule(key: string, callback: () => unknown): void {
|
|
58
|
+
const existing = this.timers.get(key);
|
|
59
|
+
if (existing) {
|
|
60
|
+
clearTimeout(existing);
|
|
61
|
+
this.logger.debug("debounced schedule coalesced", { component: "debounced-scheduler", operation: "schedule" });
|
|
62
|
+
} else if (this.timers.size >= this.maxKeys) {
|
|
63
|
+
this.logger.warn("debounced schedule rejected", {
|
|
64
|
+
component: "debounced-scheduler",
|
|
65
|
+
operation: "schedule",
|
|
66
|
+
code: "DebounceCapacityExceeded",
|
|
67
|
+
});
|
|
68
|
+
throw new DebounceCapacityExceeded(key, this.maxKeys);
|
|
69
|
+
}
|
|
70
|
+
const reportFailure = (error: unknown): void => {
|
|
71
|
+
this.logger.warn("debounced callback failed", {
|
|
72
|
+
component: "debounced-scheduler",
|
|
73
|
+
operation: "fire",
|
|
74
|
+
code: error instanceof Error ? error.name || "Error" : "Error",
|
|
75
|
+
});
|
|
76
|
+
};
|
|
77
|
+
const timer = setTimeout(() => {
|
|
78
|
+
this.timers.delete(key);
|
|
79
|
+
try {
|
|
80
|
+
Promise.resolve(callback()).catch(reportFailure);
|
|
81
|
+
} catch (error: unknown) {
|
|
82
|
+
reportFailure(error);
|
|
83
|
+
}
|
|
84
|
+
}, this.delayMs);
|
|
85
|
+
this.timers.set(key, timer);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Cancels `key`'s pending fire, if any. Idempotent -- an unknown or already-fired key is a safe no-op. */
|
|
89
|
+
cancel(key: string): void {
|
|
90
|
+
const existing = this.timers.get(key);
|
|
91
|
+
if (!existing) return;
|
|
92
|
+
clearTimeout(existing);
|
|
93
|
+
this.timers.delete(key);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** True while `key` has a fire pending. */
|
|
97
|
+
has(key: string): boolean {
|
|
98
|
+
return this.timers.has(key);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Cancels every pending key at once -- for clean shutdown. */
|
|
102
|
+
clear(): void {
|
|
103
|
+
for (const timer of this.timers.values()) clearTimeout(timer);
|
|
104
|
+
this.timers.clear();
|
|
105
|
+
}
|
|
106
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./debounced-scheduler.js";
|
package/src/index.ts
CHANGED
|
@@ -5,14 +5,16 @@
|
|
|
5
5
|
* (model-facing narrative blocks), operations (descriptors, effect
|
|
6
6
|
* classification, invocation context), events, manifest, client (the port a
|
|
7
7
|
* caller programs against), approvals (the Approval Gate's wire shapes), jobs
|
|
8
|
-
* (Vehicle Jobs' pure pieces), schedules, watches,
|
|
9
|
-
* JSON
|
|
10
|
-
*
|
|
8
|
+
* (Vehicle Jobs' pure pieces), schedules, watches, persistence (atomic
|
|
9
|
+
* JSON), and concurrency (timer-based scheduling primitives) -- the latter
|
|
10
|
+
* two are technical utilities, not Vehicle protocol capabilities, kept
|
|
11
|
+
* distinct for that reason. Every symbol below is re-exported unchanged
|
|
11
12
|
* from its historical flat-file home, so root-level `import { X } from
|
|
12
13
|
* "@danypops/vehicle-core"` usage is completely unaffected by this layout.
|
|
13
14
|
*/
|
|
14
15
|
export * from "./approvals/index.js";
|
|
15
16
|
export * from "./client/index.js";
|
|
17
|
+
export * from "./concurrency/index.js";
|
|
16
18
|
export * from "./content/index.js";
|
|
17
19
|
export * from "./errors/index.js";
|
|
18
20
|
export * from "./events/index.js";
|
package/src/schemas/index.ts
CHANGED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { VehicleSchemaIssue } from "./codec.js";
|
|
2
|
+
|
|
3
|
+
/** The `{ success: false, issues }` half of VehicleSchemaResult -- named on its own since every hand-written safeParse's failure branch is this exact shape, never a bare Error. */
|
|
4
|
+
export interface VehicleSchemaFailure {
|
|
5
|
+
readonly success: false;
|
|
6
|
+
readonly issues: readonly VehicleSchemaIssue[];
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* A safeParse's own first, universal check: the value wasn't even an object. Every hand-written
|
|
11
|
+
* object-shaped VehicleSchemaCodec across the ecosystem re-derived this exact literal before this
|
|
12
|
+
* existed as a shared primitive -- one canonical wording now, not five near-identical copies.
|
|
13
|
+
*/
|
|
14
|
+
export function notAnObjectIssue(): VehicleSchemaFailure {
|
|
15
|
+
return { success: false, issues: [{ path: [], message: "input must be an object" }] };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* One field-scoped failure. `path` accepts a single key (the common case: a top-level field) or
|
|
20
|
+
* a full path segment array (for a nested/array-indexed field, matching VehicleSchemaIssue.path's
|
|
21
|
+
* own `readonly (string | number)[]` shape directly) -- both real shapes existing hand-written
|
|
22
|
+
* safeParse implementations across the ecosystem already needed.
|
|
23
|
+
*/
|
|
24
|
+
export function schemaIssue(path: string | number | readonly (string | number)[], message: string): VehicleSchemaFailure {
|
|
25
|
+
return { success: false, issues: [{ path: Array.isArray(path) ? path : [path], message }] };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** True for a real object value -- not null, not an array (JSON Schema's own object/array distinction; `typeof [] === "object"` is not what a caller checking "is this a plain object" means). */
|
|
29
|
+
export function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
30
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** A non-empty string -- the shape every identifier-like field (workspaceId, ref, path, ...) across the ecosystem actually requires; an empty string is a real, distinct failure from "not a string at all". */
|
|
34
|
+
export function isNonEmptyString(value: unknown): value is string {
|
|
35
|
+
return typeof value === "string" && value.length > 0;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** A real integer safely representable in a double -- the base every bounded-count/size field below builds on. */
|
|
39
|
+
export function isSafeInteger(value: unknown): value is number {
|
|
40
|
+
return typeof value === "number" && Number.isSafeInteger(value);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** A safe integer that is zero or more -- e.g. an offset/cursor field where zero is a real, valid value, unlike a positive-only count. */
|
|
44
|
+
export function isNonNegativeSafeInteger(value: unknown): value is number {
|
|
45
|
+
return isSafeInteger(value) && value >= 0;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* A safe integer that is at least 1 -- the shape every bounded-count/size field (maxBytes,
|
|
50
|
+
* maxCount, maxResults, maxMatches, ...) across the ecosystem actually requires. `maximum`, when
|
|
51
|
+
* given, additionally caps the accepted value inclusively -- a field that must be positive AND
|
|
52
|
+
* never exceed some hard ceiling (e.g. a page size capped well below an unbounded read) is a real,
|
|
53
|
+
* recurring combination, not a hypothetical one.
|
|
54
|
+
*/
|
|
55
|
+
export function isPositiveSafeInteger(value: unknown, maximum?: number): value is number {
|
|
56
|
+
return isSafeInteger(value) && value >= 1 && (maximum === undefined || value <= maximum);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** An array whose every element is a string -- the shape every string-list field (labels, tags, pathspecs, ...) across the ecosystem actually requires; a mixed-type array is a real, distinct failure from "not an array at all". */
|
|
60
|
+
export function isStringArray(value: unknown): value is string[] {
|
|
61
|
+
return Array.isArray(value) && value.every((item) => typeof item === "string");
|
|
62
|
+
}
|