@gotillit/tllt 0.3.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.
@@ -0,0 +1,86 @@
1
+ # Workflow: import → diff → deploy
2
+
3
+ The CLI manages configuration as files so you can review, edit, version, and
4
+ promote it between environments.
5
+
6
+ ## 1. import — snapshot live config to files
7
+
8
+ ```bash
9
+ tllt import --profile acme-prod
10
+ ```
11
+
12
+ Writes to `./{profile}/{api}/{Entity}/...` in the **current directory** (override
13
+ with `--dir <path>`), so the config can live in its own git repo. Volatile fields (`id`,
14
+ `createdAt`, `updatedAt`) are stripped, so a content difference always reflects
15
+ a real configuration change. Each leaf folder gets a `_meta.json` recording how
16
+ to route writes (api, microservice, endpoint, and for scheduler the dataTemplate).
17
+
18
+ ### Snapshot layout
19
+
20
+ ```
21
+ acme-prod/ # written into the current directory (or --dir)
22
+ do/
23
+ Asset/
24
+ _meta.json # routing: {api:"do", microservice:"core", endpoint:"assets", entity:"Asset"}
25
+ _schema.json # distilled, agent-readable contract (see schemas.md)
26
+ _schema.source.json # raw authoritative schema (DO dynamic-UI model)
27
+ Filler 1.json
28
+ ActivityTemplate/
29
+ Pre start check/
30
+ ActivityTemplateItem/
31
+ _meta.json # nested entities carry their own meta
32
+ CostImpact.json
33
+ scheduler/
34
+ DataTemplate/
35
+ Line A.json
36
+ Line A/ # scheduler config is nested per dataTemplate
37
+ Equipment/
38
+ _meta.json # {api:"scheduler", endpoint:"equipment", entity:"Equipment", dataTemplate:"Line A"}
39
+ Filler.json
40
+ ```
41
+
42
+ The **filename is the natural key** (the record's `name`, or a composite for
43
+ some entities). This is what `diff`/`deploy` match on across environments.
44
+ Sidecar files (any `_*.json`) are schema/routing metadata and are ignored by
45
+ `diff`/`deploy`.
46
+
47
+ ## 2. diff — compute a changeset
48
+
49
+ ```bash
50
+ tllt diff --from acme-stage --to acme-prod
51
+ ```
52
+
53
+ Produces the changes needed to make `to` look like `from`:
54
+
55
+ - **create** — in `from`, not in `to`
56
+ - **update** — in both, content differs
57
+ - **delete** — in `to`, not in `from`
58
+
59
+ Entity/endpoint routing for each change is resolved from the nearest `_meta.json`,
60
+ so nested entities are attributed correctly. Save with `--out plan.json`.
61
+
62
+ ## 3. deploy — apply the changeset
63
+
64
+ ```bash
65
+ tllt deploy --from acme-stage --to acme-prod --dry-run # preview
66
+ tllt deploy --from acme-stage --to acme-prod --yes # apply
67
+ tllt deploy --changeset plan.json --to acme-prod --yes # from a saved plan
68
+ ```
69
+
70
+ - `create` → `POST` the record. `update` → `PUT /{id}`. `delete` → `DELETE /{id}`.
71
+ - Target **ids are resolved by natural key**: deploy fetches the live collection
72
+ and matches on `name` (or `code`), since ids aren't in the snapshot.
73
+ - **Deletes require `--prune`** so a partial snapshot can't wipe live config.
74
+ - `--dry-run` needs no credentials (it only reads local snapshots).
75
+
76
+ ## Limitations to be aware of
77
+
78
+ - Update/delete matching is by `name`/`code`. Entities whose natural key is a
79
+ **composite** (e.g. `ActivityAssignment`, some activity sub-entities) may not
80
+ resolve a live id for update/delete; create still works.
81
+ - For **DO**, referential ordering isn't solved automatically — if a record
82
+ references another that doesn't exist yet on the target, that single change may
83
+ fail (reported in `applied[].error`); re-running after dependencies exist
84
+ resolves it. For **scheduler**, deploy applies changes in dependency order
85
+ (parents before children, deletes in reverse) and resolves references by natural
86
+ key, so ordering is handled within a single deploy.
package/import.js ADDED
@@ -0,0 +1,580 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+
4
+ import {resolveProfile} from './lib/config.js';
5
+ import {apiClient, fetchAsset} from './lib/client.js';
6
+ import {getApi} from './lib/apis.js';
7
+ import {distillSchema} from './lib/schema.js';
8
+ import {info, result} from './lib/output.js';
9
+ import {
10
+ SCHEDULER_ENTITIES,
11
+ expandParam,
12
+ toPortable,
13
+ matchKey,
14
+ scopeRows,
15
+ } from './lib/scheduler-entities.js';
16
+
17
+ // Re-exported so existing consumers (schema.js) keep importing it from here.
18
+ export {SCHEDULER_ENTITIES};
19
+
20
+ /**
21
+ * Where snapshots are written/read. Defaults to the current working directory
22
+ * (so config can live in its own git repo); override with --dir. Each profile
23
+ * is a subfolder: <baseDir>/<profile>/<api>/...
24
+ */
25
+ export function resolveBaseDir(opts = {}) {
26
+ return opts.dir ? path.resolve(opts.dir) : process.cwd();
27
+ }
28
+
29
+ /**
30
+ * DO (Digital Operations) entities to snapshot. Schema-driven: the microservice
31
+ * and endpoint are read from the published entity schema, and many-to-one
32
+ * relationships are expanded so the snapshot is self-describing.
33
+ */
34
+ export const DO_ENTITIES = [
35
+ // --- Asset model ---
36
+ {entityName: 'Asset', filter: {'active.equals': true}},
37
+ {entityName: 'AssetClass'},
38
+ {entityName: 'AssetProfile'},
39
+ {entityName: 'AssetStatusColor', fileNameField: (o) => o.status},
40
+ {
41
+ entityName: 'AssetAttribute',
42
+ folderName: 'asset',
43
+ parentFolder: 'Asset',
44
+ fileNameField: (o) => o.attribute?.name,
45
+ },
46
+ {
47
+ entityName: 'AssetTolerance',
48
+ folderName: 'asset',
49
+ parentFolder: 'Asset',
50
+ fileNameField: (o) => o.processVariable?.name,
51
+ },
52
+ {entityName: 'AssetMeterTemplate', folderName: 'asset', parentFolder: 'Asset'},
53
+
54
+ // --- Attributes & process variables ---
55
+ {entityName: 'Attribute'},
56
+ {entityName: 'AttributeGroup'},
57
+ {
58
+ entityName: 'AttributeGroupItem',
59
+ folderName: 'attributeGroup',
60
+ parentFolder: 'AttributeGroup',
61
+ fileNameField: (o) => o.attribute?.name,
62
+ },
63
+ {
64
+ entityName: 'AttributeGroupAssignment',
65
+ fileNameField: (o) =>
66
+ [
67
+ o.attributeGroup?.name,
68
+ o.asset?.name ?? o.material?.externalId ?? o.order?.orderNumber,
69
+ ]
70
+ .filter(Boolean)
71
+ .join(' - '),
72
+ },
73
+ {entityName: 'ProcessVariable'},
74
+ {entityName: 'UnitOfMeasure'},
75
+ {entityName: 'EventType'},
76
+
77
+ // --- Sites, calendars, shifts ---
78
+ {entityName: 'Site'},
79
+ {
80
+ entityName: 'SiteAttribute',
81
+ folderName: 'site',
82
+ parentFolder: 'Site',
83
+ fileNameField: (o) => o.attribute?.name,
84
+ },
85
+ {entityName: 'ShiftTemplate'},
86
+ {
87
+ entityName: 'ShiftTemplateItem',
88
+ folderName: 'shiftTemplate',
89
+ parentFolder: 'ShiftTemplate',
90
+ fileNameField: (o) => o.startTime,
91
+ },
92
+ {entityName: 'Calendar'},
93
+ {
94
+ entityName: 'CalendarItem',
95
+ folderName: 'calendar',
96
+ parentFolder: 'Calendar',
97
+ fileNameField: (o) =>
98
+ [o.shiftTemplate?.name, o.validFrom].filter(Boolean).join(' - '),
99
+ },
100
+
101
+ // --- Materials ---
102
+ {entityName: 'MaterialGroup'},
103
+ {entityName: 'MaterialProperty', fileNameField: (o) => o.externalId},
104
+ {entityName: 'Material', filter: {'active.equals': true}},
105
+ {
106
+ entityName: 'MaterialAttribute',
107
+ folderName: 'material',
108
+ parentFolder: 'Material',
109
+ fileNameField: (o) => o.attribute?.name,
110
+ },
111
+ {
112
+ entityName: 'MaterialComponent',
113
+ folderName: 'material',
114
+ parentFolder: 'Material',
115
+ fileNameField: (o) => o.component?.externalId ?? o.component?.name,
116
+ },
117
+ {
118
+ entityName: 'MaterialConversion',
119
+ folderName: 'material',
120
+ parentFolder: 'Material',
121
+ fileNameField: (o) => o.uom?.name,
122
+ },
123
+ {
124
+ entityName: 'MaterialTolerance',
125
+ folderName: 'material',
126
+ parentFolder: 'Material',
127
+ fileNameField: (o) => o.processVariable?.name,
128
+ },
129
+
130
+ // --- Inventory movements ---
131
+ {entityName: 'MovementType'},
132
+ {
133
+ entityName: 'MovementTypeField',
134
+ folderName: 'movementType',
135
+ parentFolder: 'MovementType',
136
+ },
137
+
138
+ // --- Alarms ---
139
+ {entityName: 'AlarmType', fileNameField: (o) => o.faultCode},
140
+ {
141
+ entityName: 'AlarmTypeAssignment',
142
+ folderName: 'alarmType',
143
+ parentFolder: 'AlarmType',
144
+ fileNameField: (o) => o.asset?.name,
145
+ },
146
+
147
+ // --- Edge & data sources ---
148
+ {entityName: 'DatasourceTemplate'},
149
+ {
150
+ entityName: 'DatasourceTemplateTag',
151
+ folderName: 'datasourceTemplate',
152
+ parentFolder: 'DatasourceTemplate',
153
+ fileNameField: (o) => o.processVariable?.name,
154
+ },
155
+ {
156
+ entityName: 'DatasourceTemplateTrigger',
157
+ fileNameField: (o) =>
158
+ [o.datasourceTemplateTag?.processVariable?.name, o.eventType?.name]
159
+ .filter(Boolean)
160
+ .join(' - '),
161
+ },
162
+ {
163
+ entityName: 'DatasourceTemplateAssignment',
164
+ folderName: 'datasourceTemplate',
165
+ parentFolder: 'DatasourceTemplate',
166
+ fileNameField: (o) => o.asset?.name ?? o.assetClass?.name,
167
+ },
168
+ {entityName: 'Edge'},
169
+ {entityName: 'EdgeDataSource', folderName: 'edge', parentFolder: 'Edge'},
170
+ {
171
+ entityName: 'EdgeDataTag',
172
+ fileNameField: (o) => [o.dataSource?.name, o.name].filter(Boolean).join(' - '),
173
+ },
174
+ {
175
+ entityName: 'EdgeTrigger',
176
+ fileNameField: (o) =>
177
+ [o.dataTag?.name, o.eventType?.name].filter(Boolean).join(' - '),
178
+ },
179
+
180
+ // --- Events ---
181
+ {
182
+ entityName: 'EventRelay',
183
+ fileNameField: (o) =>
184
+ [o.eventType?.name, o.fromAsset?.name ?? o.fromAssetClass?.name]
185
+ .filter(Boolean)
186
+ .join(' - '),
187
+ },
188
+ {entityName: 'EventSchedule'},
189
+
190
+ // --- Control parameters (manufacturing recipes) ---
191
+ {entityName: 'ControlParameter'},
192
+ {entityName: 'ControlParameterRecipe'},
193
+ {entityName: 'ControlParameterRule'},
194
+ {
195
+ entityName: 'ControlParameterRuleAttribute',
196
+ fileNameField: (o) =>
197
+ [o.controlParameterRule?.name, o.attribute?.name].filter(Boolean).join(' - '),
198
+ },
199
+
200
+ // --- Run rates ---
201
+ {
202
+ entityName: 'RunRateTemplate',
203
+ fileNameField: (o) =>
204
+ [
205
+ o.assetClass?.name ?? o.asset?.name,
206
+ o.material?.externalId ?? o.materialGroup?.name,
207
+ ]
208
+ .filter(Boolean)
209
+ .join(' - '),
210
+ },
211
+
212
+ // --- Order templates ---
213
+ {entityName: 'OrderStatus', fileNameField: (o) => o.status},
214
+ {entityName: 'OrderNumberAttribute', fileNameField: (o) => o.attribute?.name},
215
+ {entityName: 'OrderTemplate'},
216
+ {
217
+ entityName: 'OrderTemplateComponent',
218
+ folderName: 'orderTemplate',
219
+ parentFolder: 'OrderTemplate',
220
+ fileNameField: (o) => o.material?.externalId ?? o.materialGroup?.name,
221
+ },
222
+ {entityName: 'OrderTemplateDependency'},
223
+
224
+ // --- Activities ---
225
+ {entityName: 'ActivityClass'},
226
+ {entityName: 'ActivityItemOptionGroup', parentFolder: 'ActivityItemOptionGroup'},
227
+ {
228
+ entityName: 'ActivityItemOptionItem',
229
+ folderName: 'optionGroup',
230
+ parentFolder: 'ActivityItemOptionGroup',
231
+ },
232
+ {entityName: 'ActivityTemplate', filter: {'status.equals': 'LIVE'}},
233
+ {
234
+ entityName: 'ActivityTemplateItem',
235
+ fileNameField: (obj) => obj.itemKey,
236
+ folderName: 'activityTemplate',
237
+ parentFolder: 'ActivityTemplate',
238
+ filter: {'activityTemplate.status.equals': 'LIVE'},
239
+ },
240
+ {
241
+ entityName: 'ActivityStarter',
242
+ fileNameField: (obj) =>
243
+ obj.eventType.name +
244
+ (obj.activityKey != null ? ' - ' + obj.activityKey : '') +
245
+ (obj.targetAsset != null ? ' - ' + obj.targetAsset.name : ''),
246
+ folderName: 'template',
247
+ parentFolder: 'ActivityTemplate',
248
+ filter: {'template.status.equals': 'LIVE'},
249
+ },
250
+ {
251
+ entityName: 'ActivityAssignment',
252
+ folderName: 'template',
253
+ parentFolder: 'ActivityTemplate',
254
+ fileNameField: (obj) =>
255
+ [
256
+ ...(obj.site != null ? [obj.site.name] : []),
257
+ ...(obj.assetClass != null ? [obj.assetClass.name] : []),
258
+ ...(obj.asset != null ? [obj.asset.name] : []),
259
+ ...(obj.material != null ? [obj.material.externalId] : []),
260
+ ...(obj.materialGroup != null ? [obj.materialGroup.name] : []),
261
+ ...(obj.attribute != null ? [obj.attribute.name] : []),
262
+ ...(obj.attributeValue != null ? [obj.attributeValue] : []),
263
+ ...(obj.sourceAsset != null ? [obj.sourceAsset.name] : []),
264
+ ].join(' - '),
265
+ filter: {'template.status.equals': 'LIVE'},
266
+ },
267
+ {
268
+ entityName: 'ActivityProcessStarter',
269
+ fileNameField: (o) =>
270
+ [o.eventType?.name, o.activityKey].filter(Boolean).join(' - '),
271
+ },
272
+ {
273
+ entityName: 'ActivityProcessTrigger',
274
+ fileNameField: (o) => o.activityKey ?? o.triggerType,
275
+ },
276
+ {
277
+ entityName: 'ActivityAssignmentAttribute',
278
+ fileNameField: (o) => o.attribute?.name,
279
+ },
280
+ ];
281
+
282
+ const STRIP_FIELDS = ['id', 'createdAt', 'updatedAt'];
283
+
284
+ const doImport = async (opts = {}) => {
285
+ const profile = resolveProfile(opts.profile);
286
+ const onlyApi = opts.api; // optional: 'do' | 'scheduler'
287
+
288
+ const baseDir = resolveBaseDir(opts);
289
+ const baseFolder = path.join(baseDir, profile.name);
290
+ if (fs.existsSync(baseFolder)) {
291
+ fs.rmSync(baseFolder, {recursive: true, force: true});
292
+ }
293
+ createFolder(baseDir);
294
+ createFolder(baseFolder);
295
+
296
+ info(`Importing configuration for "${profile.name}" (${profile.baseUrl})`);
297
+
298
+ const summary = {profile: profile.name, baseUrl: profile.baseUrl, entities: []};
299
+
300
+ if (!onlyApi || onlyApi === 'do') {
301
+ await importApi('do', async (client) => {
302
+ for (const entity of DO_ENTITIES) {
303
+ const count = await importDoEntity({profile, client, entity, baseFolder});
304
+ summary.entities.push({api: 'do', entity: entity.entityName, count});
305
+ }
306
+ });
307
+ }
308
+
309
+ if (!onlyApi || onlyApi === 'scheduler') {
310
+ await importApi('scheduler', async (client) => {
311
+ const results = await importScheduler({profile, client, baseFolder});
312
+ summary.entities.push(...results);
313
+ });
314
+ }
315
+
316
+ // Build the client for an API and run its import block. If the client can't
317
+ // be built (e.g. an auth method the API doesn't accept), skip it with a
318
+ // warning rather than aborting the whole import.
319
+ async function importApi(apiId, block) {
320
+ let client;
321
+ try {
322
+ client = await apiClient(profile, apiId);
323
+ } catch (err) {
324
+ info(` ${apiId}: skipped (${err.message})`);
325
+ summary.skipped = summary.skipped || [];
326
+ summary.skipped.push({api: apiId, reason: err.message});
327
+ return;
328
+ }
329
+ await block(client);
330
+ }
331
+
332
+ const total = summary.entities.reduce((n, e) => n + (e.count || 0), 0);
333
+ info(`\nImported ${total} records into ${baseFolder}`);
334
+ result(`Imported ${total} records for "${profile.name}".`, {ok: true, ...summary, path: baseFolder});
335
+ };
336
+
337
+ async function importDoEntity({profile, client, entity, baseFolder}) {
338
+ const {entityName} = entity;
339
+ const api = getApi('do');
340
+ try {
341
+ const schema = await fetchAsset(profile, 'do', `${api.schemaPath}/${entityName}.json`);
342
+ const expand = (schema.relationships || [])
343
+ .filter((a) => a.relationshipType === 'many-to-one')
344
+ .map((a) => a.relationshipName)
345
+ .join(',');
346
+
347
+ const data = await client
348
+ .get(`${schema.microserviceName}/${schema.endpoint}`, {
349
+ searchParams: {expand, size: 10000, ...(entity.filter || {})},
350
+ })
351
+ .json();
352
+
353
+ const meta = {
354
+ api: 'do',
355
+ microservice: schema.microserviceName,
356
+ endpoint: schema.endpoint,
357
+ entity: entityName,
358
+ };
359
+ const distilled = distillSchema(schema, 'do');
360
+ const entityFolder = writeEntityMeta(baseFolder, 'do', entityName, meta, distilled, schema);
361
+
362
+ let count = 0;
363
+ for (const obj of data) {
364
+ writeRecord({obj, entity, entityName, entityFolder, baseFolder, api: 'do', meta, schema: distilled});
365
+ count++;
366
+ }
367
+ info(` do/${entityName}: ${count}`);
368
+ return count;
369
+ } catch (error) {
370
+ info(` do/${entityName}: skipped (${error.message})`);
371
+ return 0;
372
+ }
373
+ }
374
+
375
+ /**
376
+ * Scheduler config is reached through the normalized REST surface. Global
377
+ * entities (Location, DataTemplate, OptimisationProfile) live at
378
+ * /api/scheduler/{endpoint}; everything else is partitioned per DataTemplate and
379
+ * addressed by path: /api/scheduler/{locationCode}/{dataTemplateId}/{endpoint}.
380
+ * The dataTemplate id and locationCode are environment-specific, so snapshots
381
+ * nest under scheduler/{templateName}/{Entity}/ and record the template *name*
382
+ * (the stable cross-environment key) in meta. The published schema is
383
+ * authoritative for these write shapes (unlike the deprecated flattened gateway).
384
+ */
385
+ async function importScheduler({profile, client, baseFolder}) {
386
+ const results = [];
387
+ const api = getApi('scheduler');
388
+
389
+ // Fetch + distill each entity's live schema once (reused across templates).
390
+ const schemaCache = new Map();
391
+ const getSchema = async (entityName) => {
392
+ if (schemaCache.has(entityName)) return schemaCache.get(entityName);
393
+ let val = null;
394
+ try {
395
+ const raw = await fetchAsset(profile, 'scheduler', `${api.schemaPath}/${entityName}.json`);
396
+ val = {distilled: distillSchema(raw, 'scheduler'), raw};
397
+ } catch {
398
+ val = null; // schema is best-effort; import still proceeds without it
399
+ }
400
+ schemaCache.set(entityName, val);
401
+ return val;
402
+ };
403
+
404
+ // The data templates drive the per-template loop and the scope path.
405
+ let templates;
406
+ try {
407
+ const data = await client.get('data-templates', {searchParams: {size: 10000}}).json();
408
+ templates = Array.isArray(data) ? data : data?.content ?? [];
409
+ } catch (error) {
410
+ info(` scheduler: skipped (${error.message})`);
411
+ return [{api: 'scheduler', entity: 'DataTemplate', count: 0, error: error.message}];
412
+ }
413
+
414
+ // Global entities first (no scope prefix). DataTemplate reuses the list above.
415
+ for (const desc of SCHEDULER_ENTITIES.filter((d) => d.scope === 'global')) {
416
+ const records = desc.entityName === 'DataTemplate' ? templates : null;
417
+ const count = await importSchedulerCollection({
418
+ client, desc, prefix: '', dataTemplate: null, records, baseFolder, getSchema,
419
+ });
420
+ results.push({api: 'scheduler', entity: desc.entityName, count});
421
+ }
422
+
423
+ // Per-template scoped entities, addressed by {locationCode}/{dataTemplateId}.
424
+ for (const tpl of templates) {
425
+ const tplName = cleanFileName(String(tpl.name ?? `template-${tpl.id}`));
426
+ const locationCode = tpl.location?.code;
427
+ if (locationCode == null) {
428
+ info(` scheduler/${tplName}: skipped (template has no location)`);
429
+ continue;
430
+ }
431
+ const prefix = `${locationCode}/${tpl.id}`;
432
+ for (const desc of SCHEDULER_ENTITIES.filter((d) => d.scope === 'template')) {
433
+ const count = await importSchedulerCollection({
434
+ client, desc, prefix, dataTemplate: tplName, dataTemplateId: tpl.id, records: null, baseFolder, getSchema,
435
+ });
436
+ results.push({api: 'scheduler', dataTemplate: tplName, entity: desc.entityName, count});
437
+ }
438
+ }
439
+ return results;
440
+ }
441
+
442
+ /**
443
+ * Snapshot one scheduler collection (global or template-scoped). Records are
444
+ * reduced to portable form (volatile ids stripped, references collapsed to their
445
+ * natural keys) and keyed by their natural key — which is now stable, so
446
+ * diff/deploy can match them for update/delete instead of create-only.
447
+ */
448
+ async function importSchedulerCollection({client, desc, prefix, dataTemplate, dataTemplateId, records, baseFolder, getSchema}) {
449
+ const {entityName, endpoint} = desc;
450
+ const label = dataTemplate ? `scheduler/${dataTemplate}/${entityName}` : `scheduler/${entityName}`;
451
+ try {
452
+ let data = records;
453
+ if (!data) {
454
+ const expand = expandParam(desc);
455
+ const searchParams = {size: 10000, ...(expand ? {expand} : {})};
456
+ const url = prefix ? `${prefix}/${endpoint}` : endpoint;
457
+ data = await client.get(url, {searchParams}).json();
458
+ }
459
+ let rows = Array.isArray(data) ? data : data?.content ?? [];
460
+ // Some leaf endpoints ignore the path scope; filter to this template.
461
+ rows = scopeRows(rows, desc, dataTemplateId);
462
+
463
+ const folder = dataTemplate
464
+ ? path.join(baseFolder, 'scheduler', dataTemplate, entityName)
465
+ : path.join(baseFolder, 'scheduler', entityName);
466
+ createFolder(path.dirname(folder));
467
+ createFolder(folder);
468
+
469
+ const meta = {
470
+ api: 'scheduler',
471
+ entity: entityName,
472
+ endpoint,
473
+ scope: desc.scope,
474
+ ...(dataTemplate ? {dataTemplate} : {}),
475
+ };
476
+ fs.writeFileSync(path.join(folder, '_meta.json'), JSON.stringify(meta, null, 2));
477
+ const schema = await getSchema(entityName);
478
+ if (schema?.distilled) {
479
+ fs.writeFileSync(path.join(folder, '_schema.json'), JSON.stringify(schema.distilled, null, 2));
480
+ }
481
+ if (schema?.raw) {
482
+ fs.writeFileSync(path.join(folder, '_schema.source.json'), JSON.stringify(schema.raw, null, 2));
483
+ }
484
+
485
+ let count = 0;
486
+ const used = new Set();
487
+ for (const obj of rows) {
488
+ let fileName = matchKey(obj, desc) || `record-${count}`;
489
+ if (used.has(fileName)) fileName = `${fileName}~${count}`; // guard rare collisions
490
+ used.add(fileName);
491
+ fs.writeFileSync(path.join(folder, `${fileName}.json`), JSON.stringify(toPortable(obj, desc), null, 2));
492
+ count++;
493
+ }
494
+ info(` ${label}: ${count}`);
495
+ return count;
496
+ } catch (error) {
497
+ info(` ${label}: skipped (${error.message})`);
498
+ return 0;
499
+ }
500
+ }
501
+
502
+ /** Resolve the target folder for a record and write it as cleaned JSON. */
503
+ function writeRecord({obj, entity, entityName, entityFolder, baseFolder, api, meta, schema}) {
504
+ const rawFileName = entity.fileNameField != null ? entity.fileNameField(obj) : obj.name;
505
+ const fileName = cleanFileName(String(rawFileName ?? 'unnamed'));
506
+
507
+ let targetFolder = entityFolder;
508
+ const apiRoot = path.join(baseFolder, api);
509
+ if (entity.folderName) {
510
+ const folder = cleanFileName(obj[entity.folderName].name);
511
+ targetFolder = path.join(apiRoot, entity.parentFolder, folder, entityName);
512
+ createFolder(path.join(apiRoot, entity.parentFolder));
513
+ createFolder(path.join(apiRoot, entity.parentFolder, folder));
514
+ createFolder(targetFolder);
515
+ delete obj[entity.folderName];
516
+ } else if (entity.parentFolder) {
517
+ const folder = cleanFileName(obj.name);
518
+ targetFolder = path.join(apiRoot, entity.parentFolder, folder);
519
+ createFolder(path.join(apiRoot, entity.parentFolder));
520
+ createFolder(targetFolder);
521
+ }
522
+
523
+ // Drop routing (_meta.json) and contract (_schema.json) sidecars into the
524
+ // leaf folder (once) so diff/deploy and agents can resolve them from the
525
+ // nearest sidecar, regardless of nesting.
526
+ if (meta) {
527
+ const leafMeta = path.join(targetFolder, '_meta.json');
528
+ if (!fs.existsSync(leafMeta)) fs.writeFileSync(leafMeta, JSON.stringify(meta, null, 2));
529
+ }
530
+ if (schema) {
531
+ const leafSchema = path.join(targetFolder, '_schema.json');
532
+ if (!fs.existsSync(leafSchema)) fs.writeFileSync(leafSchema, JSON.stringify(schema, null, 2));
533
+ }
534
+
535
+ const filePath = path.join(targetFolder, `${fileName}.json`);
536
+ fs.writeFileSync(filePath, JSON.stringify(stripVolatile(obj), null, 2));
537
+ }
538
+
539
+ /**
540
+ * Create the api/entity folder and write its sidecars:
541
+ * _meta.json routing (api, microservice, endpoint, dataTemplate)
542
+ * _schema.json distilled, agent-readable contract for the entity
543
+ * _schema.source.json the raw authoritative schema (DO: the same
544
+ * /assets/data/schema/{Entity}.json that drives the
545
+ * dynamic UI), kept for completeness
546
+ */
547
+ function writeEntityMeta(baseFolder, api, entityName, meta, schema, rawSchema) {
548
+ const folder = path.join(baseFolder, api, entityName);
549
+ createFolder(path.join(baseFolder, api));
550
+ createFolder(folder);
551
+ fs.writeFileSync(path.join(folder, '_meta.json'), JSON.stringify(meta, null, 2));
552
+ if (schema) fs.writeFileSync(path.join(folder, '_schema.json'), JSON.stringify(schema, null, 2));
553
+ if (rawSchema) {
554
+ fs.writeFileSync(path.join(folder, '_schema.source.json'), JSON.stringify(rawSchema, null, 2));
555
+ }
556
+ return folder;
557
+ }
558
+
559
+ function stripVolatile(obj) {
560
+ if (Array.isArray(obj)) return obj.map(stripVolatile);
561
+ if (obj && typeof obj === 'object') {
562
+ const out = {};
563
+ for (const key of Object.keys(obj)) {
564
+ if (STRIP_FIELDS.includes(key)) continue;
565
+ out[key] = stripVolatile(obj[key]);
566
+ }
567
+ return out;
568
+ }
569
+ return obj;
570
+ }
571
+
572
+ function createFolder(dir) {
573
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, {recursive: true});
574
+ }
575
+
576
+ function cleanFileName(raw) {
577
+ return raw.replace(/[<>:"/\\|?*]/g, '');
578
+ }
579
+
580
+ export default doImport;
package/lib/apis.js ADDED
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Registry of the TilliT API surfaces the CLI talks to.
3
+ *
4
+ * Both are served from the per-tenant subdomain (https://{tenant}.tillit.cloud)
5
+ * under the shared /api gateway prefix, and accept the same auth (basic or
6
+ * api-key/bearer) plus the tillit-tenant header.
7
+ *
8
+ * - do Digital Operations REST API (microservices: core, activity,
9
+ * tenant, edge, user-settings, report, cube). Prefix /api.
10
+ * - scheduler Scheduler REST API. Prefix /api/scheduler. Config is partitioned
11
+ * per dataTemplate (the bare /scheduler path is the web app).
12
+ */
13
+ export const APIS = {
14
+ do: {
15
+ id: 'do',
16
+ label: 'Digital Operations API',
17
+ prefix: '/api',
18
+ // Where the entity JSON schemas are published for schema-driven import.
19
+ schemaPath: '/assets/data/schema',
20
+ },
21
+ scheduler: {
22
+ id: 'scheduler',
23
+ label: 'Scheduler API',
24
+ // Served under the shared /api gateway prefix (the /scheduler path is the
25
+ // Bryntum web app, not the API).
26
+ prefix: '/api/scheduler',
27
+ // Entity schemas are published live, same format as DO, under a
28
+ // scheduler/ subfolder.
29
+ schemaPath: '/assets/data/schema/scheduler',
30
+ },
31
+ };
32
+
33
+ export function getApi(id) {
34
+ const api = APIS[id];
35
+ if (!api) {
36
+ throw new Error(`Unknown API "${id}". Known APIs: ${Object.keys(APIS).join(', ')}`);
37
+ }
38
+ return api;
39
+ }