@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
package/diff.js
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
|
|
4
|
+
import {resolveBaseDir} from './import.js';
|
|
5
|
+
import {info, result} from './lib/output.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Compare two imported snapshots and produce a changeset that would make the
|
|
9
|
+
* `to` snapshot match the `from` snapshot. This powers config promotion
|
|
10
|
+
* (e.g. promote stage → prod) and the deploy step.
|
|
11
|
+
*
|
|
12
|
+
* tllt diff --from acme-stage --to acme-prod
|
|
13
|
+
*
|
|
14
|
+
* Records are matched by their snapshot-relative path, which encodes
|
|
15
|
+
* api/entity/<natural-key> — i.e. the same natural key used as the filename on
|
|
16
|
+
* import. Volatile fields (id/createdAt/updatedAt) were already stripped on
|
|
17
|
+
* import, so a content difference reflects a real configuration change.
|
|
18
|
+
*/
|
|
19
|
+
export function computeChangeset(fromProfile, toProfile, opts = {}) {
|
|
20
|
+
const baseDir = resolveBaseDir(opts);
|
|
21
|
+
const fromDir = snapshotDir(fromProfile, baseDir);
|
|
22
|
+
const toDir = snapshotDir(toProfile, baseDir);
|
|
23
|
+
|
|
24
|
+
const fromFiles = readSnapshot(fromDir);
|
|
25
|
+
const toFiles = readSnapshot(toDir);
|
|
26
|
+
|
|
27
|
+
const changes = [];
|
|
28
|
+
for (const [rel, from] of fromFiles) {
|
|
29
|
+
const to = toFiles.get(rel);
|
|
30
|
+
if (!to) {
|
|
31
|
+
changes.push({op: 'create', ...describe(rel, from.meta), record: from.record});
|
|
32
|
+
} else if (!deepEqual(from.record, to.record)) {
|
|
33
|
+
changes.push({op: 'update', ...describe(rel, from.meta), record: from.record, current: to.record});
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
for (const [rel, to] of toFiles) {
|
|
37
|
+
if (!fromFiles.has(rel)) {
|
|
38
|
+
changes.push({op: 'delete', ...describe(rel, to.meta), record: to.record});
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return changes;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const doDiff = async (opts = {}) => {
|
|
45
|
+
const from = opts.from;
|
|
46
|
+
const to = opts.to;
|
|
47
|
+
if (!from || !to) {
|
|
48
|
+
throw new Error('diff requires --from <profile> and --to <profile>.');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const changes = computeChangeset(from, to, opts);
|
|
52
|
+
const counts = tally(changes);
|
|
53
|
+
|
|
54
|
+
info(`Diff ${from} → ${to}: +${counts.create} create ~${counts.update} update -${counts.delete} delete`);
|
|
55
|
+
for (const c of changes) {
|
|
56
|
+
const glyph = c.op === 'create' ? '+' : c.op === 'delete' ? '-' : '~';
|
|
57
|
+
info(` ${glyph} ${c.api}/${c.entity}/${c.key}`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (opts.out) {
|
|
61
|
+
fs.writeFileSync(opts.out, JSON.stringify({from, to, changes}, null, 2));
|
|
62
|
+
info(`\nChangeset written to ${opts.out}`);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
result(`${changes.length} change(s) between ${from} and ${to}.`, {
|
|
66
|
+
ok: true,
|
|
67
|
+
from,
|
|
68
|
+
to,
|
|
69
|
+
counts,
|
|
70
|
+
changes: changes.map(({current, ...rest}) => rest),
|
|
71
|
+
});
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
export function snapshotDir(profile, baseDir) {
|
|
75
|
+
const dir = path.join(baseDir, profile);
|
|
76
|
+
if (!fs.existsSync(dir)) {
|
|
77
|
+
throw new Error(
|
|
78
|
+
`No snapshot for "${profile}" in ${baseDir}. Run \`tllt import --profile ${profile}\` first (or pass --dir).`
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
return dir;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Map of snapshot-relative path → {record, meta}, where meta is the nearest
|
|
86
|
+
* _meta.json (routing info) walking up from the record's folder. This makes
|
|
87
|
+
* entity/endpoint resolution correct even for nested layouts.
|
|
88
|
+
*/
|
|
89
|
+
export function readSnapshot(dir) {
|
|
90
|
+
const files = new Map();
|
|
91
|
+
walk(dir, (file) => {
|
|
92
|
+
// Skip sidecar files (_meta.json, _schema.json, _schema.source.json, ...).
|
|
93
|
+
if (path.basename(file).startsWith('_')) return;
|
|
94
|
+
if (!file.endsWith('.json')) return;
|
|
95
|
+
const rel = path.relative(dir, file).split(path.sep).join('/');
|
|
96
|
+
const meta = nearestMeta(path.dirname(file), dir);
|
|
97
|
+
files.set(rel, {record: JSON.parse(fs.readFileSync(file, 'utf8')), meta});
|
|
98
|
+
});
|
|
99
|
+
return files;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Walk up from `startDir` to `rootDir` looking for the closest _meta.json. */
|
|
103
|
+
function nearestMeta(startDir, rootDir) {
|
|
104
|
+
let dir = startDir;
|
|
105
|
+
const root = path.resolve(rootDir);
|
|
106
|
+
while (true) {
|
|
107
|
+
const candidate = path.join(dir, '_meta.json');
|
|
108
|
+
if (fs.existsSync(candidate)) return JSON.parse(fs.readFileSync(candidate, 'utf8'));
|
|
109
|
+
if (path.resolve(dir) === root) break;
|
|
110
|
+
const parent = path.dirname(dir);
|
|
111
|
+
if (parent === dir) break;
|
|
112
|
+
dir = parent;
|
|
113
|
+
}
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function describe(rel, meta) {
|
|
118
|
+
const parts = rel.split('/');
|
|
119
|
+
const api = meta?.api ?? parts[0];
|
|
120
|
+
const entity = meta?.entity ?? parts[1];
|
|
121
|
+
const key = path.basename(rel, '.json');
|
|
122
|
+
return {api, entity, key, rel, meta};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function tally(changes) {
|
|
126
|
+
return changes.reduce(
|
|
127
|
+
(acc, c) => {
|
|
128
|
+
acc[c.op]++;
|
|
129
|
+
return acc;
|
|
130
|
+
},
|
|
131
|
+
{create: 0, update: 0, delete: 0}
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function walk(dir, fn) {
|
|
136
|
+
for (const entry of fs.readdirSync(dir, {withFileTypes: true})) {
|
|
137
|
+
const full = path.join(dir, entry.name);
|
|
138
|
+
if (entry.isDirectory()) walk(full, fn);
|
|
139
|
+
else fn(full);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function deepEqual(a, b) {
|
|
144
|
+
return JSON.stringify(canonical(a)) === JSON.stringify(canonical(b));
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Stable key ordering so equality ignores property order. */
|
|
148
|
+
function canonical(value) {
|
|
149
|
+
if (Array.isArray(value)) return value.map(canonical);
|
|
150
|
+
if (value && typeof value === 'object') {
|
|
151
|
+
const out = {};
|
|
152
|
+
for (const key of Object.keys(value).sort()) out[key] = canonical(value[key]);
|
|
153
|
+
return out;
|
|
154
|
+
}
|
|
155
|
+
return value;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export default doDiff;
|
package/docs/apis.md
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# The two config surfaces
|
|
2
|
+
|
|
3
|
+
The CLI manages configuration across two TilliT APIs. Both live on the same
|
|
4
|
+
per-tenant connection and use the same auth (`basic` or `apikey`); you select
|
|
5
|
+
entities with `--api do` / `--api scheduler`, and routing is handled for you.
|
|
6
|
+
|
|
7
|
+
## DO — Digital Operations
|
|
8
|
+
|
|
9
|
+
- The main configuration surface (assets, attributes, activities, …).
|
|
10
|
+
- Import is **schema-driven**: the CLI learns each entity's data model from the
|
|
11
|
+
live schema and expands related records so the snapshot is self-describing.
|
|
12
|
+
|
|
13
|
+
Entities snapshotted today (`./{profile}/do/...`), grouped by domain:
|
|
14
|
+
|
|
15
|
+
- **Asset model**: `Asset`, `AssetClass`, `AssetProfile`, `AssetStatusColor`,
|
|
16
|
+
`AssetAttribute`, `AssetTolerance`, `AssetMeterTemplate`.
|
|
17
|
+
- **Attributes & process variables**: `Attribute`, `AttributeGroup`,
|
|
18
|
+
`AttributeGroupItem`, `AttributeGroupAssignment`, `ProcessVariable`,
|
|
19
|
+
`UnitOfMeasure`, `EventType`.
|
|
20
|
+
- **Sites, calendars, shifts**: `Site`, `SiteAttribute`, `ShiftTemplate`,
|
|
21
|
+
`ShiftTemplateItem`, `Calendar`, `CalendarItem`.
|
|
22
|
+
- **Materials**: `MaterialGroup`, `MaterialProperty`, `Material`,
|
|
23
|
+
`MaterialAttribute`, `MaterialComponent`, `MaterialConversion`,
|
|
24
|
+
`MaterialTolerance`.
|
|
25
|
+
- **Inventory movements**: `MovementType`, `MovementTypeField`.
|
|
26
|
+
- **Alarms**: `AlarmType`, `AlarmTypeAssignment`.
|
|
27
|
+
- **Edge & data sources**: `DatasourceTemplate`, `DatasourceTemplateTag`,
|
|
28
|
+
`DatasourceTemplateTrigger`, `DatasourceTemplateAssignment`, `Edge`,
|
|
29
|
+
`EdgeDataSource`, `EdgeDataTag`, `EdgeTrigger`.
|
|
30
|
+
- **Events**: `EventRelay`, `EventSchedule`.
|
|
31
|
+
- **Control parameters (recipes)**: `ControlParameter`, `ControlParameterRecipe`,
|
|
32
|
+
`ControlParameterRule`, `ControlParameterRuleAttribute`.
|
|
33
|
+
- **Run rates**: `RunRateTemplate`.
|
|
34
|
+
- **Order templates**: `OrderStatus`, `OrderNumberAttribute`, `OrderTemplate`,
|
|
35
|
+
`OrderTemplateComponent`, `OrderTemplateDependency`.
|
|
36
|
+
- **Activities**: `ActivityClass`, `ActivityItemOptionGroup`,
|
|
37
|
+
`ActivityItemOptionItem`, `ActivityTemplate`, `ActivityTemplateItem`,
|
|
38
|
+
`ActivityStarter`, `ActivityAssignment`, `ActivityProcessStarter`,
|
|
39
|
+
`ActivityProcessTrigger`, `ActivityAssignmentAttribute`.
|
|
40
|
+
|
|
41
|
+
To add an entity, extend `DO_ENTITIES` in `import.js` (entity name + optional
|
|
42
|
+
filter / filename / folder nesting). Parents must appear before their children
|
|
43
|
+
so deploys create them in the right order.
|
|
44
|
+
|
|
45
|
+
## Scheduler
|
|
46
|
+
|
|
47
|
+
- Production-scheduling configuration, driven through the **normalized REST API**
|
|
48
|
+
(`/api/scheduler/{locationCode}/{dataTemplateId}/{entity}`). **Organised per
|
|
49
|
+
`DataTemplate`** — snapshots nest under each template, and records reference one
|
|
50
|
+
another by natural key. See [`scheduler.md`](scheduler.md) before touching
|
|
51
|
+
scheduler config.
|
|
52
|
+
|
|
53
|
+
Entities snapshotted today (`./{profile}/scheduler/...`):
|
|
54
|
+
|
|
55
|
+
- **Global**: `Location`, `DataTemplate`, `OptimisationProfile`.
|
|
56
|
+
- **Equipment**: `EquipmentClass`, `Equipment`, `EquipmentClassMembership`.
|
|
57
|
+
- **Personnel**: `PersonnelClass`, `Personnel`, `PersonnelClassMembership`.
|
|
58
|
+
- **Availability & downtime**: `AvailabilityTemplate`, `AvailabilityTemplateItem`,
|
|
59
|
+
`DowntimePeriod`, `EquipmentDowntimePeriod`, `PersonDowntimePeriod`.
|
|
60
|
+
- **Changeovers**: `Property`, `PropertyValue`, `ChangeoverSet`,
|
|
61
|
+
`ChangeoverSetItem`.
|
|
62
|
+
- **Materials**: `MaterialGroup`, `MaterialDefinition`, `MaterialProperty`.
|
|
63
|
+
- **Routing**: `Operation`, `Route`, `Segment`, `SegmentMaterial`,
|
|
64
|
+
`SegmentEquipment`, `SegmentDependency`.
|
|
65
|
+
|
|
66
|
+
To add an entity, extend `SCHEDULER_ENTITIES` in `lib/scheduler-entities.js`
|
|
67
|
+
(descriptor: endpoint, scope, natural key, and any references).
|
|
68
|
+
|
|
69
|
+
## Entity schemas
|
|
70
|
+
|
|
71
|
+
Both surfaces publish a per-entity schema that the CLI fetches on import and
|
|
72
|
+
exposes via `tllt schema`. See [`schemas.md`](schemas.md) for how to read
|
|
73
|
+
it — it's authoritative for both APIs.
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# Authentication
|
|
2
|
+
|
|
3
|
+
A **connection = environment + tenant**. Each connection ("profile") records its
|
|
4
|
+
own auth. Profiles live in `~/.tillit/config.json`, named `{tenant}-{environment}`.
|
|
5
|
+
|
|
6
|
+
Base URL is a per-tenant subdomain derived from the environment (override with
|
|
7
|
+
`--base-url`):
|
|
8
|
+
|
|
9
|
+
| Environment | Base URL |
|
|
10
|
+
| ----------- | -------- |
|
|
11
|
+
| `prod` | `https://{tenant}.tillit.cloud` |
|
|
12
|
+
| `stage` | `https://{tenant}.tillit-stage.cloud` |
|
|
13
|
+
| `sandbox` | `https://{tenant}.tillit-sandbox.cloud` |
|
|
14
|
+
| `dev` | `https://{tenant}.tillit-dev.cloud` |
|
|
15
|
+
|
|
16
|
+
## Method 1 — basic auth
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
tllt configure -t acme -e prod -a basic -u alice -w 's3cret'
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Sends `Authorization: Basic base64(<username>@<tenant>.tillit.cloud:<password>)`
|
|
23
|
+
plus the `tillit-tenant: <tenant>` header.
|
|
24
|
+
|
|
25
|
+
- Works against **both** the DO and Scheduler APIs.
|
|
26
|
+
|
|
27
|
+
## Method 2 — api key + secret (Cognito bearer token)
|
|
28
|
+
|
|
29
|
+
The api key is a Cognito app-client id; the secret is its client secret. The CLI
|
|
30
|
+
performs the OAuth2 `client_credentials` flow against the tenant's Cognito token
|
|
31
|
+
endpoint, then uses the returned access token as a bearer token.
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
tllt configure -t acme -e prod -a apikey \
|
|
35
|
+
--api-key <clientId> --api-secret <secret> \
|
|
36
|
+
--token-url https://<domain>.auth.<region>.amazoncognito.com/oauth2/token \
|
|
37
|
+
--scopes 'api/*.read,api/*.write'
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Sends `Authorization: Bearer <token>` plus `tillit-tenant: <tenant>`.
|
|
41
|
+
|
|
42
|
+
- Works against both DO and Scheduler.
|
|
43
|
+
- Tokens are cached in `~/.tillit/tokens.json` until shortly before expiry.
|
|
44
|
+
- `--token-url` is the Cognito hosted-domain token endpoint
|
|
45
|
+
(`.../oauth2/token`). Ask the platform team if you don't know the domain.
|
|
46
|
+
|
|
47
|
+
## Files
|
|
48
|
+
|
|
49
|
+
| Path | Contents |
|
|
50
|
+
| ---- | -------- |
|
|
51
|
+
| `~/.tillit/config.json` | profiles (mode 600) |
|
|
52
|
+
| `~/.tillit/tokens.json` | cached bearer tokens (mode 600) |
|
|
53
|
+
| `~/.tillit/.env` | legacy format from older CLI versions, still read |
|
package/docs/commands.md
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# Command reference
|
|
2
|
+
|
|
3
|
+
Global options (before the command):
|
|
4
|
+
|
|
5
|
+
| Flag | Meaning |
|
|
6
|
+
| ---- | ------- |
|
|
7
|
+
| `-p, --profile <name>` | Connection to use (default: the configured default profile) |
|
|
8
|
+
| `--json` | Emit machine-readable JSON on stdout; progress on stderr (or `TILLIT_JSON=1`) |
|
|
9
|
+
| `-V, --version` | Print version |
|
|
10
|
+
| `-h, --help` | Help for any command |
|
|
11
|
+
|
|
12
|
+
Exit codes: `0` success, non-zero on error (and on `deploy` if any change failed).
|
|
13
|
+
In `--json` mode, errors are `{"ok": false, "error": "<message>"}` on stderr.
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## configure (alias: init)
|
|
18
|
+
|
|
19
|
+
Bootstrap or update a connection. Interactive with no flags; fully
|
|
20
|
+
non-interactive when the required flags are present.
|
|
21
|
+
|
|
22
|
+
| Flag | Notes |
|
|
23
|
+
| ---- | ----- |
|
|
24
|
+
| `-t, --tenant <tenant>` | required |
|
|
25
|
+
| `-e, --environment <env>` | `prod` \| `stage` \| `sandbox` \| `dev` |
|
|
26
|
+
| `-a, --auth <method>` | `basic` \| `apikey` |
|
|
27
|
+
| `-u, --username` / `-w, --password` | for `basic` |
|
|
28
|
+
| `--api-key` / `--api-secret` / `--token-url` | for `apikey` (Cognito) |
|
|
29
|
+
| `--scopes <a,b>` | optional OAuth scopes for `apikey` |
|
|
30
|
+
| `--base-url <url>` | override the derived base URL |
|
|
31
|
+
| `--default` | make this the default connection |
|
|
32
|
+
|
|
33
|
+
Profile is saved as `{tenant}-{environment}` in `~/.tillit/config.json`.
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
tllt --json configure -t acme -e prod -a basic -u alice -w 's3cret' --default
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
```json
|
|
40
|
+
{ "ok": true, "profile": "acme-prod", "tenant": "acme", "environment": "prod",
|
|
41
|
+
"baseUrl": "https://acme.tillit.cloud", "authMethod": "basic", "profiles": [ ... ] }
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## profiles (alias: connections)
|
|
45
|
+
|
|
46
|
+
List configured connections. `*` marks the default.
|
|
47
|
+
|
|
48
|
+
```json
|
|
49
|
+
{ "ok": true, "profiles": [
|
|
50
|
+
{ "name": "acme-prod", "tenant": "acme", "baseUrl": "https://acme.tillit.cloud",
|
|
51
|
+
"method": "basic", "isDefault": true } ] }
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## schema
|
|
55
|
+
|
|
56
|
+
Describe an entity's data model (fields, types, required/unique, enums, and
|
|
57
|
+
relationship targets). See [`schemas.md`](schemas.md).
|
|
58
|
+
|
|
59
|
+
| Arg/Flag | Notes |
|
|
60
|
+
| -------- | ----- |
|
|
61
|
+
| `[entity]` | entity name (e.g. `Asset`); omit to list all entities |
|
|
62
|
+
| `--api <api>` | force `do` or `scheduler` (otherwise inferred) |
|
|
63
|
+
| `-p, --profile <name>` | connection to fetch DO schemas from (global flag) |
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
tllt --json schema Asset
|
|
67
|
+
```
|
|
68
|
+
```json
|
|
69
|
+
{ "ok": true, "schema": { "api": "do", "entity": "Asset", "naturalKey": "name",
|
|
70
|
+
"fields": [ { "name": "type", "type": "enum", "required": true,
|
|
71
|
+
"enum": ["AREA","LINE","EQUIPMENT"] } ],
|
|
72
|
+
"relationships": [ { "name": "assetClass", "kind": "many-to-one",
|
|
73
|
+
"references": "AssetClass", "keyField": "name", "required": true } ] } }
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## import
|
|
77
|
+
|
|
78
|
+
Snapshot a connection's live configuration into `./{profile}/{api}/...` (the
|
|
79
|
+
current directory, so it can live in its own git repo).
|
|
80
|
+
|
|
81
|
+
| Flag | Notes |
|
|
82
|
+
| ---- | ----- |
|
|
83
|
+
| `-p, --profile <name>` | which connection (global flag) |
|
|
84
|
+
| `--api <api>` | limit to `do` or `scheduler` |
|
|
85
|
+
| `-d, --dir <path>` | output directory (default: current directory) |
|
|
86
|
+
|
|
87
|
+
```json
|
|
88
|
+
{ "ok": true, "profile": "acme-prod", "baseUrl": "https://acme.tillit.cloud",
|
|
89
|
+
"entities": [ { "api": "do", "entity": "Asset", "count": 26 }, ... ],
|
|
90
|
+
"skipped": [ { "api": "scheduler", "reason": "..." } ],
|
|
91
|
+
"path": "/cwd/acme-prod" }
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
A per-API failure (e.g. wrong auth method for that API) is **skipped**, not fatal.
|
|
95
|
+
|
|
96
|
+
## diff
|
|
97
|
+
|
|
98
|
+
Compare two snapshots and print the changeset that would make `to` match `from`.
|
|
99
|
+
|
|
100
|
+
| Flag | Notes |
|
|
101
|
+
| ---- | ----- |
|
|
102
|
+
| `-f, --from <profile>` | required — desired state |
|
|
103
|
+
| `-t, --to <profile>` | required — target |
|
|
104
|
+
| `-o, --out <file>` | also write the changeset JSON to a file |
|
|
105
|
+
| `-d, --dir <path>` | snapshots directory (default: current directory) |
|
|
106
|
+
|
|
107
|
+
```json
|
|
108
|
+
{ "ok": true, "from": "acme-stage", "to": "acme-prod",
|
|
109
|
+
"counts": { "create": 1, "update": 1, "delete": 1 },
|
|
110
|
+
"changes": [ { "op": "update", "api": "do", "entity": "Asset", "key": "Filler 1",
|
|
111
|
+
"rel": "do/Asset/Filler 1.json", "meta": {...}, "record": {...} } ] }
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
## deploy
|
|
115
|
+
|
|
116
|
+
Apply the difference between two snapshots to the live `to` target.
|
|
117
|
+
|
|
118
|
+
| Flag | Notes |
|
|
119
|
+
| ---- | ----- |
|
|
120
|
+
| `-f, --from <profile>` | desired state (or use `--changeset`) |
|
|
121
|
+
| `-t, --to <profile>` | required — the connection being changed |
|
|
122
|
+
| `--changeset <file>` | apply a saved changeset instead of computing one |
|
|
123
|
+
| `--api <api>` | limit to `do` or `scheduler` |
|
|
124
|
+
| `--dry-run` | print the plan, change nothing (no credentials needed) |
|
|
125
|
+
| `--prune` | also delete target records missing from the source (destructive) |
|
|
126
|
+
| `-y, --yes` | skip the confirmation prompt (required for unattended runs) |
|
|
127
|
+
| `-d, --dir <path>` | snapshots directory (default: current directory) |
|
|
128
|
+
|
|
129
|
+
Without `--prune`, deletes are excluded. Without `--yes` (and not in `--json`
|
|
130
|
+
mode) it prompts before applying.
|
|
131
|
+
|
|
132
|
+
```json
|
|
133
|
+
{ "ok": true, "to": "acme-prod", "counts": { "create": 1, "update": 1, "delete": 0 },
|
|
134
|
+
"applied": [ { "op": "create", "api": "do", "entity": "Asset", "key": "Filler 2", "ok": true } ] }
|
|
135
|
+
```
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
# Scheduler config — what's different
|
|
2
|
+
|
|
3
|
+
Scheduler config doesn't behave quite like DO config. You never talk to the API
|
|
4
|
+
directly — the CLI handles all routing — but a few things change what you see in
|
|
5
|
+
snapshots and how records relate to each other.
|
|
6
|
+
|
|
7
|
+
The CLI drives the Scheduler's **normalized REST API**, addressed by path:
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
/api/scheduler/{locationCode}/{dataTemplateId}/{entity} # template-scoped
|
|
11
|
+
/api/scheduler/{entity} # global
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
The write shapes match the **published schema**, so unlike DO's older
|
|
15
|
+
behaviour, `tllt schema <Entity>` *is* authoritative here too.
|
|
16
|
+
|
|
17
|
+
## Everything is organised per DataTemplate
|
|
18
|
+
|
|
19
|
+
A `DataTemplate` is the root of scheduler config (each is tied to a Location).
|
|
20
|
+
Global entities (`Location`, `DataTemplate`, `OptimisationProfile`) live at the
|
|
21
|
+
top of `scheduler/`; everything else belongs to exactly one template. Snapshots
|
|
22
|
+
mirror that:
|
|
23
|
+
|
|
24
|
+
```
|
|
25
|
+
{profile}/scheduler/
|
|
26
|
+
Location/ # global
|
|
27
|
+
DataTemplate/ # global (each carries its location)
|
|
28
|
+
OptimisationProfile/ # global
|
|
29
|
+
Line A/ # one folder per template, keyed by NAME
|
|
30
|
+
EquipmentClass/
|
|
31
|
+
Property/ PropertyValue/
|
|
32
|
+
ChangeoverSet/ ChangeoverSetItem/
|
|
33
|
+
Equipment/ EquipmentClassMembership/
|
|
34
|
+
AvailabilityTemplate/ AvailabilityTemplateItem/
|
|
35
|
+
MaterialGroup/ MaterialDefinition/ MaterialProperty/
|
|
36
|
+
Operation/ Route/ Segment/ SegmentMaterial/ SegmentEquipment/
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
- Templates are keyed by **name**, not id (ids differ between environments).
|
|
40
|
+
- On `deploy`, the CLI resolves the template name to the target's id **and**
|
|
41
|
+
location code automatically, and builds the path. If the template doesn't
|
|
42
|
+
exist on the target it is created (its `location` is resolved by code), and any
|
|
43
|
+
records scoped to it follow.
|
|
44
|
+
|
|
45
|
+
## The schema is authoritative — but mind the natural keys
|
|
46
|
+
|
|
47
|
+
For scheduler entities, `tllt schema <Entity>` (and the `_schema.json`
|
|
48
|
+
sidecar) describes exactly what you write — fields, enums, and which entity each
|
|
49
|
+
nested reference points at. Author records to that contract.
|
|
50
|
+
|
|
51
|
+
Records are keyed by a **stable natural key** (the snapshot filename), so
|
|
52
|
+
`diff`/`deploy` match them for update/delete — no more create-only. Some keys are
|
|
53
|
+
composite (joined with ` - `):
|
|
54
|
+
|
|
55
|
+
| Entity | Natural key |
|
|
56
|
+
| ------ | ----------- |
|
|
57
|
+
| `EquipmentClass`, `Equipment`, `MaterialGroup`, `MaterialDefinition` | `externalId` |
|
|
58
|
+
| `Property`, `ChangeoverSet`, `AvailabilityTemplate`, `OptimisationProfile` | `name` |
|
|
59
|
+
| `Location` | `code` |
|
|
60
|
+
| `Operation` | `operationCode` |
|
|
61
|
+
| `PropertyValue` | `property - value` |
|
|
62
|
+
| `ChangeoverSetItem` | `set - property - fromValue - toValue` |
|
|
63
|
+
| `EquipmentClassMembership` | `equipment - equipmentClass` |
|
|
64
|
+
| `Route` / `Segment` | `operationCode - routeCode[ - segmentCode]` |
|
|
65
|
+
|
|
66
|
+
## How references travel between environments
|
|
67
|
+
|
|
68
|
+
A record's references are stored by **natural key**, never by numeric id (ids
|
|
69
|
+
differ per environment). On deploy the CLI resolves them:
|
|
70
|
+
|
|
71
|
+
- **Resolved by the CLI** (the create endpoint needs a numeric id): `Equipment.changeoverSet`,
|
|
72
|
+
`EquipmentClassMembership.{equipment,equipmentClass}`, `PropertyValue.property`,
|
|
73
|
+
`ChangeoverSetItem.{changeoverSet,property,fromValue,toValue}`,
|
|
74
|
+
`AvailabilityTemplateItem.availabilityTemplate`, `DataTemplate.location`,
|
|
75
|
+
`MaterialDefinition.materialGroup`, `MaterialProperty.materialDefinition`,
|
|
76
|
+
`SegmentEquipment.equipmentClass`. The CLI looks up the referenced record on the
|
|
77
|
+
target by its key and injects the id.
|
|
78
|
+
- **Resolved by the API** (the body carries an inline code/externalId):
|
|
79
|
+
`Route`/`Segment`/`SegmentMaterial`/`SegmentEquipment` parents (via inline
|
|
80
|
+
`operationCode`/`routeCode`/`segmentCode`), and `SegmentMaterial.materialDefinition`
|
|
81
|
+
(`externalId`).
|
|
82
|
+
|
|
83
|
+
> ⚠️ **Plain `POST /{entity}/` create endpoints do NOT resolve refs by `externalId`** —
|
|
84
|
+
> only the `/upsert` routes do. So `MaterialDefinition.materialGroup`,
|
|
85
|
+
> `SegmentEquipment.equipmentClass` and `MaterialProperty.materialDefinition` 500
|
|
86
|
+
> (`null value in column …_id`) if sent by externalId on create; the CLI resolves
|
|
87
|
+
> them to ids itself. (`SegmentMaterial.materialDefinition` is the exception — its
|
|
88
|
+
> create *does* resolve by externalId.)
|
|
89
|
+
|
|
90
|
+
Because of these dependencies, deploy applies creates/updates in **dependency
|
|
91
|
+
order** (parents before children) and deletes in reverse. A referenced parent
|
|
92
|
+
must therefore be present in the same deploy (or already on the target) — if it
|
|
93
|
+
isn't, the child fails with a clear "cannot resolve …" message.
|
|
94
|
+
|
|
95
|
+
## Binding changeover sets & availability to equipment (important)
|
|
96
|
+
|
|
97
|
+
These two behave very differently — a hard-won learning, don't forget it:
|
|
98
|
+
|
|
99
|
+
- **Changeover set → equipment: bound on create, via the `changeoverSet` ref.**
|
|
100
|
+
In the normalized model `Equipment.changeoverSet` is a **required** (`nullable:false`)
|
|
101
|
+
foreign key (`changeover_set_id`). You assign it simply by setting
|
|
102
|
+
`"changeoverSet": { "name": "<set>" }` on the equipment record — the CLI resolves
|
|
103
|
+
the name to the target id on deploy and it **persists**. There is **no separate
|
|
104
|
+
step and no "calendarised changeover" object**: the FK on the equipment row *is*
|
|
105
|
+
the assignment, and the optimiser reads it directly. Verify with
|
|
106
|
+
`GET /{loc}/{dt}/equipment?expand=changeoverSet`.
|
|
107
|
+
- ⛔️ This is the **opposite of the old flattened gateway path**, where the
|
|
108
|
+
equipment write shape had no `changeoverSet` field, so equipment was created
|
|
109
|
+
with `changeoverSet: null` and needed a separate PUT to bind. On the normalized
|
|
110
|
+
REST surface that pitfall is gone — but it also means **you cannot create
|
|
111
|
+
equipment without a changeover set** (create a `ChangeoverSet` first; deploy
|
|
112
|
+
order handles this since ChangeoverSet sorts before Equipment).
|
|
113
|
+
|
|
114
|
+
**…but binding the set is NOT enough for changeovers to apply.** The optimiser
|
|
115
|
+
computes a changeover between two segments by comparing their **property maps**,
|
|
116
|
+
and a segment's property map is built from its **SegmentMaterials →
|
|
117
|
+
MaterialDefinition → MaterialProperty** (rows where `value IS NOT NULL`) — *not*
|
|
118
|
+
from order properties and *not* from the material directly. So the full working
|
|
119
|
+
chain is:
|
|
120
|
+
|
|
121
|
+
```
|
|
122
|
+
Equipment ──changeoverSet──▶ ChangeoverSet ──▶ ChangeoverSetItem (property, fromValue→toValue, time)
|
|
123
|
+
Segment ──▶ SegmentMaterial ──▶ MaterialDefinition ──▶ MaterialProperty (property name = value)
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
For each sequenced pair the optimiser builds `{property → value}` for the from-
|
|
127
|
+
and to-segment, then looks up the matching `ChangeoverSetItem` on the line's set
|
|
128
|
+
(falling back to the set's `defaultTime`). **If a segment has no SegmentMaterial
|
|
129
|
+
carrying the relevant MaterialProperty, its property map is empty and no
|
|
130
|
+
changeover is found.** Item `fromValue`/`toValue` may be `'*'` (wildcard = any
|
|
131
|
+
value), and `aggregationType` MAX/SUM combines multiple matched properties.
|
|
132
|
+
|
|
133
|
+
> ✅ **Practical rule:** give **every** segment a SegmentMaterial carrying the
|
|
134
|
+
> keying property — not just the output stage. In two-stage canning the **fill**
|
|
135
|
+
> segment must also carry the SKU (or a recipe material with `Brand`), otherwise
|
|
136
|
+
> filling-line changeovers never compute. The pattern is to put the produced SKU
|
|
137
|
+
> on both the Fill and Pack segments.
|
|
138
|
+
|
|
139
|
+
> **SegmentDependency** (`segment-dependencies`) links two segments of a route by
|
|
140
|
+
> their inline codes: `{operationCode, routeCode, fromSegmentCode, toSegmentCode,
|
|
141
|
+
> dependencyType, dependencyFactor}`. `dependencyType` ∈ `FINISH_START`,
|
|
142
|
+
> `START_START`, `LOCKED_START_START`, `LOCKED_FINISH_START`. e.g. lock packing to
|
|
143
|
+
> start with filling (no gap): `fromSegmentCode: "Fill", toSegmentCode: "Pack",
|
|
144
|
+
> dependencyType: "LOCKED_START_START", dependencyFactor: "0"`.
|
|
145
|
+
|
|
146
|
+
- **Availability → equipment: NOT bindable via the normalized API.** There is no
|
|
147
|
+
REST route linking an `AvailabilityTemplate` to an equipment's
|
|
148
|
+
`availabilitySetId` (a `calendarised_availability_template_set`). Availability
|
|
149
|
+
must be assigned through the **gateway** integration endpoints: `POST availabilities`
|
|
150
|
+
(one row per day: `{name, dayOfTheWeek, startTime "HH:MM:SS", endTime}`) then
|
|
151
|
+
`POST equipment-availabilities` (`{equipmentCode, availabilityCode, availabilityStart,
|
|
152
|
+
availabilityEnd}`) per line. Keep the start→end horizon to ≈1 year — the
|
|
153
|
+
`getEquipment` resolver materializes every productive period in the window, so a
|
|
154
|
+
far-future end (e.g. 2099) explodes into tens of thousands of periods and 413s
|
|
155
|
+
the schedule view.
|
|
156
|
+
|
|
157
|
+
## Known API quirks the CLI works around
|
|
158
|
+
|
|
159
|
+
These are pending a server-side fix; the CLI handles them so you don't have to:
|
|
160
|
+
|
|
161
|
+
- **Numeric-as-string.** A few number fields are emitted as strings by the API
|
|
162
|
+
but validated as numbers on write (`ChangeoverSetItem.time`,
|
|
163
|
+
`AvailabilityTemplateItem.startTime/endTime`, `OptimisationProfile` weights).
|
|
164
|
+
The CLI stores and sends them as numbers.
|
|
165
|
+
- **Unscoped leaf collections.** `property-values`, `changeover-set-items`,
|
|
166
|
+
`availability-template-items` and `material-properties` ignore the path scope
|
|
167
|
+
and return the whole tenant. The CLI filters them to the right template by the
|
|
168
|
+
parent's id (so you still see the correct per-template set).
|
|
169
|
+
|
|
170
|
+
## Caveats
|
|
171
|
+
|
|
172
|
+
- Always preview scheduler deploys with `--dry-run` first.
|
|
173
|
+
- Transactional records (live orders) are intentionally **not** managed.
|
package/docs/schemas.md
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# Understanding the data model (schemas)
|
|
2
|
+
|
|
3
|
+
Every record is a JSON object, but JSON alone doesn't tell you the field types,
|
|
4
|
+
which fields are required, the allowed enum values, or what a nested
|
|
5
|
+
`{ "name": "Site 1" }` actually points at. The CLI surfaces that contract in two
|
|
6
|
+
ways.
|
|
7
|
+
|
|
8
|
+
## 1. Schema sidecars in the snapshot
|
|
9
|
+
|
|
10
|
+
On `import`, every entity folder gets schema sidecars next to its records:
|
|
11
|
+
|
|
12
|
+
| File | What it is |
|
|
13
|
+
| ---- | ---------- |
|
|
14
|
+
| `_meta.json` | routing — api, microservice/endpoint, (scheduler) scope + dataTemplate |
|
|
15
|
+
| `_schema.json` | **distilled, agent-friendly contract** (read this) |
|
|
16
|
+
| `_schema.source.json` | the **raw authoritative schema** (the same model that drives the dynamic UI) |
|
|
17
|
+
|
|
18
|
+
So an agent editing `do/Asset/Filler 1.json` can read `do/Asset/_schema.json`
|
|
19
|
+
in the same folder to know exactly what's valid.
|
|
20
|
+
|
|
21
|
+
### Distilled `_schema.json` shape
|
|
22
|
+
|
|
23
|
+
```json
|
|
24
|
+
{
|
|
25
|
+
"api": "do",
|
|
26
|
+
"entity": "Asset",
|
|
27
|
+
"endpoint": "assets",
|
|
28
|
+
"microservice": "core",
|
|
29
|
+
"naturalKey": "name",
|
|
30
|
+
"fields": [
|
|
31
|
+
{ "name": "name", "type": "string", "required": true, "unique": true },
|
|
32
|
+
{ "name": "type", "type": "enum", "required": true,
|
|
33
|
+
"enum": ["AREA", "LINE", "EQUIPMENT"] },
|
|
34
|
+
{ "name": "displayOrder", "type": "number", "required": false }
|
|
35
|
+
],
|
|
36
|
+
"relationships": [
|
|
37
|
+
{ "name": "assetClass", "kind": "many-to-one", "references": "AssetClass",
|
|
38
|
+
"keyField": "name", "required": true },
|
|
39
|
+
{ "name": "parent", "kind": "many-to-one", "references": "Asset",
|
|
40
|
+
"keyField": "name" }
|
|
41
|
+
]
|
|
42
|
+
}
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
- `naturalKey` is the field used as the filename and for cross-environment
|
|
46
|
+
matching in diff/deploy (usually `name`).
|
|
47
|
+
- A relationship means the field appears in the record as
|
|
48
|
+
`{ "<keyField>": "<value>" }` (e.g. `"assetClass": { "name": "Palletisers" }`)
|
|
49
|
+
and resolves to a record of `references`.
|
|
50
|
+
|
|
51
|
+
## 2. The `schema` command
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
tllt schema # list every known entity
|
|
55
|
+
tllt schema Asset # full contract for one entity (live, DO)
|
|
56
|
+
tllt schema Equipment # scheduler entity (live, under scheduler/)
|
|
57
|
+
tllt --json schema Asset # machine-readable (same shape as _schema.json)
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Sources & reliability
|
|
61
|
+
|
|
62
|
+
Schemas are fetched **live** on import, so they're always current (the same model
|
|
63
|
+
the dynamic UI uses). The fetch is best-effort: if a schema can't be retrieved,
|
|
64
|
+
the import still proceeds — just without that entity's `_schema.json` sidecar.
|
|
65
|
+
|
|
66
|
+
### Scheduler: the schema is authoritative too
|
|
67
|
+
|
|
68
|
+
The CLI drives the Scheduler's **normalized REST API**, whose write shapes match
|
|
69
|
+
the published schema — so `tllt schema <Entity>` is authoritative
|
|
70
|
+
for scheduler entities just like DO. Nested references are stored by natural key
|
|
71
|
+
(e.g. `"changeoverSet": { "name": "Bottling" }`) and resolved to the target's id
|
|
72
|
+
on deploy. A few API quirks (number fields emitted as strings; unscoped leaf
|
|
73
|
+
collections) are normalised by the CLI — see [`scheduler.md`](scheduler.md).
|