@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 ADDED
@@ -0,0 +1,59 @@
1
+ # TilliT CLI — agent guide
2
+
3
+ You are driving `tllt` (`@gotillit/tllt`), a tool for managing TilliT
4
+ configuration across two APIs: **Digital Operations (DO)** and **Scheduler**.
5
+
6
+ ## How to use this tool effectively
7
+
8
+ 1. **Always pass `--json`.** Structured results go to **stdout**; human progress
9
+ goes to stderr. On failure you get `{"ok": false, "error": "..."}` and a
10
+ non-zero exit code. (Or set `TILLIT_JSON=1`.)
11
+ 2. **Everything is flag-driven** — never rely on interactive prompts. Supply all
12
+ required flags and commands run non-interactively.
13
+ 3. **Select a connection with `--profile <name>`** (or rely on the default).
14
+
15
+ ## The golden path
16
+
17
+ ```bash
18
+ tllt configure --tenant <t> --environment <env> --auth basic \
19
+ --username <u> --password <p> --default # bootstrap a connection (once)
20
+
21
+ tllt --json import --profile <t>-<env> # snapshot live config to files
22
+ tllt --json diff --from <A> --to <B> # changeset to make B look like A
23
+ tllt --json deploy --from <A> --to <B> --dry-run # preview
24
+ tllt --json deploy --from <A> --to <B> --yes # apply
25
+ ```
26
+
27
+ ## Read these for detail
28
+
29
+ - [`docs/commands.md`](docs/commands.md) — every command, flag, and JSON shape
30
+ - [`docs/schemas.md`](docs/schemas.md) — **how to learn each entity's data model** (read before editing records)
31
+ - [`docs/authentication.md`](docs/authentication.md) — basic vs api-key/Cognito
32
+ - [`docs/workflow.md`](docs/workflow.md) — snapshot layout, diff/deploy semantics
33
+ - [`docs/apis.md`](docs/apis.md) — the two APIs and what each owns
34
+ - [`docs/scheduler.md`](docs/scheduler.md) — how scheduler config differs (important & different)
35
+
36
+ ## Things that will bite you if you forget
37
+
38
+ - Before creating/editing a record, **read its schema** — either the
39
+ `_schema.json` in the same folder or `tllt schema <Entity>`. It tells you
40
+ required fields, enums, and what each nested `{name}` reference points at.
41
+ - A **connection = environment + tenant**; the profile name is `{tenant}-{environment}`.
42
+ - `deploy` matches records across environments by **natural key** (the `name`,
43
+ used as the filename), because volatile `id`/`createdAt`/`updatedAt` are
44
+ stripped on import.
45
+ - `deploy` **never deletes** unless you pass `--prune`.
46
+ - **Scheduler config is organised per `DataTemplate`** and driven through the
47
+ normalized REST API (`/{locationCode}/{dataTemplateId}/{entity}`). Snapshots
48
+ nest under the template name; global entities (`Location`, `DataTemplate`,
49
+ `OptimisationProfile`) sit at the top. Deploy creates a missing template and
50
+ applies records in dependency order (parents first). See `docs/scheduler.md`.
51
+ - **For scheduler records, `schema` IS authoritative.** Write shapes match the
52
+ published schema. Nested references are stored by natural key
53
+ (`{ "name": "Bottling" }`) and the CLI resolves them to the target's id on
54
+ deploy. A referenced parent must be in the same deploy or already on the target.
55
+ - **Changeover set binds to equipment via the `changeoverSet` ref on create** (a
56
+ required FK — equipment can't be created without it); it persists, no separate
57
+ step. **Availability does NOT** bind via the normalized API — use the gateway
58
+ `availabilities` + `equipment-availabilities` endpoints (≈1yr horizon). See
59
+ `docs/scheduler.md` → "Binding changeover sets & availability to equipment".
package/README.MD ADDED
@@ -0,0 +1,106 @@
1
+ # TilliT CLI
2
+
3
+ Manage TilliT configuration across the **Digital Operations (DO)** and **Scheduler**
4
+ APIs. Built to be friendly for both humans and AI agents: bootstrap a connection once,
5
+ then `import` → `diff` → `deploy`.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ npm install -g @gotillit/tllt # or run ad-hoc with: npx @gotillit/tllt <command>
11
+ ```
12
+
13
+ ## First run — bootstrap a connection
14
+
15
+ A **connection is identified by environment + tenant**, so you can keep several side by
16
+ side (e.g. `client1` on prod and `client2` on stage). Profiles are stored in
17
+ `~/.tillit/config.json` and named `{tenant}-{environment}`.
18
+
19
+ Interactive:
20
+
21
+ ```bash
22
+ tllt configure
23
+ ```
24
+
25
+ Non-interactive (agent-friendly) — basic auth:
26
+
27
+ ```bash
28
+ tllt configure --tenant client1 --environment prod \
29
+ --auth basic --username alice --password '••••' --default
30
+ ```
31
+
32
+ Non-interactive — API key + secret (Cognito `client_credentials` → bearer token):
33
+
34
+ ```bash
35
+ tllt configure --tenant client2 --environment stage \
36
+ --auth apikey --api-key <cognitoClientId> --api-secret <secret> \
37
+ --token-url https://<domain>.auth.<region>.amazoncognito.com/oauth2/token \
38
+ --scopes 'api/*.read,api/*.write'
39
+ ```
40
+
41
+ List connections:
42
+
43
+ ```bash
44
+ tllt profiles
45
+ ```
46
+
47
+ ## Authentication
48
+
49
+ | Method | How it authenticates | APIs |
50
+ | -------- | ------------------------------------------------------------------------------- | ----------- |
51
+ | `basic` | `Authorization: Basic base64(username@{tenant}.tillit.cloud:password)` | DO |
52
+ | `apikey` | Cognito `client_credentials` → cached bearer token; `tillit-tenant` header set | DO + Scheduler |
53
+
54
+ Tokens obtained via `apikey` are cached in `~/.tillit/tokens.json` until shortly before
55
+ they expire.
56
+
57
+ ## Workflow
58
+
59
+ ```bash
60
+ # 1. Snapshot the live configuration into ./{profile}/ (the current directory)
61
+ tllt import --profile client2-stage # use --dir <path> to write elsewhere
62
+
63
+ # 2. Compare two snapshots (desired -> target) and review the changeset
64
+ tllt diff --from client2-stage --to client1-prod
65
+
66
+ # 3. Apply the difference to the live target
67
+ tllt deploy --from client2-stage --to client1-prod --dry-run # preview
68
+ tllt deploy --from client2-stage --to client1-prod # apply (asks to confirm)
69
+ tllt deploy --from client2-stage --to client1-prod --prune -y # also delete extras, no prompt
70
+ ```
71
+
72
+ Snapshots are written to the **current directory** (override with `--dir`), so you can keep
73
+ config in its own git repo. They live under `./{profile}/{api}/{Entity}/…` with a `_meta.json` per entity
74
+ recording how to route writes. Volatile fields (`id`, `createdAt`, `updatedAt`) are
75
+ stripped on import, so a content difference reflects a real configuration change. Records
76
+ are matched across environments by their natural key (the `name`, used as the filename).
77
+
78
+ ## Agent-friendly notes
79
+
80
+ - Add `--json` to any command for machine-readable output on **stdout** (progress goes to
81
+ stderr). Errors are emitted as `{ "ok": false, "error": "…" }` with a non-zero exit code.
82
+ - All commands are fully flag-driven — no interactive prompts when the required flags are
83
+ supplied. Set `TILLIT_JSON=1` to force JSON mode.
84
+ - `--profile <name>` selects a connection; otherwise the default profile is used.
85
+
86
+ ## Documentation (for humans and AI agents)
87
+
88
+ Agent-readable guides live alongside the code:
89
+
90
+ - [`AGENTS.md`](AGENTS.md) — start here: golden path + gotchas
91
+ - [`docs/commands.md`](docs/commands.md) — full command/flag/JSON reference
92
+ - [`docs/schemas.md`](docs/schemas.md) — how to learn each entity's data model
93
+ - [`docs/authentication.md`](docs/authentication.md) — basic vs api-key/Cognito
94
+ - [`docs/workflow.md`](docs/workflow.md) — snapshot layout + diff/deploy semantics
95
+ - [`docs/apis.md`](docs/apis.md) — the DO and Scheduler APIs
96
+ - [`docs/scheduler.md`](docs/scheduler.md) — Scheduler's dataTemplate scoping
97
+
98
+ ## Commands
99
+
100
+ | Command | Description |
101
+ | ----------- | ----------------------------------------------------------------- |
102
+ | `configure` | Bootstrap or update a connection (alias: `init`) |
103
+ | `profiles` | List configured connections (alias: `connections`) |
104
+ | `import` | Snapshot a connection's current configuration into local files |
105
+ | `diff` | Compare two snapshots and show the changeset |
106
+ | `deploy` | Apply the difference between two snapshots to the live target |
package/configure.js ADDED
@@ -0,0 +1,243 @@
1
+ import {input, select, password, confirm} from '@inquirer/prompts';
2
+
3
+ import {
4
+ ENVIRONMENTS,
5
+ ENTERPRISE_TIERS,
6
+ defaultBaseUrl,
7
+ profileName,
8
+ saveProfile,
9
+ listProfiles,
10
+ } from './lib/config.js';
11
+ import {apiClient} from './lib/client.js';
12
+ import {info, result, isJsonMode} from './lib/output.js';
13
+
14
+ /**
15
+ * Bootstrap (or update) a connection. A connection is identified by
16
+ * environment + tenant, so you can keep several side by side, e.g. client1 on
17
+ * prod and client2 on stage.
18
+ *
19
+ * Fully interactive with no flags (first-run bootstrap); fully non-interactive
20
+ * when the required flags are supplied (agent-friendly).
21
+ *
22
+ * Flags: --tenant --environment --auth basic|apikey
23
+ * basic: --username --password
24
+ * apikey: --api-key --api-secret --token-url [--scopes a,b]
25
+ * common: --base-url (override) --default
26
+ */
27
+ const doConfigure = async (opts = {}) => {
28
+ const answers = await collectAnswers(opts);
29
+
30
+ const {tenant, environment, enterprise, tier} = answers;
31
+ const baseUrl = answers.baseUrl || defaultBaseUrl(tenant, environment, {enterprise, tier});
32
+ const name = profileName(tenant, environment, {enterprise, tier});
33
+
34
+ const profile = {tenant, environment, baseUrl, auth: buildAuth(answers)};
35
+ if (environment === 'enterprise') {
36
+ profile.enterprise = enterprise;
37
+ profile.tier = tier || 'stage';
38
+ }
39
+ saveProfile(name, profile, {makeDefault: answers.makeDefault});
40
+
41
+ info(`\nSaved connection "${name}" (${answers.method} auth) → ${baseUrl}`);
42
+
43
+ // Smoke-test the connection so a bad host/credential is caught now, not on
44
+ // the first import. Querying sites is a cheap, always-present DO endpoint.
45
+ const test = await testConnection({name, ...profile});
46
+ if (test.ok) {
47
+ info(`Connection test: OK — ${test.count} site(s) returned.`);
48
+ } else {
49
+ info(`Connection test: FAILED — ${test.error}`);
50
+ info('The connection was saved anyway; fix the details and re-run configure if needed.');
51
+ }
52
+
53
+ info('\nNext steps:');
54
+ info(` tllt import --profile ${name} # snapshot the current configuration`);
55
+ info(` tllt diff --profile ${name} # see local vs live changes`);
56
+ info(` tllt deploy --profile ${name} # apply the changes`);
57
+
58
+ result(`Connection "${name}" configured.`, {
59
+ ok: true,
60
+ profile: name,
61
+ tenant,
62
+ environment,
63
+ baseUrl,
64
+ authMethod: answers.method,
65
+ test,
66
+ profiles: listProfiles(),
67
+ });
68
+ };
69
+
70
+ /**
71
+ * Verify a freshly-saved connection by querying sites (a small, always-present
72
+ * DO endpoint). Resolves credentials/token too, so it catches bad hosts, wrong
73
+ * passwords, and Cognito misconfig. Never throws — returns {ok, count|error}.
74
+ */
75
+ async function testConnection(profile) {
76
+ try {
77
+ const client = await apiClient(profile, 'do');
78
+ const data = await client.get('core/sites', {searchParams: {size: 1000}}).json();
79
+ const sites = Array.isArray(data) ? data : data?.content ?? [];
80
+ return {ok: true, count: sites.length};
81
+ } catch (err) {
82
+ const status = err.response?.statusCode;
83
+ return {ok: false, error: status ? `HTTP ${status}: ${err.message}` : err.message};
84
+ }
85
+ }
86
+
87
+ function buildAuth(a) {
88
+ if (a.method === 'basic') {
89
+ return {method: 'basic', username: a.username, password: a.password};
90
+ }
91
+ return {
92
+ method: 'apikey',
93
+ apiKey: a.apiKey,
94
+ apiSecret: a.apiSecret,
95
+ tokenUrl: a.tokenUrl,
96
+ scopes: a.scopes,
97
+ };
98
+ }
99
+
100
+ async function collectAnswers(opts) {
101
+ const flags = normalizeFlags(opts);
102
+ // If the essentials are present we run fully non-interactive.
103
+ if (canRunNonInteractive(flags)) {
104
+ validateNonInteractive(flags);
105
+ return flags;
106
+ }
107
+ if (isJsonMode()) {
108
+ throw new Error(
109
+ 'Missing required flags for non-interactive configure. Provide --tenant, --environment, --auth and the matching credential flags.'
110
+ );
111
+ }
112
+ return promptForAnswers(flags);
113
+ }
114
+
115
+ function normalizeFlags(opts) {
116
+ const scopes = opts.scopes
117
+ ? String(opts.scopes)
118
+ .split(',')
119
+ .map((s) => s.trim())
120
+ .filter(Boolean)
121
+ : undefined;
122
+ return {
123
+ tenant: opts.tenant,
124
+ environment: opts.environment,
125
+ enterprise: opts.enterprise,
126
+ tier: opts.tier,
127
+ method: opts.auth,
128
+ username: opts.username,
129
+ password: opts.password,
130
+ apiKey: opts.apiKey,
131
+ apiSecret: opts.apiSecret,
132
+ tokenUrl: opts.tokenUrl,
133
+ scopes,
134
+ baseUrl: opts.baseUrl,
135
+ makeDefault: Boolean(opts.default),
136
+ };
137
+ }
138
+
139
+ function canRunNonInteractive(f) {
140
+ if (!f.tenant || !f.environment || !f.method) return false;
141
+ if (f.environment === 'enterprise' && !f.enterprise) return false;
142
+ if (f.method === 'basic') return Boolean(f.username && f.password);
143
+ if (f.method === 'apikey') return Boolean(f.apiKey && f.apiSecret && f.tokenUrl);
144
+ return false;
145
+ }
146
+
147
+ function validateNonInteractive(f) {
148
+ if (!ENVIRONMENTS.includes(f.environment)) {
149
+ throw new Error(`--environment must be one of: ${ENVIRONMENTS.join(', ')}`);
150
+ }
151
+ if (f.environment === 'enterprise') {
152
+ if (!f.enterprise) throw new Error('--enterprise <name> is required when --environment enterprise');
153
+ if (f.tier && !ENTERPRISE_TIERS.includes(f.tier)) {
154
+ throw new Error(`--tier must be one of: ${ENTERPRISE_TIERS.join(', ')}`);
155
+ }
156
+ }
157
+ if (!['basic', 'apikey'].includes(f.method)) {
158
+ throw new Error('--auth must be "basic" or "apikey"');
159
+ }
160
+ }
161
+
162
+ async function promptForAnswers(flags) {
163
+ const merged = {...flags};
164
+
165
+ if (!merged.tenant) {
166
+ merged.tenant = await input({message: 'Tenant', validate: req});
167
+ }
168
+ if (!merged.environment) {
169
+ merged.environment = await select({
170
+ message: 'Environment',
171
+ choices: ENVIRONMENTS.map((v) => ({name: v, value: v})),
172
+ });
173
+ }
174
+ if (!merged.method) {
175
+ merged.method = await select({
176
+ message: 'Authentication method',
177
+ choices: [
178
+ {name: 'Username + password (basic auth)', value: 'basic'},
179
+ {name: 'API key + secret (Cognito bearer token)', value: 'apikey'},
180
+ ],
181
+ });
182
+ }
183
+
184
+ // Enterprise (dedicated) environments need an enterprise name + tier so the
185
+ // base URL becomes {tenant}.{enterprise}.tillit-{tier}.cloud.
186
+ if (merged.environment === 'enterprise') {
187
+ if (!merged.enterprise) {
188
+ merged.enterprise = await input({message: 'Enterprise name (e.g. acme)', validate: req});
189
+ }
190
+ if (!merged.tier) {
191
+ merged.tier = await select({
192
+ message: 'Underlying tier',
193
+ choices: ENTERPRISE_TIERS.map((v) => ({name: v, value: v})),
194
+ default: 'stage',
195
+ });
196
+ }
197
+ }
198
+
199
+ if (merged.method === 'basic') {
200
+ if (!merged.username) {
201
+ merged.username = await input({message: 'Username', validate: req});
202
+ }
203
+ if (!merged.password) {
204
+ merged.password = await password({message: 'Password', validate: req});
205
+ }
206
+ } else {
207
+ if (!merged.apiKey) {
208
+ merged.apiKey = await input({message: 'API key (Cognito app client id)', validate: req});
209
+ }
210
+ if (!merged.apiSecret) {
211
+ merged.apiSecret = await password({message: 'API secret', validate: req});
212
+ }
213
+ if (!merged.tokenUrl) {
214
+ merged.tokenUrl = await input({
215
+ message: 'Cognito OAuth2 token URL (…/oauth2/token)',
216
+ validate: req,
217
+ });
218
+ }
219
+ }
220
+
221
+ if (!merged.baseUrl) {
222
+ const value = await input({
223
+ message: `Base URL [default ${defaultBaseUrl(merged.tenant, merged.environment, {enterprise: merged.enterprise, tier: merged.tier})}]`,
224
+ default: '',
225
+ });
226
+ merged.baseUrl = value.trim() || undefined;
227
+ }
228
+
229
+ if (!merged.makeDefault) {
230
+ merged.makeDefault = await confirm({
231
+ message: 'Make this the default connection?',
232
+ default: true,
233
+ });
234
+ }
235
+
236
+ return merged;
237
+ }
238
+
239
+ function req(value) {
240
+ return value && String(value).trim() ? true : 'Required';
241
+ }
242
+
243
+ export default doConfigure;
package/deploy.js ADDED
@@ -0,0 +1,289 @@
1
+ import fs from 'fs';
2
+ import {confirm} from '@inquirer/prompts';
3
+
4
+ import {resolveProfile} from './lib/config.js';
5
+ import {apiClient} from './lib/client.js';
6
+ import {computeChangeset} from './diff.js';
7
+ import {info, result, isJsonMode} from './lib/output.js';
8
+ import {
9
+ getSchedulerEntity,
10
+ idRefs,
11
+ resolveRefs,
12
+ matchKey,
13
+ expandParam,
14
+ scopeRows,
15
+ schedulerDeployOrder,
16
+ } from './lib/scheduler-entities.js';
17
+
18
+ /**
19
+ * Apply the difference between two snapshots to a live target. The target is
20
+ * the `--to` profile (the system being changed); the desired state is the
21
+ * `--from` snapshot.
22
+ *
23
+ * tllt deploy --from acme-stage --to acme-prod # apply
24
+ * tllt deploy --from acme-stage --to acme-prod --dry-run # plan only
25
+ * tllt deploy --changeset plan.json --to acme-prod # from a saved plan
26
+ *
27
+ * Because volatile ids are stripped on import, deploy resolves the target id
28
+ * for updates/deletes by matching the record's natural key (its `name`, the
29
+ * same value used as the snapshot filename) against the live collection.
30
+ *
31
+ * Deletes are opt-in (`--prune`) so a partial snapshot never wipes live config.
32
+ */
33
+ const doDeploy = async (opts = {}) => {
34
+ const to = opts.to;
35
+ if (!to) throw new Error('deploy requires --to <profile> (the target to change).');
36
+
37
+ let changes;
38
+ if (opts.changeset) {
39
+ changes = JSON.parse(fs.readFileSync(opts.changeset, 'utf8')).changes;
40
+ } else if (opts.from) {
41
+ changes = computeChangeset(opts.from, to, opts);
42
+ } else {
43
+ throw new Error('deploy requires either --from <profile> or --changeset <file>.');
44
+ }
45
+
46
+ // Drop deletes unless explicitly pruning.
47
+ if (!opts.prune) changes = changes.filter((c) => c.op !== 'delete');
48
+ if (opts.api) changes = changes.filter((c) => c.api === opts.api);
49
+
50
+ if (changes.length === 0) {
51
+ info('Nothing to deploy — target is already in sync.');
52
+ return result('Nothing to deploy.', {ok: true, to, applied: [], counts: empty()});
53
+ }
54
+
55
+ info(`Deploy to ${to}`);
56
+ for (const c of changes) {
57
+ const glyph = c.op === 'create' ? '+' : c.op === 'delete' ? '-' : '~';
58
+ info(` ${glyph} ${c.api}/${c.entity}/${c.key}`);
59
+ }
60
+
61
+ // A dry-run only inspects local snapshots — no live credentials required.
62
+ if (opts.dryRun) {
63
+ info('\n--dry-run: no changes applied.');
64
+ return result('Dry run complete.', {ok: true, dryRun: true, to, planned: summarize(changes)});
65
+ }
66
+
67
+ const targetProfile = resolveProfile(to);
68
+
69
+ if (!opts.yes && !isJsonMode()) {
70
+ const confirmed = await confirm({
71
+ message: `Apply ${changes.length} change(s) to ${to}?`,
72
+ default: false,
73
+ });
74
+ if (!confirmed) {
75
+ info('Aborted.');
76
+ return result('Aborted.', {ok: false, aborted: true});
77
+ }
78
+ }
79
+
80
+ const applied = await applyChanges({targetProfile, to, changes});
81
+ const counts = applied.reduce(
82
+ (acc, a) => {
83
+ acc[a.ok ? a.op : 'failed']++;
84
+ return acc;
85
+ },
86
+ {...empty(), failed: 0}
87
+ );
88
+
89
+ info(`\nDone: +${counts.create} ~${counts.update} -${counts.delete} (${counts.failed} failed)`);
90
+ if (counts.failed > 0) process.exitCode = 1;
91
+ return result('Deploy complete.', {ok: counts.failed === 0, to, counts, applied});
92
+ };
93
+
94
+ async function applyChanges({targetProfile, to, changes}) {
95
+ const clients = new Map(); // api -> got client
96
+ const getClient = async (api) => {
97
+ if (!clients.has(api)) clients.set(api, await apiClient(targetProfile, api));
98
+ return clients.get(api);
99
+ };
100
+
101
+ const tplCache = {map: null}; // dataTemplate name -> {id, locationCode}
102
+ const liveMaps = new Map(); // `${prefix}|${entity}` -> Map(naturalKey -> id)
103
+
104
+ // 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.
107
+ 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));
110
+
111
+ // Group consecutive changes by api+entity+dataTemplate so each live
112
+ // collection is fetched once. Grouping preserves the dependency ordering.
113
+ const applied = [];
114
+ for (const group of groupChanges([...writes, ...deletes])) {
115
+ const {api, gk, meta, items} = group;
116
+ if (!meta) {
117
+ for (const c of items) applied.push({...ref(c), ok: false, error: `No routing metadata for ${gk}; re-import.`});
118
+ continue;
119
+ }
120
+
121
+ let ctx;
122
+ try {
123
+ const client = await getClient(api);
124
+ ctx = await resolveContext({client, api, meta, tplCache, liveMaps});
125
+ } catch (err) {
126
+ for (const c of items) applied.push({...ref(c), ok: false, error: err.message});
127
+ continue;
128
+ }
129
+
130
+ const client = await getClient(api);
131
+ const {collPath, desc, lookups, prefix, dataTemplateId} = ctx;
132
+
133
+ // Natural-key → id map (from live) for updates/deletes.
134
+ const needsLive = items.some((c) => c.op !== 'create');
135
+ const ownMap = needsLive
136
+ ? await liveKeyIdMap({client, api, collPath, desc, liveMaps, prefix, entity: meta.entity, dataTemplateId})
137
+ : new Map();
138
+
139
+ for (const c of items) {
140
+ try {
141
+ const body = desc ? resolveRefs(c.record, desc, lookups) : c.record;
142
+ if (c.op === 'create') {
143
+ await client.post(collPath, {json: body});
144
+ } else {
145
+ const id = ownMap.get(c.key);
146
+ if (id == null) throw new Error(`No live ${meta.entity} keyed "${c.key}" to ${c.op}.`);
147
+ if (c.op === 'update') await client.put(`${collPath}/${id}`, {json: body});
148
+ else await client.delete(`${collPath}/${id}`);
149
+ }
150
+ applied.push({...ref(c), ok: true});
151
+ info(` ok ${c.op} ${gk}/${c.key}`);
152
+ } catch (err) {
153
+ applied.push({...ref(c), ok: false, error: err.message});
154
+ info(` FAIL ${c.op} ${gk}/${c.key}: ${err.message}`);
155
+ }
156
+ }
157
+ }
158
+ return applied;
159
+ }
160
+
161
+ /** Group changes by api+entity+dataTemplate, preserving input (dependency) order. */
162
+ function groupChanges(changes) {
163
+ const groups = new Map();
164
+ for (const c of changes) {
165
+ const gk = [c.api, c.entity, c.meta?.dataTemplate].filter(Boolean).join('/');
166
+ if (!groups.has(gk)) groups.set(gk, {api: c.api, gk, meta: null, items: []});
167
+ const g = groups.get(gk);
168
+ if (!g.meta && c.meta) g.meta = c.meta;
169
+ g.items.push(c);
170
+ }
171
+ return groups.values();
172
+ }
173
+
174
+ /**
175
+ * Resolve where and how a group of changes is written. For DO this is just the
176
+ * `microservice/endpoint` path. For scheduler it resolves the descriptor, the
177
+ * scope path (`{locationCode}/{dataTemplateId}/{endpoint}` for template-scoped
178
+ * entities, bare `{endpoint}` for global ones), and the live key→id lookup maps
179
+ * needed to resolve id references in the body.
180
+ */
181
+ async function resolveContext({client, api, meta, tplCache, liveMaps}) {
182
+ if (api !== 'scheduler') {
183
+ return {collPath: `${meta.microservice}/${meta.endpoint}`, desc: null, lookups: new Map(), prefix: '', dataTemplateId: null};
184
+ }
185
+ const desc = getSchedulerEntity(meta.entity);
186
+ if (!desc) throw new Error(`Unknown scheduler entity "${meta.entity}"; re-import.`);
187
+
188
+ let prefix = '';
189
+ let dataTemplateId = null;
190
+ if (desc.scope === 'template') {
191
+ const tpl = await resolveTemplate(client, meta.dataTemplate, tplCache);
192
+ prefix = `${tpl.locationCode}/${tpl.id}`;
193
+ dataTemplateId = tpl.id;
194
+ }
195
+ const collPath = prefix ? `${prefix}/${desc.endpoint}` : desc.endpoint;
196
+
197
+ // Build a key→id map for every id-reference's target collection. Each lookup
198
+ // is scoped to its own entity: template-scoped refs share this template's
199
+ // path + dataTemplate filter; global refs (e.g. Location) are unscoped.
200
+ const lookups = new Map();
201
+ for (const r of idRefs(desc)) {
202
+ const lookDesc = getSchedulerEntity(r.lookupEntity);
203
+ if (!lookDesc) throw new Error(`${desc.entityName}.${r.field} references unknown entity ${r.lookupEntity}.`);
204
+ const global = lookDesc.scope === 'global';
205
+ const lookPrefix = global ? '' : prefix;
206
+ const lookPath = lookPrefix ? `${lookPrefix}/${lookDesc.endpoint}` : lookDesc.endpoint;
207
+ const map = await liveKeyIdMap({
208
+ client, api, collPath: lookPath, desc: lookDesc, liveMaps,
209
+ prefix: lookPrefix, entity: r.lookupEntity, dataTemplateId: global ? null : dataTemplateId,
210
+ });
211
+ lookups.set(r.lookupEntity, map);
212
+ }
213
+ return {collPath, desc, lookups, prefix, dataTemplateId};
214
+ }
215
+
216
+ /**
217
+ * Resolve a dataTemplate name (the stable cross-environment key) to the target's
218
+ * numeric id and location code — both needed to build the scheduler path.
219
+ */
220
+ async function resolveTemplate(client, name, tplCache) {
221
+ if (!name) throw new Error('Scheduler change is missing its dataTemplate name; re-import.');
222
+ if (!tplCache.map) {
223
+ const data = await client.get('data-templates', {searchParams: {size: 10000}}).json();
224
+ const templates = Array.isArray(data) ? data : data?.content ?? [];
225
+ tplCache.map = new Map();
226
+ tplCache.live = null;
227
+ for (const t of templates) {
228
+ const entry = {id: t.id, locationCode: t.location?.code, name: t.name};
229
+ if (t.name != null) tplCache.map.set(String(t.name), entry);
230
+ // Remember the live template as a fallback for missing names.
231
+ if (t.isLive && tplCache.live == null) tplCache.live = entry;
232
+ }
233
+ }
234
+ let tpl = tplCache.map.get(String(name));
235
+ if (!tpl) {
236
+ // The named template doesn't exist on the target — fall back to the live
237
+ // one (mirrors the scheduler API's own "no dataTemplate → live" behaviour).
238
+ if (!tplCache.live) {
239
+ throw new Error(`Target has no dataTemplate named "${name}" and no live template to fall back to.`);
240
+ }
241
+ info(` dataTemplate "${name}" not found on target; defaulting to the live template "${tplCache.live.name}".`);
242
+ tpl = tplCache.live;
243
+ }
244
+ if (tpl.locationCode == null) {
245
+ throw new Error(`Target dataTemplate "${tpl.name ?? name}" has no location code.`);
246
+ }
247
+ return tpl;
248
+ }
249
+
250
+ /**
251
+ * Build (and cache) a natural-key → id map from a live collection. Scheduler
252
+ * keys are computed with the entity descriptor (matching the snapshot keys) and
253
+ * request the `expand` relations those keys depend on; DO falls back to name/code.
254
+ */
255
+ async function liveKeyIdMap({client, api, collPath, desc, liveMaps, prefix, entity, dataTemplateId}) {
256
+ const cacheKey = `${prefix}|${entity}`;
257
+ if (liveMaps.has(cacheKey)) return liveMaps.get(cacheKey);
258
+
259
+ const expand = api === 'scheduler' && desc ? expandParam(desc) : undefined;
260
+ const searchParams = {size: 10000, ...(expand ? {expand} : {})};
261
+ const data = await client.get(collPath, {searchParams}).json();
262
+ let records = Array.isArray(data) ? data : data?.content ?? [];
263
+ // Some leaf endpoints ignore the path scope; filter to this template so keys
264
+ // don't collide with identically-named records under other templates.
265
+ if (desc) records = scopeRows(records, desc, dataTemplateId);
266
+
267
+ const map = new Map();
268
+ for (const r of records) {
269
+ if (r?.id == null) continue;
270
+ const key = api === 'scheduler' && desc ? matchKey(r, desc) : r?.name ?? r?.code;
271
+ if (key != null) map.set(String(key), r.id);
272
+ }
273
+ liveMaps.set(cacheKey, map);
274
+ return map;
275
+ }
276
+
277
+ function ref(c) {
278
+ return {op: c.op, api: c.api, entity: c.entity, key: c.key};
279
+ }
280
+
281
+ function summarize(changes) {
282
+ return changes.map(ref);
283
+ }
284
+
285
+ function empty() {
286
+ return {create: 0, update: 0, delete: 0};
287
+ }
288
+
289
+ export default doDeploy;