@grepr/cli 1.4.8-4a11a52 → 1.5.0-550a383

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.
Files changed (44) hide show
  1. package/README.md +51 -19
  2. package/build/dist/commands/job-apply-command.d.ts +18 -0
  3. package/build/dist/commands/job-apply-command.d.ts.map +1 -0
  4. package/build/dist/commands/job-apply-command.js +103 -0
  5. package/build/dist/commands/job-apply-command.js.map +1 -0
  6. package/build/dist/commands/job-command.js +1 -1
  7. package/build/dist/commands/job-draft-command.d.ts +12 -0
  8. package/build/dist/commands/job-draft-command.d.ts.map +1 -0
  9. package/build/dist/commands/job-draft-command.js +205 -0
  10. package/build/dist/commands/job-draft-command.js.map +1 -0
  11. package/build/dist/commands/job-plan-command.d.ts +12 -0
  12. package/build/dist/commands/job-plan-command.d.ts.map +1 -0
  13. package/build/dist/commands/job-plan-command.js +51 -0
  14. package/build/dist/commands/job-plan-command.js.map +1 -0
  15. package/build/dist/grepr.js +7 -0
  16. package/build/dist/grepr.js.map +1 -1
  17. package/build/dist/lib/grepr-api-client.d.ts +14 -1
  18. package/build/dist/lib/grepr-api-client.d.ts.map +1 -1
  19. package/build/dist/lib/grepr-api-client.js +42 -4
  20. package/build/dist/lib/grepr-api-client.js.map +1 -1
  21. package/build/dist/lib/job-graph-log-pipeline-constants.d.ts +12 -0
  22. package/build/dist/lib/job-graph-log-pipeline-constants.d.ts.map +1 -0
  23. package/build/dist/lib/job-graph-log-pipeline-constants.js +16 -0
  24. package/build/dist/lib/job-graph-log-pipeline-constants.js.map +1 -0
  25. package/build/dist/lib/job-graph-transformer.d.ts +30 -0
  26. package/build/dist/lib/job-graph-transformer.d.ts.map +1 -1
  27. package/build/dist/lib/job-graph-transformer.js +271 -5
  28. package/build/dist/lib/job-graph-transformer.js.map +1 -1
  29. package/build/dist/lib/job-patch.d.ts +157 -0
  30. package/build/dist/lib/job-patch.d.ts.map +1 -0
  31. package/build/dist/lib/job-patch.js +1070 -0
  32. package/build/dist/lib/job-patch.js.map +1 -0
  33. package/build/dist/lib/job-plan.d.ts +40 -0
  34. package/build/dist/lib/job-plan.d.ts.map +1 -0
  35. package/build/dist/lib/job-plan.js +451 -0
  36. package/build/dist/lib/job-plan.js.map +1 -0
  37. package/build/dist/lib/option-parsers.d.ts +5 -0
  38. package/build/dist/lib/option-parsers.d.ts.map +1 -1
  39. package/build/dist/lib/option-parsers.js +7 -0
  40. package/build/dist/lib/option-parsers.js.map +1 -1
  41. package/build/dist/types.d.ts +34 -0
  42. package/build/dist/types.d.ts.map +1 -1
  43. package/build/dist/types.js.map +1 -1
  44. package/package.json +1 -1
