@thinremote/thinr-cli 1.1.0 → 1.2.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.
Files changed (92) hide show
  1. package/CHANGELOG.md +71 -0
  2. package/README.md +230 -16
  3. package/bin/thinr.js +23 -24
  4. package/commands/_shared.js +154 -0
  5. package/commands/dashboard.js +17 -0
  6. package/commands/device/_shared.js +17 -34
  7. package/commands/device/console.js +1 -0
  8. package/commands/device/delete.js +53 -0
  9. package/commands/device/exec.js +7 -6
  10. package/commands/device/fs.js +193 -0
  11. package/commands/device/index.js +9 -0
  12. package/commands/device/list.js +35 -23
  13. package/commands/device/logs.js +178 -0
  14. package/commands/device/property.js +30 -28
  15. package/commands/device/proxy.js +3 -0
  16. package/commands/device/resource.js +30 -29
  17. package/commands/device/status.js +35 -14
  18. package/commands/device/transfer.js +236 -0
  19. package/commands/device/update.js +15 -10
  20. package/commands/fleet/index.js +17 -0
  21. package/commands/fleet/upgrade.js +275 -0
  22. package/commands/logout.js +10 -11
  23. package/commands/playbook/_shared.js +12 -0
  24. package/commands/playbook/index.js +18 -0
  25. package/commands/playbook/run.js +302 -0
  26. package/commands/playbook/validate.js +38 -0
  27. package/commands/product/_shared.js +8 -0
  28. package/commands/product/exec.js +243 -0
  29. package/commands/product/fs.js +218 -0
  30. package/commands/product/index.js +35 -0
  31. package/commands/product/list.js +86 -0
  32. package/commands/product/logs.js +289 -0
  33. package/commands/product/metric.js +124 -0
  34. package/commands/product/playbook.js +911 -0
  35. package/commands/product/property.js +76 -0
  36. package/commands/product/props.js +125 -0
  37. package/commands/product/push.js +184 -0
  38. package/commands/product/resource.js +76 -0
  39. package/commands/product/status.js +213 -0
  40. package/commands/profile.js +76 -8
  41. package/dist/dashboard.js +5644 -0
  42. package/lib/agent-releases.js +59 -0
  43. package/lib/agent-versions.js +73 -0
  44. package/lib/alarms.js +252 -0
  45. package/lib/api.js +8 -2
  46. package/lib/auth.js +30 -17
  47. package/lib/bucket.js +87 -0
  48. package/lib/command-stream.js +199 -0
  49. package/lib/concurrency.js +52 -0
  50. package/lib/config.js +5 -3
  51. package/lib/console.js +12 -4
  52. package/lib/constants.js +76 -0
  53. package/lib/dashboard/event-stream.js +239 -0
  54. package/lib/dashboard/metric-aggregate.js +64 -0
  55. package/lib/dashboard/metric-filter.js +39 -0
  56. package/lib/debug-log.js +93 -0
  57. package/lib/device-api.js +109 -147
  58. package/lib/devices.js +18 -21
  59. package/lib/fleet-upgrade.js +106 -0
  60. package/lib/format.js +114 -0
  61. package/lib/mcp/registry.js +14 -0
  62. package/lib/mcp/tools-alarms.js +510 -0
  63. package/lib/mcp/tools-buckets.js +103 -0
  64. package/lib/mcp/tools-devices.js +33 -3
  65. package/lib/mcp/tools-exec.js +30 -17
  66. package/lib/mcp/tools-filesystem.js +102 -6
  67. package/lib/mcp/tools-install.js +153 -0
  68. package/lib/mcp/tools-logs.js +225 -0
  69. package/lib/mcp/tools-playbook.js +211 -0
  70. package/lib/mcp/tools-products.js +1343 -10
  71. package/lib/mcp/tools-profile.js +634 -0
  72. package/lib/mcp/tools-tokens.js +371 -0
  73. package/lib/monitoring.js +26 -1
  74. package/lib/playbook/actions.js +447 -0
  75. package/lib/playbook/expression.js +302 -0
  76. package/lib/playbook/fleet.js +175 -0
  77. package/lib/playbook/loader.js +247 -0
  78. package/lib/playbook/runner.js +300 -0
  79. package/lib/playbook/schema.js +209 -0
  80. package/lib/playbook/vars.js +209 -0
  81. package/lib/product/log-presets.js +196 -0
  82. package/lib/product/logs.js +485 -0
  83. package/lib/product-orchestrator.js +124 -0
  84. package/lib/product.js +523 -20
  85. package/lib/profile.js +665 -0
  86. package/lib/property.js +7 -40
  87. package/lib/proxy.js +9 -8
  88. package/lib/resource.js +10 -14
  89. package/lib/status.js +78 -37
  90. package/lib/tokens.js +374 -0
  91. package/package.json +15 -3
  92. package/commands/product.js +0 -167
