@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 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 changes are
106
- // independent of scheduler ordering and keep their original order.
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 writes = changes.filter((c) => c.op !== 'delete').sort((a, b) => order(a) - order(b));
109
- const deletes = changes.filter((c) => c.op === 'delete').sort((a, b) => order(b) - order(a));
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
- const body = desc ? resolveRefs(c.record, desc, lookups) : c.record;
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
- return {collPath: `${meta.microservice}/${meta.endpoint}`, desc: null, lookups: new Map(), prefix: '', dataTemplateId: null};
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
- const cacheKey = `${prefix}|${entity}`;
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
- const key = api === 'scheduler' && desc ? matchKey(r, desc) : r?.name ?? r?.code;
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.0",
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('0.3.0')
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
 
File without changes