Skip to content

Commit da1692c

Browse files
mschmickingclaude
andauthored
feat(cli): sweep the adapter markers a deleted script leaves behind (#30)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 021472a commit da1692c

12 files changed

Lines changed: 1014 additions & 38 deletions

AGENTS.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,22 @@ instruction from the user:
3131
4. **Deletion is explicit.** Only `remove`, `rename` and `move` delete anything, each
3232
requires `--yes`, and each writes the full object JSON to `.iobroker-sync/trash/`
3333
_before_ deleting. A failed backup aborts the operation.
34+
35+
The one thing they delete without backing it up is the pair of adapter markers
36+
belonging to the script being deleted — `javascript.<n>.scriptEnabled.<id>` and
37+
`javascript.<n>.scriptProblem.<id>` — and only after that script is gone. Those are
38+
adapter-generated derived state (`common.enabled` and `common.engine` are already in
39+
the trash copy), so there is nothing in them to lose. See `cleanUpScriptMarkers` in
40+
`commands/remove.ts` for why they have to be swept at all, and
41+
`ObjectsApi.deleteScriptMarker` for the ordering rule (value first, object second,
42+
never the reverse). The sweep is best-effort: it warns, it never fails the command.
43+
44+
**Both kinds or neither.** The adapter creates and deletes the two together, so code
45+
that handles only `scriptEnabled` cleans up half a mess and reports success. That is
46+
not hypothetical: the first version of this sweep shipped that way and left eight
47+
orphaned `scriptProblem` states on a live instance while `doctor` called it clean.
48+
`MARKER_KINDS` in `types.ts` is the single list; anything iterating markers iterates it.
49+
3450
5. **Copy-then-delete must verify first.** ioBroker has no native rename/move, so both
3551
are implemented as copy-verify-delete. The verification compares the actual source
3652
text. Checking only that "something exists at the new id" is not verification — a

README.md

Lines changed: 20 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -108,29 +108,29 @@ If `logs` shows nothing, that is usually the adapter's own log level rather than
108108

109109
**Sync**
110110

111-
| Command | Description |
112-
| ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
113-
| `init` | Write `.iobroker-sync.json`, verify the connection, create the script folder. Asks interactively when run without flags. `--types` also sets up TypeScript definitions. |
114-
| `types` | Set up editor intellisense (`log`, `schedule`, ...). `--force`, `--offline`. |
115-
| `login` / `logout` | Save or remove the password for this instance. Never stored in the project. |
116-
| `trust` | Accept the instance's current TLS certificate. Only needed after it changes. `--yes` skips the prompt. |
117-
| `doctor` | Check config, certificate, login, connection and a live round-trip, and say which one is wrong. Read-only, never prompts. Run this first when something looks broken. |
118-
| `pull [pattern]` | Download scripts to disk. Never deletes or overwrites local files. |
119-
| `push [pattern]` | Upload locally modified scripts. Never deletes remote objects. |
120-
| `status` | Show what changed, locally and remotely. |
121-
| `diff [pattern]` | Unified diff of local vs server. `--against <snapshot>` compares with a backup instead. |
122-
| `watch` | Push on save. `--pull` also applies remote changes. |
123-
| `logs [pattern]` | Stream the server log. `--level`, `--limit`. Read-only. |
124-
| `backup [pattern]` | Snapshot every script — source _and_ full object — to `.iobroker-sync/backup/<timestamp>/`. Read-only against the server. |
111+
| Command | Description |
112+
| ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
113+
| `init` | Write `.iobroker-sync.json`, verify the connection, create the script folder. Asks interactively when run without flags. `--types` also sets up TypeScript definitions. |
114+
| `types` | Set up editor intellisense (`log`, `schedule`, ...). `--force`, `--offline`. |
115+
| `login` / `logout` | Save or remove the password for this instance. Never stored in the project. |
116+
| `trust` | Accept the instance's current TLS certificate. Only needed after it changes. `--yes` skips the prompt. |
117+
| `doctor` | Check config, certificate, login, connection, a live round-trip and leftover adapter markers, and say which one is wrong. Read-only, never prompts. Run this first when something looks broken. |
118+
| `pull [pattern]` | Download scripts to disk. Never deletes or overwrites local files. |
119+
| `push [pattern]` | Upload locally modified scripts. Never deletes remote objects. |
120+
| `status` | Show what changed, locally and remotely. |
121+
| `diff [pattern]` | Unified diff of local vs server. `--against <snapshot>` compares with a backup instead. |
122+
| `watch` | Push on save. `--pull` also applies remote changes. |
123+
| `logs [pattern]` | Stream the server log. `--level`, `--limit`. Read-only. |
124+
| `backup [pattern]` | Snapshot every script — source _and_ full object — to `.iobroker-sync/backup/<timestamp>/`. Read-only against the server. |
125125

126126
**Lifecycle**
127127

128-
| Command | Description |
129-
| ---------------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
130-
| `list` | All scripts with instance and enabled state. |
131-
| `start` / `stop` / `restart` `<pattern>` | Toggle `common.enabled`. |
132-
| `new <path>` | Create a new script (disabled) plus any missing folders. |
133-
| `rename` / `move` / `remove` | Destructive. Require `--yes` and back up the object first. `remove` keeps the local file unless `--delete-local`. |
128+
| Command | Description |
129+
| ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
130+
| `list` | All scripts with instance and enabled state. |
131+
| `start` / `stop` / `restart` `<pattern>` | Toggle `common.enabled`. |
132+
| `new <path>` | Create a new script (disabled) plus any missing folders. |
133+
| `rename` / `move` / `remove` | Destructive. Require `--yes` and back up the object first. `remove` keeps the local file unless `--delete-local`. All three also clean up the `scriptEnabled`/`scriptProblem` states the old id leaves behind on every javascript instance. |
134134

135135
`--dry-run`, `--verbose`, `--json` and `-C <dir>` are global and work with every command.
136136
When in doubt, `--dry-run` shows what would happen and changes nothing.

docs/TROUBLESHOOTING.md

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,9 @@
33
Things that look like bugs and are not. See the [README](../README.md) for the overview.
44

55
**Start with `iob-sync doctor`.** It checks the config, the certificate, the login, the
6-
connection and a live round-trip, and names the one that is wrong — including the two
7-
cases below, which are the ones that reliably send people down the wrong path. It is
8-
read-only and never prompts, so it is safe to run at any time.
6+
connection, a live round-trip and the adapter's leftover script markers, and names the one
7+
that is wrong — including the cases below, which are the ones that reliably send people
8+
down the wrong path. It is read-only and never prompts, so it is safe to run at any time.
99

1010
## Commands time out, but the connection "works"
1111

@@ -86,6 +86,48 @@ a script or move it to another javascript instance, it cannot — that is delibe
8686
sync bug cannot stop a running script. Use `start` / `stop` for `enabled`; instance moves
8787
must be done in Admin.
8888

89+
## `doctor` warns about orphaned markers
90+
91+
Nothing is broken, and no script is affected.
92+
93+
The javascript adapter keeps two bookkeeping states beside every script, on **every**
94+
javascript instance — not only the one that runs it:
95+
96+
```
97+
javascript.<n>.scriptEnabled.<script id>
98+
javascript.<n>.scriptProblem.<script id>
99+
```
100+
101+
Both are created by the adapter's `load()`, which calls `createActiveObject` and
102+
`createProblemObject` _before_ `prepareScript` checks `common.engine` to decide whether
103+
this instance should actually run the script. Every instance runs `load()` for every
104+
non-global script at startup, and again on every source change. So on a three-instance
105+
system, ten scripts mean sixty of these states, and that is normal.
106+
107+
Deletion, however, _is_ gated on the engine: only the instance that owned the script at
108+
the moment it was deleted removes its own pair. The pairs on the other instances stay for
109+
the life of the system, js-controller complains about them, and nothing in ioBroker ever
110+
collects them.
111+
112+
This has nothing to do with who did the deleting — the Admin UI leaves exactly the same
113+
residue. Verified against ioBroker.javascript v8.9.2.
114+
115+
`iob-sync doctor` lists them by id, both kinds. To clear one script's leftovers:
116+
117+
```bash
118+
iob-sync remove script.js.diag.retired-check --yes
119+
```
120+
121+
`remove` sweeps the markers even when the script itself is already gone from the server —
122+
in that case it deletes nothing else and leaves your local file alone. `rename` and `move`
123+
sweep the old id's markers as they go, so this does not accumulate from normal use.
124+
125+
One detail worth knowing if you clean these up by hand: delete the **state value first,
126+
then the object**. The reverse order leaves a value with no object behind it, which is the
127+
shape js-controller actually warns about — and it is the order the adapter's own cleanup
128+
uses (`delObject` then `delState`), so that warning may well have come from ioBroker
129+
itself rather than from anything you did.
130+
89131
## A push is refused as a conflict
90132

91133
Local and remote both changed since the last sync. Inspect with `iob-sync diff`, then

src/client/objects.ts

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,12 @@
66
import {
77
FolderObject,
88
IoBrokerObject,
9+
MARKER_KINDS,
10+
MarkerKind,
911
ObjectsApi,
1012
ObjectViewResult,
1113
ScriptCommon,
14+
ScriptMarkerEntry,
1215
ScriptObject,
1316
SocketClient,
1417
} from '../types';
@@ -17,6 +20,43 @@ const SCRIPT_NAMESPACE = 'script.js.';
1720
// Verified endkey used by the Admin UI itself to bound a getObjectView range scan.
1821
const VIEW_ENDKEY = `${SCRIPT_NAMESPACE}香`;
1922

23+
const JS_NAMESPACE = 'javascript.';
24+
25+
/** `javascript.*.<kind>.*` — every instance's markers of one kind, for every script. */
26+
function markerPattern(kind: MarkerKind): string {
27+
return `${JS_NAMESPACE}*.${kind}.*`;
28+
}
29+
30+
/**
31+
* Splits a marker id into the script it belongs to and which kind it is:
32+
* `javascript.2.scriptEnabled.common.garage` -> `script.js.common.garage`, scriptEnabled.
33+
*
34+
* Returns null for anything that is not one — including ids with something other than
35+
* a bare instance number in front of the kind, so that a state someone else parked
36+
* under `javascript.` cannot be mistaken for ours and deleted.
37+
*/
38+
export function parseMarkerId(stateId: string): { scriptId: string; kind: MarkerKind } | null {
39+
if (!stateId.startsWith(JS_NAMESPACE)) {
40+
return null;
41+
}
42+
for (const kind of MARKER_KINDS) {
43+
const infix = `.${kind}.`;
44+
const kindAt = stateId.indexOf(infix);
45+
if (kindAt === -1) {
46+
continue;
47+
}
48+
const instance = stateId.slice(JS_NAMESPACE.length, kindAt);
49+
if (!/^\d+$/.test(instance)) {
50+
continue;
51+
}
52+
const suffix = stateId.slice(kindAt + infix.length);
53+
if (suffix) {
54+
return { scriptId: `${SCRIPT_NAMESPACE}${suffix}`, kind };
55+
}
56+
}
57+
return null;
58+
}
59+
2060
export class AdminObjectsApi implements ObjectsApi {
2161
constructor(private readonly socket: SocketClient) {}
2262

@@ -111,4 +151,89 @@ export class AdminObjectsApi implements ObjectsApi {
111151
async deleteObject(id: string): Promise<void> {
112152
await this.socket.emit('delObject', [id]);
113153
}
154+
155+
/**
156+
* Reads the value side and the object side separately and unions them, because a
157+
* marker can exist as either half alone and the halves are what we need to tell apart.
158+
*
159+
* One half failing is tolerated: `getStates` and `getForeignObjects` are separate
160+
* commands with separate histories across Admin versions, and a partial answer here
161+
* still beats reporting nothing. Only a total failure propagates.
162+
*/
163+
async listScriptMarkers(): Promise<ScriptMarkerEntry[]> {
164+
const perKind = await Promise.all(MARKER_KINDS.map((kind) => this.listMarkersOfKind(kind)));
165+
return perKind.flat().sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
166+
}
167+
168+
private async listMarkersOfKind(kind: MarkerKind): Promise<ScriptMarkerEntry[]> {
169+
const pattern = markerPattern(kind);
170+
const [values, objects] = await Promise.allSettled([
171+
this.readMarkerValues(pattern),
172+
this.socket.emit<Record<string, unknown> | null>('getForeignObjects', [pattern, 'state']),
173+
]);
174+
175+
if (values.status === 'rejected' && objects.status === 'rejected') {
176+
throw values.reason;
177+
}
178+
179+
const valueIds = new Set(values.status === 'fulfilled' ? Object.keys(values.value ?? {}) : []);
180+
const objectIds = new Set(
181+
objects.status === 'fulfilled' ? Object.keys(objects.value ?? {}) : [],
182+
);
183+
184+
const entries: ScriptMarkerEntry[] = [];
185+
for (const id of new Set([...valueIds, ...objectIds])) {
186+
const parsed = parseMarkerId(id);
187+
// The pattern is a wildcard match, so it can catch ids that merely look the part
188+
// (`javascript.0.scriptEnabled` with no suffix, or a non-numeric instance).
189+
// parseMarkerId is the authority on what is really ours.
190+
if (parsed?.kind !== kind) {
191+
continue;
192+
}
193+
entries.push({
194+
id,
195+
scriptId: parsed.scriptId,
196+
kind,
197+
hasValue: valueIds.has(id),
198+
hasObject: objectIds.has(id),
199+
});
200+
}
201+
return entries;
202+
}
203+
204+
/**
205+
* `getStates` is the current command; `getForeignStates` is its deprecated alias and
206+
* the only one older Admin builds answer. Trying both costs one extra round trip on
207+
* instances that need it and nothing on instances that do not.
208+
*/
209+
private async readMarkerValues(pattern: string): Promise<Record<string, unknown> | null> {
210+
try {
211+
return await this.socket.emit<Record<string, unknown> | null>('getStates', [pattern]);
212+
} catch {
213+
return this.socket.emit<Record<string, unknown> | null>('getForeignStates', [pattern]);
214+
}
215+
}
216+
217+
async deleteScriptMarker(entry: ScriptMarkerEntry): Promise<void> {
218+
if (!parseMarkerId(entry.id)) {
219+
// Belt and braces: this API is reachable from command code, and the whole point
220+
// of it is deleting things, so it refuses anything outside its own namespace.
221+
throw new Error(`Refusing to delete "${entry.id}": not a script marker.`);
222+
}
223+
224+
// Value first. If this throws we stop here on purpose — deleting the object while
225+
// the value survives would manufacture the exact orphan this code exists to remove.
226+
if (entry.hasValue) {
227+
await this.socket.emit('delState', [entry.id]);
228+
}
229+
230+
if (entry.hasObject) {
231+
// Admin's delState is documented to take the object with it. Verify rather than
232+
// assume: on an instance where it does not, the object has to go separately.
233+
const remaining = await this.socket.emit<IoBrokerObject | null>('getObject', [entry.id]);
234+
if (remaining) {
235+
await this.socket.emit('delObject', [entry.id]);
236+
}
237+
}
238+
}
114239
}

0 commit comments

Comments
 (0)