package/lib/profile.js ADDED
@@ -0,0 +1,665 @@
1
+ // @ts-check
2
+ import api from './api.js';
3
+ import { apiError, inputError } from './errors.js';
4
+ import { requireConfig } from './config.js';
5
+
6
+ /**
7
+ * Helpers for product *profile* configuration.
8
+ *
9
+ * Concept primer
10
+ * ──────────────
11
+ * A product `profile` is the server-side wiring of a product. It bundles
12
+ * the four declarative subsystems the platform consumes when a device of
13
+ * that product emits or receives data:
14
+ *
15
+ * - `profile.api.<name>` — REST API resources callable on every
16
+ * device of the product (or invoked by
17
+ * a script). Pick a `target` and the
18
+ * platform proxies the request there.
19
+ * - `profile.buckets.<name>` — auto-managed time-series buckets fed
20
+ * from a declared source (`resource`,
21
+ * `resource_stream`, `product_stream`,
22
+ * `topic`, `event`).
23
+ * - `profile.properties.<name>` — auto-managed device properties fed
24
+ * from the same family of sources, with
25
+ * optional JSON-merge semantics via
26
+ * `data.patch`.
27
+ * - `profile.code` — script-storage link (handled
28
+ * elsewhere; not exposed here).
29
+ *
30
+ * REST endpoints these helpers wrap:
31
+ * - GET /v1/users/{user}/products/{p}/profile/api
32
+ * - PUT /v1/users/{user}/products/{p}/profile/api/{name}
33
+ * - GET /v1/users/{user}/products/{p}/profile/api/{name}
34
+ * - DELETE /v1/users/{user}/products/{p}/profile/api/{name}
35
+ * - …same shape for /buckets and /properties.
36
+ *
37
+ * IMPORTANT: do NOT confuse `profile/properties/<name>` with
38
+ * `/products/{p}/properties/{name}`. The former is a *handler* that fans
39
+ * incoming source data into device properties; the latter (handled by
40
+ * `getProductProperty`/`setProductProperty` in `product.js`) is a JSON
41
+ * value attached to the product itself. Same word, completely different
42
+ * concept — the helpers in this file consistently use the
43
+ * `getProductProfileProperty` naming to keep callers honest.
44
+ *
45
+ * Canonical end-to-end use case
46
+ * ─────────────────────────────
47
+ * Fan-out a `backup_complete` stream resource → `backups` bucket
48
+ * (history) and `backups` device property (last-known):
49
+ *
50
+ * 1. setProductApiResource(p, 'backup_complete', user, {
51
+ * enabled: true,
52
+ * request: { data: { target: 'resource_stream', resource_stream: 'backup_complete', payload_type: 'source_payload', payload: '' } },
53
+ * response: { data: { payload_type: 'none' } },
54
+ * })
55
+ * 2. setProductBucket(p, 'backups', user, {
56
+ * enabled: true, name: 'Backups', backend: 'mongodb',
57
+ * retention: { period: 1, unit: 'years' },
58
+ * data: { source: 'resource_stream', resource_stream: 'backup_complete', payload_type: 'source_payload', payload: '{{payload}}' },
59
+ * })
60
+ * 3. setProductProfileProperty(p, 'backups', user, {
61
+ * enabled: true, name: 'Backups',
62
+ * data: { source: 'resource_stream', resource_stream: 'backup_complete', payload_type: 'source_payload', payload: '{{payload}}' },
63
+ * })
64
+ *
65
+ * After this, every device of the product can call its own `backup_complete`
66
+ * stream resource. The platform fans the payload out to the bucket (history)
67
+ * and to the device property `backups` (last-known) automatically — no
68
+ * device code change.
69
+ */
70
+
71
+ function resolveUser(user) {
72
+ return user || requireConfig().username;
73
+ }
74
+
75
+ function v1(user) {
76
+ return `/v1/users/${resolveUser(user)}`;
77
+ }
78
+
79
+ // ─── Listener config validators ──────────────────────────────────────
80
+ //
81
+ // Each `data.source` shape (for buckets/properties) and each request
82
+ // `target` (for API resources) demands a matching key in the data block.
83
+ // We enforce these client-side so the caller gets a clear error long
84
+ // before the server returns a generic 400.
85
+
86
+ /** Sources accepted by `profile.buckets[*].data.source` and `profile.properties[*].data.source`. */
87
+ export const PROFILE_DATA_SOURCES = ['resource', 'resource_stream', 'product_stream', 'topic', 'event'];
88
+
89
+ /** Targets accepted by `profile.api[*].request.data.target`. */
90
+ export const PROFILE_API_TARGETS = [
91
+ 'resource',
92
+ 'resource_stream',
93
+ 'product_stream',
94
+ 'property',
95
+ 'function',
96
+ 'endpoint',
97
+ 'topic',
98
+ ];
99
+
100
+ /** Payload types recognised by the platform on both request and response sides. */
101
+ export const PROFILE_PAYLOAD_TYPES = [
102
+ 'template_payload',
103
+ 'source_payload',
104
+ 'source_event',
105
+ 'none',
106
+ ];
107
+
108
+ /** Required key in `source_args` for each `data.source` value. */
109
+ const SOURCE_KEY = {
110
+ resource: 'resource',
111
+ resource_stream: 'resource_stream',
112
+ product_stream: 'product_stream',
113
+ topic: 'topic',
114
+ event: 'event',
115
+ };
116
+
117
+ /** Required key in `target_args` for each `request.data.target` value. */
118
+ const TARGET_KEY = {
119
+ resource: 'resource',
120
+ resource_stream: 'resource_stream',
121
+ property: 'property',
122
+ function: 'function',
123
+ endpoint: 'endpoint',
124
+ topic: 'topic',
125
+ // product_stream needs BOTH `product` and `product_stream` — handled inline.
126
+ product_stream: 'product_stream',
127
+ };
128
+
129
+ function assertValidSource(source) {
130
+ if (!PROFILE_DATA_SOURCES.includes(source)) {
131
+ throw inputError(
132
+ `Invalid source "${source}". Allowed: ${PROFILE_DATA_SOURCES.join(', ')}.`,
133
+ );
134
+ }
135
+ }
136
+
137
+ function assertValidTarget(target) {
138
+ if (!PROFILE_API_TARGETS.includes(target)) {
139
+ throw inputError(
140
+ `Invalid target "${target}". Allowed: ${PROFILE_API_TARGETS.join(', ')}.`,
141
+ );
142
+ }
143
+ }
144
+
145
+ function assertValidPayloadType(value, label = 'payload_type') {
146
+ if (value === undefined || value === null) return;
147
+ if (!PROFILE_PAYLOAD_TYPES.includes(value)) {
148
+ throw inputError(
149
+ `Invalid ${label} "${value}". Allowed: ${PROFILE_PAYLOAD_TYPES.join(', ')}.`,
150
+ );
151
+ }
152
+ }
153
+
154
+ /**
155
+ * Build the `data` block for a bucket or property listener from the
156
+ * caller's source + source_args + payload bits. Returns a plain object
157
+ * the server will accept verbatim.
158
+ *
159
+ * @param {{
160
+ * source: string,
161
+ * source_args?: Record<string, unknown>,
162
+ * payload_type?: string,
163
+ * payload?: string,
164
+ * payload_function?: string,
165
+ * patch?: boolean,
166
+ * }} opts
167
+ */
168
+ export function buildListenerDataBlock(opts) {
169
+ const source = opts.source;
170
+ assertValidSource(source);
171
+ const args = opts.source_args || {};
172
+
173
+ const data = { source };
174
+
175
+ const requiredKey = SOURCE_KEY[source];
176
+ if (requiredKey) {
177
+ const value = args[requiredKey];
178
+ if (typeof value !== 'string' || !value) {
179
+ throw inputError(
180
+ `source "${source}" requires source_args.${requiredKey} to be a non-empty string`,
181
+ );
182
+ }
183
+ data[requiredKey] = value;
184
+ }
185
+
186
+ if (source === 'resource') {
187
+ // `resource` source supports an optional polling cadence. When
188
+ // omitted, the server defaults to event mode (interval = 0).
189
+ const update = args.update;
190
+ if (update !== undefined) {
191
+ if (update !== 'interval' && update !== 'event') {
192
+ throw inputError(
193
+ `source_args.update must be "interval" or "event" (got "${update}")`,
194
+ );
195
+ }
196
+ data.update = update;
197
+ }
198
+ if (args.interval !== undefined) {
199
+ const n = Number(args.interval);
200
+ if (!Number.isFinite(n) || n < 0) {
201
+ throw inputError('source_args.interval must be a non-negative number');
202
+ }
203
+ data.interval = n;
204
+ }
205
+ if (args.magnitude !== undefined) {
206
+ data.magnitude = String(args.magnitude);
207
+ }
208
+ }
209
+
210
+ if (source === 'product_stream') {
211
+ // The bucket/property listener listens on the *current* product's
212
+ // stream by default. A foreign-product listener is unusual — the
213
+ // server checks `data.product` only when present. Pass it through.
214
+ if (args.product !== undefined) {
215
+ if (typeof args.product !== 'string' || !args.product) {
216
+ throw inputError('source_args.product must be a non-empty string');
217
+ }
218
+ data.product = args.product;
219
+ }
220
+ }
221
+
222
+ if (source === 'event' && args.filter !== undefined) {
223
+ if (typeof args.filter !== 'object' || Array.isArray(args.filter)) {
224
+ throw inputError('source_args.filter must be an object');
225
+ }
226
+ data.filter = args.filter;
227
+ }
228
+
229
+ // Payload shape — common to every listener kind.
230
+ assertValidPayloadType(opts.payload_type);
231
+ if (opts.payload_type !== undefined) data.payload_type = opts.payload_type;
232
+ if (opts.payload !== undefined) {
233
+ if (typeof opts.payload !== 'string') {
234
+ throw inputError('payload must be a string (template or "{{payload}}")');
235
+ }
236
+ data.payload = opts.payload;
237
+ }
238
+ if (opts.payload_function !== undefined) {
239
+ if (typeof opts.payload_function !== 'string') {
240
+ throw inputError('payload_function must be a string');
241
+ }
242
+ data.payload_function = opts.payload_function;
243
+ }
244
+
245
+ // Property handler flag — only meaningful for property listeners but
246
+ // accepted on the base helper for symmetry; the bucket handler simply
247
+ // ignores it.
248
+ if (opts.patch !== undefined) {
249
+ data.patch = !!opts.patch;
250
+ }
251
+
252
+ return data;
253
+ }
254
+
255
+ /**
256
+ * Build the `request.data` block for a profile API resource.
257
+ *
258
+ * @param {{
259
+ * target: string,
260
+ * target_args?: Record<string, unknown>,
261
+ * resource_name?: string,
262
+ * payload_type?: string,
263
+ * payload?: string,
264
+ * payload_function?: string,
265
+ * }} opts
266
+ */
267
+ export function buildApiRequestData(opts) {
268
+ const target = opts.target;
269
+ assertValidTarget(target);
270
+ const args = opts.target_args || {};
271
+ const data = { target };
272
+
273
+ if (target === 'product_stream') {
274
+ if (typeof args.product !== 'string' || !args.product) {
275
+ throw inputError(
276
+ 'target "product_stream" requires target_args.product (the destination product id)',
277
+ );
278
+ }
279
+ if (typeof args.product_stream !== 'string' || !args.product_stream) {
280
+ throw inputError(
281
+ 'target "product_stream" requires target_args.product_stream (the destination stream name)',
282
+ );
283
+ }
284
+ data.product = args.product;
285
+ data.product_stream = args.product_stream;
286
+ } else if (target === 'resource_stream') {
287
+ // resource_stream defaults to the API resource name itself, so the
288
+ // caller can omit target_args entirely for a self-named stream.
289
+ const stream = args.resource_stream || opts.resource_name;
290
+ if (!stream) {
291
+ throw inputError(
292
+ 'target "resource_stream" needs target_args.resource_stream (defaults to the API resource name when omitted)',
293
+ );
294
+ }
295
+ data.resource_stream = stream;
296
+ } else {
297
+ const requiredKey = TARGET_KEY[target];
298
+ if (requiredKey) {
299
+ const value = args[requiredKey];
300
+ if (typeof value !== 'string' || !value) {
301
+ throw inputError(
302
+ `target "${target}" requires target_args.${requiredKey} to be a non-empty string`,
303
+ );
304
+ }
305
+ data[requiredKey] = value;
306
+ }
307
+ }
308
+
309
+ assertValidPayloadType(opts.payload_type);
310
+ // template_payload is the platform default. The frontend encodes it
311
+ // as an empty string when round-tripping, but we accept the explicit
312
+ // value here too and pass it through unchanged.
313
+ if (opts.payload_type !== undefined) data.payload_type = opts.payload_type;
314
+ if (opts.payload !== undefined) {
315
+ if (typeof opts.payload !== 'string') {
316
+ throw inputError('payload must be a string');
317
+ }
318
+ data.payload = opts.payload;
319
+ }
320
+ if (opts.payload_function !== undefined) {
321
+ if (typeof opts.payload_function !== 'string') {
322
+ throw inputError('payload_function must be a string');
323
+ }
324
+ data.payload_function = opts.payload_function;
325
+ }
326
+
327
+ return data;
328
+ }
329
+
330
+ /**
331
+ * Build the `response.data` block for a profile API resource. `response`
332
+ * is optional on the wire; this helper produces a sensible default of
333
+ * `{ payload_type: 'none' }` when the caller wants a fire-and-forget
334
+ * resource, which matches the stream-style shape used by the
335
+ * `backup_complete` example.
336
+ *
337
+ * @param {{
338
+ * payload_type?: string,
339
+ * source?: string,
340
+ * payload?: string,
341
+ * payload_function?: string,
342
+ * } | undefined | null} response
343
+ */
344
+ export function buildApiResponseData(response) {
345
+ if (!response) return { payload_type: 'none' };
346
+ const data = {};
347
+ assertValidPayloadType(response.payload_type, 'response.payload_type');
348
+ if (response.payload_type !== undefined) data.payload_type = response.payload_type;
349
+ if (response.source !== undefined) {
350
+ if (typeof response.source !== 'string') {
351
+ throw inputError('response.source must be a string');
352
+ }
353
+ data.source = response.source;
354
+ }
355
+ if (response.payload !== undefined) {
356
+ if (typeof response.payload !== 'string') {
357
+ throw inputError('response.payload must be a string');
358
+ }
359
+ data.payload = response.payload;
360
+ }
361
+ if (response.payload_function !== undefined) {
362
+ if (typeof response.payload_function !== 'string') {
363
+ throw inputError('response.payload_function must be a string');
364
+ }
365
+ data.payload_function = response.payload_function;
366
+ }
367
+ if (Object.keys(data).length === 0) {
368
+ return { payload_type: 'none' };
369
+ }
370
+ return data;
371
+ }
372
+
373
+ // ─── Low-level: profile/api/<name> ───────────────────────────────────
374
+
375
+ /** Returns the `{ resourceName: definition }` map; `{}` when none. */
376
+ export async function getProductApiResources(productId, user) {
377
+ try {
378
+ const res = await api.get(`${v1(user)}/products/${productId}/profile/api`);
379
+ return res.data || {};
380
+ } catch (e) {
381
+ if (e.response?.status === 404) return {};
382
+ throw apiError(e);
383
+ }
384
+ }
385
+
386
+ export async function getProductApiResource(productId, name, user) {
387
+ try {
388
+ const res = await api.get(
389
+ `${v1(user)}/products/${productId}/profile/api/${name}`,
390
+ );
391
+ return res.data || {};
392
+ } catch (e) {
393
+ throw apiError(e, {
394
+ notFound: `API resource not found: ${name} on product ${productId}`,
395
+ });
396
+ }
397
+ }
398
+
399
+ /**
400
+ * Upsert a profile API resource by passing the full `{request, response, ...}`
401
+ * document straight through. Use `setProfileApiResource` (below) for the
402
+ * higher-level builder that takes target/target_args/payload* fields and
403
+ * assembles the document for you.
404
+ */
405
+ export async function setProductApiResource(productId, name, user, payload) {
406
+ try {
407
+ await api.put(
408
+ `${v1(user)}/products/${productId}/profile/api/${name}`,
409
+ payload,
410
+ );
411
+ } catch (e) {
412
+ throw apiError(e);
413
+ }
414
+ }
415
+
416
+ export async function deleteProductApiResource(productId, name, user) {
417
+ try {
418
+ await api.delete(
419
+ `${v1(user)}/products/${productId}/profile/api/${name}`,
420
+ );
421
+ return true;
422
+ } catch (e) {
423
+ if (e.response?.status === 404) return false;
424
+ throw apiError(e);
425
+ }
426
+ }
427
+
428
+ /**
429
+ * Upsert a profile API resource using the structured target/payload shape.
430
+ * Lets callers configure any of the platform's dispatch targets without
431
+ * hand-rolling the JSON. Required fields per target are validated client-
432
+ * side: missing `target_args.resource` for `target: "resource"` raises
433
+ * `inputError` instead of waiting for a 400 from the server.
434
+ *
435
+ * @param {string} productId
436
+ * @param {string} name API resource name (becomes `profile.api.<name>`).
437
+ * @param {string|null|undefined} user
438
+ * @param {{
439
+ * target: string,
440
+ * target_args?: Record<string, unknown>,
441
+ * payload_type?: string,
442
+ * payload?: string,
443
+ * payload_function?: string,
444
+ * response?: {
445
+ * payload_type?: string,
446
+ * source?: string,
447
+ * payload?: string,
448
+ * payload_function?: string,
449
+ * } | null,
450
+ * enabled?: boolean,
451
+ * }} opts
452
+ */
453
+ export async function setProfileApiResource(productId, name, user, opts) {
454
+ if (!productId) throw inputError('productId is required');
455
+ if (!name) throw inputError('name is required');
456
+ const requestData = buildApiRequestData({ ...opts, resource_name: name });
457
+ const responseData = buildApiResponseData(opts.response);
458
+ const payload = {
459
+ enabled: opts.enabled !== false,
460
+ request: { data: requestData },
461
+ response: { data: responseData },
462
+ };
463
+ await setProductApiResource(productId, name, user, payload);
464
+ return payload;
465
+ }
466
+
467
+ // ─── Low-level: profile/buckets/<name> ───────────────────────────────
468
+
469
+ export async function getProductBuckets(productId, user) {
470
+ try {
471
+ const res = await api.get(`${v1(user)}/products/${productId}/profile/buckets`);
472
+ return res.data || {};
473
+ } catch (e) {
474
+ if (e.response?.status === 404) return {};
475
+ throw apiError(e);
476
+ }
477
+ }
478
+
479
+ export async function getProductBucket(productId, name, user) {
480
+ try {
481
+ const res = await api.get(
482
+ `${v1(user)}/products/${productId}/profile/buckets/${name}`,
483
+ );
484
+ return res.data || {};
485
+ } catch (e) {
486
+ throw apiError(e, {
487
+ notFound: `Profile bucket not found: ${name} on product ${productId}`,
488
+ });
489
+ }
490
+ }
491
+
492
+ export async function setProductBucket(productId, name, user, payload) {
493
+ try {
494
+ await api.put(
495
+ `${v1(user)}/products/${productId}/profile/buckets/${name}`,
496
+ payload,
497
+ );
498
+ } catch (e) {
499
+ throw apiError(e);
500
+ }
501
+ }
502
+
503
+ export async function deleteProductBucket(productId, name, user) {
504
+ try {
505
+ await api.delete(
506
+ `${v1(user)}/products/${productId}/profile/buckets/${name}`,
507
+ );
508
+ return true;
509
+ } catch (e) {
510
+ if (e.response?.status === 404) return false;
511
+ throw apiError(e);
512
+ }
513
+ }
514
+
515
+ const RETENTION_UNITS = ['minutes', 'hours', 'days', 'months', 'years'];
516
+
517
+ function buildRetention(retention) {
518
+ if (retention === undefined || retention === null) return undefined;
519
+ if (typeof retention !== 'object' || Array.isArray(retention)) {
520
+ throw inputError('retention must be an object { period, unit }');
521
+ }
522
+ const period = Number(retention.period);
523
+ if (!Number.isFinite(period) || period <= 0) {
524
+ throw inputError('retention.period must be a positive number');
525
+ }
526
+ const unit = retention.unit;
527
+ if (!RETENTION_UNITS.includes(unit)) {
528
+ throw inputError(
529
+ `retention.unit must be one of: ${RETENTION_UNITS.join(', ')}`,
530
+ );
531
+ }
532
+ return { period, unit };
533
+ }
534
+
535
+ /**
536
+ * Upsert a profile bucket using the structured source/payload shape. The
537
+ * server creates / migrates the underlying time-series bucket lazily on
538
+ * the first event delivery (see `product_bucket_handler::initialize_bucket`).
539
+ *
540
+ * @param {string} productId
541
+ * @param {string} name
542
+ * @param {string|null|undefined} user
543
+ * @param {{
544
+ * source: string,
545
+ * source_args?: Record<string, unknown>,
546
+ * payload_type?: string,
547
+ * payload?: string,
548
+ * payload_function?: string,
549
+ * name_label?: string,
550
+ * description?: string,
551
+ * enabled?: boolean,
552
+ * backend?: string,
553
+ * retention?: { period: number, unit: string },
554
+ * tags?: string[],
555
+ * }} opts
556
+ */
557
+ export async function setProfileBucket(productId, name, user, opts) {
558
+ if (!productId) throw inputError('productId is required');
559
+ if (!name) throw inputError('name is required');
560
+
561
+ const data = buildListenerDataBlock(opts);
562
+ const payload = {
563
+ enabled: opts.enabled !== false,
564
+ name: opts.name_label || name,
565
+ description: opts.description || `Bucket for ${productId} product`,
566
+ backend: opts.backend || 'mongodb',
567
+ data,
568
+ tags: Array.isArray(opts.tags) ? opts.tags : [],
569
+ };
570
+ const retention = buildRetention(opts.retention);
571
+ if (retention) payload.retention = retention;
572
+
573
+ await setProductBucket(productId, name, user, payload);
574
+ return payload;
575
+ }
576
+
577
+ // ─── Low-level: profile/properties/<name> ────────────────────────────
578
+ //
579
+ // NOTE: `getProductProfileProperty` ≠ `getProductProperty`. The former
580
+ // lives under `profile/properties/<name>` and configures a *handler* that
581
+ // fans listener output into device properties. The latter (in product.js)
582
+ // reads a JSON value attached to the product itself. See file header.
583
+
584
+ export async function getProductProfileProperties(productId, user) {
585
+ try {
586
+ const res = await api.get(`${v1(user)}/products/${productId}/profile/properties`);
587
+ return res.data || {};
588
+ } catch (e) {
589
+ if (e.response?.status === 404) return {};
590
+ throw apiError(e);
591
+ }
592
+ }
593
+
594
+ export async function getProductProfileProperty(productId, name, user) {
595
+ try {
596
+ const res = await api.get(
597
+ `${v1(user)}/products/${productId}/profile/properties/${name}`,
598
+ );
599
+ return res.data || {};
600
+ } catch (e) {
601
+ throw apiError(e, {
602
+ notFound: `Profile property not found: ${name} on product ${productId}`,
603
+ });
604
+ }
605
+ }
606
+
607
+ export async function setProductProfileProperty(productId, name, user, payload) {
608
+ try {
609
+ await api.put(
610
+ `${v1(user)}/products/${productId}/profile/properties/${name}`,
611
+ payload,
612
+ );
613
+ } catch (e) {
614
+ throw apiError(e);
615
+ }
616
+ }
617
+
618
+ export async function deleteProductProfileProperty(productId, name, user) {
619
+ try {
620
+ await api.delete(
621
+ `${v1(user)}/products/${productId}/profile/properties/${name}`,
622
+ );
623
+ return true;
624
+ } catch (e) {
625
+ if (e.response?.status === 404) return false;
626
+ throw apiError(e);
627
+ }
628
+ }
629
+
630
+ /**
631
+ * Upsert a profile property handler using the structured source/payload
632
+ * shape. When `patch: true` is passed, the platform applies the resulting
633
+ * value as a JSON merge-patch on top of the existing device property
634
+ * instead of replacing it wholesale (see
635
+ * `product_property_handler::set_config` for the `data.patch` flag).
636
+ *
637
+ * @param {string} productId
638
+ * @param {string} name
639
+ * @param {string|null|undefined} user
640
+ * @param {{
641
+ * source: string,
642
+ * source_args?: Record<string, unknown>,
643
+ * payload_type?: string,
644
+ * payload?: string,
645
+ * payload_function?: string,
646
+ * name_label?: string,
647
+ * description?: string,
648
+ * enabled?: boolean,
649
+ * patch?: boolean,
650
+ * }} opts
651
+ */
652
+ export async function setProfileProperty(productId, name, user, opts) {
653
+ if (!productId) throw inputError('productId is required');
654
+ if (!name) throw inputError('name is required');
655
+
656
+ const data = buildListenerDataBlock(opts);
657
+ const payload = {
658
+ enabled: opts.enabled !== false,
659
+ name: opts.name_label || name,
660
+ description: opts.description || `Property handler for ${productId} product`,
661
+ data,
662
+ };
663
+ await setProductProfileProperty(productId, name, user, payload);
664
+ return payload;
665
+ }