@checkstack/automation-backend 0.2.0 → 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/CHANGELOG.md +544 -0
- package/drizzle/0003_sparkling_xorn.sql +17 -0
- package/drizzle/0004_cultured_spyke.sql +2 -0
- package/drizzle/0005_classy_the_hand.sql +19 -0
- package/drizzle/0006_burly_wallop.sql +10 -0
- package/drizzle/0007_nappy_jackal.sql +1 -0
- package/drizzle/0008_remove_seeded_auto_incident_automations.sql +13 -0
- package/drizzle/0009_steady_liz_osborn.sql +12 -0
- package/drizzle/0010_chunky_changeling.sql +2 -0
- package/drizzle/meta/0003_snapshot.json +1007 -0
- package/drizzle/meta/0004_snapshot.json +1028 -0
- package/drizzle/meta/0005_snapshot.json +1164 -0
- package/drizzle/meta/0006_snapshot.json +1261 -0
- package/drizzle/meta/0007_snapshot.json +1215 -0
- package/drizzle/meta/0008_snapshot.json +1215 -0
- package/drizzle/meta/0009_snapshot.json +1328 -0
- package/drizzle/meta/0010_snapshot.json +1349 -0
- package/drizzle/meta/_journal.json +56 -0
- package/package.json +23 -12
- package/src/action-types.ts +23 -0
- package/src/artifact-store.ts +16 -1
- package/src/automation-store.test.ts +143 -0
- package/src/automation-store.ts +30 -8
- package/src/builtin-triggers.test.ts +77 -74
- package/src/builtin-triggers.ts +105 -108
- package/src/dispatch/action-kind.ts +2 -0
- package/src/dispatch/assemble-get-service.ts +31 -0
- package/src/dispatch/cancel-resurrect.test.ts +147 -0
- package/src/dispatch/concurrency-race.test.ts +255 -0
- package/src/dispatch/concurrency-scope.test.ts +166 -0
- package/src/dispatch/condition.ts +24 -5
- package/src/dispatch/dwell-queue.ts +65 -0
- package/src/dispatch/dwell-store.ts +154 -0
- package/src/dispatch/dwell.it.test.ts +142 -0
- package/src/dispatch/dwell.test.ts +799 -0
- package/src/dispatch/dwell.ts +257 -0
- package/src/dispatch/engine.test.ts +189 -2
- package/src/dispatch/engine.ts +555 -9
- package/src/dispatch/entity-scope.test.ts +176 -0
- package/src/dispatch/get-service-wiring.test.ts +318 -0
- package/src/dispatch/numeric.test.ts +71 -0
- package/src/dispatch/numeric.ts +96 -0
- package/src/dispatch/render.test.ts +34 -0
- package/src/dispatch/render.ts +31 -11
- package/src/dispatch/reseed-run-secrets.ts +230 -0
- package/src/dispatch/run-secret-registry.test.ts +189 -0
- package/src/dispatch/run-secret-registry.ts +247 -0
- package/src/dispatch/run-state-masking.test.ts +376 -0
- package/src/dispatch/run-state-store.ts +95 -38
- package/src/dispatch/run-state.ts +226 -59
- package/src/dispatch/scope-artifact-masking.test.ts +138 -0
- package/src/dispatch/secret-ref-ids.test.ts +19 -0
- package/src/dispatch/secret-ref-ids.ts +17 -0
- package/src/dispatch/snapshots.test.ts +86 -0
- package/src/dispatch/snapshots.ts +79 -0
- package/src/dispatch/stage1-router.test.ts +324 -0
- package/src/dispatch/stage1-router.ts +152 -0
- package/src/dispatch/stage1.it.test.ts +84 -0
- package/src/dispatch/stage2-dispatch.test.ts +285 -0
- package/src/dispatch/stage2-dispatch.ts +207 -0
- package/src/dispatch/stage2-stalled.it.test.ts +132 -0
- package/src/dispatch/stalled-sweeper.test.ts +197 -0
- package/src/dispatch/stalled-sweeper.ts +112 -5
- package/src/dispatch/state-scope.test.ts +234 -0
- package/src/dispatch/state-scope.ts +322 -0
- package/src/dispatch/structured-conditions.test.ts +246 -0
- package/src/dispatch/structured-conditions.ts +146 -0
- package/src/dispatch/test-fixtures.ts +306 -38
- package/src/dispatch/trigger-fanin.test.ts +111 -0
- package/src/dispatch/trigger-subscriber.ts +316 -14
- package/src/dispatch/types.ts +263 -8
- package/src/dispatch/wait-timeout-queue.ts +89 -0
- package/src/dispatch/wait-until-entity-wake.test.ts +544 -0
- package/src/dispatch/wait-until.test.ts +540 -0
- package/src/dispatch/wake-refs.test.ts +158 -0
- package/src/dispatch/wake-refs.ts +348 -0
- package/src/dispatch/window-gate.test.ts +513 -0
- package/src/dispatch/window-store.test.ts +162 -0
- package/src/dispatch/window-store.ts +102 -0
- package/src/entity/change-derivers.test.ts +148 -0
- package/src/entity/change-derivers.ts +143 -0
- package/src/entity/change-emitter.test.ts +66 -0
- package/src/entity/change-emitter.ts +76 -0
- package/src/entity/create-handle.ts +344 -0
- package/src/entity/cross-pod-read-consistency.it.test.ts +281 -0
- package/src/entity/define-entity.ts +157 -0
- package/src/entity/diff.test.ts +57 -0
- package/src/entity/diff.ts +54 -0
- package/src/entity/entity-store.test.ts +30 -0
- package/src/entity/entity-store.ts +171 -0
- package/src/entity/extension-point.ts +56 -0
- package/src/entity/fake-entity-store.ts +130 -0
- package/src/entity/hook.ts +19 -0
- package/src/entity/index.ts +50 -0
- package/src/entity/mutate-handle.test.ts +517 -0
- package/src/entity/on-entity-changed.test.ts +189 -0
- package/src/entity/on-entity-changed.ts +214 -0
- package/src/entity/registry.test.ts +181 -0
- package/src/entity/registry.ts +200 -0
- package/src/entity/stable-stringify.test.ts +55 -0
- package/src/entity/stable-stringify.ts +49 -0
- package/src/entity/wake-index.it.test.ts +251 -0
- package/src/entity/with-entity-write.test.ts +100 -0
- package/src/entity/with-entity-write.ts +69 -0
- package/src/entity-driven-trigger.ts +46 -0
- package/src/extension-points.ts +35 -0
- package/src/gitops-docs.test.ts +215 -0
- package/src/gitops-docs.ts +151 -0
- package/src/gitops-kinds.test.ts +174 -0
- package/src/gitops-kinds.ts +137 -0
- package/src/index.ts +355 -11
- package/src/migration/flapping-to-window.test.ts +123 -0
- package/src/migration/flapping-to-window.ts +205 -0
- package/src/router.test.ts +182 -1
- package/src/router.ts +73 -2
- package/src/schema.ts +236 -3
- package/src/script-test-replay.test.ts +88 -0
- package/src/script-test-replay.ts +100 -0
- package/src/script-test-shell-env.test.ts +41 -0
- package/src/script-test-shell-env.ts +89 -0
- package/src/script-test.test.ts +386 -0
- package/src/script-test.ts +258 -0
- package/src/trigger-registry.ts +2 -0
- package/src/validate-definition.test.ts +1 -0
- package/tsconfig.json +24 -0
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wake-index reference extraction (reactive automation engine §8.3).
|
|
3
|
+
*
|
|
4
|
+
* A reactive `wait_until` no longer polls — at suspend time we statically
|
|
5
|
+
* extract the `state.*` entity refs its condition reads and insert one
|
|
6
|
+
* wake-index row per ref (§8.1). A relevant `ENTITY_CHANGED` then wakes the
|
|
7
|
+
* wait and re-evaluates the full condition synchronously.
|
|
8
|
+
*
|
|
9
|
+
* This module is the static-analysis half: given a {@link Condition}, return
|
|
10
|
+
* the set of dependency refs the condition depends on. Each ref is one of:
|
|
11
|
+
*
|
|
12
|
+
* - A concrete entity ref `{ kind, id }` → wake-index ref `${kind}:${id}`.
|
|
13
|
+
* - A kind-level WILDCARD `{ kind, id: "*" }` → wake-index ref `${kind}:*`,
|
|
14
|
+
* emitted when the walker can see the kind but not a concrete id (a
|
|
15
|
+
* dynamic/computed id). The wait then wakes on ANY change of that kind
|
|
16
|
+
* and re-evaluates — a few extra wakes, never a silent stall (§12).
|
|
17
|
+
*
|
|
18
|
+
* When extraction is wholly indeterminate (not even a kind), the result is
|
|
19
|
+
* `{ refs: [], indeterminate: true }` so the caller can fall back to the
|
|
20
|
+
* durable timeout timer only and log at `warn` (§8.3) — never silent.
|
|
21
|
+
*
|
|
22
|
+
* Grammar coverage:
|
|
23
|
+
* - Structured `state` conditions → `health:<entity>` (the current health
|
|
24
|
+
* entity's kind is `health`; `evaluateStateCondition` reads
|
|
25
|
+
* `health.systems[entity]`).
|
|
26
|
+
* - Structured `numeric_state` conditions whose `value` is a path/template
|
|
27
|
+
* into `state.<kind>.<id>.<field>` (or the rich `health.*` snapshot).
|
|
28
|
+
* - Template-string conditions: every member-expression rooted at
|
|
29
|
+
* `state.<kind>.<id>` or `health.systems[<id>]` / `health.system`.
|
|
30
|
+
* - `and` / `or` / `not` combinators recurse into their operands.
|
|
31
|
+
*/
|
|
32
|
+
import {
|
|
33
|
+
parseCondition,
|
|
34
|
+
type Expr,
|
|
35
|
+
} from "@checkstack/template-engine";
|
|
36
|
+
import type { Condition } from "@checkstack/automation-common";
|
|
37
|
+
|
|
38
|
+
/** The entity kind the rich `health.*` scope snapshot maps to. */
|
|
39
|
+
export const HEALTH_ENTITY_KIND = "health";
|
|
40
|
+
|
|
41
|
+
/** A single extracted dependency. `id === "*"` is the kind-level wildcard. */
|
|
42
|
+
export interface WakeRef {
|
|
43
|
+
kind: string;
|
|
44
|
+
/** Concrete entity id, or `"*"` for the kind-level wildcard. */
|
|
45
|
+
id: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface ExtractedRefs {
|
|
49
|
+
/** De-duplicated refs the condition depends on. */
|
|
50
|
+
refs: ReadonlyArray<WakeRef>;
|
|
51
|
+
/**
|
|
52
|
+
* True when the walker could not derive ANY ref (not even a kind) from a
|
|
53
|
+
* condition that nonetheless references live state — the caller falls back
|
|
54
|
+
* to the timeout timer only and logs a warning.
|
|
55
|
+
*/
|
|
56
|
+
indeterminate: boolean;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Serialize a {@link WakeRef} to its wake-index `ref` column value. */
|
|
60
|
+
export function refToString(ref: WakeRef): string {
|
|
61
|
+
return `${ref.kind}:${ref.id}`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Read the operand array of an `and` / `or` combinator, or undefined when
|
|
66
|
+
* `condition` is not that combinator. Avoids a type-predicate over the
|
|
67
|
+
* `Condition` union (which includes `string`).
|
|
68
|
+
*/
|
|
69
|
+
function combinatorOperands(
|
|
70
|
+
condition: Exclude<Condition, string>,
|
|
71
|
+
key: "and" | "or",
|
|
72
|
+
): ReadonlyArray<Condition> | undefined {
|
|
73
|
+
const value = (condition as Record<string, unknown>)[key];
|
|
74
|
+
return Array.isArray(value) ? (value as ReadonlyArray<Condition>) : undefined;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Extract the wake-index dependency refs for a wait condition.
|
|
79
|
+
*
|
|
80
|
+
* The result is de-duplicated. `indeterminate` is set only when the
|
|
81
|
+
* condition clearly reads state (a template / structured variant) but no
|
|
82
|
+
* ref — concrete or wildcard — could be derived.
|
|
83
|
+
*/
|
|
84
|
+
export function extractWakeRefs(condition: Condition): ExtractedRefs {
|
|
85
|
+
const collector = new RefCollector();
|
|
86
|
+
collector.visitCondition(condition);
|
|
87
|
+
return collector.result();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
class RefCollector {
|
|
91
|
+
private readonly seen = new Set<string>();
|
|
92
|
+
private readonly refs: WakeRef[] = [];
|
|
93
|
+
/** Set when at least one state-reading shape was encountered. */
|
|
94
|
+
private touchedState = false;
|
|
95
|
+
|
|
96
|
+
result(): ExtractedRefs {
|
|
97
|
+
return {
|
|
98
|
+
refs: this.refs,
|
|
99
|
+
// Only surface indeterminate when state was read but nothing resolved
|
|
100
|
+
// to a concrete-or-wildcard ref — the caller then relies on the
|
|
101
|
+
// timeout timer alone and logs a warning.
|
|
102
|
+
indeterminate: this.touchedState && this.refs.length === 0,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
private add(ref: WakeRef): void {
|
|
107
|
+
this.touchedState = true;
|
|
108
|
+
const key = refToString(ref);
|
|
109
|
+
if (this.seen.has(key)) return;
|
|
110
|
+
this.seen.add(key);
|
|
111
|
+
this.refs.push(ref);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Note that we saw a state-reading shape we couldn't resolve to a ref.
|
|
116
|
+
* `result()` reports `indeterminate` only if NO ref was recorded overall.
|
|
117
|
+
*/
|
|
118
|
+
private markIndeterminate(): void {
|
|
119
|
+
this.touchedState = true;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
visitCondition(condition: Condition): void {
|
|
123
|
+
if (typeof condition === "string") {
|
|
124
|
+
this.visitTemplate(condition);
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
const andOps = combinatorOperands(condition, "and");
|
|
128
|
+
if (andOps) {
|
|
129
|
+
for (const c of andOps) this.visitCondition(c);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
const orOps = combinatorOperands(condition, "or");
|
|
133
|
+
if (orOps) {
|
|
134
|
+
for (const c of orOps) this.visitCondition(c);
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
if ("not" in condition) {
|
|
138
|
+
this.visitCondition(condition.not);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
if ("state" in condition) {
|
|
142
|
+
this.touchedState = true;
|
|
143
|
+
// The structured `state` condition reads `health.systems[entity]`.
|
|
144
|
+
const entity = condition.state.entity;
|
|
145
|
+
if (typeof entity === "string" && entity.length > 0) {
|
|
146
|
+
this.add({ kind: HEALTH_ENTITY_KIND, id: entity });
|
|
147
|
+
} else {
|
|
148
|
+
this.markIndeterminate();
|
|
149
|
+
}
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
if ("numeric_state" in condition) {
|
|
153
|
+
this.touchedState = true;
|
|
154
|
+
const value = condition.numeric_state.value;
|
|
155
|
+
if (typeof value === "string") {
|
|
156
|
+
// A path/template string into state.<kind>.<id>.<field> or health.*.
|
|
157
|
+
this.visitTemplate(value);
|
|
158
|
+
}
|
|
159
|
+
// A literal number references no entity — nothing to extract.
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
if ("time" in condition) {
|
|
163
|
+
// `time` reads no entity state.
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Parse a template/path string and walk its member-expression chains for
|
|
170
|
+
* `state.<kind>.<id>` and rich `health.*` snapshot roots.
|
|
171
|
+
*/
|
|
172
|
+
private visitTemplate(source: string): void {
|
|
173
|
+
let root: Expr;
|
|
174
|
+
try {
|
|
175
|
+
root = parseCondition(source).root;
|
|
176
|
+
} catch {
|
|
177
|
+
// Unparseable — treat as a state read we can't resolve (so the caller
|
|
178
|
+
// wildcards / falls back rather than silently never waking).
|
|
179
|
+
this.touchedState = true;
|
|
180
|
+
this.markIndeterminate();
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
this.visitExpr(root);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
private visitExpr(expr: Expr): void {
|
|
187
|
+
switch (expr.kind) {
|
|
188
|
+
case "member":
|
|
189
|
+
case "index": {
|
|
190
|
+
// Resolve the access chain rooted at this node; if it begins at a
|
|
191
|
+
// state/health root, record the ref and stop descending into the
|
|
192
|
+
// path segments (they're field accesses, not new roots).
|
|
193
|
+
const matched = this.tryRecordChain(expr);
|
|
194
|
+
if (matched) return;
|
|
195
|
+
// Not a state chain — descend into the object (and the index, which
|
|
196
|
+
// may itself contain a state ref) so nested expressions are covered.
|
|
197
|
+
if (expr.kind === "member") {
|
|
198
|
+
this.visitExpr(expr.object);
|
|
199
|
+
} else {
|
|
200
|
+
this.visitExpr(expr.object);
|
|
201
|
+
this.visitExpr(expr.index);
|
|
202
|
+
}
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
case "binary": {
|
|
206
|
+
this.visitExpr(expr.left);
|
|
207
|
+
this.visitExpr(expr.right);
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
case "unary": {
|
|
211
|
+
this.visitExpr(expr.operand);
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
case "ternary": {
|
|
215
|
+
this.visitExpr(expr.condition);
|
|
216
|
+
this.visitExpr(expr.consequent);
|
|
217
|
+
this.visitExpr(expr.alternate);
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
case "pipe": {
|
|
221
|
+
this.visitExpr(expr.value);
|
|
222
|
+
for (const a of expr.args) this.visitExpr(a);
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
case "identifier":
|
|
226
|
+
case "literal": {
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* If `expr` is an access chain rooted at a recognised state/health root,
|
|
234
|
+
* record the corresponding ref and return true. Otherwise return false.
|
|
235
|
+
*
|
|
236
|
+
* Recognised roots:
|
|
237
|
+
* - `state.<kind>.<id>[…]` → { kind, id }
|
|
238
|
+
* - `state.<kind>[<dynamic>][…]` → { kind, "*" } (wildcard)
|
|
239
|
+
* - `health.systems.<id>[…]` → { health, id }
|
|
240
|
+
* - `health.systems[<dynamic>][…]` → { health, "*" }
|
|
241
|
+
* - `health.system[…]` → { health, "*" } (the implicit
|
|
242
|
+
* context system — unknown id)
|
|
243
|
+
*/
|
|
244
|
+
private tryRecordChain(expr: Expr): boolean {
|
|
245
|
+
const chain = flattenChain(expr);
|
|
246
|
+
if (!chain) return false;
|
|
247
|
+
const head = chain.root;
|
|
248
|
+
const rest = chain.segments;
|
|
249
|
+
|
|
250
|
+
if (head === "state") {
|
|
251
|
+
this.touchedState = true;
|
|
252
|
+
const kindSeg = rest[0];
|
|
253
|
+
if (kindSeg === undefined || kindSeg.dynamic) {
|
|
254
|
+
// `state[<dynamic>]` — kind itself unknown.
|
|
255
|
+
this.markIndeterminate();
|
|
256
|
+
return true;
|
|
257
|
+
}
|
|
258
|
+
const kind = kindSeg.name;
|
|
259
|
+
const idSeg = rest[1];
|
|
260
|
+
if (idSeg === undefined) {
|
|
261
|
+
// `state.<kind>` with no id — wildcard the whole kind.
|
|
262
|
+
this.add({ kind, id: "*" });
|
|
263
|
+
return true;
|
|
264
|
+
}
|
|
265
|
+
this.add({ kind, id: idSeg.dynamic ? "*" : idSeg.name });
|
|
266
|
+
return true;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
if (head === "health") {
|
|
270
|
+
this.touchedState = true;
|
|
271
|
+
const next = rest[0];
|
|
272
|
+
if (next === undefined) {
|
|
273
|
+
this.markIndeterminate();
|
|
274
|
+
return true;
|
|
275
|
+
}
|
|
276
|
+
if (!next.dynamic && next.name === "systems") {
|
|
277
|
+
const idSeg = rest[1];
|
|
278
|
+
if (idSeg === undefined) {
|
|
279
|
+
// `health.systems` with no id — wildcard.
|
|
280
|
+
this.add({ kind: HEALTH_ENTITY_KIND, id: "*" });
|
|
281
|
+
return true;
|
|
282
|
+
}
|
|
283
|
+
this.add({
|
|
284
|
+
kind: HEALTH_ENTITY_KIND,
|
|
285
|
+
id: idSeg.dynamic ? "*" : idSeg.name,
|
|
286
|
+
});
|
|
287
|
+
return true;
|
|
288
|
+
}
|
|
289
|
+
if (!next.dynamic && next.name === "system") {
|
|
290
|
+
// The implicit context system — its concrete id isn't in the path.
|
|
291
|
+
this.add({ kind: HEALTH_ENTITY_KIND, id: "*" });
|
|
292
|
+
return true;
|
|
293
|
+
}
|
|
294
|
+
// `health.<other>` — unknown shape; wildcard health to stay safe.
|
|
295
|
+
this.add({ kind: HEALTH_ENTITY_KIND, id: "*" });
|
|
296
|
+
return true;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
return false;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** A single segment of a flattened access chain. */
|
|
304
|
+
interface ChainSegment {
|
|
305
|
+
/** The property/index name, when statically known. */
|
|
306
|
+
name: string;
|
|
307
|
+
/** True when the segment is a non-literal index (a computed key). */
|
|
308
|
+
dynamic: boolean;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** A member/index chain flattened to a root identifier + ordered segments. */
|
|
312
|
+
interface FlattenedChain {
|
|
313
|
+
/** Root identifier name (e.g. `state`, `health`). */
|
|
314
|
+
root: string;
|
|
315
|
+
/** Member/index segments after the root, in source order. */
|
|
316
|
+
segments: ChainSegment[];
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Flatten a member/index access chain into `{ root, segments }`.
|
|
321
|
+
* Returns undefined when the chain isn't rooted at a plain identifier
|
|
322
|
+
* (e.g. `(a ? b : c).x`) — such expressions are visited structurally
|
|
323
|
+
* instead.
|
|
324
|
+
*/
|
|
325
|
+
function flattenChain(expr: Expr): FlattenedChain | undefined {
|
|
326
|
+
const segments: ChainSegment[] = [];
|
|
327
|
+
let node: Expr = expr;
|
|
328
|
+
// Walk from the outermost access inward, prepending as we go.
|
|
329
|
+
while (node.kind === "member" || node.kind === "index") {
|
|
330
|
+
if (node.kind === "member") {
|
|
331
|
+
segments.unshift({ name: node.property, dynamic: false });
|
|
332
|
+
node = node.object;
|
|
333
|
+
} else {
|
|
334
|
+
const idx = node.index;
|
|
335
|
+
if (idx.kind === "literal" && typeof idx.value === "string") {
|
|
336
|
+
segments.unshift({ name: idx.value, dynamic: false });
|
|
337
|
+
} else if (idx.kind === "literal" && typeof idx.value === "number") {
|
|
338
|
+
segments.unshift({ name: String(idx.value), dynamic: false });
|
|
339
|
+
} else {
|
|
340
|
+
// A computed key — id (or kind) is dynamic.
|
|
341
|
+
segments.unshift({ name: "", dynamic: true });
|
|
342
|
+
}
|
|
343
|
+
node = node.object;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
if (node.kind !== "identifier") return undefined;
|
|
347
|
+
return { root: node.name, segments };
|
|
348
|
+
}
|