@pikku/mongodb 0.12.6 → 0.12.8
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/CHANGELOG.md +87 -0
- package/dist/src/mongodb-secret-service.d.ts +1 -1
- package/dist/src/mongodb-workflow-service.d.ts +7 -2
- package/dist/src/mongodb-workflow-service.js +25 -4
- package/package.json +2 -2
- package/src/mongodb-secret-service.ts +4 -2
- package/src/mongodb-workflow-service.ts +25 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,92 @@
|
|
|
1
1
|
# @pikku/mongodb
|
|
2
2
|
|
|
3
|
+
## 0.12.8
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 72694f6: feat(workflow): expose per-step attempt count + record running/succeeded/failed timestamps
|
|
8
|
+
|
|
9
|
+
`getRunStatus` now returns `attempts` (the latest attempt count) per step, so
|
|
10
|
+
consumers can show retry counts without a second history query. It already
|
|
11
|
+
computed `duration` from `runningAt`/`succeededAt`, but the kysely and mongodb
|
|
12
|
+
workflow stores only stamped those timestamps on the _insert_ path — the
|
|
13
|
+
`running` / `succeeded` / `failed` status transitions updated the history row's
|
|
14
|
+
status without setting `runningAt` / `succeededAt` / `failedAt`, so `duration`
|
|
15
|
+
was always undefined. The transitions now stamp the matching timestamp, so step
|
|
16
|
+
duration is populated for kysely- and mongodb-backed runs. (Redis already
|
|
17
|
+
stamped on transition.) A shared service-suite test guards both behaviours.
|
|
18
|
+
|
|
19
|
+
- Updated dependencies [4be205f]
|
|
20
|
+
- Updated dependencies [061c717]
|
|
21
|
+
- Updated dependencies [2c55e13]
|
|
22
|
+
- Updated dependencies [c745c26]
|
|
23
|
+
- Updated dependencies [57900b5]
|
|
24
|
+
- Updated dependencies [72694f6]
|
|
25
|
+
- @pikku/core@0.12.39
|
|
26
|
+
|
|
27
|
+
## 0.12.7
|
|
28
|
+
|
|
29
|
+
### Patch Changes
|
|
30
|
+
|
|
31
|
+
- 92cd5b1: feat(workflow): workflow-owned step retries + stable invocationId
|
|
32
|
+
|
|
33
|
+
The workflow — not the queue — now owns step retry policy, and each step
|
|
34
|
+
invocation gets a stable idempotency key.
|
|
35
|
+
- **Default `retries: 5` with exponential backoff.** A step with no `retries`
|
|
36
|
+
previously inherited the queue's bare default (e.g. pg-boss `retry_limit 2`,
|
|
37
|
+
no backoff) so retries fired instantly and couldn't outlast a transient
|
|
38
|
+
outage. Retries now default to 5 with backoff, resolved at the workflow layer.
|
|
39
|
+
- **`retries: 0` is honored.** Dispatch previously passed `undefined` options
|
|
40
|
+
for `retries: 0`, letting the queue re-run a non-idempotent step up to its own
|
|
41
|
+
default. The resolved policy now always sets `attempts` (`retries: 0` →
|
|
42
|
+
`attempts: 1`), so the queue never second-guesses the workflow. The persisted
|
|
43
|
+
step retries and the dispatched `attempts` are resolved together so
|
|
44
|
+
"retries exhausted" and "no more redeliveries" are the same event.
|
|
45
|
+
- **`workflowStep.invocationId`** — a deterministic, dependency-free
|
|
46
|
+
`uuidv5(runId:stepName)` handed to every step. Unlike `stepId` (minted per
|
|
47
|
+
attempt), it is identical across retries, so a step can dedupe on it
|
|
48
|
+
(`ON CONFLICT (invocationId)`, Stripe idempotency keys, etc.).
|
|
49
|
+
- **queue-bullmq**: `mapPikkuJobToBull` now maps `backoff` (previously dropped,
|
|
50
|
+
so a step's backoff silently never applied on Redis), and `registerQueues`
|
|
51
|
+
throws a clear error when no logger is available (matching queue-pg-boss).
|
|
52
|
+
- **Dispatch failures are recoverable, not fatal.** A step is now marked
|
|
53
|
+
`scheduled` only _after_ it is successfully handed to its transport (queue or
|
|
54
|
+
scheduler) — a failed hand-off leaves it `pending` so a replay re-dispatches
|
|
55
|
+
it, instead of stranding it in `scheduled` (replay would pause forever on a
|
|
56
|
+
job that was never enqueued). A transport outage (e.g. pg-boss momentarily
|
|
57
|
+
down) is surfaced as a new `WorkflowDispatchException`, which the orchestrator
|
|
58
|
+
treats as transient: the run is left running and the orchestrator job is
|
|
59
|
+
rethrown for redelivery (it replays idempotently from the snapshot) rather
|
|
60
|
+
than the whole run being marked `failed`. The orchestrator job now also
|
|
61
|
+
carries its own retry policy, so this holds even when the orchestrator queue
|
|
62
|
+
is configured `retry_limit 0`. A genuine step error still fails the run.
|
|
63
|
+
- **Same step name can be invoked multiple times in one run.** Step rows are now
|
|
64
|
+
keyed per _invocation_: the Nth reach of a step name in a replay resolves to a
|
|
65
|
+
physical key (`name` for the first, `name#N` for repeats), so a literal
|
|
66
|
+
duplicate name no longer clobbers the earlier step's state. The first reach
|
|
67
|
+
keeps the bare name, so existing rows, graph-node matching and `invocationId`s
|
|
68
|
+
are unchanged. Ordinals are derived deterministically from DSL execution order
|
|
69
|
+
and reset each replay.
|
|
70
|
+
- **Step provenance (`fromStepName`) + graph cycles.** Every step now records
|
|
71
|
+
the predecessor it was scheduled from (`fromStepName`; entry steps have none),
|
|
72
|
+
persisted on the step row across all stores (in-memory, kysely, redis,
|
|
73
|
+
mongodb, cloudflare DO) and carried in the queued payload. The DSL wire
|
|
74
|
+
exposes the derived `fromInvocationId` (`uuidv5(runId:fromStepName)`) so
|
|
75
|
+
consumers get the stable predecessor key without a second persisted id —
|
|
76
|
+
`fromStepName` is the source of truth (it is replay-deterministic; `stepId`,
|
|
77
|
+
minted per row, is not). This makes the walked path reconstructable even when
|
|
78
|
+
a node is reached more than once: in `a → b → a → c` the second `a` is a
|
|
79
|
+
distinct ordinal instance (`a#1`) whose `fromStepName` is `b`.
|
|
80
|
+
The graph runner now supports **cycles**: a forward edge into an
|
|
81
|
+
already-started node still collapses to a single run (joins/diamonds are
|
|
82
|
+
unchanged), but a _back-edge_ — one whose target can reach its source — fires
|
|
83
|
+
a fresh ordinal instance, so a node can loop back to itself. Termination is
|
|
84
|
+
the graph's responsibility (branch routing must converge); the engine enforces
|
|
85
|
+
no visit cap.
|
|
86
|
+
|
|
87
|
+
- Updated dependencies [92cd5b1]
|
|
88
|
+
- @pikku/core@0.12.38
|
|
89
|
+
|
|
3
90
|
## 0.12.6
|
|
4
91
|
|
|
5
92
|
### Patch Changes
|
|
@@ -25,6 +25,6 @@ export declare class MongoDBSecretService implements SecretService {
|
|
|
25
25
|
hasSecret(key: string): Promise<boolean>;
|
|
26
26
|
setSecret(key: string, value: unknown): Promise<void>;
|
|
27
27
|
deleteSecret(key: string): Promise<void>;
|
|
28
|
-
getSecrets(keys: string[]): Promise<
|
|
28
|
+
getSecrets<T extends Record<string, unknown> = Record<string, unknown>>(keys: (keyof T & string)[]): Promise<T>;
|
|
29
29
|
rotateKEK(): Promise<number>;
|
|
30
30
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { SerializedError } from '@pikku/core';
|
|
2
|
-
import { PikkuWorkflowService, type WorkflowPlannedStep, type WorkflowRun, type WorkflowRunWire, type StepState, type WorkflowStatus, type WorkflowVersionStatus } from '@pikku/core/workflow';
|
|
2
|
+
import { PikkuWorkflowService, type WorkflowPlannedStep, type WorkflowRun, type WorkflowRunWire, type StepState, type StepStatus, type WorkflowStatus, type WorkflowVersionStatus } from '@pikku/core/workflow';
|
|
3
3
|
import type { Db } from 'mongodb';
|
|
4
4
|
export declare class MongoDBWorkflowService extends PikkuWorkflowService {
|
|
5
5
|
private initialized;
|
|
@@ -19,7 +19,7 @@ export declare class MongoDBWorkflowService extends PikkuWorkflowService {
|
|
|
19
19
|
protected insertStepStateImpl(runId: string, stepName: string, rpcName: string | null, data: any, stepOptions?: {
|
|
20
20
|
retries?: number;
|
|
21
21
|
retryDelay?: string | number;
|
|
22
|
-
}): Promise<StepState>;
|
|
22
|
+
}, fromStepName?: string): Promise<StepState>;
|
|
23
23
|
getStepState(runId: string, stepName: string): Promise<StepState>;
|
|
24
24
|
getRunHistory(runId: string): Promise<Array<StepState & {
|
|
25
25
|
stepName: string;
|
|
@@ -40,6 +40,11 @@ export declare class MongoDBWorkflowService extends PikkuWorkflowService {
|
|
|
40
40
|
branchKeys: Record<string, string>;
|
|
41
41
|
}>;
|
|
42
42
|
getNodesWithoutSteps(runId: string, nodeIds: string[]): Promise<string[]>;
|
|
43
|
+
getStepInstances(runId: string): Promise<Array<{
|
|
44
|
+
stepName: string;
|
|
45
|
+
status: StepStatus;
|
|
46
|
+
fromStepName?: string;
|
|
47
|
+
}>>;
|
|
43
48
|
getNodeResults(runId: string, nodeIds: string[]): Promise<Record<string, any>>;
|
|
44
49
|
protected setBranchTakenImpl(stepId: string, branchKey: string): Promise<void>;
|
|
45
50
|
protected updateRunStateImpl(runId: string, name: string, value: unknown): Promise<void>;
|
|
@@ -62,7 +62,7 @@ export class MongoDBWorkflowService extends PikkuWorkflowService {
|
|
|
62
62
|
},
|
|
63
63
|
});
|
|
64
64
|
}
|
|
65
|
-
async insertStepStateImpl(runId, stepName, rpcName, data, stepOptions) {
|
|
65
|
+
async insertStepStateImpl(runId, stepName, rpcName, data, stepOptions, fromStepName) {
|
|
66
66
|
const stepId = crypto.randomUUID();
|
|
67
67
|
const now = new Date();
|
|
68
68
|
await this.steps.insertOne({
|
|
@@ -77,6 +77,7 @@ export class MongoDBWorkflowService extends PikkuWorkflowService {
|
|
|
77
77
|
branchTaken: null,
|
|
78
78
|
retries: stepOptions?.retries ?? null,
|
|
79
79
|
retryDelay: stepOptions?.retryDelay?.toString() ?? null,
|
|
80
|
+
fromStepName: fromStepName ?? null,
|
|
80
81
|
createdAt: now,
|
|
81
82
|
updatedAt: now,
|
|
82
83
|
});
|
|
@@ -89,6 +90,7 @@ export class MongoDBWorkflowService extends PikkuWorkflowService {
|
|
|
89
90
|
attemptCount: 1,
|
|
90
91
|
retries: stepOptions?.retries,
|
|
91
92
|
retryDelay: stepOptions?.retryDelay?.toString(),
|
|
93
|
+
fromStepName,
|
|
92
94
|
createdAt: now,
|
|
93
95
|
updatedAt: now,
|
|
94
96
|
};
|
|
@@ -112,6 +114,7 @@ export class MongoDBWorkflowService extends PikkuWorkflowService {
|
|
|
112
114
|
attemptCount,
|
|
113
115
|
retries: row.retries != null ? Number(row.retries) : undefined,
|
|
114
116
|
retryDelay: row.retryDelay ?? undefined,
|
|
117
|
+
fromStepName: row.fromStepName ?? undefined,
|
|
115
118
|
createdAt: new Date(row.createdAt),
|
|
116
119
|
updatedAt: new Date(row.updatedAt),
|
|
117
120
|
};
|
|
@@ -127,7 +130,7 @@ export class MongoDBWorkflowService extends PikkuWorkflowService {
|
|
|
127
130
|
.limit(1)
|
|
128
131
|
.toArray();
|
|
129
132
|
if (latestHistory.length > 0) {
|
|
130
|
-
await this.stepHistory.updateOne({ _id: latestHistory[0]._id }, { $set: { status: 'running' } });
|
|
133
|
+
await this.stepHistory.updateOne({ _id: latestHistory[0]._id }, { $set: { status: 'running', runningAt: new Date() } });
|
|
131
134
|
}
|
|
132
135
|
}
|
|
133
136
|
async setStepScheduledImpl(stepId) {
|
|
@@ -190,7 +193,7 @@ export class MongoDBWorkflowService extends PikkuWorkflowService {
|
|
|
190
193
|
.limit(1)
|
|
191
194
|
.toArray();
|
|
192
195
|
if (latestHistory.length > 0) {
|
|
193
|
-
await this.stepHistory.updateOne({ _id: latestHistory[0]._id }, { $set: { status: 'succeeded', result } });
|
|
196
|
+
await this.stepHistory.updateOne({ _id: latestHistory[0]._id }, { $set: { status: 'succeeded', result, succeededAt: new Date() } });
|
|
194
197
|
}
|
|
195
198
|
}
|
|
196
199
|
async setStepErrorImpl(stepId, error) {
|
|
@@ -213,7 +216,13 @@ export class MongoDBWorkflowService extends PikkuWorkflowService {
|
|
|
213
216
|
.limit(1)
|
|
214
217
|
.toArray();
|
|
215
218
|
if (latestHistory.length > 0) {
|
|
216
|
-
await this.stepHistory.updateOne({ _id: latestHistory[0]._id }, {
|
|
219
|
+
await this.stepHistory.updateOne({ _id: latestHistory[0]._id }, {
|
|
220
|
+
$set: {
|
|
221
|
+
status: 'failed',
|
|
222
|
+
error: serializedError,
|
|
223
|
+
failedAt: new Date(),
|
|
224
|
+
},
|
|
225
|
+
});
|
|
217
226
|
}
|
|
218
227
|
}
|
|
219
228
|
async createRetryAttemptImpl(stepId, status) {
|
|
@@ -240,6 +249,7 @@ export class MongoDBWorkflowService extends PikkuWorkflowService {
|
|
|
240
249
|
attemptCount,
|
|
241
250
|
retries: row.retries != null ? Number(row.retries) : undefined,
|
|
242
251
|
retryDelay: row.retryDelay ?? undefined,
|
|
252
|
+
fromStepName: row.fromStepName ?? undefined,
|
|
243
253
|
createdAt: new Date(row.createdAt),
|
|
244
254
|
updatedAt: new Date(row.updatedAt),
|
|
245
255
|
};
|
|
@@ -293,6 +303,17 @@ export class MongoDBWorkflowService extends PikkuWorkflowService {
|
|
|
293
303
|
const existingStepNames = new Set(result.map((r) => r.stepName));
|
|
294
304
|
return nodeIds.filter((id) => !existingStepNames.has(id));
|
|
295
305
|
}
|
|
306
|
+
async getStepInstances(runId) {
|
|
307
|
+
const rows = await this.steps
|
|
308
|
+
.find({ workflowRunId: runId })
|
|
309
|
+
.project({ stepName: 1, status: 1, fromStepName: 1 })
|
|
310
|
+
.toArray();
|
|
311
|
+
return rows.map((r) => ({
|
|
312
|
+
stepName: r.stepName,
|
|
313
|
+
status: r.status,
|
|
314
|
+
fromStepName: r.fromStepName ?? undefined,
|
|
315
|
+
}));
|
|
316
|
+
}
|
|
296
317
|
async getNodeResults(runId, nodeIds) {
|
|
297
318
|
if (nodeIds.length === 0)
|
|
298
319
|
return {};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pikku/mongodb",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.8",
|
|
4
4
|
"author": "yasser.fadl@gmail.com",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"module": "dist/src/index.js",
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
"prepublishOnly": "yarn build"
|
|
18
18
|
},
|
|
19
19
|
"peerDependencies": {
|
|
20
|
-
"@pikku/core": "^0.12.
|
|
20
|
+
"@pikku/core": "^0.12.39"
|
|
21
21
|
},
|
|
22
22
|
"dependencies": {
|
|
23
23
|
"mongodb": "^6.12.0"
|
|
@@ -131,7 +131,9 @@ export class MongoDBSecretService implements SecretService {
|
|
|
131
131
|
await this.logAudit(key, 'delete')
|
|
132
132
|
}
|
|
133
133
|
|
|
134
|
-
async getSecrets
|
|
134
|
+
async getSecrets<
|
|
135
|
+
T extends Record<string, unknown> = Record<string, unknown>,
|
|
136
|
+
>(keys: (keyof T & string)[]): Promise<T> {
|
|
135
137
|
const rows = await this.secrets
|
|
136
138
|
.find({ _id: { $in: keys } } as any)
|
|
137
139
|
.toArray()
|
|
@@ -148,7 +150,7 @@ export class MongoDBSecretService implements SecretService {
|
|
|
148
150
|
// skip secrets that fail to decrypt
|
|
149
151
|
}
|
|
150
152
|
}
|
|
151
|
-
return out
|
|
153
|
+
return out as T
|
|
152
154
|
}
|
|
153
155
|
|
|
154
156
|
async rotateKEK(): Promise<number> {
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
type WorkflowRun,
|
|
6
6
|
type WorkflowRunWire,
|
|
7
7
|
type StepState,
|
|
8
|
+
type StepStatus,
|
|
8
9
|
type WorkflowStatus,
|
|
9
10
|
type WorkflowVersionStatus,
|
|
10
11
|
} from '@pikku/core/workflow'
|
|
@@ -41,6 +42,7 @@ interface WorkflowStepDoc {
|
|
|
41
42
|
branchTaken: string | null
|
|
42
43
|
retries: number | null
|
|
43
44
|
retryDelay: string | null
|
|
45
|
+
fromStepName?: string | null
|
|
44
46
|
createdAt: Date
|
|
45
47
|
updatedAt: Date
|
|
46
48
|
}
|
|
@@ -172,7 +174,8 @@ export class MongoDBWorkflowService extends PikkuWorkflowService {
|
|
|
172
174
|
stepName: string,
|
|
173
175
|
rpcName: string | null,
|
|
174
176
|
data: any,
|
|
175
|
-
stepOptions?: { retries?: number; retryDelay?: string | number }
|
|
177
|
+
stepOptions?: { retries?: number; retryDelay?: string | number },
|
|
178
|
+
fromStepName?: string
|
|
176
179
|
): Promise<StepState> {
|
|
177
180
|
const stepId = crypto.randomUUID()
|
|
178
181
|
const now = new Date()
|
|
@@ -189,6 +192,7 @@ export class MongoDBWorkflowService extends PikkuWorkflowService {
|
|
|
189
192
|
branchTaken: null,
|
|
190
193
|
retries: stepOptions?.retries ?? null,
|
|
191
194
|
retryDelay: stepOptions?.retryDelay?.toString() ?? null,
|
|
195
|
+
fromStepName: fromStepName ?? null,
|
|
192
196
|
createdAt: now,
|
|
193
197
|
updatedAt: now,
|
|
194
198
|
})
|
|
@@ -203,6 +207,7 @@ export class MongoDBWorkflowService extends PikkuWorkflowService {
|
|
|
203
207
|
attemptCount: 1,
|
|
204
208
|
retries: stepOptions?.retries,
|
|
205
209
|
retryDelay: stepOptions?.retryDelay?.toString(),
|
|
210
|
+
fromStepName,
|
|
206
211
|
createdAt: now,
|
|
207
212
|
updatedAt: now,
|
|
208
213
|
}
|
|
@@ -232,6 +237,7 @@ export class MongoDBWorkflowService extends PikkuWorkflowService {
|
|
|
232
237
|
attemptCount,
|
|
233
238
|
retries: row.retries != null ? Number(row.retries) : undefined,
|
|
234
239
|
retryDelay: row.retryDelay ?? undefined,
|
|
240
|
+
fromStepName: row.fromStepName ?? undefined,
|
|
235
241
|
createdAt: new Date(row.createdAt),
|
|
236
242
|
updatedAt: new Date(row.updatedAt),
|
|
237
243
|
}
|
|
@@ -258,7 +264,7 @@ export class MongoDBWorkflowService extends PikkuWorkflowService {
|
|
|
258
264
|
if (latestHistory.length > 0) {
|
|
259
265
|
await this.stepHistory.updateOne(
|
|
260
266
|
{ _id: latestHistory[0]!._id },
|
|
261
|
-
{ $set: { status: 'running' } }
|
|
267
|
+
{ $set: { status: 'running', runningAt: new Date() } }
|
|
262
268
|
)
|
|
263
269
|
}
|
|
264
270
|
}
|
|
@@ -353,7 +359,7 @@ export class MongoDBWorkflowService extends PikkuWorkflowService {
|
|
|
353
359
|
if (latestHistory.length > 0) {
|
|
354
360
|
await this.stepHistory.updateOne(
|
|
355
361
|
{ _id: latestHistory[0]!._id },
|
|
356
|
-
{ $set: { status: 'succeeded', result } }
|
|
362
|
+
{ $set: { status: 'succeeded', result, succeededAt: new Date() } }
|
|
357
363
|
)
|
|
358
364
|
}
|
|
359
365
|
}
|
|
@@ -389,7 +395,7 @@ export class MongoDBWorkflowService extends PikkuWorkflowService {
|
|
|
389
395
|
if (latestHistory.length > 0) {
|
|
390
396
|
await this.stepHistory.updateOne(
|
|
391
397
|
{ _id: latestHistory[0]!._id },
|
|
392
|
-
{ $set: { status: 'failed', error: serializedError } }
|
|
398
|
+
{ $set: { status: 'failed', error: serializedError, failedAt: new Date() } }
|
|
393
399
|
)
|
|
394
400
|
}
|
|
395
401
|
}
|
|
@@ -427,6 +433,7 @@ export class MongoDBWorkflowService extends PikkuWorkflowService {
|
|
|
427
433
|
attemptCount,
|
|
428
434
|
retries: row.retries != null ? Number(row.retries) : undefined,
|
|
429
435
|
retryDelay: row.retryDelay ?? undefined,
|
|
436
|
+
fromStepName: row.fromStepName ?? undefined,
|
|
430
437
|
createdAt: new Date(row.createdAt),
|
|
431
438
|
updatedAt: new Date(row.updatedAt),
|
|
432
439
|
}
|
|
@@ -500,6 +507,20 @@ export class MongoDBWorkflowService extends PikkuWorkflowService {
|
|
|
500
507
|
return nodeIds.filter((id) => !existingStepNames.has(id))
|
|
501
508
|
}
|
|
502
509
|
|
|
510
|
+
async getStepInstances(runId: string): Promise<
|
|
511
|
+
Array<{ stepName: string; status: StepStatus; fromStepName?: string }>
|
|
512
|
+
> {
|
|
513
|
+
const rows = await this.steps
|
|
514
|
+
.find({ workflowRunId: runId })
|
|
515
|
+
.project({ stepName: 1, status: 1, fromStepName: 1 })
|
|
516
|
+
.toArray()
|
|
517
|
+
return rows.map((r: any) => ({
|
|
518
|
+
stepName: r.stepName,
|
|
519
|
+
status: r.status as StepStatus,
|
|
520
|
+
fromStepName: r.fromStepName ?? undefined,
|
|
521
|
+
}))
|
|
522
|
+
}
|
|
523
|
+
|
|
503
524
|
async getNodeResults(
|
|
504
525
|
runId: string,
|
|
505
526
|
nodeIds: string[]
|