@@ -0,0 +1,1070 @@
1
+ /** Structured patch format for grepr pipelines: a list of ops applied locally before any production write (see {@link JobBackend} for the two substrates). */
2
+ import { LogAttributesRemapperType, LogReducerType, GrokParserType, LogsFilterType, DatadogQueryPredicateType, DatadogLogSinkType, SplunkLogSinkType, NewRelicLogSinkType, SumoLogSinkType, OtlpLogSinkType, LogsIcebergTableSinkType, TemplateOperationType, TemplateQueryExceptionType, SumAttributesMergeStrategyType, MinAttributesMergeStrategyType, MaxAttributesMergeStrategyType, AverageAttributesMergeStrategyType, } from '../openapi/openApiTypes.js';
3
+ import { parseEdge } from './job-graph-utils.js';
4
+ import { RAW_ATTRIBUTES_REMAPPER, RAW_ATTRIBUTES_REMAPPER_TYPE, RAW_JSON_PROCESSOR, RAW_JSON_PROCESSOR_TYPE, RAW_LOG_REDUCER, RAW_PARSER_TYPES, RAW_PRE_EXCEPTIONS_FILTER, RAW_PRE_PARSER_FILTER, RAW_PRE_WAREHOUSE_FILTER, } from './job-graph-log-pipeline-constants.js';
5
+ /** Vendor log sink types the UI supports as `add-sink target: 'vendor'`. */
6
+ const VENDOR_LOG_SINK_TYPES = new Set([
7
+ DatadogLogSinkType.datadog_log_sink,
8
+ SplunkLogSinkType.splunk_log_sink,
9
+ NewRelicLogSinkType.newrelic_log_sink,
10
+ SumoLogSinkType.sumologic_log_sink,
11
+ OtlpLogSinkType.otlp_log_sink,
12
+ ]);
13
+ const LOGS_ICEBERG_TABLE_SINK_TYPE = LogsIcebergTableSinkType.logs_iceberg_table_sink;
14
+ // Raw data-lake and processed-logs sinks share the logs-iceberg-table-sink type
15
+ // and are distinguished only by these vertex-name prefixes — a naming convention
16
+ // this CLI must match, not invent.
17
+ const RAW_DATA_LAKE_SINK_NAME_PREFIX = 'raw_data_sink';
18
+ const PROCESSED_LOGS_SINK_NAME_PREFIX = 'processed_logs_';
19
+ /**
20
+ * Required fields per op, keyed by op name. Typed as `Record<JobPatchOp['op'], …>`
21
+ * so a new op added to the union fails to compile until its entry is added here —
22
+ * keeping this table exhaustive alongside the apply-time dispatch.
23
+ */
24
+ const REQUIRED_OP_FIELDS = {
25
+ 'add-message-attribute': [['attributePath', 'string']],
26
+ 'add-group-by': [['attributePath', 'string']],
27
+ 'add-aggregation-strategy': [['attributePath', 'string'], ['strategies', 'array']],
28
+ 'add-reducer-exception': [['predicate', 'object']],
29
+ 'add-grok-rule': [['pattern', 'string']],
30
+ 'set-input-field': [['path', 'string'], ['value', 'present']],
31
+ 'unset-input-field': [['path', 'string']],
32
+ 'add-parser': [['parser', 'object']],
33
+ 'remove-parser': [['name', 'string']],
34
+ 'set-filter': [['phase', 'string'], ['filter', 'object']],
35
+ 'clear-filter': [['phase', 'string']],
36
+ 'add-source': [['source', 'object']],
37
+ 'remove-source': [['name', 'string']],
38
+ // Target-conditional fields (vendor removal's `name`, sink shape) stay with the apply-time guards.
39
+ 'add-sink': [['target', 'string'], ['sink', 'object']],
40
+ 'remove-sink': [['target', 'string']],
41
+ 'set-raw-dataset': [['datasetId', 'string']],
42
+ };
43
+ function fieldMatches(value, type) {
44
+ switch (type) {
45
+ case 'string': return typeof value === 'string';
46
+ case 'array': return Array.isArray(value);
47
+ case 'object': return value !== null && typeof value === 'object' && !Array.isArray(value);
48
+ case 'present': return value !== undefined;
49
+ }
50
+ }
51
+ function describeFieldType(type) {
52
+ switch (type) {
53
+ case 'present': return 'a defined value';
54
+ case 'array': return 'an array';
55
+ case 'object': return 'an object';
56
+ case 'string': return 'a string';
57
+ }
58
+ }
59
+ function isRecord(value) {
60
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
61
+ }
62
+ function isKnownPatchOpName(opName) {
63
+ return Object.prototype.hasOwnProperty.call(REQUIRED_OP_FIELDS, opName);
64
+ }
65
+ function assertValidPatchOperation(op, index) {
66
+ if (!isRecord(op) || typeof op['op'] !== 'string') {
67
+ throw new Error(`Operation ${index} must be an object with a string "op" field`);
68
+ }
69
+ const opName = op['op'];
70
+ if (!isKnownPatchOpName(opName)) {
71
+ throw new Error(`Operation ${index}: unknown op "${opName}"`);
72
+ }
73
+ for (const [field, type] of REQUIRED_OP_FIELDS[opName]) {
74
+ if (!fieldMatches(op[field], type)) {
75
+ throw new Error(`Operation ${index} (${opName}): "${field}" is required and must be ${describeFieldType(type)}`);
76
+ }
77
+ }
78
+ }
79
+ /**
80
+ * Validate the patch file shape, including each op's name and required fields, so
81
+ * a typo'd or under-specified op (e.g. `add-group-by` with no `attributePath`)
82
+ * fails cleanly at the file boundary rather than deep inside `applyPatch` with a
83
+ * confusing internal error. `applyPatch` still does the deeper
84
+ * semantic/target-conditional validation.
85
+ */
86
+ export function parsePatch(raw) {
87
+ if (raw === null || typeof raw !== 'object') {
88
+ throw new Error('Patch file must be a JSON object with an "operations" array');
89
+ }
90
+ const operations = raw.operations;
91
+ if (!Array.isArray(operations)) {
92
+ throw new Error('Patch file must have an "operations" array. ' +
93
+ 'Example: { "operations": [{ "op": "add-group-by", "attributePath": "service" }] }');
94
+ }
95
+ const parsedOperations = [];
96
+ for (let i = 0; i < operations.length; i++) {
97
+ const op = operations[i];
98
+ assertValidPatchOperation(op, i);
99
+ parsedOperations.push(op);
100
+ }
101
+ return { operations: parsedOperations };
102
+ }
103
+ /** Detect whether a job is template-backed (a `template-operation` vertex) or job-graph. */
104
+ export function detectBackend(job) {
105
+ const vertices = job.jobGraph?.vertices;
106
+ if (!Array.isArray(vertices))
107
+ return 'job-graph';
108
+ return vertices.some(v => v.type === TemplateOperationType.template_operation) ? 'template' : 'job-graph';
109
+ }
110
+ /**
111
+ * Apply a patch to a fetched job, producing the `SchemaUpdateJob` payload for
112
+ * `job:apply`. Pure (clones input); dispatches on backend. Throws if an op
113
+ * targets a missing field/vertex or a topology op hits a non-UI raw graph.
114
+ */
115
+ export function applyPatch(job, patch) {
116
+ const backend = detectBackend(job);
117
+ return backend === 'template' ? applyPatchToTemplate(job, patch) : applyPatchToJobGraph(job, patch);
118
+ }
119
+ function applyPatchToTemplate(job, patch) {
120
+ const cloned = structuredClone(job);
121
+ const templateOp = findTemplateOperation(cloned);
122
+ const input = readTemplateInput(templateOp);
123
+ for (let i = 0; i < patch.operations.length; i++) {
124
+ applyOperation(input, patch.operations[i], i);
125
+ }
126
+ if (patch.operations.some(touchesSourceConfig)) {
127
+ assertProposedTemplateHasSource(input);
128
+ }
129
+ writeTemplateInput(templateOp, input);
130
+ // Apply path always submits draftMode: false (the draft path sets it later).
131
+ templateOp.draftMode = false;
132
+ return {
133
+ desiredState: cloned.desiredState,
134
+ fromVersion: cloned.version,
135
+ jobGraph: cloned.jobGraph,
136
+ teamIds: cloned.teamIds,
137
+ };
138
+ }
139
+ /**
140
+ * Apply a patch to a job-graph (non-template) pipeline. Clones the job and
141
+ * mutates the resolved graph on the clone (the input is left untouched). Field
142
+ * names match the template inputs except `add-reducer-exception`, which appends
143
+ * a raw `EventPredicate` to the reducer vertex's `logReducerExceptions`. Topology
144
+ * ops require the canonical UI log-pipeline chain.
145
+ */
146
+ function applyPatchToJobGraph(job, patch) {
147
+ const cloned = structuredClone(job);
148
+ const jobGraph = cloned.jobGraph;
149
+ const vertices = jobGraph?.vertices;
150
+ if (!jobGraph || !Array.isArray(vertices) || vertices.length === 0) {
151
+ throw new Error('Job has no jobGraph.vertices to patch');
152
+ }
153
+ if (!Array.isArray(jobGraph.edges)) {
154
+ jobGraph.edges = [];
155
+ }
156
+ for (let i = 0; i < patch.operations.length; i++) {
157
+ applyJobGraphOperation(jobGraph, patch.operations[i], i);
158
+ }
159
+ if (patch.operations.some(touchesSourceConfig)) {
160
+ assertProposedJobGraphHasSource(jobGraph);
161
+ }
162
+ return {
163
+ desiredState: cloned.desiredState,
164
+ fromVersion: cloned.version,
165
+ jobGraph: cloned.jobGraph,
166
+ teamIds: cloned.teamIds,
167
+ };
168
+ }
169
+ function applyOperation(input, op, index) {
170
+ switch (op.op) {
171
+ case 'add-message-attribute':
172
+ return applyAddMessageAttribute(input, op.attributePath, index);
173
+ case 'add-group-by':
174
+ return applyAddGroupBy(input, op.attributePath, index);
175
+ case 'add-aggregation-strategy':
176
+ return applyAddAggregation(input, op.attributePath, op.strategies, index);
177
+ case 'add-reducer-exception':
178
+ return applyAddReducerException(input, op.predicate);
179
+ case 'add-grok-rule':
180
+ return applyAddGrokRule(input, op.pattern, op.parserName, op.extractAttribute, index);
181
+ case 'set-input-field':
182
+ return applySetInputField(input, op.path, op.value, index);
183
+ case 'unset-input-field':
184
+ return applyUnsetInputField(input, op.path, index);
185
+ case 'add-parser':
186
+ return applyAddParser(input, op.parser, index);
187
+ case 'remove-parser':
188
+ return applyRemoveParser(input, op.name, index);
189
+ case 'set-filter':
190
+ return applySetFilter(input, op.phase, op.filter, index);
191
+ case 'clear-filter':
192
+ return applyClearFilter(input, op.phase);
193
+ case 'add-source':
194
+ return applyAddSource(input, op.source, index);
195
+ case 'remove-source':
196
+ return applyRemoveSource(input, op.name, index);
197
+ case 'add-sink':
198
+ // `filter`/`name` live only on one target variant of the split union;
199
+ // read via `in` so a JSON patch's stray field still reaches the guard.
200
+ return applyAddSink(input, op.target, op.sink, 'filter' in op ? op.filter : undefined, index);
201
+ case 'remove-sink':
202
+ return applyRemoveSink(input, op.target, 'name' in op ? op.name : undefined, index);
203
+ case 'set-raw-dataset':
204
+ return applySetRawDataset(input, op.datasetId);
205
+ default:
206
+ return throwUnknownOp(op, index);
207
+ }
208
+ }
209
+ /** Exhaustiveness guard: `op` is `never` when every JobPatchOp case is handled. */
210
+ function throwUnknownOp(op, index) {
211
+ const unknownOp = op;
212
+ throw new Error(`Operation ${index}: unknown op "${unknownOp.op ?? '(missing)'}"`);
213
+ }
214
+ function applyJobGraphOperation(jobGraph, op, index) {
215
+ const vertices = jobGraph.vertices;
216
+ switch (op.op) {
217
+ case 'add-message-attribute':
218
+ return jobGraphAddMessageAttribute(vertices, op.attributePath, index);
219
+ case 'add-group-by':
220
+ return jobGraphAddGroupBy(vertices, op.attributePath, index);
221
+ case 'add-aggregation-strategy':
222
+ return jobGraphAddAggregation(vertices, op.attributePath, op.strategies, index);
223
+ case 'add-reducer-exception':
224
+ return jobGraphAddReducerException(vertices, op.predicate, index);
225
+ case 'add-grok-rule':
226
+ return jobGraphAddGrokRule(vertices, op.pattern, op.parserName, op.extractAttribute, index);
227
+ case 'add-parser':
228
+ return jobGraphAddParser(jobGraph, op.parser, index);
229
+ case 'remove-parser':
230
+ return jobGraphRemoveParser(jobGraph, op.name, index);
231
+ case 'set-filter':
232
+ return jobGraphSetFilter(jobGraph, op.phase, op.filter, index);
233
+ case 'clear-filter':
234
+ return jobGraphClearFilter(jobGraph, op.phase, index);
235
+ case 'add-source':
236
+ return jobGraphAddSource(jobGraph, op.source, index);
237
+ case 'remove-source':
238
+ return jobGraphRemoveSource(jobGraph, op.name, index);
239
+ case 'add-sink':
240
+ // See the template dispatch: read the target-specific field via `in`.
241
+ return jobGraphAddSink(jobGraph, op.target, op.sink, 'filter' in op ? op.filter : undefined, index);
242
+ case 'remove-sink':
243
+ return jobGraphRemoveSink(jobGraph, op.target, 'name' in op ? op.name : undefined, index);
244
+ case 'set-raw-dataset':
245
+ return jobGraphSetRawDataset(jobGraph, op.datasetId, index);
246
+ case 'set-input-field':
247
+ case 'unset-input-field':
248
+ throw new Error(`Operation ${index} (${op.op}): generic template-input paths are not supported on raw job graphs. ` +
249
+ `Use a semantic operation, or apply this change directly via ` +
250
+ `'grepr job:get' + manual edit + 'grepr job:update'.`);
251
+ default:
252
+ return throwUnknownOp(op, index);
253
+ }
254
+ }
255
+ function findUniqueVertexByType(vertices, type, opLabel, index) {
256
+ const matches = vertices.filter(v => v.type === type);
257
+ if (matches.length === 0) {
258
+ throw new Error(`Operation ${index} (${opLabel}): no ${type} vertex found in jobGraph.vertices. ` +
259
+ `Non-template pipelines must already include this vertex; this CLI doesn't add new vertices to job-graph pipelines.`);
260
+ }
261
+ if (matches.length > 1) {
262
+ throw new Error(`Operation ${index} (${opLabel}): expected exactly one ${type} vertex but found ${matches.length}. ` +
263
+ `Ambiguous target; resolve by hand via 'grepr job:get' + 'grepr job:update'.`);
264
+ }
265
+ return matches[0];
266
+ }
267
+ function jobGraphAddMessageAttribute(vertices, attributePath, index) {
268
+ const remapper = findUniqueVertexByType(vertices, LogAttributesRemapperType.log_attributes_remapper, 'add-message-attribute', index);
269
+ applyMessageAttributeToRemapper(remapper, attributePath, index);
270
+ }
271
+ function jobGraphAddGroupBy(vertices, attributePath, index) {
272
+ const reducer = findUniqueVertexByType(vertices, LogReducerType.log_reducer, 'add-group-by', index);
273
+ applyGroupByToReducer(reducer, attributePath, index);
274
+ }
275
+ function jobGraphAddAggregation(vertices, attributePath, strategies, index) {
276
+ const reducer = findUniqueVertexByType(vertices, LogReducerType.log_reducer, 'add-aggregation-strategy', index);
277
+ applyAggregationToReducer(reducer, attributePath, strategies, index);
278
+ }
279
+ /** Append a predicate to the reducer's `logReducerExceptions` (stored raw, unlike the template path's `TemplateQueryException` wrapper). */
280
+ function jobGraphAddReducerException(vertices, predicate, index) {
281
+ const reducer = findUniqueVertexByType(vertices, LogReducerType.log_reducer, 'add-reducer-exception', index);
282
+ const existing = reducer.logReducerExceptions ?? [];
283
+ const serialized = JSON.stringify(predicate);
284
+ if (existing.some(p => JSON.stringify(p) === serialized)) {
285
+ reducer.logReducerExceptions = existing;
286
+ return; // idempotent
287
+ }
288
+ existing.push(predicate);
289
+ reducer.logReducerExceptions = existing;
290
+ }
291
+ function jobGraphAddGrokRule(vertices, pattern, parserName, extractAttribute, index) {
292
+ const groks = vertices.filter(v => v.type === GrokParserType.grok_parser);
293
+ const grok = selectGrokParser(groks, parserName, 'jobGraph.vertices', index);
294
+ if (!grok) {
295
+ throw new Error(`Operation ${index} (add-grok-rule): ${parserName ? `grok-parser "${parserName}" not found` : 'no grok-parser vertex found in jobGraph.vertices'}. ` +
296
+ `Add a grok-parser vertex via 'grepr job:update' before using add-grok-rule on this pipeline.`);
297
+ }
298
+ applyGrokRuleToParser(grok, pattern, extractAttribute);
299
+ }
300
+ function selectGrokParser(groks, parserName, location, index) {
301
+ if (parserName) {
302
+ return groks.find(parser => parser.name === parserName);
303
+ }
304
+ if (groks.length > 1) {
305
+ throw new Error(`Operation ${index} (add-grok-rule): found ${groks.length} grok parsers in ${location}; ` +
306
+ `pass parserName to choose the target parser.`);
307
+ }
308
+ return groks[0];
309
+ }
310
+ function applyMessageAttributeToRemapper(remapper, attributePath, index) {
311
+ const parts = splitPath(attributePath, index);
312
+ if (parts.length === 1) {
313
+ const list = remapper.messageReservedAttributes ?? [];
314
+ addUniqueString(list, parts[0]);
315
+ remapper.messageReservedAttributes = list;
316
+ }
317
+ else {
318
+ const list = remapper.messageReservedAttributePaths ?? [];
319
+ addUniqueStringArray(list, parts);
320
+ remapper.messageReservedAttributePaths = list;
321
+ }
322
+ }
323
+ function applyGroupByToReducer(reducer, attributePath, index) {
324
+ const parts = splitPath(attributePath, index);
325
+ if (parts.length === 1) {
326
+ const list = reducer.partitionByAttributes ?? [];
327
+ addUniqueString(list, parts[0]);
328
+ reducer.partitionByAttributes = list;
329
+ }
330
+ else {
331
+ const list = reducer.partitionByAttributePaths ?? [];
332
+ addUniqueStringArray(list, parts);
333
+ reducer.partitionByAttributePaths = list;
334
+ }
335
+ }
336
+ function applyAggregationToReducer(reducer, attributePath, strategies, index) {
337
+ if (strategies.length === 0) {
338
+ throw new Error(`Operation ${index} (add-aggregation-strategy): strategies array must not be empty`);
339
+ }
340
+ const parts = splitPath(attributePath, index);
341
+ const existing = reducer.attributeMergeStrategyEntries ?? [];
342
+ for (const strategy of strategies) {
343
+ if (existing.some(e => arraysEqual(e.attributePath, parts) && e.strategy?.type === strategyTypeFor(strategy))) {
344
+ continue;
345
+ }
346
+ existing.push({
347
+ attributePath: parts,
348
+ strategy: { type: strategyTypeFor(strategy) },
349
+ });
350
+ }
351
+ reducer.attributeMergeStrategyEntries = existing;
352
+ }
353
+ function applyGrokRuleToParser(parser, pattern, extractAttribute) {
354
+ const rules = parser.grokParsingRules ?? [];
355
+ if (!rules.includes(pattern))
356
+ rules.push(pattern);
357
+ parser.grokParsingRules = rules;
358
+ if (extractAttribute !== undefined) {
359
+ parser.extractAttribute = extractAttribute;
360
+ }
361
+ }
362
+ const DEFAULT_EDGE_OUTPUT = 'output';
363
+ const DEFAULT_EDGE_INPUT = 'input';
364
+ function jobGraphAddSource(jobGraph, source, index) {
365
+ assertRawUiLogGraph(jobGraph, 'add-source', index, [RAW_PRE_PARSER_FILTER]);
366
+ assertOperationIdentity(source, 'add-source', index, 'source');
367
+ if (findVertexIndexByName(jobGraph, source.name) !== -1) {
368
+ throw new Error(`Operation ${index} (add-source): source "${source.name}" already exists`);
369
+ }
370
+ jobGraph.vertices.push(source);
371
+ addEdgeIfMissing(jobGraph, source.name, DEFAULT_EDGE_OUTPUT, RAW_PRE_PARSER_FILTER, DEFAULT_EDGE_INPUT);
372
+ }
373
+ function jobGraphRemoveSource(jobGraph, name, index) {
374
+ assertRawUiLogGraph(jobGraph, 'remove-source', index, [RAW_PRE_PARSER_FILTER]);
375
+ const idx = findVertexIndexByName(jobGraph, name);
376
+ if (idx === -1) {
377
+ throw new Error(`Operation ${index} (remove-source): source "${name}" not found in jobGraph.vertices`);
378
+ }
379
+ if (!isCanonicalRawSource(jobGraph, name)) {
380
+ throw unsupportedRawShapeError(index, 'remove-source', `vertex "${name}" is not a canonical UI source feeding ${RAW_PRE_PARSER_FILTER}`);
381
+ }
382
+ jobGraph.vertices.splice(idx, 1);
383
+ removeEdgesTouching(jobGraph, name);
384
+ }
385
+ function jobGraphAddSink(jobGraph, target, sink, filter, index) {
386
+ assertOperationIdentity(sink, 'add-sink', index, 'sink');
387
+ assertSinkTargetShape(target, sink, filter, index);
388
+ // Anchors on a unique log_reducer (assertRawUiLogGraph rejects missing/duplicate).
389
+ assertRawUiLogGraph(jobGraph, 'add-sink', index, [RAW_LOG_REDUCER]);
390
+ if (findVertexIndexByName(jobGraph, sink.name) !== -1) {
391
+ throw new Error(`Operation ${index} (add-sink): sink "${sink.name}" already exists`);
392
+ }
393
+ // processed-logs is a singular slot — mirror the template-backend guard.
394
+ // Only reject a reducer-fed iceberg sink (direct or 1-hop through a filter);
395
+ // a raw data-lake sink fed by pre_data_warehouse_filter is a separate slot.
396
+ if (target === 'processed-logs' && hasReducerFedIcebergSink(jobGraph)) {
397
+ throw new Error(`Operation ${index} (add-sink): a logs-iceberg-table-sink already exists. ` +
398
+ `Remove it first with remove-sink (target: processed-logs) before adding a new one.`);
399
+ }
400
+ if (filter !== undefined) {
401
+ if (typeof filter !== 'object') {
402
+ throw new Error(`Operation ${index} (add-sink): filter must be an object`);
403
+ }
404
+ const filterName = `${sink.name}_filter`;
405
+ if (findVertexIndexByName(jobGraph, filterName) !== -1) {
406
+ throw new Error(`Operation ${index} (add-sink): generated filter vertex "${filterName}" already exists`);
407
+ }
408
+ const filterVertex = {
409
+ ...filter,
410
+ name: filterName,
411
+ type: LogsFilterType.logs_filter,
412
+ };
413
+ jobGraph.vertices.push(filterVertex, sink);
414
+ addEdgeIfMissing(jobGraph, RAW_LOG_REDUCER, DEFAULT_EDGE_OUTPUT, filterName, DEFAULT_EDGE_INPUT);
415
+ addEdgeIfMissing(jobGraph, filterName, DEFAULT_EDGE_OUTPUT, sink.name, DEFAULT_EDGE_INPUT);
416
+ return;
417
+ }
418
+ jobGraph.vertices.push(sink);
419
+ addEdgeIfMissing(jobGraph, RAW_LOG_REDUCER, DEFAULT_EDGE_OUTPUT, sink.name, DEFAULT_EDGE_INPUT);
420
+ }
421
+ function jobGraphRemoveSink(jobGraph, target, name, index) {
422
+ assertValidSinkTarget(target, 'remove-sink', index);
423
+ const sinkName = resolveRawSinkToRemove(jobGraph, target, name, index);
424
+ // If the sink is fed by its generated single-use filter, drop that too.
425
+ const generatedFilterName = `${sinkName}_filter`;
426
+ const fedByGeneratedFilter = parsedEdges(jobGraph).some(edge => edge.targetVertex === sinkName && edge.sourceVertex === generatedFilterName);
427
+ removeVertexByName(jobGraph, sinkName);
428
+ removeEdgesTouching(jobGraph, sinkName);
429
+ if (fedByGeneratedFilter && findVertexByName(jobGraph, generatedFilterName)) {
430
+ removeVertexByName(jobGraph, generatedFilterName);
431
+ removeEdgesTouching(jobGraph, generatedFilterName);
432
+ }
433
+ }
434
+ /**
435
+ * Resolve which raw-graph sink a `remove-sink` op targets. `vendor` requires a
436
+ * `name` matching a vendor sink type; `processed-logs` ignores `name` and
437
+ * removes the unique `logs-iceberg-table-sink` (rejecting 0 or multiple).
438
+ */
439
+ function resolveRawSinkToRemove(jobGraph, target, name, index) {
440
+ if (target === 'vendor') {
441
+ if (typeof name !== 'string' || name.length === 0) {
442
+ throw new Error(`Operation ${index} (remove-sink): name is required for target "vendor"`);
443
+ }
444
+ const vertex = findVertexByName(jobGraph, name);
445
+ if (!vertex) {
446
+ throw new Error(`Operation ${index} (remove-sink): sink "${name}" not found in jobGraph.vertices`);
447
+ }
448
+ if (!VENDOR_LOG_SINK_TYPES.has(vertex.type)) {
449
+ throw new Error(`Operation ${index} (remove-sink): vertex "${name}" is not a vendor sink (type "${vertex.type}")`);
450
+ }
451
+ return name;
452
+ }
453
+ return findIcebergSinkByRole(jobGraph, PROCESSED_LOGS_SINK_NAME_PREFIX, 'processed-logs', 'remove-sink', 'remove the sink', index).name;
454
+ }
455
+ /**
456
+ * The single `logs-iceberg-table-sink` vertex playing the given role, identified
457
+ * by its name prefix. Selecting by type alone is ambiguous — raw and processed
458
+ * sinks share the type — so a count-based pick can target the wrong dataset sink.
459
+ * Errors on 0 or >1 matches for the role.
460
+ */
461
+ function findIcebergSinkByRole(jobGraph, namePrefix, role, opLabel, action, index) {
462
+ const matches = jobGraph.vertices.filter(v => v.type === LOGS_ICEBERG_TABLE_SINK_TYPE && v.name.startsWith(namePrefix));
463
+ if (matches.length !== 1) {
464
+ throw new Error(`Operation ${index} (${opLabel}): found ${matches.length} ${role} sinks (logs-iceberg-table-sink named "${namePrefix}*"); ` +
465
+ `expected exactly one to ${action}. Use 'grepr job:get' + 'grepr job:update' to ${action} explicitly.`);
466
+ }
467
+ return matches[0];
468
+ }
469
+ function jobGraphSetRawDataset(jobGraph, datasetId, index) {
470
+ const sink = findIcebergSinkByRole(jobGraph, RAW_DATA_LAKE_SINK_NAME_PREFIX, 'raw data-lake', 'set-raw-dataset', 'set the dataset', index);
471
+ sink.datasetId = datasetId;
472
+ }
473
+ function jobGraphSetFilter(jobGraph, phase, filter, index) {
474
+ if (!filter || typeof filter !== 'object') {
475
+ throw new Error(`Operation ${index} (set-filter): filter must be an object`);
476
+ }
477
+ const rawName = rawFilterNameForPhase(phase, index, 'set-filter');
478
+ assertRawUiLogGraph(jobGraph, 'set-filter', index, [rawName]);
479
+ replaceRawFilterVertex(jobGraph, rawName, filter, 'set-filter', index);
480
+ }
481
+ function jobGraphClearFilter(jobGraph, phase, index) {
482
+ const rawName = rawFilterNameForPhase(phase, index, 'clear-filter');
483
+ assertRawUiLogGraph(jobGraph, 'clear-filter', index, [rawName]);
484
+ replaceRawFilterVertex(jobGraph, rawName, { predicate: { type: DatadogQueryPredicateType.datadog_query, query: '' } }, 'clear-filter', index);
485
+ }
486
+ function replaceRawFilterVertex(jobGraph, rawName, patch, opLabel, index) {
487
+ const existing = findVertexByName(jobGraph, rawName);
488
+ if (!existing) {
489
+ throw unsupportedRawShapeError(index, opLabel, `vertex "${rawName}" not found`);
490
+ }
491
+ replaceVertexByName(jobGraph, rawName, {
492
+ ...existing,
493
+ ...patch,
494
+ type: LogsFilterType.logs_filter,
495
+ name: rawName,
496
+ }, opLabel, index);
497
+ }
498
+ function jobGraphAddParser(jobGraph, parser, index) {
499
+ assertRawUiLogGraph(jobGraph, 'add-parser', index, [RAW_PRE_PARSER_FILTER, RAW_PRE_WAREHOUSE_FILTER]);
500
+ assertOperationIdentity(parser, 'add-parser', index, 'parser');
501
+ if (!RAW_PARSER_TYPES.has(parser.type)) {
502
+ throw new Error(`Operation ${index} (add-parser): parser type "${parser.type}" is not supported for raw UI graph insertion`);
503
+ }
504
+ if (findVertexIndexByName(jobGraph, parser.name) !== -1) {
505
+ throw new Error(`Operation ${index} (add-parser): parser "${parser.name}" already exists`);
506
+ }
507
+ if (parser.type === RAW_JSON_PROCESSOR_TYPE) {
508
+ if (parser.name !== RAW_JSON_PROCESSOR) {
509
+ throw unsupportedRawShapeError(index, 'add-parser', `json processor must be named "${RAW_JSON_PROCESSOR}"`);
510
+ }
511
+ if (findVertexByName(jobGraph, RAW_JSON_PROCESSOR) || findVertexByType(jobGraph, RAW_JSON_PROCESSOR_TYPE)) {
512
+ throw new Error(`Operation ${index} (add-parser): json-log-processor already exists in the raw graph`);
513
+ }
514
+ const successor = orderedParserNames(jobGraph, 'add-parser', index)[0] ?? RAW_PRE_WAREHOUSE_FILTER;
515
+ return insertRawVertexBetween(jobGraph, parser, RAW_PRE_PARSER_FILTER, successor, 'add-parser', index);
516
+ }
517
+ if (parser.type === RAW_ATTRIBUTES_REMAPPER_TYPE) {
518
+ if (parser.name !== RAW_ATTRIBUTES_REMAPPER) {
519
+ throw unsupportedRawShapeError(index, 'add-parser', `attributes remapper must be named "${RAW_ATTRIBUTES_REMAPPER}"`);
520
+ }
521
+ if (findVertexByName(jobGraph, RAW_ATTRIBUTES_REMAPPER) || findVertexByType(jobGraph, RAW_ATTRIBUTES_REMAPPER_TYPE)) {
522
+ throw new Error(`Operation ${index} (add-parser): log-attributes-remapper already exists in the raw graph`);
523
+ }
524
+ // Locate the json processor by type, not name: live UI graphs use suffixed
525
+ // names (e.g. json_log_processor_1), and a name-only lookup would silently
526
+ // fall back to RAW_PRE_PARSER_FILTER and wire the remapper before it.
527
+ const jsonProcessor = findVertexByType(jobGraph, RAW_JSON_PROCESSOR_TYPE);
528
+ const predecessor = jsonProcessor ? jsonProcessor.name : RAW_PRE_PARSER_FILTER;
529
+ const successor = singleMainSuccessor(jobGraph, predecessor, 'add-parser', index);
530
+ return insertRawVertexBetween(jobGraph, parser, predecessor, successor, 'add-parser', index);
531
+ }
532
+ const existingParserNames = orderedParserNames(jobGraph, 'add-parser', index);
533
+ const predecessor = existingParserNames[existingParserNames.length - 1] ?? RAW_PRE_PARSER_FILTER;
534
+ insertRawVertexBetween(jobGraph, parser, predecessor, RAW_PRE_WAREHOUSE_FILTER, 'add-parser', index);
535
+ }
536
+ function jobGraphRemoveParser(jobGraph, name, index) {
537
+ assertRawUiLogGraph(jobGraph, 'remove-parser', index, [RAW_PRE_PARSER_FILTER, RAW_PRE_WAREHOUSE_FILTER]);
538
+ const parser = findVertexByName(jobGraph, name);
539
+ if (!parser) {
540
+ throw new Error(`Operation ${index} (remove-parser): parser "${name}" not found in jobGraph.vertices`);
541
+ }
542
+ if (!RAW_PARSER_TYPES.has(parser.type)) {
543
+ throw new Error(`Operation ${index} (remove-parser): vertex "${name}" is not a supported parser type`);
544
+ }
545
+ const incoming = parsedEdges(jobGraph).filter(edge => edge.targetVertex === name);
546
+ const outgoing = parsedEdges(jobGraph).filter(edge => edge.sourceVertex === name);
547
+ if (incoming.length !== 1 || outgoing.length !== 1) {
548
+ throw unsupportedRawShapeError(index, 'remove-parser', `parser "${name}" must have exactly one incoming and one outgoing edge`);
549
+ }
550
+ removeVertexByName(jobGraph, name);
551
+ removeEdgesTouching(jobGraph, name);
552
+ const from = incoming[0];
553
+ const to = outgoing[0];
554
+ addEdgeIfMissing(jobGraph, from.sourceVertex, from.sourcePort, to.targetVertex, to.targetPort);
555
+ }
556
+ function rawFilterNameForPhase(phase, index, opLabel) {
557
+ switch (phase) {
558
+ case 'pre-parser':
559
+ return RAW_PRE_PARSER_FILTER;
560
+ case 'pre-warehouse':
561
+ return RAW_PRE_WAREHOUSE_FILTER;
562
+ case 'pre-exceptions':
563
+ return RAW_PRE_EXCEPTIONS_FILTER;
564
+ case 'pre-aggregation':
565
+ throw new Error(`Operation ${index} (${opLabel}): phase "pre-aggregation" has no canonical UI raw-graph stage. ` +
566
+ `Use "pre-warehouse" or "pre-exceptions" for raw UI log graphs.`);
567
+ }
568
+ }
569
+ function assertRawUiLogGraph(jobGraph, opLabel, index, requiredNames) {
570
+ const seen = new Set();
571
+ for (const vertex of jobGraph.vertices) {
572
+ if (typeof vertex.name !== 'string' || vertex.name.length === 0) {
573
+ throw unsupportedRawShapeError(index, opLabel, 'every vertex must have a non-empty name');
574
+ }
575
+ if (seen.has(vertex.name)) {
576
+ throw unsupportedRawShapeError(index, opLabel, `duplicate vertex name "${vertex.name}"`);
577
+ }
578
+ seen.add(vertex.name);
579
+ }
580
+ const missing = requiredNames.filter(name => !seen.has(name));
581
+ if (missing.length > 0) {
582
+ throw unsupportedRawShapeError(index, opLabel, `missing canonical vertex: ${missing.join(', ')}`);
583
+ }
584
+ }
585
+ function unsupportedRawShapeError(index, opLabel, detail) {
586
+ return new Error(`Operation ${index} (${opLabel}): unsupported raw job graph shape. ` +
587
+ `UI-level topology edits require a canonical UI log pipeline graph; ${detail}.`);
588
+ }
589
+ function assertOperationIdentity(operation, opLabel, index, fieldName) {
590
+ if (!operation || typeof operation !== 'object') {
591
+ throw new Error(`Operation ${index} (${opLabel}): ${fieldName} must be an object`);
592
+ }
593
+ if (typeof operation.name !== 'string' || operation.name.length === 0) {
594
+ throw new Error(`Operation ${index} (${opLabel}): ${fieldName}.name must be a non-empty string`);
595
+ }
596
+ if (typeof operation.type !== 'string' || operation.type.length === 0) {
597
+ throw new Error(`Operation ${index} (${opLabel}): ${fieldName}.type must be a non-empty string`);
598
+ }
599
+ }
600
+ function findVertexByName(jobGraph, name) {
601
+ return jobGraph.vertices.find(vertex => vertex.name === name);
602
+ }
603
+ function findVertexByType(jobGraph, type) {
604
+ return jobGraph.vertices.find(vertex => vertex.type === type);
605
+ }
606
+ function findVertexIndexByName(jobGraph, name) {
607
+ return jobGraph.vertices.findIndex(vertex => vertex.name === name);
608
+ }
609
+ function replaceVertexByName(jobGraph, name, replacement, opLabel, index) {
610
+ const idx = findVertexIndexByName(jobGraph, name);
611
+ if (idx === -1) {
612
+ throw unsupportedRawShapeError(index, opLabel, `vertex "${name}" not found`);
613
+ }
614
+ jobGraph.vertices[idx] = replacement;
615
+ }
616
+ function removeVertexByName(jobGraph, name) {
617
+ const idx = findVertexIndexByName(jobGraph, name);
618
+ if (idx !== -1) {
619
+ jobGraph.vertices.splice(idx, 1);
620
+ }
621
+ }
622
+ function graphEdges(jobGraph) {
623
+ const graph = jobGraph;
624
+ if (!Array.isArray(graph.edges)) {
625
+ graph.edges = []; // Lazily initializes edges to [] if absent — normalizes the schema type omission.
626
+ }
627
+ return graph.edges;
628
+ }
629
+ function parsedEdges(jobGraph) {
630
+ return graphEdges(jobGraph).map((edge, index) => ({ edge, index, ...parseEdge(edge) }));
631
+ }
632
+ /** True if any `logs-iceberg-table-sink` is reachable from `RAW_LOG_REDUCER` within two hops. */
633
+ function hasReducerFedIcebergSink(jobGraph) {
634
+ const edges = parsedEdges(jobGraph);
635
+ const reducerNeighbors = new Set(edges.filter(e => e.sourceVertex === RAW_LOG_REDUCER).map(e => e.targetVertex));
636
+ return jobGraph.vertices.some(v => v.type === LOGS_ICEBERG_TABLE_SINK_TYPE &&
637
+ (reducerNeighbors.has(v.name) ||
638
+ edges.some(e => reducerNeighbors.has(e.sourceVertex) && e.targetVertex === v.name)));
639
+ }
640
+ function formatEdge(sourceVertex, sourcePort, targetVertex, targetPort) {
641
+ const source = sourcePort === DEFAULT_EDGE_OUTPUT ? sourceVertex : `${sourceVertex}:${sourcePort}`;
642
+ const target = targetPort === DEFAULT_EDGE_INPUT ? targetVertex : `${targetVertex}:${targetPort}`;
643
+ return `${source} -> ${target}`;
644
+ }
645
+ function addEdgeIfMissing(jobGraph, sourceVertex, sourcePort, targetVertex, targetPort) {
646
+ const candidate = formatEdge(sourceVertex, sourcePort, targetVertex, targetPort);
647
+ const exists = parsedEdges(jobGraph).some(edge => edge.sourceVertex === sourceVertex &&
648
+ edge.sourcePort === sourcePort &&
649
+ edge.targetVertex === targetVertex &&
650
+ edge.targetPort === targetPort);
651
+ if (!exists) {
652
+ graphEdges(jobGraph).push(candidate);
653
+ }
654
+ }
655
+ function removeEdgesTouching(jobGraph, name) {
656
+ const keep = graphEdges(jobGraph).filter(edge => {
657
+ const parsed = parseEdge(edge);
658
+ return parsed.sourceVertex !== name && parsed.targetVertex !== name;
659
+ });
660
+ jobGraph.edges = keep;
661
+ }
662
+ function removeEdgeAt(jobGraph, index) {
663
+ graphEdges(jobGraph).splice(index, 1);
664
+ }
665
+ function insertRawVertexBetween(jobGraph, vertex, predecessor, successor, opLabel, index) {
666
+ const directEdges = parsedEdges(jobGraph).filter(edge => edge.sourceVertex === predecessor && edge.targetVertex === successor);
667
+ if (directEdges.length !== 1) {
668
+ throw unsupportedRawShapeError(index, opLabel, `expected exactly one parser-chain edge ${predecessor} -> ${successor}`);
669
+ }
670
+ const directEdge = directEdges[0];
671
+ jobGraph.vertices.push(vertex);
672
+ removeEdgeAt(jobGraph, directEdge.index);
673
+ addEdgeIfMissing(jobGraph, predecessor, directEdge.sourcePort, vertex.name, DEFAULT_EDGE_INPUT);
674
+ addEdgeIfMissing(jobGraph, vertex.name, DEFAULT_EDGE_OUTPUT, successor, directEdge.targetPort);
675
+ }
676
+ function orderedParserNames(jobGraph, opLabel, index) {
677
+ const names = [];
678
+ let current = RAW_PRE_PARSER_FILTER;
679
+ const seen = new Set([current]);
680
+ while (current !== RAW_PRE_WAREHOUSE_FILTER) {
681
+ const outgoing = parsedEdges(jobGraph).filter(edge => edge.sourceVertex === current);
682
+ if (outgoing.length !== 1) {
683
+ throw unsupportedRawShapeError(index, opLabel, `parser chain vertex "${current}" must have exactly one outgoing main-chain edge`);
684
+ }
685
+ const next = outgoing[0].targetVertex;
686
+ if (next === RAW_PRE_WAREHOUSE_FILTER) {
687
+ return names;
688
+ }
689
+ const nextVertex = findVertexByName(jobGraph, next);
690
+ if (!nextVertex || !RAW_PARSER_TYPES.has(nextVertex.type)) {
691
+ throw unsupportedRawShapeError(index, opLabel, `vertex "${next}" between ${RAW_PRE_PARSER_FILTER} and ${RAW_PRE_WAREHOUSE_FILTER} is not a supported parser`);
692
+ }
693
+ if (seen.has(next)) {
694
+ throw unsupportedRawShapeError(index, opLabel, `cycle detected at parser vertex "${next}"`);
695
+ }
696
+ names.push(next);
697
+ seen.add(next);
698
+ current = next;
699
+ }
700
+ return names;
701
+ }
702
+ function singleMainSuccessor(jobGraph, name, opLabel, index) {
703
+ const outgoing = parsedEdges(jobGraph).filter(edge => edge.sourceVertex === name);
704
+ if (outgoing.length !== 1) {
705
+ throw unsupportedRawShapeError(index, opLabel, `vertex "${name}" must have exactly one outgoing main-chain edge`);
706
+ }
707
+ return outgoing[0].targetVertex;
708
+ }
709
+ function isCanonicalRawSource(jobGraph, name) {
710
+ const edges = parsedEdges(jobGraph);
711
+ const hasIncoming = edges.some(edge => edge.targetVertex === name);
712
+ const feedsPreParser = edges.some(edge => edge.sourceVertex === name && edge.targetVertex === RAW_PRE_PARSER_FILTER);
713
+ return !hasIncoming && feedsPreParser;
714
+ }
715
+ function assertProposedTemplateHasSource(input) {
716
+ if (!Array.isArray(input.sources) || input.sources.length === 0) {
717
+ throw new Error('Proposed job graph has zero sources; a log pipeline must keep at least one source.');
718
+ }
719
+ }
720
+ function assertProposedJobGraphHasSource(jobGraph) {
721
+ if (!jobGraph.vertices.some(vertex => typeof vertex.name === 'string' && isCanonicalRawSource(jobGraph, vertex.name))) {
722
+ throw new Error('Proposed job graph has zero sources; a log pipeline must keep at least one source.');
723
+ }
724
+ }
725
+ function applyAddMessageAttribute(input, attributePath, index) {
726
+ const remapper = findRemapper(input);
727
+ if (!remapper) {
728
+ throw new Error(`Operation ${index} (add-message-attribute): no log-attributes-remapper in input.parsers. ` +
729
+ `Template-backed pipelines normally include a remapper by default; if yours doesn't, add one via add-parser first.`);
730
+ }
731
+ applyMessageAttributeToRemapper(remapper, attributePath, index);
732
+ }
733
+ function applyAddGroupBy(input, attributePath, index) {
734
+ applyGroupByToReducer(input.reducer, attributePath, index);
735
+ }
736
+ function applyAddAggregation(input, attributePath, strategies, index) {
737
+ applyAggregationToReducer(input.reducer, attributePath, strategies, index);
738
+ }
739
+ function applyAddReducerException(input, predicate) {
740
+ // Wrapped in a TemplateQueryException; matching logs bypass aggregation.
741
+ const serialized = JSON.stringify(predicate);
742
+ const isDuplicate = input.exceptions.some(e => e.type === TemplateQueryExceptionType.query_exception &&
743
+ JSON.stringify(e.predicate) === serialized);
744
+ if (isDuplicate)
745
+ return; // idempotent
746
+ const exception = {
747
+ type: TemplateQueryExceptionType.query_exception,
748
+ predicate,
749
+ };
750
+ input.exceptions.push(exception);
751
+ }
752
+ function applyAddGrokRule(input, pattern, parserName, extractAttribute, index) {
753
+ const parsers = input.parsers;
754
+ const grok = parserName
755
+ ? parsers.find(p => p.name === parserName)
756
+ : selectGrokParser(parsers.filter(p => p.type === GrokParserType.grok_parser), undefined, 'input.parsers', index);
757
+ if (!grok) {
758
+ throw new Error(`Operation ${index} (add-grok-rule): ${parserName ? `parser "${parserName}" not found` : 'no grok-parser found in input.parsers'}. ` +
759
+ `If you need to introduce one, use add-parser first.`);
760
+ }
761
+ if (grok.type !== GrokParserType.grok_parser) {
762
+ throw new Error(`Operation ${index} (add-grok-rule): parser "${grok.name}" is not a grok-parser`);
763
+ }
764
+ applyGrokRuleToParser(grok, pattern, extractAttribute);
765
+ }
766
+ function applySetInputField(input, path, value, index) {
767
+ const parts = splitPath(path, index);
768
+ let cursor = input;
769
+ for (let i = 0; i < parts.length - 1; i++) {
770
+ const key = parts[i];
771
+ const next = cursor[key];
772
+ if (next === undefined || next === null) {
773
+ throw new Error(`Operation ${index} (set-input-field): path "${path}" traverses into a null/undefined intermediate (at "${key}")`);
774
+ }
775
+ if (typeof next !== 'object' || Array.isArray(next)) {
776
+ throw new Error(`Operation ${index} (set-input-field): path "${path}" traverses into a non-object intermediate (at "${key}")`);
777
+ }
778
+ cursor = next;
779
+ }
780
+ cursor[parts[parts.length - 1]] = value;
781
+ }
782
+ function applyUnsetInputField(input, path, index) {
783
+ const parts = splitPath(path, index);
784
+ let cursor = input;
785
+ for (let i = 0; i < parts.length - 1; i++) {
786
+ const next = cursor[parts[i]];
787
+ if (next === undefined || next === null || typeof next !== 'object' || Array.isArray(next))
788
+ return;
789
+ cursor = next;
790
+ }
791
+ Reflect.deleteProperty(cursor, parts[parts.length - 1]);
792
+ }
793
+ function applyAddParser(input, parser, index) {
794
+ assertOperationIdentity(parser, 'add-parser', index, 'parser');
795
+ if (input.parsers.some(p => p.name === parser.name)) {
796
+ throw new Error(`Operation ${index} (add-parser): parser "${parser.name}" already exists`);
797
+ }
798
+ input.parsers.push(parser);
799
+ }
800
+ function applyRemoveParser(input, name, index) {
801
+ const idx = input.parsers.findIndex(p => p.name === name);
802
+ if (idx === -1) {
803
+ throw new Error(`Operation ${index} (remove-parser): parser "${name}" not found in input.parsers`);
804
+ }
805
+ input.parsers.splice(idx, 1);
806
+ }
807
+ function applySetFilter(input, phase, filter, index) {
808
+ if (!filter || typeof filter !== 'object') {
809
+ throw new Error(`Operation ${index} (set-filter): filter must be an object`);
810
+ }
811
+ const filters = input.filters ?? {};
812
+ filters[phase] = { ...(filters[phase] ?? {}), ...filter };
813
+ input.filters = filters;
814
+ }
815
+ function applyClearFilter(input, phase) {
816
+ const filters = input.filters ?? {};
817
+ const existing = filters[phase];
818
+ if (!existing || typeof existing !== 'object')
819
+ return;
820
+ filters[phase] = { ...existing, predicate: { type: DatadogQueryPredicateType.datadog_query, query: '' } };
821
+ input.filters = filters;
822
+ }
823
+ function applyAddSource(input, source, index) {
824
+ assertOperationIdentity(source, 'add-source', index, 'source');
825
+ if (input.sources.some(s => s.name === source.name)) {
826
+ throw new Error(`Operation ${index} (add-source): source "${source.name}" already exists`);
827
+ }
828
+ input.sources.push(source);
829
+ }
830
+ function applyRemoveSource(input, name, index) {
831
+ const idx = input.sources.findIndex(s => s.name === name);
832
+ if (idx === -1) {
833
+ throw new Error(`Operation ${index} (remove-source): source "${name}" not found in input.sources`);
834
+ }
835
+ input.sources.splice(idx, 1);
836
+ }
837
+ /** Reject a `target` not in {@link SinkTarget}; a typo like `"processed-log"` would otherwise fall through to the processed-logs branch. */
838
+ function assertValidSinkTarget(target, opLabel, index) {
839
+ if (target !== 'vendor' && target !== 'processed-logs') {
840
+ throw new Error(`Operation ${index} (${opLabel}): target must be "vendor" or "processed-logs", got ${JSON.stringify(target)}.`);
841
+ }
842
+ }
843
+ /** Validate the sink type matches the target and `filter` is vendor-only; shared by both backends. */
844
+ function assertSinkTargetShape(target, sink, filter, index) {
845
+ assertValidSinkTarget(target, 'add-sink', index);
846
+ if (target === 'vendor') {
847
+ if (!VENDOR_LOG_SINK_TYPES.has(sink.type)) {
848
+ throw new Error(`Operation ${index} (add-sink): vendor sink type "${sink.type}" is not supported. ` +
849
+ `Supported: ${[...VENDOR_LOG_SINK_TYPES].join(', ')}.`);
850
+ }
851
+ return;
852
+ }
853
+ // processed-logs
854
+ if (sink.type !== LOGS_ICEBERG_TABLE_SINK_TYPE) {
855
+ throw new Error(`Operation ${index} (add-sink): target "processed-logs" requires a ${LOGS_ICEBERG_TABLE_SINK_TYPE} sink, got "${sink.type}".`);
856
+ }
857
+ if (filter !== undefined) {
858
+ throw new Error(`Operation ${index} (add-sink): filter is only supported for target "vendor"; ` +
859
+ `the processed-logs sink has no per-sink filter.`);
860
+ }
861
+ }
862
+ function applyAddSink(input, target, sink, filter, index) {
863
+ assertOperationIdentity(sink, 'add-sink', index, 'sink');
864
+ assertSinkTargetShape(target, sink, filter, index);
865
+ if (target === 'vendor') {
866
+ const sinks = input.sinks ?? [];
867
+ if (sinks.some(entry => entry.sink?.name === sink.name)) {
868
+ throw new Error(`Operation ${index} (add-sink): sink "${sink.name}" already exists in input.sinks`);
869
+ }
870
+ sinks.push(filter !== undefined ? { sink, filter } : { sink });
871
+ input.sinks = sinks;
872
+ return;
873
+ }
874
+ // processed-logs: singular slot, don't silently replace.
875
+ if (input.processedLogsSink !== undefined && input.processedLogsSink !== null) {
876
+ throw new Error(`Operation ${index} (add-sink): processedLogsSink is already set. ` +
877
+ `Remove it first with remove-sink (target: processed-logs) before adding a new one.`);
878
+ }
879
+ // Validated as a logs-iceberg-table-sink by assertSinkTargetShape.
880
+ input.processedLogsSink = sink;
881
+ }
882
+ function applyRemoveSink(input, target, name, index) {
883
+ assertValidSinkTarget(target, 'remove-sink', index);
884
+ if (target === 'vendor') {
885
+ if (typeof name !== 'string' || name.length === 0) {
886
+ throw new Error(`Operation ${index} (remove-sink): name is required for target "vendor"`);
887
+ }
888
+ const sinks = input.sinks ?? [];
889
+ const idx = sinks.findIndex(entry => entry.sink?.name === name);
890
+ if (idx === -1) {
891
+ throw new Error(`Operation ${index} (remove-sink): sink "${name}" not found in input.sinks`);
892
+ }
893
+ sinks.splice(idx, 1);
894
+ input.sinks = sinks;
895
+ return;
896
+ }
897
+ // processed-logs
898
+ if (input.processedLogsSink === undefined || input.processedLogsSink === null) {
899
+ throw new Error(`Operation ${index} (remove-sink): no processedLogsSink set to remove`);
900
+ }
901
+ delete input.processedLogsSink;
902
+ }
903
+ function applySetRawDataset(input, datasetId) {
904
+ input.datasetId = datasetId;
905
+ // rawSinkConfig.datasetId is an override that takes precedence over input.datasetId
906
+ // for the raw sink, so when it is set, update it too or the raw sink keeps writing
907
+ // to the old dataset.
908
+ const rawSinkConfig = input.rawSinkConfig;
909
+ if (rawSinkConfig?.datasetId != null) {
910
+ rawSinkConfig.datasetId = datasetId;
911
+ }
912
+ }
913
+ /** Returns limitation strings for the user-facing draft preamble; non-empty when the patch touches sinks (external delivery is not verified). */
914
+ export function draftVerificationLimitations(patch) {
915
+ return patch.operations.some(op => touchesSinkConfig(op))
916
+ ? ['Sink/data-lake output edits are submitted for graph/upstream verification only; external sink delivery is not verified.']
917
+ : [];
918
+ }
919
+ /** Classify a patch by what it touches; recorded on the plan so consumers needn't recompute. */
920
+ export function classifyPatch(patch) {
921
+ let source = false;
922
+ let sink = false;
923
+ for (const op of patch.operations) {
924
+ if (touchesSourceConfig(op))
925
+ source = true;
926
+ if (touchesSinkConfig(op))
927
+ sink = true;
928
+ }
929
+ if (source && sink)
930
+ return 'mixed';
931
+ if (source)
932
+ return 'source';
933
+ if (sink)
934
+ return 'sink';
935
+ return 'transform';
936
+ }
937
+ /**
938
+ * What a write to each top-level `templateInputs.input` field touches. Keyed by
939
+ * `keyof SchemaLogReducerTemplateInput` so the compiler forces every schema
940
+ * field to be classified — a new field is a build error here, not a silent
941
+ * `unknown`. `datasetId` is the raw-logs dataset (semantic equivalent of
942
+ * set-raw-dataset).
943
+ */
944
+ const INPUT_FIELD_TOUCHES = {
945
+ sources: 'source',
946
+ sinks: 'sink',
947
+ processedLogsSink: 'sink',
948
+ rawSinkConfig: 'sink',
949
+ datasetId: 'sink',
950
+ reducer: 'transform',
951
+ parsers: 'transform',
952
+ filters: 'transform',
953
+ exceptions: 'transform',
954
+ sampler: 'transform',
955
+ sqlOperations: 'transform',
956
+ };
957
+ /**
958
+ * Classify a generic `set/unset-input-field` path by its top-level field.
959
+ * Fails closed: an unrecognized field returns `unknown`, which callers treat as
960
+ * touching both source and sink so the change routes to a conservative
961
+ * (source-preserving, non-replay) draft rather than being misclassified
962
+ * `transform` and previewed on a chain that never exercises it.
963
+ */
964
+ function classifyInputPath(path) {
965
+ const top = (path.split('.')[0] ?? '').split('[')[0] ?? '';
966
+ return (top in INPUT_FIELD_TOUCHES) ? INPUT_FIELD_TOUCHES[top] : 'unknown';
967
+ }
968
+ function touchesSourceConfig(op) {
969
+ switch (op.op) {
970
+ case 'add-source':
971
+ case 'remove-source':
972
+ return true;
973
+ case 'set-input-field':
974
+ case 'unset-input-field': {
975
+ const cls = classifyInputPath(op.path);
976
+ return cls === 'source' || cls === 'unknown';
977
+ }
978
+ default:
979
+ return false;
980
+ }
981
+ }
982
+ function touchesSinkConfig(op) {
983
+ switch (op.op) {
984
+ case 'add-sink':
985
+ case 'remove-sink':
986
+ case 'set-raw-dataset':
987
+ return true;
988
+ case 'set-input-field':
989
+ case 'unset-input-field': {
990
+ const cls = classifyInputPath(op.path);
991
+ return cls === 'sink' || cls === 'unknown';
992
+ }
993
+ default:
994
+ return false;
995
+ }
996
+ }
997
+ /**
998
+ * Find the single `template-operation` vertex (exactly one per template-backed
999
+ * job); throws on 0 (non-template) or >1 (unsupported). Accepts `SchemaReadJob`
1000
+ * or `SchemaUpdateJob` without casts.
1001
+ */
1002
+ export function findTemplateOperation(job) {
1003
+ const vertices = job.jobGraph?.vertices;
1004
+ if (!Array.isArray(vertices)) {
1005
+ throw new Error('Job has no jobGraph');
1006
+ }
1007
+ const matches = vertices.filter(v => v.type === TemplateOperationType.template_operation);
1008
+ if (matches.length === 0) {
1009
+ throw new Error(`Pipeline is not template-backed (no template-operation vertex). ` +
1010
+ `This helper only applies to template-backed pipelines.`);
1011
+ }
1012
+ if (matches.length > 1) {
1013
+ throw new Error(`Pipeline has ${matches.length} template-operation vertices; expected exactly 1. ` +
1014
+ `Use grepr job:get --resolved -f raw to inspect.`);
1015
+ }
1016
+ return matches[0];
1017
+ }
1018
+ function readTemplateInput(templateOp) {
1019
+ const inputs = templateOp.templateInputs;
1020
+ if (!inputs || typeof inputs !== 'object') {
1021
+ throw new Error('template-operation vertex has no templateInputs');
1022
+ }
1023
+ const input = inputs['input'];
1024
+ if (!input || typeof input !== 'object') {
1025
+ throw new Error('template-operation vertex has no templateInputs.input');
1026
+ }
1027
+ return input;
1028
+ }
1029
+ function writeTemplateInput(templateOp, input) {
1030
+ if (!templateOp.templateInputs)
1031
+ templateOp.templateInputs = {};
1032
+ // templateInputs values are typed `unknown`, so the structured input assigns directly — no cast needed.
1033
+ templateOp.templateInputs['input'] = input;
1034
+ }
1035
+ function findRemapper(input) {
1036
+ return input.parsers.find(p => p.type === LogAttributesRemapperType.log_attributes_remapper);
1037
+ }
1038
+ function splitPath(path, index) {
1039
+ if (path.length === 0) {
1040
+ throw new Error(`Operation ${index}: path must not be empty`);
1041
+ }
1042
+ return path.split('.');
1043
+ }
1044
+ function strategyTypeFor(strategy) {
1045
+ switch (strategy) {
1046
+ case 'sum': return SumAttributesMergeStrategyType.sum;
1047
+ case 'min': return MinAttributesMergeStrategyType.min;
1048
+ case 'max': return MaxAttributesMergeStrategyType.max;
1049
+ case 'avg': return AverageAttributesMergeStrategyType.avg;
1050
+ }
1051
+ }
1052
+ function addUniqueString(list, value) {
1053
+ if (!list.includes(value))
1054
+ list.push(value);
1055
+ }
1056
+ function addUniqueStringArray(list, value) {
1057
+ if (!list.some(existing => arraysEqual(existing, value))) {
1058
+ list.push(value);
1059
+ }
1060
+ }
1061
+ function arraysEqual(a, b) {
1062
+ if (a.length !== b.length)
1063
+ return false;
1064
+ for (let i = 0; i < a.length; i++) {
1065
+ if (a[i] !== b[i])
1066
+ return false;
1067
+ }
1068
+ return true;
1069
+ }
1070
+ //# sourceMappingURL=job-patch.js.map