@nebulahome/package-sdk 0.0.0-stage → 1.0.1

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.
@@ -0,0 +1,608 @@
1
+ import { t as SDK_VERSION } from "./chunks/version.js";
2
+ //#region src/testing/schema.ts
3
+ /**
4
+ * One line for the problems a schema found.
5
+ *
6
+ * @param issues - The problems.
7
+ * @returns What they say, joined.
8
+ */
9
+ function messageOf(issues) {
10
+ return issues.map((issue) => issue.message).join("; ");
11
+ }
12
+ /**
13
+ * Checks a value.
14
+ *
15
+ * @param schema - The schema.
16
+ * @param value - The value.
17
+ * @returns The value it gave back, or what is wrong.
18
+ */
19
+ async function check(schema, value) {
20
+ if ("~standard" in schema) {
21
+ const result = await schema["~standard"].validate(value);
22
+ return result.issues === void 0 ? {
23
+ isValid: true,
24
+ value: result.value
25
+ } : {
26
+ isValid: false,
27
+ message: messageOf(result.issues)
28
+ };
29
+ }
30
+ const result = schema.safeParse(value);
31
+ return result.success ? {
32
+ isValid: true,
33
+ value: result.data
34
+ } : {
35
+ isValid: false,
36
+ message: String(result.error)
37
+ };
38
+ }
39
+ //#endregion
40
+ //#region src/testing/index.ts
41
+ /** Service names the platform keeps for itself. */
42
+ const RESERVED_SERVICES = /* @__PURE__ */ new Set([
43
+ "command",
44
+ "rpc",
45
+ "settings",
46
+ "health",
47
+ "events",
48
+ "info"
49
+ ]);
50
+ /** Who calls a method when a test does not say. */
51
+ const TEST_CALLER = {
52
+ userId: "test-user",
53
+ username: "test",
54
+ role: "owner"
55
+ };
56
+ /**
57
+ * Says whether a value is a record.
58
+ *
59
+ * @param value - The value.
60
+ * @returns True for a non-null, non-array object.
61
+ */
62
+ function isRecord(value) {
63
+ return typeof value === "object" && value !== null && !Array.isArray(value);
64
+ }
65
+ /**
66
+ * Says whether a value is one a custom entity's field holds.
67
+ *
68
+ * @param value - The value.
69
+ * @returns True for a string, a number, a boolean or null.
70
+ */
71
+ function isCustomValue(value) {
72
+ return value === null || typeof value === "string" || typeof value === "number" || typeof value === "boolean";
73
+ }
74
+ /**
75
+ * The fields of a settings declaration, by key.
76
+ *
77
+ * @param definition - The declaration.
78
+ * @returns Each field, as a record.
79
+ */
80
+ function fieldsOf(definition) {
81
+ const fields = isRecord(definition) && isRecord(definition["fields"]) ? definition["fields"] : {};
82
+ return new Map(Object.entries(fields).flatMap(([key, field]) => isRecord(field) ? [[key, field]] : []));
83
+ }
84
+ /**
85
+ * Builds a context with no platform behind it.
86
+ *
87
+ * @param options - Its id, settings and the house it reads.
88
+ * @returns The context, and what the package does with it.
89
+ */
90
+ function createTestContext(options = {}) {
91
+ const id = options.id ?? "test-package";
92
+ const prefix = `${id.replaceAll("-", "_")}_`;
93
+ const fields = fieldsOf(options.definition);
94
+ let values = {
95
+ ...Object.fromEntries([...fields].flatMap(([key, field]) => field["default"] === void 0 ? [] : [[key, field["default"]]])),
96
+ ...options.settings
97
+ };
98
+ const abort = new AbortController();
99
+ const reports = [];
100
+ const logs = [];
101
+ const entities = new Map((options.entities ?? []).map((each) => [each.entityId, each]));
102
+ const methods = /* @__PURE__ */ new Map();
103
+ const services = /* @__PURE__ */ new Map();
104
+ const answers = /* @__PURE__ */ new Map();
105
+ const customHandlers = /* @__PURE__ */ new Map();
106
+ const settingsHandlers = /* @__PURE__ */ new Set();
107
+ const subscriptions = /* @__PURE__ */ new Set();
108
+ let commandHandler;
109
+ let synced;
110
+ /**
111
+ * Says whether the settings, as recorded, are the server's: whatever its declaration types
112
+ * them as, the test vouches for them.
113
+ *
114
+ * @param value - The settings.
115
+ * @returns True for a record.
116
+ */
117
+ const isSettings = (value) => isRecord(value);
118
+ /**
119
+ * The settings, as the server reads them.
120
+ *
121
+ * @returns Them.
122
+ * @throws {Error} Never: they are always a record.
123
+ */
124
+ const settingsNow = () => {
125
+ if (!isSettings(values)) throw new Error("The settings are not a record");
126
+ return values;
127
+ };
128
+ /**
129
+ * A logger that records each line, with the fields of its parents.
130
+ *
131
+ * @param bound - The fields every line carries.
132
+ * @returns The logger.
133
+ */
134
+ const loggerWith = (bound) => {
135
+ /**
136
+ * One log method.
137
+ *
138
+ * @param level - Its level.
139
+ * @returns The method.
140
+ */
141
+ const method = (level) => (first, message) => {
142
+ logs.push(typeof first === "string" ? {
143
+ level,
144
+ fields: bound,
145
+ message: first
146
+ } : {
147
+ level,
148
+ fields: {
149
+ ...bound,
150
+ ...first
151
+ },
152
+ message
153
+ });
154
+ };
155
+ return {
156
+ trace: method("trace"),
157
+ debug: method("debug"),
158
+ info: method("info"),
159
+ warn: method("warn"),
160
+ error: method("error"),
161
+ fatal: method("fatal"),
162
+ /**
163
+ * A logger whose lines carry more fields.
164
+ *
165
+ * @param more - The fields it adds.
166
+ * @returns The logger.
167
+ */
168
+ child: (more) => loggerWith({
169
+ ...bound,
170
+ ...more
171
+ })
172
+ };
173
+ };
174
+ /**
175
+ * The object id of one of the package's entities.
176
+ *
177
+ * @param entityId - The entity.
178
+ * @returns What follows the prefix, or undefined for anyone else's.
179
+ */
180
+ const objectIdOf = (entityId) => {
181
+ const objectId = entityId.split(".")[1];
182
+ return objectId?.startsWith(prefix) === true ? objectId.slice(prefix.length) : void 0;
183
+ };
184
+ /**
185
+ * Says whether an entity is one a filter names.
186
+ *
187
+ * @param filter - The filter.
188
+ * @param entityId - The entity.
189
+ * @returns True when it is.
190
+ */
191
+ const matches = (filter, entityId) => (filter.domain === void 0 || entityId.startsWith(`${filter.domain}.`)) && (filter.entityIds === void 0 || filter.entityIds.includes(entityId));
192
+ /**
193
+ * Registers a method of the package's interface.
194
+ *
195
+ * @param name - The method.
196
+ * @param method - Who may call it, and what it takes.
197
+ * @param method.access - Who may call it.
198
+ * @param method.input - What checks its input, if anything.
199
+ * @param handler - What it does.
200
+ * @throws {Error} When the name is taken.
201
+ */
202
+ function rpcMethod(name, method, handler) {
203
+ /**
204
+ * Says whether an input is the method's: its schema checked it, or it is the JSON the
205
+ * interface sent to a method that takes any.
206
+ *
207
+ * @param input - The input.
208
+ * @returns True.
209
+ */
210
+ const isInput = (input) => input === input;
211
+ if (methods.has(name)) throw new Error(`The method ${name} is registered already`);
212
+ methods.set(name, {
213
+ input: method.input,
214
+ /**
215
+ * Hands the method its input, once checked.
216
+ *
217
+ * @param input - The input.
218
+ * @param call - Who calls.
219
+ * @returns What the method answers.
220
+ * @throws {Error} Never: every input is the method's.
221
+ */
222
+ handler: (input, call) => {
223
+ if (!isInput(input)) throw new Error(`${name} was handed what it does not take`);
224
+ return handler(input, call);
225
+ }
226
+ });
227
+ }
228
+ /**
229
+ * Offers a service.
230
+ *
231
+ * @param name - The service.
232
+ * @param serviceOrHandler - What it takes, or what it answers.
233
+ * @param handler - What it answers, after its schema.
234
+ * @throws {Error} When the name is reserved or taken, or there is no handler.
235
+ */
236
+ function provide(name, serviceOrHandler, handler) {
237
+ /**
238
+ * Says whether an input is the service's: its schema checked it, or it takes any.
239
+ *
240
+ * @param input - The input.
241
+ * @returns True.
242
+ */
243
+ const isInput = (input) => input === input;
244
+ const answer = typeof serviceOrHandler === "function" ? serviceOrHandler : handler;
245
+ if (RESERVED_SERVICES.has(name) || name.startsWith("_")) throw new Error(`${name} is a name the platform keeps for itself`);
246
+ if (services.has(name)) throw new Error(`The service ${name} is provided already`);
247
+ if (answer === void 0) throw new Error(`The service ${name} has no handler`);
248
+ services.set(name, {
249
+ input: typeof serviceOrHandler === "function" ? void 0 : serviceOrHandler.input,
250
+ /**
251
+ * Hands the service its input, once checked.
252
+ *
253
+ * @param input - The input.
254
+ * @param call - Who calls.
255
+ * @returns What the service answers.
256
+ * @throws {Error} Never: every input is the service's.
257
+ */
258
+ handler: (input, call) => {
259
+ if (!isInput(input)) throw new Error(`${name} was handed what it does not take`);
260
+ return answer(input, call);
261
+ }
262
+ });
263
+ }
264
+ /**
265
+ * Calls a service.
266
+ *
267
+ * @param target - The package or service.
268
+ * @param name - The service.
269
+ * @param input - What it is handed.
270
+ * @param call - What it answers.
271
+ * @param call.timeoutMs - In milliseconds.
272
+ * @param call.output - What checks the answer.
273
+ * @returns What it answered.
274
+ * @throws {Error} When nothing answers it, or the answer is not what was asked.
275
+ */
276
+ async function callService(target, name, input, call) {
277
+ const answer = answers.get(`${target}.${name}`);
278
+ if (answer === void 0) throw new Error(`Nothing answers ${target}.${name}: answer it with answerService`);
279
+ const value = await answer(input);
280
+ if (call?.output === void 0) return value;
281
+ const checked = await check(call.output, value);
282
+ if (!checked.isValid) throw new Error(`${target}.${name} answered what was not asked: ${checked.message}`);
283
+ return checked.value;
284
+ }
285
+ return {
286
+ ctx: {
287
+ id,
288
+ version: options.version ?? "0.0.0",
289
+ platform: { version: "0.0.0-test" },
290
+ sdk: { version: SDK_VERSION },
291
+ /**
292
+ * The settings as they stand.
293
+ *
294
+ * @returns Them.
295
+ */
296
+ get settings() {
297
+ return settingsNow();
298
+ },
299
+ /**
300
+ * Hears live changes of the settings.
301
+ *
302
+ * @param handler - What hears them.
303
+ * @returns What stops it hearing.
304
+ */
305
+ onSettingsChange: (handler) => {
306
+ settingsHandlers.add(handler);
307
+ return () => {
308
+ settingsHandlers.delete(handler);
309
+ };
310
+ },
311
+ dataDir: options.dataDir ?? `/tmp/${id}/data`,
312
+ cacheDir: options.cacheDir ?? `/tmp/${id}/cache`,
313
+ signal: abort.signal,
314
+ logger: loggerWith({}),
315
+ /**
316
+ * One of the package's entities' ids.
317
+ *
318
+ * @param domain - Its domain.
319
+ * @param objectId - What follows the prefix.
320
+ * @returns The id.
321
+ */
322
+ entityId: (domain, objectId) => `${domain}.${prefix}${objectId}`,
323
+ objectIdOf,
324
+ devices: {
325
+ /**
326
+ * Records the devices, and answers as the platform would.
327
+ *
328
+ * @param devices - Every device.
329
+ * @returns What was written.
330
+ */
331
+ sync: async (devices) => {
332
+ synced = devices;
333
+ return Promise.resolve({
334
+ devices: devices.map((device) => ({
335
+ deviceId: `device-${device.externalId}`,
336
+ externalId: device.externalId,
337
+ entityIds: device.entities.map((entity) => `${"type" in entity ? "custom" : entity.domain}.${prefix}${entity.objectId}`)
338
+ })),
339
+ refused: [],
340
+ kept: []
341
+ });
342
+ },
343
+ /**
344
+ * Records a report.
345
+ *
346
+ * @param entityId - The entity.
347
+ * @param state - Its state.
348
+ * @param reportOptions - Its attributes and availability.
349
+ */
350
+ report: (entityId, state, reportOptions) => {
351
+ reports.push({
352
+ entityId,
353
+ state,
354
+ ...reportOptions === void 0 ? {} : { options: reportOptions }
355
+ });
356
+ },
357
+ /**
358
+ * Takes the handler of commands.
359
+ *
360
+ * @param handler - The handler.
361
+ * @returns What removes it.
362
+ * @throws {Error} When there is one already.
363
+ */
364
+ onCommand: (handler) => {
365
+ if (commandHandler !== void 0) throw new Error("devices.onCommand has a handler already");
366
+ commandHandler = handler;
367
+ return () => {
368
+ commandHandler = void 0;
369
+ };
370
+ }
371
+ },
372
+ /**
373
+ * The entities of one of the package's entity types.
374
+ *
375
+ * @param type - The entity type.
376
+ * @returns Their ids, reports and commands.
377
+ */
378
+ custom: (type) => ({
379
+ /**
380
+ * The id of one of its entities of this type.
381
+ *
382
+ * @param objectId - What follows the prefix.
383
+ * @returns The id.
384
+ */
385
+ entityId: (objectId) => `custom.${prefix}${objectId}`,
386
+ /**
387
+ * Records a report of an entity's values.
388
+ *
389
+ * @param entityId - The entity.
390
+ * @param entityValues - Its values.
391
+ * @param reportOptions - Its attributes and availability.
392
+ */
393
+ report: (entityId, entityValues, reportOptions) => {
394
+ reports.push({
395
+ entityId,
396
+ state: { custom: { values: Object.fromEntries(Object.entries(entityValues).filter((entry) => isCustomValue(entry[1]))) } },
397
+ ...reportOptions === void 0 ? {} : { options: reportOptions }
398
+ });
399
+ },
400
+ /**
401
+ * Takes the handler of one of the type's commands.
402
+ *
403
+ * @param command - The command's id.
404
+ * @param handler - The handler.
405
+ * @returns What removes it.
406
+ * @throws {Error} When the command has one already.
407
+ */
408
+ onCommand: (command, handler) => {
409
+ const key = `${type.id}:${command}`;
410
+ /**
411
+ * Says whether a command's arguments are its own: the platform checked them against
412
+ * the entity type before routing it.
413
+ *
414
+ * @param args - The arguments.
415
+ * @returns True for a record.
416
+ */
417
+ const isArgs = (args) => isRecord(args);
418
+ if (customHandlers.has(key)) throw new Error(`${type.id} has a handler for ${command} already`);
419
+ customHandlers.set(key, (call) => {
420
+ if (!isArgs(call.args)) throw new Error(`${command} was sent without arguments`);
421
+ return handler({
422
+ entityId: call.entityId,
423
+ objectId: call.objectId,
424
+ args: call.args
425
+ });
426
+ });
427
+ return () => {
428
+ customHandlers.delete(key);
429
+ };
430
+ }
431
+ }),
432
+ entities: {
433
+ /**
434
+ * One entity of the house.
435
+ *
436
+ * @param entityId - The entity.
437
+ * @returns It, or undefined.
438
+ */
439
+ get: async (entityId) => Promise.resolve(entities.get(entityId)),
440
+ /**
441
+ * The entities a filter names.
442
+ *
443
+ * @param filter - Which.
444
+ * @returns Them.
445
+ */
446
+ list: async (filter = {}) => Promise.resolve([...entities.values()].filter((each) => matches(filter, each.entityId))),
447
+ /**
448
+ * Hears changes of the entities a filter names.
449
+ *
450
+ * @param filter - Which.
451
+ * @param handler - What hears them.
452
+ * @returns What stops it hearing.
453
+ */
454
+ subscribe: (filter, handler) => {
455
+ const subscription = {
456
+ filter,
457
+ handler
458
+ };
459
+ subscriptions.add(subscription);
460
+ return () => {
461
+ subscriptions.delete(subscription);
462
+ };
463
+ }
464
+ },
465
+ rpc: { method: rpcMethod },
466
+ services: {
467
+ provide,
468
+ call: callService
469
+ }
470
+ },
471
+ /**
472
+ * The last list of devices.
473
+ *
474
+ * @returns It.
475
+ */
476
+ synced: () => synced,
477
+ reports: {
478
+ all: reports,
479
+ /**
480
+ * The newest state of one entity.
481
+ *
482
+ * @param entityId - The entity.
483
+ * @returns Its state.
484
+ */
485
+ last: (entityId) => reports.findLast((each) => each.entityId === entityId)?.state
486
+ },
487
+ logs,
488
+ /**
489
+ * Routes a command.
490
+ *
491
+ * @param command - The command.
492
+ * @throws {Error} When nothing handles it.
493
+ */
494
+ sendCommand: async (command) => {
495
+ if (command.capability === "custom") {
496
+ const objectId = objectIdOf(command.entityId) ?? "";
497
+ const entity = synced?.flatMap((device) => device.entities).find((each) => "type" in each && each.objectId === objectId);
498
+ const typeId = entity !== void 0 && "type" in entity ? entity.type.id : void 0;
499
+ const handler = customHandlers.get(`${typeId ?? ""}:${command.command.command}`);
500
+ if (handler !== void 0) {
501
+ await handler({
502
+ entityId: command.entityId,
503
+ objectId,
504
+ args: command.command.args
505
+ });
506
+ return;
507
+ }
508
+ }
509
+ if (commandHandler === void 0) throw new Error(`Nothing handles commands to ${command.entityId}`);
510
+ await commandHandler(command);
511
+ },
512
+ /**
513
+ * Calls a method.
514
+ *
515
+ * @param method - The method.
516
+ * @param input - What it is handed.
517
+ * @param caller - Who calls.
518
+ * @returns What it answered.
519
+ * @throws {Error} When there is no such method, or it refuses the input.
520
+ */
521
+ callRpc: async (method, input, caller = TEST_CALLER) => {
522
+ const registered = methods.get(method);
523
+ if (registered === void 0) throw new Error(`There is no method ${method}`);
524
+ if (registered.input === void 0) return registered.handler(input, { caller });
525
+ const checked = await check(registered.input, input);
526
+ if (!checked.isValid) throw new Error(`${method} refused its input: ${checked.message}`);
527
+ return registered.handler(checked.value, { caller });
528
+ },
529
+ /**
530
+ * Calls a service the package provides.
531
+ *
532
+ * @param name - The service.
533
+ * @param input - What it is handed.
534
+ * @param caller - Who calls.
535
+ * @returns What it answered.
536
+ * @throws {Error} When there is no such service, or it refuses the input.
537
+ */
538
+ callService: async (name, input, caller = "test") => {
539
+ const service = services.get(name);
540
+ if (service === void 0) throw new Error(`The package provides no service ${name}`);
541
+ if (service.input === void 0) return service.handler(input, { caller });
542
+ const checked = await check(service.input, input);
543
+ if (!checked.isValid) throw new Error(`${name} refused its input: ${checked.message}`);
544
+ return service.handler(checked.value, { caller });
545
+ },
546
+ /**
547
+ * Changes settings.
548
+ *
549
+ * @param patch - What changes.
550
+ * @returns Whether the platform would restart the package.
551
+ */
552
+ changeSettings: async (patch) => {
553
+ const changed = Object.keys(patch).filter((key) => patch[key] !== values[key]);
554
+ values = {
555
+ ...values,
556
+ ...patch
557
+ };
558
+ if (changed.some((key) => fields.get(key)?.["isLive"] !== true)) return { isRestart: true };
559
+ /**
560
+ * Says whether the keys changed are the settings': they are the patch's own.
561
+ *
562
+ * @param keys - The keys.
563
+ * @returns True.
564
+ */
565
+ const areKeys = (keys) => Array.isArray(keys);
566
+ if (areKeys(changed)) for (const handler of settingsHandlers) await handler({
567
+ values: settingsNow(),
568
+ changed
569
+ });
570
+ return { isRestart: false };
571
+ },
572
+ /**
573
+ * Puts an entity of the house.
574
+ *
575
+ * @param snapshot - The entity.
576
+ */
577
+ setEntity: (snapshot) => {
578
+ entities.set(snapshot.entityId, snapshot);
579
+ },
580
+ /**
581
+ * Changes an entity of the house.
582
+ *
583
+ * @param change - The change.
584
+ */
585
+ emitStateChange: async (change) => {
586
+ entities.set(change.entityId, change.current);
587
+ for (const subscription of subscriptions) if (matches(subscription.filter, change.entityId)) await subscription.handler(change);
588
+ },
589
+ /**
590
+ * Answers a service the package calls.
591
+ *
592
+ * @param target - The package or service.
593
+ * @param name - The service.
594
+ * @param handler - What it answers.
595
+ */
596
+ answerService: (target, name, handler) => {
597
+ answers.set(`${target}.${name}`, handler);
598
+ },
599
+ /**
600
+ * Asks the package to stop.
601
+ */
602
+ stop: () => {
603
+ abort.abort();
604
+ }
605
+ };
606
+ }
607
+ //#endregion
608
+ export { createTestContext };