@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
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Descriptor-driven model of the normalized Scheduler REST surface.
|
|
3
|
+
*
|
|
4
|
+
* The CLI talks to the Scheduler's normalized REST API, whose write
|
|
5
|
+
* shapes match the published `/assets/data/schema/scheduler/*` schemas. Two
|
|
6
|
+
* addressing scopes share the `/api/scheduler` prefix:
|
|
7
|
+
*
|
|
8
|
+
* global -> /api/scheduler/{endpoint}
|
|
9
|
+
* template -> /api/scheduler/{locationCode}/{dataTemplateId}/{endpoint}
|
|
10
|
+
*
|
|
11
|
+
* (The older flattened gateway style `/{endpoint}?dataTemplate={id}` is
|
|
12
|
+
* deprecated and no longer used.)
|
|
13
|
+
*
|
|
14
|
+
* Each descriptor declares:
|
|
15
|
+
* entityName PascalCase; matches the published schema file + snapshot folder.
|
|
16
|
+
* endpoint REST collection segment.
|
|
17
|
+
* scope 'global' | 'template'.
|
|
18
|
+
* key(obj) natural key — stable across environments. Composite keys are
|
|
19
|
+
* joined with KEY_SEP. Used as the snapshot filename, the diff
|
|
20
|
+
* match key, and the live key->id map key for deploy.
|
|
21
|
+
* refs foreign-key references. Each:
|
|
22
|
+
* field property on the record holding the reference.
|
|
23
|
+
* kind 'id' resolve the ref's natural key to the
|
|
24
|
+
* target's numeric id at deploy time (the API
|
|
25
|
+
* only accepts these refs by id).
|
|
26
|
+
* 'key' the API resolves the ref itself from a
|
|
27
|
+
* natural key carried in the body; send as-is.
|
|
28
|
+
* 'drop'redundant with sibling inline *Code fields
|
|
29
|
+
* the API resolves from; dropped on import.
|
|
30
|
+
* keyFields fields kept when reducing the ref to portable form
|
|
31
|
+
* (kind 'id'/'key').
|
|
32
|
+
* lookupEntity (kind 'id') entityName whose live collection is
|
|
33
|
+
* searched to resolve the id.
|
|
34
|
+
* keyOf(ref,record) (kind 'id') natural key used for that lookup;
|
|
35
|
+
* must match lookupEntity.key()'s output.
|
|
36
|
+
* nullable the ref may be null.
|
|
37
|
+
* numeric fields the API validates as numbers (@IsNumber) but serializes
|
|
38
|
+
* back as strings (Postgres double precision / float / bigint
|
|
39
|
+
* columns come through node-pg as strings). Coerced to Number on
|
|
40
|
+
* both import and deploy so snapshots match the schema and writes
|
|
41
|
+
* pass validation (pending a server-side fix to converge these).
|
|
42
|
+
* templateOf(obj) (leaf sub-entities only) the numeric dataTemplate id that
|
|
43
|
+
* owns this row. A few leaf endpoints (property-values,
|
|
44
|
+
* changeover-set-items, availability-template-items,
|
|
45
|
+
* material-properties) have no dataTemplateId column and ignore the
|
|
46
|
+
* path scope, returning the whole tenant — so the CLI filters them
|
|
47
|
+
* client-side by the parent's dataTemplate id (read from `expand`).
|
|
48
|
+
*
|
|
49
|
+
* The GET `expand` param is derived from refs of kind id/key (the relations
|
|
50
|
+
* whose natural keys must be captured on import).
|
|
51
|
+
*/
|
|
52
|
+
|
|
53
|
+
export const KEY_SEP = ' - ';
|
|
54
|
+
|
|
55
|
+
/** Strip filesystem-hostile characters so a natural key is a safe filename. */
|
|
56
|
+
export function safeKey(raw) {
|
|
57
|
+
return String(raw).replace(/[<>:"/\\|?*]/g, '');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const join = (...parts) => parts.map((p) => (p == null ? '' : p)).join(KEY_SEP);
|
|
61
|
+
|
|
62
|
+
// Global scheduler entities (no location/dataTemplate scoping).
|
|
63
|
+
const GLOBAL_ENTITIES = [
|
|
64
|
+
{entityName: 'Location', endpoint: 'locations', scope: 'global', key: (o) => o.code},
|
|
65
|
+
{
|
|
66
|
+
entityName: 'DataTemplate',
|
|
67
|
+
endpoint: 'data-templates',
|
|
68
|
+
scope: 'global',
|
|
69
|
+
key: (o) => o.name,
|
|
70
|
+
refs: [
|
|
71
|
+
{field: 'location', kind: 'id', keyFields: ['code'], lookupEntity: 'Location', keyOf: (r) => r.code, nullable: true},
|
|
72
|
+
],
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
entityName: 'OptimisationProfile',
|
|
76
|
+
endpoint: 'optimisation-profiles',
|
|
77
|
+
scope: 'global',
|
|
78
|
+
key: (o) => o.name,
|
|
79
|
+
numeric: ['maxDuration', 'maxPopulation', 'changeoverWeight', 'makespanWeight', 'overdueWeight'],
|
|
80
|
+
},
|
|
81
|
+
];
|
|
82
|
+
|
|
83
|
+
// Template-scoped entities, listed in dependency (create) order. Deploy applies
|
|
84
|
+
// creates/updates in this order and deletes in reverse, so a referenced parent
|
|
85
|
+
// always exists before the records that point at it.
|
|
86
|
+
const TEMPLATE_ENTITIES = [
|
|
87
|
+
{entityName: 'EquipmentClass', endpoint: 'equipment-classes', scope: 'template', key: (o) => o.externalId},
|
|
88
|
+
{entityName: 'Property', endpoint: 'properties', scope: 'template', key: (o) => o.name},
|
|
89
|
+
{
|
|
90
|
+
entityName: 'PropertyValue',
|
|
91
|
+
endpoint: 'property-values',
|
|
92
|
+
scope: 'template',
|
|
93
|
+
key: (o) => join(o.property?.name, o.value),
|
|
94
|
+
templateOf: (o) => o.property?.dataTemplateId,
|
|
95
|
+
refs: [
|
|
96
|
+
{field: 'property', kind: 'id', keyFields: ['name'], lookupEntity: 'Property', keyOf: (r) => r.name},
|
|
97
|
+
],
|
|
98
|
+
},
|
|
99
|
+
{entityName: 'ChangeoverSet', endpoint: 'changeover-sets', scope: 'template', key: (o) => o.name},
|
|
100
|
+
{
|
|
101
|
+
entityName: 'ChangeoverSetItem',
|
|
102
|
+
endpoint: 'changeover-set-items',
|
|
103
|
+
scope: 'template',
|
|
104
|
+
key: (o) => join(o.changeoverSet?.name, o.property?.name, o.fromValue?.value, o.toValue?.value),
|
|
105
|
+
templateOf: (o) => o.changeoverSet?.dataTemplateId,
|
|
106
|
+
numeric: ['time'],
|
|
107
|
+
refs: [
|
|
108
|
+
{field: 'changeoverSet', kind: 'id', keyFields: ['name'], lookupEntity: 'ChangeoverSet', keyOf: (r) => r.name},
|
|
109
|
+
{field: 'property', kind: 'id', keyFields: ['name'], lookupEntity: 'Property', keyOf: (r) => r.name},
|
|
110
|
+
{field: 'fromValue', kind: 'id', keyFields: ['value'], lookupEntity: 'PropertyValue', keyOf: (r, rec) => join(rec.property?.name, r.value)},
|
|
111
|
+
{field: 'toValue', kind: 'id', keyFields: ['value'], lookupEntity: 'PropertyValue', keyOf: (r, rec) => join(rec.property?.name, r.value)},
|
|
112
|
+
],
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
entityName: 'Equipment',
|
|
116
|
+
endpoint: 'equipment',
|
|
117
|
+
scope: 'template',
|
|
118
|
+
key: (o) => o.externalId,
|
|
119
|
+
refs: [
|
|
120
|
+
{field: 'changeoverSet', kind: 'id', keyFields: ['name'], lookupEntity: 'ChangeoverSet', keyOf: (r) => r.name, nullable: true},
|
|
121
|
+
],
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
entityName: 'EquipmentClassMembership',
|
|
125
|
+
endpoint: 'equipment-class-memberships',
|
|
126
|
+
scope: 'template',
|
|
127
|
+
key: (o) => join(o.equipment?.externalId, o.equipmentClass?.externalId),
|
|
128
|
+
refs: [
|
|
129
|
+
{field: 'equipment', kind: 'id', keyFields: ['externalId'], lookupEntity: 'Equipment', keyOf: (r) => r.externalId},
|
|
130
|
+
{field: 'equipmentClass', kind: 'id', keyFields: ['externalId'], lookupEntity: 'EquipmentClass', keyOf: (r) => r.externalId},
|
|
131
|
+
],
|
|
132
|
+
},
|
|
133
|
+
{entityName: 'PersonnelClass', endpoint: 'personnel-classes', scope: 'template', key: (o) => o.externalId},
|
|
134
|
+
{entityName: 'Personnel', endpoint: 'personnels', scope: 'template', key: (o) => o.externalId},
|
|
135
|
+
{
|
|
136
|
+
entityName: 'PersonnelClassMembership',
|
|
137
|
+
endpoint: 'personnel-class-memberships',
|
|
138
|
+
scope: 'template',
|
|
139
|
+
key: (o) => join(o.person?.externalId, o.personnelClass?.externalId),
|
|
140
|
+
refs: [
|
|
141
|
+
{field: 'person', kind: 'id', keyFields: ['externalId'], lookupEntity: 'Personnel', keyOf: (r) => r.externalId},
|
|
142
|
+
{field: 'personnelClass', kind: 'id', keyFields: ['externalId'], lookupEntity: 'PersonnelClass', keyOf: (r) => r.externalId},
|
|
143
|
+
],
|
|
144
|
+
},
|
|
145
|
+
{entityName: 'AvailabilityTemplate', endpoint: 'availability-templates', scope: 'template', key: (o) => o.name},
|
|
146
|
+
{
|
|
147
|
+
entityName: 'AvailabilityTemplateItem',
|
|
148
|
+
endpoint: 'availability-template-items',
|
|
149
|
+
scope: 'template',
|
|
150
|
+
key: (o) => join(o.availabilityTemplate?.name, o.dayOfWeek, o.startTime, o.endTime),
|
|
151
|
+
templateOf: (o) => o.availabilityTemplate?.dataTemplate?.id,
|
|
152
|
+
numeric: ['dayOfWeek', 'startTime', 'endTime'],
|
|
153
|
+
refs: [
|
|
154
|
+
{field: 'availabilityTemplate', kind: 'id', keyFields: ['name'], lookupEntity: 'AvailabilityTemplate', keyOf: (r) => r.name},
|
|
155
|
+
],
|
|
156
|
+
},
|
|
157
|
+
{
|
|
158
|
+
entityName: 'DowntimePeriod',
|
|
159
|
+
endpoint: 'downtime-periods',
|
|
160
|
+
scope: 'template',
|
|
161
|
+
key: (o) => join(o.description, o.startTime, o.endTime),
|
|
162
|
+
},
|
|
163
|
+
{
|
|
164
|
+
entityName: 'EquipmentDowntimePeriod',
|
|
165
|
+
endpoint: 'equipment-downtime-periods',
|
|
166
|
+
scope: 'template',
|
|
167
|
+
key: (o) =>
|
|
168
|
+
join(
|
|
169
|
+
o.equipment?.externalId,
|
|
170
|
+
o.downtimePeriod?.description,
|
|
171
|
+
o.downtimePeriod?.startTime,
|
|
172
|
+
o.downtimePeriod?.endTime
|
|
173
|
+
),
|
|
174
|
+
refs: [
|
|
175
|
+
{field: 'equipment', kind: 'id', keyFields: ['externalId'], lookupEntity: 'Equipment', keyOf: (r) => r.externalId},
|
|
176
|
+
{
|
|
177
|
+
field: 'downtimePeriod',
|
|
178
|
+
kind: 'id',
|
|
179
|
+
keyFields: ['description', 'startTime', 'endTime'],
|
|
180
|
+
lookupEntity: 'DowntimePeriod',
|
|
181
|
+
keyOf: (r) => join(r.description, r.startTime, r.endTime),
|
|
182
|
+
},
|
|
183
|
+
],
|
|
184
|
+
},
|
|
185
|
+
{
|
|
186
|
+
entityName: 'PersonDowntimePeriod',
|
|
187
|
+
endpoint: 'person-downtime-periods',
|
|
188
|
+
scope: 'template',
|
|
189
|
+
key: (o) =>
|
|
190
|
+
join(
|
|
191
|
+
o.person?.externalId,
|
|
192
|
+
o.downtimePeriod?.description,
|
|
193
|
+
o.downtimePeriod?.startTime,
|
|
194
|
+
o.downtimePeriod?.endTime
|
|
195
|
+
),
|
|
196
|
+
refs: [
|
|
197
|
+
{field: 'person', kind: 'id', keyFields: ['externalId'], lookupEntity: 'Personnel', keyOf: (r) => r.externalId},
|
|
198
|
+
{
|
|
199
|
+
field: 'downtimePeriod',
|
|
200
|
+
kind: 'id',
|
|
201
|
+
keyFields: ['description', 'startTime', 'endTime'],
|
|
202
|
+
lookupEntity: 'DowntimePeriod',
|
|
203
|
+
keyOf: (r) => join(r.description, r.startTime, r.endTime),
|
|
204
|
+
},
|
|
205
|
+
],
|
|
206
|
+
},
|
|
207
|
+
{entityName: 'MaterialGroup', endpoint: 'material-groups', scope: 'template', key: (o) => o.externalId},
|
|
208
|
+
{
|
|
209
|
+
entityName: 'MaterialDefinition',
|
|
210
|
+
endpoint: 'material-definitions',
|
|
211
|
+
scope: 'template',
|
|
212
|
+
key: (o) => o.externalId,
|
|
213
|
+
// The plain create endpoint needs materialGroup by id (only the /upsert route
|
|
214
|
+
// resolves it by externalId), so the CLI resolves it.
|
|
215
|
+
refs: [{field: 'materialGroup', kind: 'id', keyFields: ['externalId'], lookupEntity: 'MaterialGroup', keyOf: (r) => r.externalId}],
|
|
216
|
+
},
|
|
217
|
+
{
|
|
218
|
+
entityName: 'MaterialProperty',
|
|
219
|
+
endpoint: 'material-properties',
|
|
220
|
+
scope: 'template',
|
|
221
|
+
key: (o) => join(o.materialDefinition?.externalId, o.externalId),
|
|
222
|
+
templateOf: (o) => o.materialDefinition?.dataTemplateId,
|
|
223
|
+
refs: [{field: 'materialDefinition', kind: 'id', keyFields: ['externalId'], lookupEntity: 'MaterialDefinition', keyOf: (r) => r.externalId}],
|
|
224
|
+
},
|
|
225
|
+
{entityName: 'Operation', endpoint: 'operations', scope: 'template', key: (o) => o.operationCode},
|
|
226
|
+
{
|
|
227
|
+
entityName: 'Route',
|
|
228
|
+
endpoint: 'routes',
|
|
229
|
+
scope: 'template',
|
|
230
|
+
key: (o) => join(o.operationCode, o.routeCode),
|
|
231
|
+
// The API resolves the parent operation from the inline operationCode.
|
|
232
|
+
refs: [{field: 'operation', kind: 'drop'}],
|
|
233
|
+
},
|
|
234
|
+
{
|
|
235
|
+
entityName: 'Segment',
|
|
236
|
+
endpoint: 'segments',
|
|
237
|
+
scope: 'template',
|
|
238
|
+
key: (o) => join(o.operationCode, o.routeCode, o.segmentCode),
|
|
239
|
+
// The API resolves the parent route from inline operationCode + routeCode.
|
|
240
|
+
refs: [{field: 'route', kind: 'drop'}],
|
|
241
|
+
},
|
|
242
|
+
{
|
|
243
|
+
entityName: 'SegmentMaterial',
|
|
244
|
+
endpoint: 'segment-materials',
|
|
245
|
+
scope: 'template',
|
|
246
|
+
key: (o) => join(o.operationCode, o.routeCode, o.segmentCode, o.materialUse, o.lineNumber),
|
|
247
|
+
refs: [
|
|
248
|
+
{field: 'segment', kind: 'drop'},
|
|
249
|
+
{field: 'materialDefinition', kind: 'key', keyFields: ['externalId']},
|
|
250
|
+
],
|
|
251
|
+
},
|
|
252
|
+
{
|
|
253
|
+
entityName: 'SegmentEquipment',
|
|
254
|
+
endpoint: 'segment-equipments',
|
|
255
|
+
scope: 'template',
|
|
256
|
+
key: (o) => join(o.operationCode, o.routeCode, o.segmentCode, o.equipmentClass?.externalId),
|
|
257
|
+
refs: [
|
|
258
|
+
{field: 'segment', kind: 'drop'},
|
|
259
|
+
// The create endpoint needs equipmentClass by id (it only resolves by
|
|
260
|
+
// externalId when the controller forwards dataTemplateId, which it doesn't
|
|
261
|
+
// on create), so the CLI resolves it.
|
|
262
|
+
{field: 'equipmentClass', kind: 'id', keyFields: ['externalId'], lookupEntity: 'EquipmentClass', keyOf: (r) => r.externalId},
|
|
263
|
+
],
|
|
264
|
+
},
|
|
265
|
+
{
|
|
266
|
+
entityName: 'SegmentDependency',
|
|
267
|
+
endpoint: 'segment-dependencies',
|
|
268
|
+
scope: 'template',
|
|
269
|
+
key: (o) => join(o.operationCode, o.routeCode, o.fromSegmentCode, o.toSegmentCode),
|
|
270
|
+
templateOf: (o) => o.dataTemplateId,
|
|
271
|
+
// The API resolves both segments from inline operation/route/segment codes.
|
|
272
|
+
refs: [
|
|
273
|
+
{field: 'route', kind: 'drop'},
|
|
274
|
+
{field: 'fromSegment', kind: 'drop'},
|
|
275
|
+
{field: 'toSegment', kind: 'drop'},
|
|
276
|
+
],
|
|
277
|
+
},
|
|
278
|
+
];
|
|
279
|
+
|
|
280
|
+
/** All descriptors in deploy (dependency) order: globals first, then scoped. */
|
|
281
|
+
export const SCHEDULER_ENTITIES = [...GLOBAL_ENTITIES, ...TEMPLATE_ENTITIES];
|
|
282
|
+
|
|
283
|
+
const BY_NAME = new Map(SCHEDULER_ENTITIES.map((d) => [d.entityName, d]));
|
|
284
|
+
|
|
285
|
+
export function getSchedulerEntity(entityName) {
|
|
286
|
+
return BY_NAME.get(entityName);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** Index of an entity in deploy order (for sorting changesets). */
|
|
290
|
+
export function schedulerDeployOrder(entityName) {
|
|
291
|
+
const i = SCHEDULER_ENTITIES.findIndex((d) => d.entityName === entityName);
|
|
292
|
+
return i === -1 ? Number.MAX_SAFE_INTEGER : i;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** Comma-separated `expand` relations to request on GET, or undefined. */
|
|
296
|
+
export function expandParam(desc) {
|
|
297
|
+
const rels = (desc.refs || [])
|
|
298
|
+
.filter((r) => r.kind === 'id' || r.kind === 'key')
|
|
299
|
+
.map((r) => r.field);
|
|
300
|
+
return rels.length ? rels.join(',') : undefined;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Volatile/environment-specific fields stripped from every scheduler record.
|
|
304
|
+
const STRIP = new Set([
|
|
305
|
+
'id',
|
|
306
|
+
'createdAt',
|
|
307
|
+
'updatedAt',
|
|
308
|
+
'dataTemplateId',
|
|
309
|
+
'availabilitySetId',
|
|
310
|
+
'dataTemplate',
|
|
311
|
+
'nestedPath',
|
|
312
|
+
]);
|
|
313
|
+
|
|
314
|
+
function stripDeep(value) {
|
|
315
|
+
if (Array.isArray(value)) return value.map(stripDeep);
|
|
316
|
+
if (value && typeof value === 'object') {
|
|
317
|
+
const out = {};
|
|
318
|
+
for (const k of Object.keys(value)) {
|
|
319
|
+
if (STRIP.has(k)) continue;
|
|
320
|
+
out[k] = stripDeep(value[k]);
|
|
321
|
+
}
|
|
322
|
+
return out;
|
|
323
|
+
}
|
|
324
|
+
return value;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function pick(obj, fields) {
|
|
328
|
+
const out = {};
|
|
329
|
+
for (const f of fields || []) if (obj[f] !== undefined) out[f] = obj[f];
|
|
330
|
+
return out;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** Coerce numeric-but-stringified fields to Number (idempotent, null-safe). */
|
|
334
|
+
function coerceNumeric(rec, desc) {
|
|
335
|
+
for (const f of desc.numeric || []) {
|
|
336
|
+
if (rec[f] != null && rec[f] !== '') rec[f] = Number(rec[f]);
|
|
337
|
+
}
|
|
338
|
+
return rec;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Reduce a live GET record to a portable, environment-independent snapshot:
|
|
343
|
+
* strip volatile ids, reduce id/key references to just their natural-key
|
|
344
|
+
* fields, and drop references the API rebuilds from inline codes.
|
|
345
|
+
*/
|
|
346
|
+
export function toPortable(obj, desc) {
|
|
347
|
+
const rec = stripDeep(obj);
|
|
348
|
+
for (const ref of desc.refs || []) {
|
|
349
|
+
if (ref.kind === 'drop') {
|
|
350
|
+
delete rec[ref.field];
|
|
351
|
+
continue;
|
|
352
|
+
}
|
|
353
|
+
if (rec[ref.field] == null) continue;
|
|
354
|
+
rec[ref.field] = pick(rec[ref.field], ref.keyFields);
|
|
355
|
+
}
|
|
356
|
+
return coerceNumeric(rec, desc);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/** The natural key as a safe, stable string (filename + match key). */
|
|
360
|
+
export function matchKey(obj, desc) {
|
|
361
|
+
return safeKey(desc.key(obj));
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Produce the deploy body: resolve each kind:'id' reference's natural key to the
|
|
366
|
+
* target's numeric id using pre-built lookup maps (lookupEntity -> matchKey->id).
|
|
367
|
+
* Throws with an actionable message if a required reference can't be resolved.
|
|
368
|
+
*/
|
|
369
|
+
export function resolveRefs(record, desc, lookups) {
|
|
370
|
+
const out = coerceNumeric({...record}, desc);
|
|
371
|
+
for (const ref of desc.refs || []) {
|
|
372
|
+
if (ref.kind !== 'id') continue;
|
|
373
|
+
const val = out[ref.field];
|
|
374
|
+
if (val == null) {
|
|
375
|
+
if (ref.nullable) continue;
|
|
376
|
+
throw new Error(`${desc.entityName}.${ref.field} is required but missing in the snapshot.`);
|
|
377
|
+
}
|
|
378
|
+
const wanted = safeKey(ref.keyOf(val, record));
|
|
379
|
+
const map = lookups.get(ref.lookupEntity);
|
|
380
|
+
const id = map?.get(wanted);
|
|
381
|
+
if (id == null) {
|
|
382
|
+
throw new Error(
|
|
383
|
+
`Cannot resolve ${desc.entityName}.${ref.field} "${wanted}" — no ${ref.lookupEntity} with that key exists on the target (deploy it first).`
|
|
384
|
+
);
|
|
385
|
+
}
|
|
386
|
+
out[ref.field] = {id};
|
|
387
|
+
}
|
|
388
|
+
return out;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/** kind:'id' references whose lookup maps must be built before deploying desc. */
|
|
392
|
+
export function idRefs(desc) {
|
|
393
|
+
return (desc.refs || []).filter((r) => r.kind === 'id');
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Restrict raw GET rows to one dataTemplate. A no-op for entities the API scopes
|
|
398
|
+
* server-side; for the unscoped leaf endpoints it filters by the parent's
|
|
399
|
+
* dataTemplate id (see `templateOf`). Operates on raw rows (ids intact).
|
|
400
|
+
*/
|
|
401
|
+
export function scopeRows(rows, desc, dataTemplateId) {
|
|
402
|
+
if (!desc.templateOf || dataTemplateId == null) return rows;
|
|
403
|
+
return rows.filter((r) => desc.templateOf(r) === dataTemplateId);
|
|
404
|
+
}
|
package/lib/schema.js
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Schema distillation: turn the verbose published entity schema into a compact,
|
|
3
|
+
* agent-friendly contract so a tool or person editing a record knows each
|
|
4
|
+
* field's type, whether it's required, allowed enum values, and — crucially —
|
|
5
|
+
* what entity a nested `{ "name": "..." }` reference points at.
|
|
6
|
+
*
|
|
7
|
+
* Both APIs publish the same schema live:
|
|
8
|
+
* DO /assets/data/schema/{Entity}.json
|
|
9
|
+
* Scheduler /assets/data/schema/scheduler/{Entity}.json
|
|
10
|
+
*
|
|
11
|
+
* Output shape (the "data dictionary" for one entity):
|
|
12
|
+
* {
|
|
13
|
+
* api, entity, endpoint, microservice, naturalKey,
|
|
14
|
+
* fields: [ { name, type, required, unique?, readOnly?, enum? } ],
|
|
15
|
+
* relationships: [ { name, kind, references, keyField, required? } ]
|
|
16
|
+
* }
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** Distill a published entity schema (/assets/data/schema[/scheduler]/{Entity}.json). */
|
|
20
|
+
export function distillSchema(raw, apiId) {
|
|
21
|
+
const fields = (raw.fields || []).map((f) => {
|
|
22
|
+
const rules = f.fieldValidateRules || [];
|
|
23
|
+
const field = {
|
|
24
|
+
name: f.fieldName,
|
|
25
|
+
type: f.dataType || f.fieldType || 'string',
|
|
26
|
+
required: rules.includes('required') || f.nullable === false,
|
|
27
|
+
};
|
|
28
|
+
if (f.unique || rules.includes('unique')) field.unique = true;
|
|
29
|
+
if (f.readOnly) field.readOnly = true;
|
|
30
|
+
if (f.translatable) field.translatable = true;
|
|
31
|
+
// Enums carry their values in `fieldValues` (comma list).
|
|
32
|
+
if (f.fieldValues) field.enum = String(f.fieldValues).split(',').map((v) => v.trim());
|
|
33
|
+
return field;
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const relationships = (raw.relationships || []).map((r) => {
|
|
37
|
+
const rel = {
|
|
38
|
+
name: r.relationshipName,
|
|
39
|
+
kind: r.relationshipType,
|
|
40
|
+
references: r.otherEntityName,
|
|
41
|
+
keyField: r.otherEntityField || 'name',
|
|
42
|
+
};
|
|
43
|
+
if (r.nullable === false) rel.required = true;
|
|
44
|
+
return rel;
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
api: apiId || (raw.microserviceName === 'scheduler' ? 'scheduler' : 'do'),
|
|
49
|
+
entity: raw.name,
|
|
50
|
+
endpoint: raw.endpoint,
|
|
51
|
+
microservice: raw.microserviceName,
|
|
52
|
+
naturalKey: naturalKeyFor(fields),
|
|
53
|
+
fields,
|
|
54
|
+
relationships,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The stable cross-environment key: the unique+required field, else name/code. */
|
|
59
|
+
function naturalKeyFor(fields) {
|
|
60
|
+
const unique = fields.find((f) => f.unique && f.required);
|
|
61
|
+
if (unique) return unique.name;
|
|
62
|
+
if (fields.some((f) => f.name === 'name')) return 'name';
|
|
63
|
+
if (fields.some((f) => f.name === 'code')) return 'code';
|
|
64
|
+
return 'name';
|
|
65
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@gotillit/tllt",
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "TilliT CLI — manage TilliT configuration across the Digital Operations and Scheduler APIs. Built to be agent- and install-friendly: bootstrap a connection once, then import / diff / deploy.",
|
|
5
|
+
"main": "tllt.js",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"tllt": "tllt.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"tllt.js",
|
|
12
|
+
"configure.js",
|
|
13
|
+
"import.js",
|
|
14
|
+
"diff.js",
|
|
15
|
+
"deploy.js",
|
|
16
|
+
"schema.js",
|
|
17
|
+
"lib/",
|
|
18
|
+
"docs/",
|
|
19
|
+
"AGENTS.md",
|
|
20
|
+
"README.MD"
|
|
21
|
+
],
|
|
22
|
+
"engines": {
|
|
23
|
+
"node": ">=22.12"
|
|
24
|
+
},
|
|
25
|
+
"scripts": {
|
|
26
|
+
"start": "node tllt.js",
|
|
27
|
+
"exec": "node tllt.js",
|
|
28
|
+
"publish-npm": "npm publish --access public"
|
|
29
|
+
},
|
|
30
|
+
"author": "Rafael Amaral",
|
|
31
|
+
"license": "ISC",
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"@inquirer/prompts": "^8.5.2",
|
|
34
|
+
"commander": "^15.0.0",
|
|
35
|
+
"got": "^15.0.5"
|
|
36
|
+
}
|
|
37
|
+
}
|
package/schema.js
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import {resolveProfile} from './lib/config.js';
|
|
2
|
+
import {fetchAsset} from './lib/client.js';
|
|
3
|
+
import {getApi} from './lib/apis.js';
|
|
4
|
+
import {distillSchema} from './lib/schema.js';
|
|
5
|
+
import {DO_ENTITIES, SCHEDULER_ENTITIES} from './import.js';
|
|
6
|
+
import {info, result} from './lib/output.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Describe an entity's data model so an agent knows how to read/create/edit a
|
|
10
|
+
* record before touching it: field types, required/unique/enum, and which
|
|
11
|
+
* entity each relationship points at (and by which key).
|
|
12
|
+
*
|
|
13
|
+
* tllt schema # list every known entity + its API
|
|
14
|
+
* tllt schema Asset # the contract for one entity (live, DO)
|
|
15
|
+
* tllt schema Equipment # scheduler entity (live, under scheduler/)
|
|
16
|
+
* tllt --json schema Asset # machine-readable
|
|
17
|
+
*
|
|
18
|
+
* Both APIs publish the same schema live:
|
|
19
|
+
* DO /assets/data/schema/{Entity}.json
|
|
20
|
+
* Scheduler /assets/data/schema/scheduler/{Entity}.json
|
|
21
|
+
*/
|
|
22
|
+
const SCHEDULER_ENTITY_NAMES = new Set(SCHEDULER_ENTITIES.map((e) => e.entityName));
|
|
23
|
+
|
|
24
|
+
const doSchema = async (opts = {}) => {
|
|
25
|
+
const entity = opts.entity;
|
|
26
|
+
if (!entity) return listEntities();
|
|
27
|
+
|
|
28
|
+
const apiId = resolveApi(entity, opts.api);
|
|
29
|
+
const profile = resolveProfile(opts.profile);
|
|
30
|
+
const api = getApi(apiId);
|
|
31
|
+
const raw = await fetchAsset(profile, apiId, `${api.schemaPath}/${entity}.json`);
|
|
32
|
+
const schema = distillSchema(raw, apiId);
|
|
33
|
+
|
|
34
|
+
printSchema(schema);
|
|
35
|
+
result(null, {ok: true, schema});
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
function listEntities() {
|
|
39
|
+
const entities = [
|
|
40
|
+
...DO_ENTITIES.map((e) => ({api: 'do', entity: e.entityName})),
|
|
41
|
+
...SCHEDULER_ENTITIES.map((e) => ({api: 'scheduler', entity: e.entityName})),
|
|
42
|
+
];
|
|
43
|
+
for (const e of entities) info(` ${e.api.padEnd(9)} ${e.entity}`);
|
|
44
|
+
info('\nRun `tllt schema <Entity>` for the full contract.');
|
|
45
|
+
result(null, {ok: true, entities});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function resolveApi(entity, explicit) {
|
|
49
|
+
if (explicit) return explicit;
|
|
50
|
+
return SCHEDULER_ENTITY_NAMES.has(entity) ? 'scheduler' : 'do';
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function printSchema(s) {
|
|
54
|
+
const loc = s.microservice ? `${s.microservice}/${s.endpoint}` : s.endpoint;
|
|
55
|
+
info(`${s.entity} (${s.api}) endpoint: ${loc} naturalKey: ${s.naturalKey}`);
|
|
56
|
+
if (s.fields?.length) {
|
|
57
|
+
info('Fields:');
|
|
58
|
+
for (const f of s.fields) {
|
|
59
|
+
const tags = [
|
|
60
|
+
f.required ? 'required' : null,
|
|
61
|
+
f.unique ? 'unique' : null,
|
|
62
|
+
f.readOnly ? 'readOnly' : null,
|
|
63
|
+
f.enum ? `enum: ${f.enum.join(', ')}` : null,
|
|
64
|
+
]
|
|
65
|
+
.filter(Boolean)
|
|
66
|
+
.join(' ');
|
|
67
|
+
info(` ${f.name.padEnd(24)} ${String(f.type).padEnd(8)} ${tags}`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
if (s.relationships?.length) {
|
|
71
|
+
info('References:');
|
|
72
|
+
for (const r of s.relationships) {
|
|
73
|
+
const req = r.required ? ' required' : '';
|
|
74
|
+
info(` ${r.name.padEnd(24)} -> ${r.references} (by ${r.keyField}) [${r.kind}]${req}`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export default doSchema;
|