@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.
- package/AGENTS.md +59 -0
- package/README.MD +106 -0
- package/configure.js +243 -0
- package/deploy.js +289 -0
- package/diff.js +158 -0
- package/docs/apis.md +73 -0
- package/docs/authentication.md +53 -0
- package/docs/commands.md +135 -0
- package/docs/scheduler.md +173 -0
- package/docs/schemas.md +73 -0
- package/docs/workflow.md +86 -0
- package/import.js +580 -0
- package/lib/apis.js +39 -0
- package/lib/auth.js +123 -0
- package/lib/client.js +52 -0
- package/lib/config.js +209 -0
- package/lib/output.js +51 -0
- package/lib/scheduler-entities.js +404 -0
- package/lib/schema.js +65 -0
- package/package.json +37 -0
- package/schema.js +79 -0
- package/tllt.js +112 -0
package/docs/workflow.md
ADDED
|
@@ -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
|
+
}
|