@intentius/chant-lexicon-temporal 0.1.6 → 0.1.9
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/dist/integrity.json +2 -2
- package/dist/manifest.json +1 -1
- package/package.json +1 -1
- package/src/codegen/docs.ts +1 -0
- package/src/composites/composites.test.ts +103 -1
- package/src/composites/watch-op.ts +101 -0
- package/src/describe-resources.test.ts +328 -0
- package/src/describe-resources.ts +300 -0
- package/src/index.ts +2 -0
- package/src/op/activities/index.ts +2 -2
- package/src/op/activities/state.ts +66 -0
- package/src/op/op-serializer.test.ts +231 -0
- package/src/op/serializer.ts +71 -9
- package/src/plugin.test.ts +2 -2
- package/src/plugin.ts +7 -0
package/dist/integrity.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"algorithm": "sha256",
|
|
3
3
|
"artifacts": {
|
|
4
|
-
"manifest.json": "
|
|
4
|
+
"manifest.json": "d761ce46ec1eddb21dc7458840f24b906f9669a4f08e6c48afebb559c225ff13",
|
|
5
5
|
"meta.json": "c77a3c415993bed3865e07fa6db4fb7b9e87de0afa8d8675f64eadf50f914077",
|
|
6
6
|
"types/index.d.ts": "5216f5256321f084a7c8211ef66ab599f621c74751cc3485cfc2a62502b81e2f",
|
|
7
7
|
"rules/tmp001.ts": "689222211a93716a7e4432f6dd6a2a2ab54020b232049fb602893872585f9978",
|
|
@@ -11,5 +11,5 @@
|
|
|
11
11
|
"skills/chant-temporal.md": "ff929983b33bb420c49ab61851f3799c5610256cb972d6c584792bb98b0cfb22",
|
|
12
12
|
"skills/chant-temporal-ops.md": "7e83bc3e6e63af4ab14b8dc07aa00132d51991cf35b1bca29560d48934bff6dc"
|
|
13
13
|
},
|
|
14
|
-
"composite": "
|
|
14
|
+
"composite": "b91b0371789bf94fcf5d1d4cb2e5da113daa0ffea29297f5879d93677a0614d3"
|
|
15
15
|
}
|
package/dist/manifest.json
CHANGED
package/package.json
CHANGED
package/src/codegen/docs.ts
CHANGED
|
@@ -10,6 +10,7 @@ export async function generateDocs(options?: { verbose?: boolean }): Promise<voi
|
|
|
10
10
|
description: "Temporal lexicon documentation",
|
|
11
11
|
distDir: "./dist",
|
|
12
12
|
outDir: "./docs",
|
|
13
|
+
basePath: process.env.DOCS_BASE_PATH ?? "/chant/lexicons/temporal/",
|
|
13
14
|
serviceFromType: (type: string) => type.split("::")[1] ?? type,
|
|
14
15
|
resourceTypeUrl: (type: string) => `#${type}`,
|
|
15
16
|
};
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Composite unit tests — TemporalDevStack, TemporalCloudStack.
|
|
2
|
+
* Composite unit tests — TemporalDevStack, TemporalCloudStack, WatchOp.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import { describe, test, expect } from "vitest";
|
|
6
6
|
import { TemporalDevStack } from "./dev-stack";
|
|
7
7
|
import { TemporalCloudStack } from "./cloud-stack";
|
|
8
|
+
import { WatchOp } from "./watch-op";
|
|
9
|
+
import { serializeOps } from "../op/serializer";
|
|
8
10
|
import { DECLARABLE_MARKER } from "@intentius/chant/declarable";
|
|
9
11
|
|
|
10
12
|
function getProps(entity: unknown): Record<string, unknown> {
|
|
@@ -129,3 +131,103 @@ describe("TemporalCloudStack: basic", () => {
|
|
|
129
131
|
expect(getProps(ns).description).toBe("Production namespace");
|
|
130
132
|
});
|
|
131
133
|
});
|
|
134
|
+
|
|
135
|
+
// ── WatchOp ──────────────────────────────────────────────────────────
|
|
136
|
+
|
|
137
|
+
describe("WatchOp: shape", () => {
|
|
138
|
+
test("returns op + schedule resources", () => {
|
|
139
|
+
const result = WatchOp({ name: "prod-watch", env: "prod", schedule: "*/15 * * * *" });
|
|
140
|
+
expect(result.op).toBeDefined();
|
|
141
|
+
expect(result.schedule).toBeDefined();
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("op has entityType Temporal::Op", () => {
|
|
145
|
+
const { op } = WatchOp({ name: "prod-watch", env: "prod", schedule: "*/15 * * * *" });
|
|
146
|
+
expect(getEntityType(op)).toBe("Temporal::Op");
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("schedule has entityType Temporal::Schedule", () => {
|
|
150
|
+
const { schedule } = WatchOp({ name: "prod-watch", env: "prod", schedule: "*/15 * * * *" });
|
|
151
|
+
expect(getEntityType(schedule)).toBe("Temporal::Schedule");
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("both entities are Declarable", () => {
|
|
155
|
+
const { op, schedule } = WatchOp({ name: "prod-watch", env: "prod", schedule: "*/15 * * * *" });
|
|
156
|
+
expect((op as Record<symbol, unknown>)[DECLARABLE_MARKER]).toBe(true);
|
|
157
|
+
expect((schedule as Record<symbol, unknown>)[DECLARABLE_MARKER]).toBe(true);
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
describe("WatchOp: configuration", () => {
|
|
162
|
+
test("op has Snapshot + Diff phases referencing the right activities", () => {
|
|
163
|
+
const { op } = WatchOp({ name: "p", env: "prod", schedule: "*/15 * * * *" });
|
|
164
|
+
const phases = (getProps(op).phases as Array<Record<string, unknown>>) ?? [];
|
|
165
|
+
expect(phases.map((p) => p.name)).toEqual(["Snapshot", "Diff"]);
|
|
166
|
+
const snapStep = (phases[0].steps as Array<Record<string, unknown>>)[0];
|
|
167
|
+
const diffStep = (phases[1].steps as Array<Record<string, unknown>>)[0];
|
|
168
|
+
expect(snapStep.fn).toBe("stateSnapshot");
|
|
169
|
+
expect(diffStep.fn).toBe("stateDiff");
|
|
170
|
+
expect(snapStep.args).toEqual({ env: "prod" });
|
|
171
|
+
expect(diffStep.args).toEqual({ env: "prod", live: true });
|
|
172
|
+
// Drift is surfaced as a workflow search attribute via outcomeAttribute (#41)
|
|
173
|
+
expect(diffStep.outcomeAttribute).toEqual({ name: "Drift", from: "drifted" });
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test("auto-emit search attrs include Watch + Env", () => {
|
|
177
|
+
const { op } = WatchOp({ name: "p", env: "prod", schedule: "* * * * *" });
|
|
178
|
+
expect(getProps(op).searchAttributes).toEqual({ Watch: "true", Env: "prod" });
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("schedule.action.workflowType is camelCase + 'Workflow'", () => {
|
|
182
|
+
const { schedule } = WatchOp({ name: "prod-watch", env: "prod", schedule: "* * * * *" });
|
|
183
|
+
const action = (getProps(schedule).action as Record<string, unknown>);
|
|
184
|
+
expect(action.workflowType).toBe("prodWatchWorkflow");
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test("schedule.spec.cronExpressions carries the configured cron", () => {
|
|
188
|
+
const { schedule } = WatchOp({ name: "p", env: "prod", schedule: "0 0 * * *" });
|
|
189
|
+
const spec = (getProps(schedule).spec as Record<string, unknown>);
|
|
190
|
+
expect(spec.cronExpressions).toEqual(["0 0 * * *"]);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test("scheduleId is `${name}-schedule`", () => {
|
|
194
|
+
const { schedule } = WatchOp({ name: "prod-watch", env: "prod", schedule: "* * * * *" });
|
|
195
|
+
expect(getProps(schedule).scheduleId).toBe("prod-watch-schedule");
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test("taskQueue defaults to name and is shared by op + schedule", () => {
|
|
199
|
+
const { op, schedule } = WatchOp({ name: "p-watch", env: "prod", schedule: "* * * * *" });
|
|
200
|
+
expect(getProps(op).taskQueue).toBe("p-watch");
|
|
201
|
+
expect((getProps(schedule).action as Record<string, unknown>).taskQueue).toBe("p-watch");
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test("taskQueue override is honored", () => {
|
|
205
|
+
const { op, schedule } = WatchOp({ name: "p", env: "prod", schedule: "* * * * *", taskQueue: "custom-q" });
|
|
206
|
+
expect(getProps(op).taskQueue).toBe("custom-q");
|
|
207
|
+
expect((getProps(schedule).action as Record<string, unknown>).taskQueue).toBe("custom-q");
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test("live: false produces a digest-only diff step", () => {
|
|
211
|
+
const { op } = WatchOp({ name: "p", env: "prod", schedule: "* * * * *", live: false });
|
|
212
|
+
const phases = getProps(op).phases as Array<Record<string, unknown>>;
|
|
213
|
+
const diffStep = (phases[1].steps as Array<Record<string, unknown>>)[0];
|
|
214
|
+
expect(diffStep.args).toEqual({ env: "prod", live: false });
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
describe("WatchOp: serialization", () => {
|
|
219
|
+
test("Op serializes into a workflow.ts containing the snapshot+diff sequence and search-attr upserts", () => {
|
|
220
|
+
const { op } = WatchOp({ name: "prod-watch", env: "prod", schedule: "*/15 * * * *" });
|
|
221
|
+
const ops = new Map([["prod-watch", op]]) as unknown as Parameters<typeof serializeOps>[0];
|
|
222
|
+
const files = serializeOps(ops);
|
|
223
|
+
const wf = files["ops/prod-watch/workflow.ts"];
|
|
224
|
+
expect(wf).toBeDefined();
|
|
225
|
+
expect(wf).toContain('upsertSearchAttributes({"OpName":["prod-watch"],"Watch":["true"],"Env":["prod"]});');
|
|
226
|
+
expect(wf).toContain('upsertSearchAttributes({ Phase: ["Snapshot"] });');
|
|
227
|
+
expect(wf).toContain('upsertSearchAttributes({ Phase: ["Diff"] });');
|
|
228
|
+
expect(wf).toContain("stateSnapshot(");
|
|
229
|
+
// Diff result captured + Drift search attribute auto-emitted
|
|
230
|
+
expect(wf).toContain("const __r0 = await stateDiff(");
|
|
231
|
+
expect(wf).toContain('upsertSearchAttributes({ "Drift": [String(__r0?.drifted)] });');
|
|
232
|
+
});
|
|
233
|
+
});
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WatchOp composite — periodic state observation via a chant Op + a
|
|
3
|
+
* TemporalSchedule.
|
|
4
|
+
*
|
|
5
|
+
* Composes existing pieces:
|
|
6
|
+
* - The Op codegen (#7) emits a workflow that runs phases sequentially
|
|
7
|
+
* - The auto-emit search-attribute behavior (#28) tags each phase
|
|
8
|
+
* - The pre-built stateSnapshot + stateDiff activities (this commit)
|
|
9
|
+
* - TemporalSchedule fires the workflow on a cron schedule
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* ```typescript
|
|
13
|
+
* export const { op, schedule } = WatchOp({
|
|
14
|
+
* name: "prod-watch",
|
|
15
|
+
* env: "prod",
|
|
16
|
+
* schedule: "0,15,30,45 * * * *", // every 15 minutes
|
|
17
|
+
* });
|
|
18
|
+
* ```
|
|
19
|
+
*
|
|
20
|
+
* @see #31 — Continuous observation (the issue this composite addresses)
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { Op, phase, activity, OpResource } from "@intentius/chant/op";
|
|
24
|
+
import { TemporalSchedule } from "../resources";
|
|
25
|
+
|
|
26
|
+
function kebabToCamel(s: string): string {
|
|
27
|
+
return s.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase());
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface WatchOpConfig {
|
|
31
|
+
/**
|
|
32
|
+
* Op name (kebab-case). Used as workflow function name (camelCase),
|
|
33
|
+
* task queue, and schedule id base.
|
|
34
|
+
*/
|
|
35
|
+
name: string;
|
|
36
|
+
/** Environment to snapshot + diff (e.g. "prod"). */
|
|
37
|
+
env: string;
|
|
38
|
+
/**
|
|
39
|
+
* Cron expression controlling how often the watch runs.
|
|
40
|
+
* @example "0,15,30,45 * * * *" — every 15 minutes
|
|
41
|
+
* @example "0 * * * *" — hourly
|
|
42
|
+
*/
|
|
43
|
+
schedule: string;
|
|
44
|
+
/**
|
|
45
|
+
* Override the task queue. Defaults to `name`.
|
|
46
|
+
*/
|
|
47
|
+
taskQueue?: string;
|
|
48
|
+
/**
|
|
49
|
+
* Run `chant state diff --live` (queries cloud APIs) instead of the
|
|
50
|
+
* default digest-only diff. Recommended for real drift detection.
|
|
51
|
+
* @default true
|
|
52
|
+
*/
|
|
53
|
+
live?: boolean;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface WatchOpResources {
|
|
57
|
+
/** Op resource — generates the snapshot+diff workflow on `chant build`. */
|
|
58
|
+
op: InstanceType<typeof OpResource>;
|
|
59
|
+
/** Temporal schedule that fires the workflow on the configured cron. */
|
|
60
|
+
schedule: InstanceType<typeof TemporalSchedule>;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function WatchOp(config: WatchOpConfig): WatchOpResources {
|
|
64
|
+
const taskQueue = config.taskQueue ?? config.name;
|
|
65
|
+
const live = config.live ?? true;
|
|
66
|
+
|
|
67
|
+
const op = Op({
|
|
68
|
+
name: config.name,
|
|
69
|
+
overview: `Periodically snapshot and diff the ${config.env} environment`,
|
|
70
|
+
taskQueue,
|
|
71
|
+
searchAttributes: {
|
|
72
|
+
Watch: "true",
|
|
73
|
+
Env: config.env,
|
|
74
|
+
},
|
|
75
|
+
phases: [
|
|
76
|
+
phase("Snapshot", [activity("stateSnapshot", { env: config.env })]),
|
|
77
|
+
phase("Diff", [
|
|
78
|
+
// outcomeAttribute surfaces stateDiff's `drifted` boolean as a
|
|
79
|
+
// workflow-level Drift search attribute, making 'show me runs that
|
|
80
|
+
// detected drift' a one-filter UI query.
|
|
81
|
+
{
|
|
82
|
+
kind: "activity",
|
|
83
|
+
fn: "stateDiff",
|
|
84
|
+
args: { env: config.env, live },
|
|
85
|
+
outcomeAttribute: { name: "Drift", from: "drifted" },
|
|
86
|
+
},
|
|
87
|
+
]),
|
|
88
|
+
],
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const schedule = new TemporalSchedule({
|
|
92
|
+
scheduleId: `${config.name}-schedule`,
|
|
93
|
+
spec: { cronExpressions: [config.schedule] },
|
|
94
|
+
action: {
|
|
95
|
+
workflowType: kebabToCamel(config.name) + "Workflow",
|
|
96
|
+
taskQueue,
|
|
97
|
+
},
|
|
98
|
+
} as Record<string, unknown>);
|
|
99
|
+
|
|
100
|
+
return { op, schedule };
|
|
101
|
+
}
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
import { describe, test, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
|
|
3
|
+
const loadChantConfigMock = vi.fn();
|
|
4
|
+
const loadTemporalClientMock = vi.fn();
|
|
5
|
+
const resolveProfileMock = vi.fn();
|
|
6
|
+
|
|
7
|
+
vi.mock("@intentius/chant/config", () => ({
|
|
8
|
+
loadChantConfig: (...args: unknown[]) => loadChantConfigMock(...args),
|
|
9
|
+
}));
|
|
10
|
+
|
|
11
|
+
vi.mock("@intentius/chant/cli/handlers/run-client", () => ({
|
|
12
|
+
loadTemporalClient: () => loadTemporalClientMock(),
|
|
13
|
+
connectionOptions: (profile: { address: string }) => ({ address: profile.address }),
|
|
14
|
+
resolveProfile: (...args: unknown[]) => resolveProfileMock(...args),
|
|
15
|
+
}));
|
|
16
|
+
|
|
17
|
+
const { describeResources } = await import("./describe-resources");
|
|
18
|
+
|
|
19
|
+
interface FakeNamespace {
|
|
20
|
+
name: string;
|
|
21
|
+
state?: number;
|
|
22
|
+
description?: string;
|
|
23
|
+
retentionSeconds?: number;
|
|
24
|
+
isGlobal?: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface FakeSchedule {
|
|
28
|
+
scheduleId: string;
|
|
29
|
+
workflowType?: string;
|
|
30
|
+
cronExpressions?: string[];
|
|
31
|
+
paused?: boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function fakeConnection(opts: {
|
|
35
|
+
namespaces: FakeNamespace[];
|
|
36
|
+
searchAttributesByNs?: Record<string, Record<string, number>>;
|
|
37
|
+
schedulesByNs?: Record<string, FakeSchedule[]>;
|
|
38
|
+
searchAttrThrows?: Set<string>;
|
|
39
|
+
scheduleThrows?: Set<string>;
|
|
40
|
+
}) {
|
|
41
|
+
const close = vi.fn(async () => {});
|
|
42
|
+
return {
|
|
43
|
+
workflowService: {
|
|
44
|
+
listNamespaces: vi.fn(async () => ({
|
|
45
|
+
namespaces: opts.namespaces.map((n) => ({
|
|
46
|
+
namespaceInfo: {
|
|
47
|
+
name: n.name,
|
|
48
|
+
state: n.state ?? 1,
|
|
49
|
+
description: n.description,
|
|
50
|
+
},
|
|
51
|
+
config: n.retentionSeconds
|
|
52
|
+
? { workflowExecutionRetentionTtl: { seconds: n.retentionSeconds } }
|
|
53
|
+
: null,
|
|
54
|
+
isGlobalNamespace: n.isGlobal ?? false,
|
|
55
|
+
})),
|
|
56
|
+
nextPageToken: null,
|
|
57
|
+
})),
|
|
58
|
+
},
|
|
59
|
+
operatorService: {
|
|
60
|
+
listSearchAttributes: vi.fn(async ({ namespace }: { namespace: string }) => {
|
|
61
|
+
if (opts.searchAttrThrows?.has(namespace)) {
|
|
62
|
+
throw new Error(`stubbed search-attribute failure for ${namespace}`);
|
|
63
|
+
}
|
|
64
|
+
return { customAttributes: opts.searchAttributesByNs?.[namespace] ?? {} };
|
|
65
|
+
}),
|
|
66
|
+
},
|
|
67
|
+
close,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function fakeClient(opts: {
|
|
72
|
+
schedulesByNs?: Record<string, FakeSchedule[]>;
|
|
73
|
+
scheduleThrows?: Set<string>;
|
|
74
|
+
}) {
|
|
75
|
+
return {
|
|
76
|
+
scheduleClient: {
|
|
77
|
+
list: ({ namespace }: { namespace?: string }) => {
|
|
78
|
+
const schedules = opts.schedulesByNs?.[namespace ?? ""] ?? [];
|
|
79
|
+
const shouldThrow = !!namespace && opts.scheduleThrows?.has(namespace);
|
|
80
|
+
return (async function* () {
|
|
81
|
+
if (shouldThrow) throw new Error(`stubbed schedule list failure for ${namespace}`);
|
|
82
|
+
for (const s of schedules) {
|
|
83
|
+
yield {
|
|
84
|
+
scheduleId: s.scheduleId,
|
|
85
|
+
spec: s.cronExpressions ? { cronExpressions: s.cronExpressions } : null,
|
|
86
|
+
action: s.workflowType ? { type: "startWorkflow", workflowType: s.workflowType } : null,
|
|
87
|
+
state: s.paused ? { paused: true } : null,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
})();
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function setupClientMock(connection: unknown, client: unknown) {
|
|
97
|
+
loadTemporalClientMock.mockResolvedValue({
|
|
98
|
+
Connection: { connect: vi.fn(async () => connection) },
|
|
99
|
+
Client: vi.fn(() => client) as unknown as new () => unknown,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
describe("describeResources", () => {
|
|
104
|
+
beforeEach(() => {
|
|
105
|
+
loadChantConfigMock.mockReset();
|
|
106
|
+
loadTemporalClientMock.mockReset();
|
|
107
|
+
resolveProfileMock.mockReset();
|
|
108
|
+
|
|
109
|
+
loadChantConfigMock.mockResolvedValue({ config: { temporal: { profiles: { dev: { address: "localhost:7233", namespace: "default", taskQueue: "q" } } } } });
|
|
110
|
+
resolveProfileMock.mockReturnValue({ address: "localhost:7233", namespace: "default", taskQueue: "q" });
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("emits one row per namespace + search-attr + schedule", async () => {
|
|
114
|
+
const connection = fakeConnection({
|
|
115
|
+
namespaces: [{ name: "default" }, { name: "prod" }],
|
|
116
|
+
searchAttributesByNs: {
|
|
117
|
+
default: { Project: 2 },
|
|
118
|
+
prod: { Phase: 1 },
|
|
119
|
+
},
|
|
120
|
+
schedulesByNs: {
|
|
121
|
+
default: [{ scheduleId: "daily-report", workflowType: "reportWf", cronExpressions: ["0 8 * * *"] }],
|
|
122
|
+
prod: [{ scheduleId: "weekly-backup", workflowType: "backupWf", paused: true }],
|
|
123
|
+
},
|
|
124
|
+
});
|
|
125
|
+
const client = fakeClient({
|
|
126
|
+
schedulesByNs: {
|
|
127
|
+
default: [{ scheduleId: "daily-report", workflowType: "reportWf", cronExpressions: ["0 8 * * *"] }],
|
|
128
|
+
prod: [{ scheduleId: "weekly-backup", workflowType: "backupWf", paused: true }],
|
|
129
|
+
},
|
|
130
|
+
});
|
|
131
|
+
setupClientMock(connection, client);
|
|
132
|
+
|
|
133
|
+
const result = await describeResources({ environment: "dev", buildOutput: "", entityNames: [], entities: new Map() });
|
|
134
|
+
|
|
135
|
+
expect(Object.keys(result).sort()).toEqual([
|
|
136
|
+
"namespace/default",
|
|
137
|
+
"namespace/prod",
|
|
138
|
+
"schedule/default/daily-report",
|
|
139
|
+
"schedule/prod/weekly-backup",
|
|
140
|
+
"searchAttribute/default/Project",
|
|
141
|
+
"searchAttribute/prod/Phase",
|
|
142
|
+
]);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("populates the resource metadata shape correctly", async () => {
|
|
146
|
+
const connection = fakeConnection({
|
|
147
|
+
namespaces: [{ name: "prod", description: "Prod namespace", retentionSeconds: 604800, isGlobal: true }],
|
|
148
|
+
searchAttributesByNs: { prod: { Project: 2 } },
|
|
149
|
+
schedulesByNs: {},
|
|
150
|
+
});
|
|
151
|
+
const client = fakeClient({
|
|
152
|
+
schedulesByNs: {
|
|
153
|
+
prod: [{ scheduleId: "daily-report", workflowType: "reportWf", cronExpressions: ["0 8 * * *"] }],
|
|
154
|
+
},
|
|
155
|
+
});
|
|
156
|
+
setupClientMock(connection, client);
|
|
157
|
+
|
|
158
|
+
const result = await describeResources({ environment: "prod", buildOutput: "", entityNames: [], entities: new Map() });
|
|
159
|
+
|
|
160
|
+
expect(result["namespace/prod"]).toEqual({
|
|
161
|
+
type: "Temporal::Namespace",
|
|
162
|
+
physicalId: "prod",
|
|
163
|
+
status: "REGISTERED",
|
|
164
|
+
attributes: {
|
|
165
|
+
description: "Prod namespace",
|
|
166
|
+
isGlobalNamespace: true,
|
|
167
|
+
retentionSeconds: 604800,
|
|
168
|
+
},
|
|
169
|
+
});
|
|
170
|
+
expect(result["searchAttribute/prod/Project"]).toEqual({
|
|
171
|
+
type: "Temporal::SearchAttribute",
|
|
172
|
+
physicalId: "prod/Project",
|
|
173
|
+
status: "REGISTERED",
|
|
174
|
+
attributes: { valueType: "Keyword", namespace: "prod" },
|
|
175
|
+
});
|
|
176
|
+
expect(result["schedule/prod/daily-report"]).toEqual({
|
|
177
|
+
type: "Temporal::Schedule",
|
|
178
|
+
physicalId: "prod/daily-report",
|
|
179
|
+
status: "ACTIVE",
|
|
180
|
+
attributes: {
|
|
181
|
+
namespace: "prod",
|
|
182
|
+
workflowType: "reportWf",
|
|
183
|
+
cronExpressions: ["0 8 * * *"],
|
|
184
|
+
},
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test("connection failure propagates with a clear error", async () => {
|
|
189
|
+
loadTemporalClientMock.mockResolvedValue({
|
|
190
|
+
Connection: { connect: vi.fn(async () => { throw new Error("UNAVAILABLE: connect ECONNREFUSED 127.0.0.1:7233"); }) },
|
|
191
|
+
Client: vi.fn() as unknown as new () => unknown,
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
await expect(describeResources({ environment: "dev", buildOutput: "", entityNames: [], entities: new Map() }))
|
|
195
|
+
.rejects.toThrow(/UNAVAILABLE/);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test("empty cluster returns empty record without throwing", async () => {
|
|
199
|
+
const connection = fakeConnection({ namespaces: [] });
|
|
200
|
+
const client = fakeClient({});
|
|
201
|
+
setupClientMock(connection, client);
|
|
202
|
+
|
|
203
|
+
const result = await describeResources({ environment: "dev", buildOutput: "", entityNames: [], entities: new Map() });
|
|
204
|
+
|
|
205
|
+
expect(result).toEqual({});
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test("per-namespace failures are warn-soft and don't abort other namespaces", async () => {
|
|
209
|
+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
210
|
+
const connection = fakeConnection({
|
|
211
|
+
namespaces: [{ name: "broken" }, { name: "ok" }],
|
|
212
|
+
searchAttributesByNs: { ok: { Project: 2 } },
|
|
213
|
+
searchAttrThrows: new Set(["broken"]),
|
|
214
|
+
});
|
|
215
|
+
const client = fakeClient({
|
|
216
|
+
schedulesByNs: {
|
|
217
|
+
ok: [{ scheduleId: "daily-report", workflowType: "reportWf" }],
|
|
218
|
+
},
|
|
219
|
+
scheduleThrows: new Set(["broken"]),
|
|
220
|
+
});
|
|
221
|
+
setupClientMock(connection, client);
|
|
222
|
+
|
|
223
|
+
const result = await describeResources({ environment: "dev", buildOutput: "", entityNames: [], entities: new Map() });
|
|
224
|
+
|
|
225
|
+
// Both namespaces present
|
|
226
|
+
expect(result["namespace/broken"]).toBeDefined();
|
|
227
|
+
expect(result["namespace/ok"]).toBeDefined();
|
|
228
|
+
// ok's children present
|
|
229
|
+
expect(result["searchAttribute/ok/Project"]).toBeDefined();
|
|
230
|
+
expect(result["schedule/ok/daily-report"]).toBeDefined();
|
|
231
|
+
// broken's children skipped
|
|
232
|
+
expect(Object.keys(result).filter((k) => k.includes("/broken/"))).toEqual([]);
|
|
233
|
+
// Warnings emitted
|
|
234
|
+
expect(warnSpy).toHaveBeenCalledTimes(2);
|
|
235
|
+
warnSpy.mockRestore();
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
// ── Round-trip mapping via entity props (#39) ───────────────────────────────
|
|
239
|
+
|
|
240
|
+
test("maps server-side namespace back to chant entity name when props.name matches", async () => {
|
|
241
|
+
const connection = fakeConnection({
|
|
242
|
+
namespaces: [{ name: "prod" }],
|
|
243
|
+
searchAttributesByNs: {},
|
|
244
|
+
schedulesByNs: {},
|
|
245
|
+
});
|
|
246
|
+
const client = fakeClient({});
|
|
247
|
+
setupClientMock(connection, client);
|
|
248
|
+
|
|
249
|
+
const entities = new Map<string, { entityType: string; props: Record<string, unknown> }>([
|
|
250
|
+
["prodNs", { entityType: "Temporal::Namespace", props: { name: "prod", retention: "30d" } }],
|
|
251
|
+
]);
|
|
252
|
+
|
|
253
|
+
const result = await describeResources({ environment: "prod", buildOutput: "", entityNames: ["prodNs"], entities });
|
|
254
|
+
|
|
255
|
+
expect(result["prodNs"]).toBeDefined();
|
|
256
|
+
expect(result["namespace/prod"]).toBeUndefined();
|
|
257
|
+
expect(result["prodNs"].physicalId).toBe("prod");
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test("maps SearchAttribute back to chant entity name via props.name + props.namespace", async () => {
|
|
261
|
+
const connection = fakeConnection({
|
|
262
|
+
namespaces: [{ name: "prod" }],
|
|
263
|
+
searchAttributesByNs: { prod: { Project: 2, OtherAttr: 1 } },
|
|
264
|
+
schedulesByNs: {},
|
|
265
|
+
});
|
|
266
|
+
const client = fakeClient({});
|
|
267
|
+
setupClientMock(connection, client);
|
|
268
|
+
|
|
269
|
+
const entities = new Map<string, { entityType: string; props: Record<string, unknown> }>([
|
|
270
|
+
["projectAttr", { entityType: "Temporal::SearchAttribute", props: { name: "Project", type: "Keyword", namespace: "prod" } }],
|
|
271
|
+
]);
|
|
272
|
+
|
|
273
|
+
const result = await describeResources({ environment: "prod", buildOutput: "", entityNames: ["projectAttr"], entities });
|
|
274
|
+
|
|
275
|
+
expect(result["projectAttr"]).toBeDefined();
|
|
276
|
+
expect(result["searchAttribute/prod/Project"]).toBeUndefined();
|
|
277
|
+
// Undeclared attr → orphan key (server-side prefix)
|
|
278
|
+
expect(result["searchAttribute/prod/OtherAttr"]).toBeDefined();
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
test("maps Schedule back to chant entity name via props.scheduleId", async () => {
|
|
282
|
+
const connection = fakeConnection({
|
|
283
|
+
namespaces: [{ name: "prod" }],
|
|
284
|
+
searchAttributesByNs: {},
|
|
285
|
+
schedulesByNs: {},
|
|
286
|
+
});
|
|
287
|
+
const client = fakeClient({
|
|
288
|
+
schedulesByNs: {
|
|
289
|
+
prod: [
|
|
290
|
+
{ scheduleId: "daily-report", workflowType: "reportWf" },
|
|
291
|
+
{ scheduleId: "weekly-cleanup", workflowType: "cleanupWf" },
|
|
292
|
+
],
|
|
293
|
+
},
|
|
294
|
+
});
|
|
295
|
+
setupClientMock(connection, client);
|
|
296
|
+
|
|
297
|
+
const entities = new Map<string, { entityType: string; props: Record<string, unknown> }>([
|
|
298
|
+
["dailyReport", { entityType: "Temporal::Schedule", props: { scheduleId: "daily-report", namespace: "prod" } }],
|
|
299
|
+
]);
|
|
300
|
+
|
|
301
|
+
const result = await describeResources({ environment: "prod", buildOutput: "", entityNames: ["dailyReport"], entities });
|
|
302
|
+
|
|
303
|
+
expect(result["dailyReport"]).toBeDefined();
|
|
304
|
+
expect(result["schedule/prod/daily-report"]).toBeUndefined();
|
|
305
|
+
expect(result["schedule/prod/weekly-cleanup"]).toBeDefined();
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
test("SearchAttribute without explicit namespace falls back to first declared namespace's name", async () => {
|
|
309
|
+
const connection = fakeConnection({
|
|
310
|
+
namespaces: [{ name: "prod" }],
|
|
311
|
+
searchAttributesByNs: { prod: { Project: 2 } },
|
|
312
|
+
schedulesByNs: {},
|
|
313
|
+
});
|
|
314
|
+
const client = fakeClient({});
|
|
315
|
+
setupClientMock(connection, client);
|
|
316
|
+
|
|
317
|
+
const entities = new Map<string, { entityType: string; props: Record<string, unknown> }>([
|
|
318
|
+
["prodNs", { entityType: "Temporal::Namespace", props: { name: "prod" } }],
|
|
319
|
+
// No namespace prop on the attribute — should default to "prod"
|
|
320
|
+
["projectAttr", { entityType: "Temporal::SearchAttribute", props: { name: "Project", type: "Keyword" } }],
|
|
321
|
+
]);
|
|
322
|
+
|
|
323
|
+
const result = await describeResources({ environment: "prod", buildOutput: "", entityNames: ["prodNs", "projectAttr"], entities });
|
|
324
|
+
|
|
325
|
+
expect(result["projectAttr"]).toBeDefined();
|
|
326
|
+
expect(result["searchAttribute/prod/Project"]).toBeUndefined();
|
|
327
|
+
});
|
|
328
|
+
});
|