@danypops/vehicle-core 0.6.0 → 0.7.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.
@@ -29,6 +29,8 @@ export interface AtomicJsonWriteOptions {
29
29
  readonly mode?: number;
30
30
  /** Pretty-prints with 2-space indentation (matching JSON.stringify(value, null, 2)) for a human-editable file. Defaults to false (compact). */
31
31
  readonly pretty?: boolean;
32
+ /** Appends a trailing "\n" -- the common POSIX text-file convention. Defaults to false (exact JSON.stringify output, unchanged). */
33
+ readonly trailingNewline?: boolean;
32
34
  }
33
35
  export interface AtomicJsonWriterOptions {
34
36
  readonly fs: AtomicJsonFsAdapter;
@@ -71,6 +71,8 @@ export function createAtomicJsonWriter(options) {
71
71
  }
72
72
  if (serialized === undefined)
73
73
  throw new Error(`atomic-json: value for ${filePath} is not JSON-serializable`);
74
+ if (options?.trailingNewline)
75
+ serialized += "\n";
74
76
  const { dir, base } = dirAndBase(filePath);
75
77
  const tempPath = `${dir}/.${base}.${pid()}.${now()}.${random()}.tmp`;
76
78
  await fsAdapter.writeFile(tempPath, serialized, options?.mode);
@@ -182,8 +182,59 @@ export interface VehicleManifestOperation extends VehicleOperationDescriptor {
182
182
  readonly available: boolean;
183
183
  readonly unavailableReason?: string;
184
184
  }
185
+ /**
186
+ * A named, schema'd event type a provider declares as part of its
187
+ * manifest -- the typed alternative to a raw PushChannel.publish(topic,
188
+ * payload) call with a hand-invented topic string. Confirmed independently
189
+ * reinvented three-plus times across Papyrus and Lector before this
190
+ * existed (see this task's own body). No `available` flag the way an
191
+ * operation has one: an event type, once declared, is always emittable --
192
+ * there's no credential-gated "this event is currently unavailable"
193
+ * concept the way a live external-service-backed operation can have.
194
+ */
195
+ export interface VehicleEventDescriptor {
196
+ readonly name: string;
197
+ readonly version: number;
198
+ readonly description: string;
199
+ readonly payloadSchema: JsonSchema;
200
+ /** Same bounded-resource discipline as an operation's own maxRequestBytes/maxResponseBytes -- required, never silently defaulted. */
201
+ readonly maxPayloadBytes: number;
202
+ }
203
+ export interface VehicleEvent<Payload> {
204
+ readonly descriptor: VehicleEventDescriptor;
205
+ readonly payload: VehicleSchemaCodec<Payload>;
206
+ }
207
+ export interface DefineVehicleEventOptions<Payload> {
208
+ readonly name: string;
209
+ readonly version: number;
210
+ readonly description: string;
211
+ readonly payload: VehicleSchemaCodec<Payload>;
212
+ readonly maxPayloadBytes: number;
213
+ }
214
+ export declare function defineVehicleEvent<Payload>(options: DefineVehicleEventOptions<Payload>): VehicleEvent<Payload>;
215
+ export type VehicleManifestEvent = VehicleEventDescriptor;
216
+ export type VehicleEventHandler<Payload> = (payload: Payload) => void;
217
+ export interface VehicleSubscription {
218
+ close(): void;
219
+ }
220
+ /**
221
+ * The wire topic name a bridge (bridgeVehicleEventsToPushChannel, in
222
+ * vehicle-server) publishes an event under, and a subscriber
223
+ * (RemoteVehicleClient.subscribe()) subscribes to -- one shared naming
224
+ * function in vehicle-core so both sides can never drift apart on the
225
+ * convention, the same failure mode this primitive exists to prevent
226
+ * providers from reinventing per-project.
227
+ */
228
+ export declare function vehicleEventTopic(name: string, version: number): string;
229
+ /**
230
+ * `events` is optional purely for backward compatibility with every
231
+ * hand-authored VehicleManifest test fixture across the ecosystem that
232
+ * predates this field -- a real VehicleRegistry.manifest() always
233
+ * populates it (as [] when no events are declared), never omits it.
234
+ */
185
235
  export interface VehicleManifest extends VehicleManifestIdentity {
186
236
  readonly operations: readonly VehicleManifestOperation[];
237
+ readonly events?: readonly VehicleManifestEvent[];
187
238
  }
188
239
  export interface VehicleClient {
189
240
  manifest(): Promise<VehicleManifest>;
@@ -69,6 +69,40 @@ export function extractVehicleContent(output) {
69
69
  }
70
70
  return blocks;
71
71
  }
72
+ function validateEventMetadata(options) {
73
+ if (!options.name.trim())
74
+ throw new Error("Vehicle event name must not be empty");
75
+ if (!Number.isInteger(options.version) || options.version < 1) {
76
+ throw new Error("Vehicle event version must be a positive integer");
77
+ }
78
+ if (!options.description.trim())
79
+ throw new Error("Vehicle event description must not be empty");
80
+ if (!Number.isSafeInteger(options.maxPayloadBytes) || options.maxPayloadBytes < 1) {
81
+ throw new Error("Vehicle event maxPayloadBytes must be a positive integer");
82
+ }
83
+ }
84
+ export function defineVehicleEvent(options) {
85
+ validateEventMetadata(options);
86
+ const descriptor = Object.freeze({
87
+ name: options.name,
88
+ version: options.version,
89
+ description: options.description,
90
+ payloadSchema: cloneJson(options.payload.jsonSchema),
91
+ maxPayloadBytes: options.maxPayloadBytes,
92
+ });
93
+ return Object.freeze({ descriptor, payload: options.payload });
94
+ }
95
+ /**
96
+ * The wire topic name a bridge (bridgeVehicleEventsToPushChannel, in
97
+ * vehicle-server) publishes an event under, and a subscriber
98
+ * (RemoteVehicleClient.subscribe()) subscribes to -- one shared naming
99
+ * function in vehicle-core so both sides can never drift apart on the
100
+ * convention, the same failure mode this primitive exists to prevent
101
+ * providers from reinventing per-project.
102
+ */
103
+ export function vehicleEventTopic(name, version) {
104
+ return `vehicle-event:${name}@${version}`;
105
+ }
72
106
  export function defineVehicleOperation(options) {
73
107
  validateOperationMetadata(options);
74
108
  const descriptor = Object.freeze({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/vehicle-core",
3
- "version": "0.6.0",
3
+ "version": "0.7.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",
@@ -31,6 +31,8 @@ export interface AtomicJsonWriteOptions {
31
31
  readonly mode?: number;
32
32
  /** Pretty-prints with 2-space indentation (matching JSON.stringify(value, null, 2)) for a human-editable file. Defaults to false (compact). */
33
33
  readonly pretty?: boolean;
34
+ /** Appends a trailing "\n" -- the common POSIX text-file convention. Defaults to false (exact JSON.stringify output, unchanged). */
35
+ readonly trailingNewline?: boolean;
34
36
  }
35
37
 
36
38
  export interface AtomicJsonWriterOptions {
@@ -118,6 +120,7 @@ export function createAtomicJsonWriter(options: AtomicJsonWriterOptions): Atomic
118
120
  throw new Error(`atomic-json: value for ${filePath} is not JSON-serializable`, { cause: error });
119
121
  }
120
122
  if (serialized === undefined) throw new Error(`atomic-json: value for ${filePath} is not JSON-serializable`);
123
+ if (options?.trailingNewline) serialized += "\n";
121
124
  const { dir, base } = dirAndBase(filePath);
122
125
  const tempPath = `${dir}/.${base}.${pid()}.${now()}.${random()}.tmp`;
123
126
  await fsAdapter.writeFile(tempPath, serialized, options?.mode);
@@ -246,8 +246,90 @@ export interface VehicleManifestOperation extends VehicleOperationDescriptor {
246
246
  readonly unavailableReason?: string;
247
247
  }
248
248
 
249
+ /**
250
+ * A named, schema'd event type a provider declares as part of its
251
+ * manifest -- the typed alternative to a raw PushChannel.publish(topic,
252
+ * payload) call with a hand-invented topic string. Confirmed independently
253
+ * reinvented three-plus times across Papyrus and Lector before this
254
+ * existed (see this task's own body). No `available` flag the way an
255
+ * operation has one: an event type, once declared, is always emittable --
256
+ * there's no credential-gated "this event is currently unavailable"
257
+ * concept the way a live external-service-backed operation can have.
258
+ */
259
+ export interface VehicleEventDescriptor {
260
+ readonly name: string;
261
+ readonly version: number;
262
+ readonly description: string;
263
+ readonly payloadSchema: JsonSchema;
264
+ /** Same bounded-resource discipline as an operation's own maxRequestBytes/maxResponseBytes -- required, never silently defaulted. */
265
+ readonly maxPayloadBytes: number;
266
+ }
267
+
268
+ export interface VehicleEvent<Payload> {
269
+ readonly descriptor: VehicleEventDescriptor;
270
+ readonly payload: VehicleSchemaCodec<Payload>;
271
+ }
272
+
273
+ export interface DefineVehicleEventOptions<Payload> {
274
+ readonly name: string;
275
+ readonly version: number;
276
+ readonly description: string;
277
+ readonly payload: VehicleSchemaCodec<Payload>;
278
+ readonly maxPayloadBytes: number;
279
+ }
280
+
281
+ function validateEventMetadata<Payload>(options: DefineVehicleEventOptions<Payload>): void {
282
+ if (!options.name.trim()) throw new Error("Vehicle event name must not be empty");
283
+ if (!Number.isInteger(options.version) || options.version < 1) {
284
+ throw new Error("Vehicle event version must be a positive integer");
285
+ }
286
+ if (!options.description.trim()) throw new Error("Vehicle event description must not be empty");
287
+ if (!Number.isSafeInteger(options.maxPayloadBytes) || options.maxPayloadBytes < 1) {
288
+ throw new Error("Vehicle event maxPayloadBytes must be a positive integer");
289
+ }
290
+ }
291
+
292
+ export function defineVehicleEvent<Payload>(options: DefineVehicleEventOptions<Payload>): VehicleEvent<Payload> {
293
+ validateEventMetadata(options);
294
+ const descriptor: VehicleEventDescriptor = Object.freeze({
295
+ name: options.name,
296
+ version: options.version,
297
+ description: options.description,
298
+ payloadSchema: cloneJson(options.payload.jsonSchema),
299
+ maxPayloadBytes: options.maxPayloadBytes,
300
+ });
301
+ return Object.freeze({ descriptor, payload: options.payload });
302
+ }
303
+
304
+ export type VehicleManifestEvent = VehicleEventDescriptor;
305
+
306
+ export type VehicleEventHandler<Payload> = (payload: Payload) => void;
307
+
308
+ export interface VehicleSubscription {
309
+ close(): void;
310
+ }
311
+
312
+ /**
313
+ * The wire topic name a bridge (bridgeVehicleEventsToPushChannel, in
314
+ * vehicle-server) publishes an event under, and a subscriber
315
+ * (RemoteVehicleClient.subscribe()) subscribes to -- one shared naming
316
+ * function in vehicle-core so both sides can never drift apart on the
317
+ * convention, the same failure mode this primitive exists to prevent
318
+ * providers from reinventing per-project.
319
+ */
320
+ export function vehicleEventTopic(name: string, version: number): string {
321
+ return `vehicle-event:${name}@${version}`;
322
+ }
323
+
324
+ /**
325
+ * `events` is optional purely for backward compatibility with every
326
+ * hand-authored VehicleManifest test fixture across the ecosystem that
327
+ * predates this field -- a real VehicleRegistry.manifest() always
328
+ * populates it (as [] when no events are declared), never omits it.
329
+ */
249
330
  export interface VehicleManifest extends VehicleManifestIdentity {
250
331
  readonly operations: readonly VehicleManifestOperation[];
332
+ readonly events?: readonly VehicleManifestEvent[];
251
333
  }
252
334
 
253
335
  export interface VehicleClient {