@gotillit/tllt 0.3.0 → 0.3.5
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/deploy.js +106 -15
- package/package.json +1 -1
- package/tllt.js +4 -1
- /package/{README.MD → README.md} +0 -0
package/deploy.js
CHANGED
|
@@ -2,7 +2,9 @@ import fs from 'fs';
|
|
|
2
2
|
import {confirm} from '@inquirer/prompts';
|
|
3
3
|
|
|
4
4
|
import {resolveProfile} from './lib/config.js';
|
|
5
|
-
import {apiClient} from './lib/client.js';
|
|
5
|
+
import {apiClient, fetchAsset} from './lib/client.js';
|
|
6
|
+
import {getApi} from './lib/apis.js';
|
|
7
|
+
import {distillSchema} from './lib/schema.js';
|
|
6
8
|
import {computeChangeset} from './diff.js';
|
|
7
9
|
import {info, result, isJsonMode} from './lib/output.js';
|
|
8
10
|
import {
|
|
@@ -77,7 +79,7 @@ const doDeploy = async (opts = {}) => {
|
|
|
77
79
|
}
|
|
78
80
|
}
|
|
79
81
|
|
|
80
|
-
const applied = await applyChanges({targetProfile, to, changes});
|
|
82
|
+
const applied = await applyChanges({targetProfile, to, changes, profile: targetProfile});
|
|
81
83
|
const counts = applied.reduce(
|
|
82
84
|
(acc, a) => {
|
|
83
85
|
acc[a.ok ? a.op : 'failed']++;
|
|
@@ -91,7 +93,7 @@ const doDeploy = async (opts = {}) => {
|
|
|
91
93
|
return result('Deploy complete.', {ok: counts.failed === 0, to, counts, applied});
|
|
92
94
|
};
|
|
93
95
|
|
|
94
|
-
async function applyChanges({targetProfile, to, changes}) {
|
|
96
|
+
async function applyChanges({targetProfile, to, changes, profile}) {
|
|
95
97
|
const clients = new Map(); // api -> got client
|
|
96
98
|
const getClient = async (api) => {
|
|
97
99
|
if (!clients.has(api)) clients.set(api, await apiClient(targetProfile, api));
|
|
@@ -100,13 +102,21 @@ async function applyChanges({targetProfile, to, changes}) {
|
|
|
100
102
|
|
|
101
103
|
const tplCache = {map: null}; // dataTemplate name -> {id, locationCode}
|
|
102
104
|
const liveMaps = new Map(); // `${prefix}|${entity}` -> Map(naturalKey -> id)
|
|
105
|
+
const doSchemaCache = new Map(); // DO entity name -> distilled schema
|
|
103
106
|
|
|
104
107
|
// Apply in dependency order: creates/updates ascending (parents before
|
|
105
|
-
// children), deletes descending (children before parents). DO
|
|
106
|
-
//
|
|
108
|
+
// children), deletes descending (children before parents). For DO we don't
|
|
109
|
+
// know cross-entity ordering, but within an entity we sort records with no
|
|
110
|
+
// self-parent (`parent == null`) first so children can resolve their parent
|
|
111
|
+
// refs to a live id on the same pass.
|
|
107
112
|
const order = (c) => (c.api === 'scheduler' ? schedulerDeployOrder(c.entity) : 0);
|
|
108
|
-
const
|
|
109
|
-
const
|
|
113
|
+
const selfParentDepth = (c) => (c.record && c.record.parent == null ? 0 : 1);
|
|
114
|
+
const writes = changes
|
|
115
|
+
.filter((c) => c.op !== 'delete')
|
|
116
|
+
.sort((a, b) => order(a) - order(b) || selfParentDepth(a) - selfParentDepth(b));
|
|
117
|
+
const deletes = changes
|
|
118
|
+
.filter((c) => c.op === 'delete')
|
|
119
|
+
.sort((a, b) => order(b) - order(a) || selfParentDepth(b) - selfParentDepth(a));
|
|
110
120
|
|
|
111
121
|
// Group consecutive changes by api+entity+dataTemplate so each live
|
|
112
122
|
// collection is fetched once. Grouping preserves the dependency ordering.
|
|
@@ -121,7 +131,7 @@ async function applyChanges({targetProfile, to, changes}) {
|
|
|
121
131
|
let ctx;
|
|
122
132
|
try {
|
|
123
133
|
const client = await getClient(api);
|
|
124
|
-
ctx = await resolveContext({client, api, meta, tplCache, liveMaps});
|
|
134
|
+
ctx = await resolveContext({client, api, meta, tplCache, liveMaps, profile, doSchemaCache});
|
|
125
135
|
} catch (err) {
|
|
126
136
|
for (const c of items) applied.push({...ref(c), ok: false, error: err.message});
|
|
127
137
|
continue;
|
|
@@ -138,9 +148,24 @@ async function applyChanges({targetProfile, to, changes}) {
|
|
|
138
148
|
|
|
139
149
|
for (const c of items) {
|
|
140
150
|
try {
|
|
141
|
-
|
|
151
|
+
// Deletes only need the live id and the URL — no body, so skip
|
|
152
|
+
// ref resolution entirely.
|
|
153
|
+
const body = c.op === 'delete' ? null : (desc ? resolveRefs(c.record, desc, lookups) : c.record);
|
|
142
154
|
if (c.op === 'create') {
|
|
143
|
-
await client.post(collPath, {json: body});
|
|
155
|
+
const respBody = await client.post(collPath, {json: body}).json();
|
|
156
|
+
// Surface the new id in our in-memory caches so subsequent records
|
|
157
|
+
// in the same batch (e.g. children that reference this parent) can
|
|
158
|
+
// resolve against it without a live re-fetch.
|
|
159
|
+
const newId = respBody?.id;
|
|
160
|
+
if (newId != null) {
|
|
161
|
+
ownMap.set(c.key, newId);
|
|
162
|
+
for (const [lookupKey, lookupMap] of lookups) {
|
|
163
|
+
if (!lookupKey.startsWith(`${meta.entity}@`)) continue;
|
|
164
|
+
const kf = lookupKey.slice(meta.entity.length + 1);
|
|
165
|
+
const kv = c.record?.[kf];
|
|
166
|
+
if (kv != null) lookupMap.set(String(kv), newId);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
144
169
|
} else {
|
|
145
170
|
const id = ownMap.get(c.key);
|
|
146
171
|
if (id == null) throw new Error(`No live ${meta.entity} keyed "${c.key}" to ${c.op}.`);
|
|
@@ -178,9 +203,59 @@ function groupChanges(changes) {
|
|
|
178
203
|
* entities, bare `{endpoint}` for global ones), and the live key→id lookup maps
|
|
179
204
|
* needed to resolve id references in the body.
|
|
180
205
|
*/
|
|
181
|
-
async function resolveContext({client, api, meta, tplCache, liveMaps}) {
|
|
206
|
+
async function resolveContext({client, api, meta, tplCache, liveMaps, profile, doSchemaCache}) {
|
|
182
207
|
if (api !== 'scheduler') {
|
|
183
|
-
|
|
208
|
+
const collPath = `${meta.microservice}/${meta.endpoint}`;
|
|
209
|
+
// Build a desc + lookups so DO bodies get their {name:..} refs resolved
|
|
210
|
+
// to {id:..} the same way scheduler does. Without this, the DO API
|
|
211
|
+
// receives un-resolved name refs and silently drops them on update
|
|
212
|
+
// (assets) or rejects on create (option items).
|
|
213
|
+
if (!profile) return {collPath, desc: null, lookups: new Map(), prefix: '', dataTemplateId: null};
|
|
214
|
+
let schema;
|
|
215
|
+
try {
|
|
216
|
+
schema = await getDoSchema(profile, meta.entity, doSchemaCache);
|
|
217
|
+
} catch (err) {
|
|
218
|
+
// If the schema can't be fetched, fall back to the original pass-through
|
|
219
|
+
// behaviour so we don't regress entities the user could deploy before.
|
|
220
|
+
return {collPath, desc: null, lookups: new Map(), prefix: '', dataTemplateId: null};
|
|
221
|
+
}
|
|
222
|
+
// For DO we treat every ref as nullable client-side and let the API
|
|
223
|
+
// enforce its own required rules. Two reasons: (a) imported records often
|
|
224
|
+
// have legitimately-null refs (e.g. Asset.calendar) that the schema marks
|
|
225
|
+
// required, and (b) on update the API merges, so omitting a field is a
|
|
226
|
+
// valid no-op.
|
|
227
|
+
const refs = (schema.relationships || []).map((r) => ({
|
|
228
|
+
field: r.name,
|
|
229
|
+
kind: 'id',
|
|
230
|
+
nullable: true,
|
|
231
|
+
keyField: r.keyField || 'name',
|
|
232
|
+
lookupEntity: r.references,
|
|
233
|
+
keyOf: (val) => (val == null ? null : val[r.keyField || 'name']),
|
|
234
|
+
}));
|
|
235
|
+
// DO refs may key by `value` (etc.), so we tag the lookup key with the
|
|
236
|
+
// keyField. resolveRefs reads lookups by ref.lookupEntity, so we rewrite
|
|
237
|
+
// each ref's lookupEntity to include the keyField suffix and store the
|
|
238
|
+
// matching map under the same composite key.
|
|
239
|
+
const desc = {entityName: meta.entity, refs: refs.map((r) => ({...r, lookupEntity: `${r.lookupEntity}@${r.keyField}`}))};
|
|
240
|
+
const lookups = new Map();
|
|
241
|
+
for (const r of refs) {
|
|
242
|
+
let refSchema;
|
|
243
|
+
try {
|
|
244
|
+
refSchema = await getDoSchema(profile, r.lookupEntity, doSchemaCache);
|
|
245
|
+
} catch (err) {
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
const refCollPath = refSchema.microservice
|
|
249
|
+
? `${refSchema.microservice}/${refSchema.endpoint}`
|
|
250
|
+
: refSchema.endpoint;
|
|
251
|
+
const map = await liveKeyIdMap({
|
|
252
|
+
client, api, collPath: refCollPath, desc: null, liveMaps,
|
|
253
|
+
prefix: '', entity: r.lookupEntity, dataTemplateId: null,
|
|
254
|
+
keyField: r.keyField,
|
|
255
|
+
});
|
|
256
|
+
lookups.set(`${r.lookupEntity}@${r.keyField}`, map);
|
|
257
|
+
}
|
|
258
|
+
return {collPath, desc, lookups, prefix: '', dataTemplateId: null};
|
|
184
259
|
}
|
|
185
260
|
const desc = getSchedulerEntity(meta.entity);
|
|
186
261
|
if (!desc) throw new Error(`Unknown scheduler entity "${meta.entity}"; re-import.`);
|
|
@@ -252,8 +327,11 @@ async function resolveTemplate(client, name, tplCache) {
|
|
|
252
327
|
* keys are computed with the entity descriptor (matching the snapshot keys) and
|
|
253
328
|
* request the `expand` relations those keys depend on; DO falls back to name/code.
|
|
254
329
|
*/
|
|
255
|
-
async function liveKeyIdMap({client, api, collPath, desc, liveMaps, prefix, entity, dataTemplateId}) {
|
|
256
|
-
|
|
330
|
+
async function liveKeyIdMap({client, api, collPath, desc, liveMaps, prefix, entity, dataTemplateId, keyField}) {
|
|
331
|
+
// DO refs may resolve by a non-name field (e.g. OptionItem.parent uses
|
|
332
|
+
// `value`). Include keyField in the cache key so name and value maps for the
|
|
333
|
+
// same entity don't collide.
|
|
334
|
+
const cacheKey = `${prefix}|${entity}|${keyField ?? 'natural'}`;
|
|
257
335
|
if (liveMaps.has(cacheKey)) return liveMaps.get(cacheKey);
|
|
258
336
|
|
|
259
337
|
const expand = api === 'scheduler' && desc ? expandParam(desc) : undefined;
|
|
@@ -267,13 +345,26 @@ async function liveKeyIdMap({client, api, collPath, desc, liveMaps, prefix, enti
|
|
|
267
345
|
const map = new Map();
|
|
268
346
|
for (const r of records) {
|
|
269
347
|
if (r?.id == null) continue;
|
|
270
|
-
|
|
348
|
+
let key;
|
|
349
|
+
if (api === 'scheduler' && desc) key = matchKey(r, desc);
|
|
350
|
+
else if (keyField) key = r?.[keyField];
|
|
351
|
+
else key = r?.name ?? r?.code;
|
|
271
352
|
if (key != null) map.set(String(key), r.id);
|
|
272
353
|
}
|
|
273
354
|
liveMaps.set(cacheKey, map);
|
|
274
355
|
return map;
|
|
275
356
|
}
|
|
276
357
|
|
|
358
|
+
async function getDoSchema(profile, entityName, cache) {
|
|
359
|
+
if (cache.has(entityName)) return cache.get(entityName);
|
|
360
|
+
const apiId = 'do';
|
|
361
|
+
const api = getApi(apiId);
|
|
362
|
+
const raw = await fetchAsset(profile, apiId, `${api.schemaPath}/${entityName}.json`);
|
|
363
|
+
const schema = distillSchema(raw, apiId);
|
|
364
|
+
cache.set(entityName, schema);
|
|
365
|
+
return schema;
|
|
366
|
+
}
|
|
367
|
+
|
|
277
368
|
function ref(c) {
|
|
278
369
|
return {op: c.op, api: c.api, entity: c.entity, key: c.key};
|
|
279
370
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gotillit/tllt",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.5",
|
|
4
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
5
|
"main": "tllt.js",
|
|
6
6
|
"type": "module",
|
package/tllt.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import {readFileSync} from 'node:fs';
|
|
2
3
|
import {Command} from 'commander';
|
|
3
4
|
|
|
4
5
|
import doConfigure from './configure.js';
|
|
@@ -9,6 +10,8 @@ import doSchema from './schema.js';
|
|
|
9
10
|
import {listProfiles} from './lib/config.js';
|
|
10
11
|
import {setJsonMode, run, result, info} from './lib/output.js';
|
|
11
12
|
|
|
13
|
+
const {version} = JSON.parse(readFileSync(new URL('./package.json', import.meta.url)));
|
|
14
|
+
|
|
12
15
|
const program = new Command();
|
|
13
16
|
|
|
14
17
|
program
|
|
@@ -26,7 +29,7 @@ program
|
|
|
26
29
|
'Agents: read AGENTS.md and docs/ to learn the tool; pass --json for machine output.',
|
|
27
30
|
].join('\n')
|
|
28
31
|
)
|
|
29
|
-
.version(
|
|
32
|
+
.version(version)
|
|
30
33
|
.option('-p, --profile <name>', 'connection profile to use (default: the configured default)')
|
|
31
34
|
.option('--json', 'emit machine-readable JSON on stdout (agent-friendly)');
|
|
32
35
|
|
/package/{README.MD → README.md}
RENAMED
|
File without changes
|