@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,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;