@chkit/plugin-backfill 0.1.0-beta.9 → 0.1.1

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 (164) hide show
  1. package/README.md +130 -0
  2. package/dist/args.d.ts +17 -0
  3. package/dist/args.d.ts.map +1 -1
  4. package/dist/args.js +11 -0
  5. package/dist/args.js.map +1 -1
  6. package/dist/async-backfill.d.ts +64 -0
  7. package/dist/async-backfill.d.ts.map +1 -0
  8. package/dist/async-backfill.js +251 -0
  9. package/dist/async-backfill.js.map +1 -0
  10. package/dist/check.d.ts +9 -0
  11. package/dist/check.d.ts.map +1 -0
  12. package/dist/check.js +79 -0
  13. package/dist/check.js.map +1 -0
  14. package/dist/chunking/analyze.d.ts +8 -0
  15. package/dist/chunking/analyze.d.ts.map +1 -0
  16. package/dist/chunking/analyze.js +8 -0
  17. package/dist/chunking/analyze.js.map +1 -0
  18. package/dist/chunking/boundary-codec.d.ts +4 -0
  19. package/dist/chunking/boundary-codec.d.ts.map +1 -0
  20. package/dist/chunking/boundary-codec.js +79 -0
  21. package/dist/chunking/boundary-codec.js.map +1 -0
  22. package/dist/chunking/build.d.ts +11 -0
  23. package/dist/chunking/build.d.ts.map +1 -0
  24. package/dist/chunking/build.js +51 -0
  25. package/dist/chunking/build.js.map +1 -0
  26. package/dist/chunking/e2e/constants.d.ts +2 -0
  27. package/dist/chunking/e2e/constants.d.ts.map +1 -0
  28. package/dist/chunking/e2e/constants.js +2 -0
  29. package/dist/chunking/e2e/constants.js.map +1 -0
  30. package/dist/chunking/e2e/seed-datasets.script.d.ts +20 -0
  31. package/dist/chunking/e2e/seed-datasets.script.d.ts.map +1 -0
  32. package/dist/chunking/e2e/seed-datasets.script.js +134 -0
  33. package/dist/chunking/e2e/seed-datasets.script.js.map +1 -0
  34. package/dist/chunking/introspect.d.ts +40 -0
  35. package/dist/chunking/introspect.d.ts.map +1 -0
  36. package/dist/chunking/introspect.js +187 -0
  37. package/dist/chunking/introspect.js.map +1 -0
  38. package/dist/chunking/partition-slices.d.ts +14 -0
  39. package/dist/chunking/partition-slices.d.ts.map +1 -0
  40. package/dist/chunking/partition-slices.js +111 -0
  41. package/dist/chunking/partition-slices.js.map +1 -0
  42. package/dist/chunking/planner.d.ts +3 -0
  43. package/dist/chunking/planner.d.ts.map +1 -0
  44. package/dist/chunking/planner.js +343 -0
  45. package/dist/chunking/planner.js.map +1 -0
  46. package/dist/chunking/services/distribution-source.d.ts +11 -0
  47. package/dist/chunking/services/distribution-source.d.ts.map +1 -0
  48. package/dist/chunking/services/distribution-source.js +60 -0
  49. package/dist/chunking/services/distribution-source.js.map +1 -0
  50. package/dist/chunking/services/metadata-source.d.ts +4 -0
  51. package/dist/chunking/services/metadata-source.d.ts.map +1 -0
  52. package/dist/chunking/services/metadata-source.js +138 -0
  53. package/dist/chunking/services/metadata-source.js.map +1 -0
  54. package/dist/chunking/services/row-probe.d.ts +12 -0
  55. package/dist/chunking/services/row-probe.d.ts.map +1 -0
  56. package/dist/chunking/services/row-probe.js +49 -0
  57. package/dist/chunking/services/row-probe.js.map +1 -0
  58. package/dist/chunking/splitter.d.ts +20 -0
  59. package/dist/chunking/splitter.d.ts.map +1 -0
  60. package/dist/chunking/splitter.js +76 -0
  61. package/dist/chunking/splitter.js.map +1 -0
  62. package/dist/chunking/sql.d.ts +18 -0
  63. package/dist/chunking/sql.d.ts.map +1 -0
  64. package/dist/chunking/sql.js +304 -0
  65. package/dist/chunking/sql.js.map +1 -0
  66. package/dist/chunking/strategies/equal-width-split.d.ts +3 -0
  67. package/dist/chunking/strategies/equal-width-split.d.ts.map +1 -0
  68. package/dist/chunking/strategies/equal-width-split.js +46 -0
  69. package/dist/chunking/strategies/equal-width-split.js.map +1 -0
  70. package/dist/chunking/strategies/group-by-key-split.d.ts +3 -0
  71. package/dist/chunking/strategies/group-by-key-split.d.ts.map +1 -0
  72. package/dist/chunking/strategies/group-by-key-split.js +54 -0
  73. package/dist/chunking/strategies/group-by-key-split.js.map +1 -0
  74. package/dist/chunking/strategies/metadata-single-chunk.d.ts +3 -0
  75. package/dist/chunking/strategies/metadata-single-chunk.d.ts.map +1 -0
  76. package/dist/chunking/strategies/metadata-single-chunk.js +5 -0
  77. package/dist/chunking/strategies/metadata-single-chunk.js.map +1 -0
  78. package/dist/chunking/strategies/quantile-range-split.d.ts +5 -0
  79. package/dist/chunking/strategies/quantile-range-split.d.ts.map +1 -0
  80. package/dist/chunking/strategies/quantile-range-split.js +132 -0
  81. package/dist/chunking/strategies/quantile-range-split.js.map +1 -0
  82. package/dist/chunking/strategies/refinement.d.ts +3 -0
  83. package/dist/chunking/strategies/refinement.d.ts.map +1 -0
  84. package/dist/chunking/strategies/refinement.js +61 -0
  85. package/dist/chunking/strategies/refinement.js.map +1 -0
  86. package/dist/chunking/strategies/string-prefix-split.d.ts +4 -0
  87. package/dist/chunking/strategies/string-prefix-split.d.ts.map +1 -0
  88. package/dist/chunking/strategies/string-prefix-split.js +73 -0
  89. package/dist/chunking/strategies/string-prefix-split.js.map +1 -0
  90. package/dist/chunking/strategies/temporal-bucket-split.d.ts +3 -0
  91. package/dist/chunking/strategies/temporal-bucket-split.d.ts.map +1 -0
  92. package/dist/chunking/strategies/temporal-bucket-split.js +67 -0
  93. package/dist/chunking/strategies/temporal-bucket-split.js.map +1 -0
  94. package/dist/chunking/strategy-policy.d.ts +3 -0
  95. package/dist/chunking/strategy-policy.d.ts.map +1 -0
  96. package/dist/chunking/strategy-policy.js +4 -0
  97. package/dist/chunking/strategy-policy.js.map +1 -0
  98. package/dist/chunking/types.d.ts +137 -0
  99. package/dist/chunking/types.d.ts.map +1 -0
  100. package/dist/chunking/types.js +2 -0
  101. package/dist/chunking/types.js.map +1 -0
  102. package/dist/chunking/utils/binary-string.d.ts +8 -0
  103. package/dist/chunking/utils/binary-string.d.ts.map +1 -0
  104. package/dist/chunking/utils/binary-string.js +52 -0
  105. package/dist/chunking/utils/binary-string.js.map +1 -0
  106. package/dist/chunking/utils/ids.d.ts +4 -0
  107. package/dist/chunking/utils/ids.d.ts.map +1 -0
  108. package/dist/chunking/utils/ids.js +11 -0
  109. package/dist/chunking/utils/ids.js.map +1 -0
  110. package/dist/chunking/utils/ranges.d.ts +5 -0
  111. package/dist/chunking/utils/ranges.d.ts.map +1 -0
  112. package/dist/chunking/utils/ranges.js +19 -0
  113. package/dist/chunking/utils/ranges.js.map +1 -0
  114. package/dist/detect.d.ts +8 -0
  115. package/dist/detect.d.ts.map +1 -0
  116. package/dist/detect.js +72 -0
  117. package/dist/detect.js.map +1 -0
  118. package/dist/index.d.ts +3 -0
  119. package/dist/index.d.ts.map +1 -1
  120. package/dist/index.js +1 -0
  121. package/dist/index.js.map +1 -1
  122. package/dist/logging.d.ts +12 -0
  123. package/dist/logging.d.ts.map +1 -0
  124. package/dist/logging.js +61 -0
  125. package/dist/logging.js.map +1 -0
  126. package/dist/options.d.ts +193 -4
  127. package/dist/options.d.ts.map +1 -1
  128. package/dist/options.js +168 -111
  129. package/dist/options.js.map +1 -1
  130. package/dist/payload.d.ts +7 -17
  131. package/dist/payload.d.ts.map +1 -1
  132. package/dist/payload.js +7 -19
  133. package/dist/payload.js.map +1 -1
  134. package/dist/planner.d.ts +10 -8
  135. package/dist/planner.d.ts.map +1 -1
  136. package/dist/planner.js +76 -97
  137. package/dist/planner.js.map +1 -1
  138. package/dist/plugin.d.ts +4 -3
  139. package/dist/plugin.d.ts.map +1 -1
  140. package/dist/plugin.js +375 -201
  141. package/dist/plugin.js.map +1 -1
  142. package/dist/queries.d.ts +21 -0
  143. package/dist/queries.d.ts.map +1 -0
  144. package/dist/queries.js +113 -0
  145. package/dist/queries.js.map +1 -0
  146. package/dist/runtime.d.ts +14 -0
  147. package/dist/runtime.d.ts.map +1 -1
  148. package/dist/runtime.js +162 -83
  149. package/dist/runtime.js.map +1 -1
  150. package/dist/sdk.d.ts +16 -0
  151. package/dist/sdk.d.ts.map +1 -0
  152. package/dist/sdk.js +11 -0
  153. package/dist/sdk.js.map +1 -0
  154. package/dist/state.d.ts +16 -28
  155. package/dist/state.d.ts.map +1 -1
  156. package/dist/state.js +73 -127
  157. package/dist/state.js.map +1 -1
  158. package/dist/table-config.d.ts +9 -0
  159. package/dist/table-config.d.ts.map +1 -0
  160. package/dist/table-config.js +2 -0
  161. package/dist/table-config.js.map +1 -0
  162. package/dist/types.d.ts +49 -119
  163. package/dist/types.d.ts.map +1 -1
  164. package/package.json +25 -2
package/dist/plugin.js CHANGED
@@ -1,259 +1,433 @@
1
+ import { createClickHouseExecutor } from '@chkit/clickhouse';
1
2
  import { wrapPluginRun } from '@chkit/core';
2
- import { PLAN_FLAGS, PLAN_ID_FLAGS, RESUME_FLAGS, RUN_FLAGS, parseCancelArgs, parseDoctorArgs, parsePlanArgs, parseResumeArgs, parseRunArgs, parseStatusArgs, } from './args.js';
3
+ import { executeBackfill } from './async-backfill.js';
4
+ import { buildChunkExecutionSql } from './chunking/sql.js';
5
+ import { generateIdempotencyToken } from './chunking/utils/ids.js';
3
6
  import { BackfillConfigError } from './errors.js';
4
- import { normalizeBackfillOptions, mergeOptions, validateBaseOptions } from './options.js';
5
- import { planPayload, runPayload, statusPayload, cancelPayload, doctorPayload } from './payload.js';
7
+ import { CheckSchema, PLAN_FLAGS, PLAN_FLAG_MAP, PLAN_ID_FLAGS, PLAN_ID_FLAG_MAP, PlanSchema, PluginConfigSchema, RESUME_FLAGS, RESUME_FLAG_MAP, ResumeSchema, RUN_FLAGS, RUN_FLAG_MAP, RunSchema, StatusSchema, SUBMIT_FLAGS, SUBMIT_FLAG_MAP, SubmitSchema, } from './options.js';
8
+ import { planPayload, statusPayload, cancelPayload, doctorPayload } from './payload.js';
6
9
  import { buildBackfillPlan } from './planner.js';
7
- import { cancelBackfillRun, evaluateBackfillCheck, executeBackfillRun, getBackfillDoctorReport, getBackfillStatus, resumeBackfillRun, } from './runtime.js';
10
+ import { evaluateBackfillCheck } from './check.js';
11
+ import { cancelBackfillRun, getBackfillDoctorReport, getBackfillStatus } from './queries.js';
12
+ import { backfillPaths, ensureEnvironmentMatch, nowIso, readPlan, readRun, summarizeRunStatus, writeJson, } from './state.js';
13
+ function formatBytes(bytes) {
14
+ if (bytes >= 1024 ** 4)
15
+ return `${(bytes / 1024 ** 4).toFixed(1)} TiB`;
16
+ if (bytes >= 1024 ** 3)
17
+ return `${(bytes / 1024 ** 3).toFixed(1)} GiB`;
18
+ if (bytes >= 1024 ** 2)
19
+ return `${(bytes / 1024 ** 2).toFixed(1)} MiB`;
20
+ if (bytes >= 1024)
21
+ return `${(bytes / 1024).toFixed(1)} KiB`;
22
+ return `${bytes} B`;
23
+ }
24
+ async function runBackfill(input) {
25
+ const { plan, stateDir } = await readPlan({
26
+ planId: input.planId,
27
+ configPath: input.configPath,
28
+ config: input.config,
29
+ stateDir: input.stateDir,
30
+ });
31
+ ensureEnvironmentMatch({
32
+ plan,
33
+ clickhouse: input.clickhouse,
34
+ forceEnvironment: input.forceEnvironment,
35
+ });
36
+ const paths = backfillPaths(stateDir, plan.planId);
37
+ // Check for existing run state
38
+ const existingRun = await readRun(paths.runPath);
39
+ const resumeFrom = input.resumeFrom;
40
+ if (existingRun && !resumeFrom) {
41
+ // `run` command (no resumeFrom) must not silently continue an existing run.
42
+ // Users should use `backfill resume` instead.
43
+ const status = existingRun.status;
44
+ if (status === 'completed') {
45
+ throw new BackfillConfigError(`Run already completed for plan ${plan.planId}. Nothing to do.`);
46
+ }
47
+ if (status === 'cancelled') {
48
+ throw new BackfillConfigError(`Run is cancelled for plan ${plan.planId}. Create a new plan or inspect with backfill doctor.`);
49
+ }
50
+ throw new BackfillConfigError(`A run already exists for plan ${plan.planId} (status: ${status}). Use backfill resume to continue.`);
51
+ }
52
+ const db = createClickHouseExecutor(input.clickhouse);
53
+ try {
54
+ const runState = {
55
+ planId: plan.planId,
56
+ target: plan.target,
57
+ status: 'running',
58
+ startedAt: existingRun?.startedAt ?? nowIso(),
59
+ updatedAt: nowIso(),
60
+ progress: resumeFrom ?? {},
61
+ };
62
+ await writeJson(paths.runPath, runState);
63
+ const result = await executeBackfill({
64
+ executor: db,
65
+ planId: plan.planId,
66
+ chunks: plan.chunkPlan.chunks.map((chunk) => ({ id: chunk.id })),
67
+ buildQuery: (chunk) => {
68
+ const planChunk = plan.chunkPlan.chunks.find((candidate) => candidate.id === chunk.id);
69
+ if (!planChunk)
70
+ throw new Error(`Chunk ${chunk.id} not found in plan`);
71
+ return buildChunkExecutionSql({
72
+ planId: plan.planId,
73
+ chunk: planChunk,
74
+ target: plan.target,
75
+ sourceTarget: plan.execution.sourceTarget,
76
+ table: plan.chunkPlan.table,
77
+ mvAsQuery: plan.execution.mvAsQuery,
78
+ targetColumns: plan.execution.targetColumns,
79
+ idempotencyToken: plan.execution.requireIdempotencyToken
80
+ ? generateIdempotencyToken(plan.planId, planChunk.id)
81
+ : '',
82
+ });
83
+ },
84
+ concurrency: input.concurrency,
85
+ pollIntervalMs: input.pollIntervalMs,
86
+ resumeFrom,
87
+ replayFailed: input.replayFailed,
88
+ onProgress: async (progress) => {
89
+ runState.progress = progress;
90
+ runState.updatedAt = nowIso();
91
+ await writeJson(paths.runPath, runState);
92
+ },
93
+ });
94
+ runState.status = result.failed > 0 ? 'failed' : 'completed';
95
+ runState.completedAt = nowIso();
96
+ runState.updatedAt = nowIso();
97
+ runState.progress = result.progress;
98
+ if (result.failed > 0) {
99
+ const failedEntry = Object.values(result.progress).find((c) => c.status === 'failed');
100
+ runState.lastError = failedEntry?.error ?? 'One or more chunks failed';
101
+ }
102
+ await writeJson(paths.runPath, runState);
103
+ const summary = summarizeRunStatus(runState, paths.runPath, plan);
104
+ if (input.jsonMode) {
105
+ input.print({
106
+ ok: result.failed === 0,
107
+ planId: plan.planId,
108
+ status: runState.status,
109
+ chunkCounts: summary.totals,
110
+ rowsWritten: summary.rowsWritten,
111
+ runPath: paths.runPath,
112
+ lastError: runState.lastError,
113
+ });
114
+ }
115
+ else {
116
+ let line = `Backfill ${plan.planId}: ${runState.status} (done=${summary.totals.done}/${summary.totals.total}, ${summary.rowsWritten} rows written)`;
117
+ if (runState.lastError)
118
+ line += ` \u2014 ${runState.lastError}`;
119
+ input.print(line);
120
+ if (runState.status === 'completed' && summary.rowsWritten === 0) {
121
+ input.print('Warning: 0 rows written across all chunks. Verify that source data exists in the time range and passes the query\'s WHERE filters.');
122
+ }
123
+ }
124
+ return result.failed > 0 ? 1 : 0;
125
+ }
126
+ finally {
127
+ await db.close();
128
+ }
129
+ }
130
+ function isRecord(value) {
131
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
132
+ }
133
+ function withFactoryDefaults(schema, defaults) {
134
+ return {
135
+ safeParse(data) {
136
+ return schema.safeParse({ ...defaults, ...(isRecord(data) ? data : {}) });
137
+ },
138
+ };
139
+ }
8
140
  export function createBackfillPlugin(options = {}) {
9
- const base = normalizeBackfillOptions(options);
10
- validateBaseOptions(base);
141
+ const factoryOptions = options;
142
+ const planOptionsSchema = withFactoryDefaults(PlanSchema, factoryOptions);
143
+ const submitOptionsSchema = withFactoryDefaults(SubmitSchema, factoryOptions);
144
+ const runOptionsSchema = withFactoryDefaults(RunSchema, factoryOptions);
145
+ const resumeOptionsSchema = withFactoryDefaults(ResumeSchema, factoryOptions);
146
+ const statusOptionsSchema = withFactoryDefaults(StatusSchema, factoryOptions);
11
147
  return {
12
148
  manifest: {
13
149
  name: 'backfill',
14
150
  apiVersion: 1,
15
151
  },
152
+ optionsSchema: withFactoryDefaults(PluginConfigSchema, factoryOptions),
16
153
  commands: [
17
154
  {
18
155
  name: 'plan',
19
156
  description: 'Build a deterministic backfill plan and persist immutable plan state',
20
157
  flags: PLAN_FLAGS,
21
- async run({ flags, jsonMode, print, options: runtimeOptions, config, configPath }) {
22
- return wrapPluginRun({
23
- command: 'plan',
24
- label: 'Backfill plan',
25
- jsonMode,
26
- print,
27
- configErrorClass: BackfillConfigError,
28
- fn: async () => {
29
- const parsed = parsePlanArgs(flags);
30
- const effectiveOptions = mergeOptions(base, runtimeOptions);
31
- validateBaseOptions(effectiveOptions);
158
+ optionsSchema: planOptionsSchema,
159
+ flagMapping: PLAN_FLAG_MAP,
160
+ run: async (context) => wrapPluginRun({
161
+ command: 'plan',
162
+ label: 'Backfill plan',
163
+ jsonMode: context.jsonMode,
164
+ print: context.print,
165
+ configErrorClass: BackfillConfigError,
166
+ fn: async () => {
167
+ const opts = context.options;
168
+ if (!context.config.clickhouse) {
169
+ throw new BackfillConfigError('ClickHouse connection is required for backfill planning. Configure clickhouse in your clickhouse.config.ts.');
170
+ }
171
+ const db = createClickHouseExecutor(context.config.clickhouse);
172
+ try {
32
173
  const output = await buildBackfillPlan({
33
- target: parsed.target,
34
- from: parsed.from,
35
- to: parsed.to,
36
- config,
37
- configPath,
38
- options: effectiveOptions,
39
- chunkHours: parsed.chunkHours,
40
- forceLargeWindow: parsed.forceLargeWindow,
174
+ opts,
175
+ configPath: context.configPath,
176
+ config: context.config,
177
+ clickhouse: context.config.clickhouse,
178
+ clickhouseQuery: async (sql, settings) => {
179
+ const result = await db.query(sql, settings);
180
+ return result;
181
+ },
182
+ // ObsessionDB enables parallel replicas by default, which inflates
183
+ // aggregate results (count, GROUP BY). Disable for planning queries
184
+ // until ObsessionDB handles it at the profile level.
185
+ querySettings: { enable_parallel_replicas: 0 },
41
186
  });
42
187
  const payload = planPayload(output);
43
- if (jsonMode) {
44
- print(payload);
188
+ if (context.jsonMode) {
189
+ context.print(payload);
45
190
  }
46
191
  else {
47
- print(`Backfill plan ${payload.planId} for ${payload.target} (${payload.chunkCount} chunks at ${payload.chunkHours}h) -> ${payload.planPath}${payload.existed ? ' [existing]' : ''}`);
192
+ const partitionCount = output.plan.chunkPlan.partitions.length;
193
+ const totalBytes = formatBytes(output.plan.chunkPlan.totalBytesCompressed);
194
+ const primarySortKey = output.plan.chunkPlan.table.sortKeys[0];
195
+ const sortKeyLabel = primarySortKey
196
+ ? `, sort key: ${primarySortKey.name} (${primarySortKey.category})`
197
+ : '';
198
+ context.print(`Backfill plan ${payload.planId} for ${payload.target} (${payload.chunkCount} chunks across ${partitionCount} partitions, ~${totalBytes}${sortKeyLabel}) -> ${payload.planPath}`);
48
199
  }
49
200
  return 0;
50
- },
51
- });
52
- },
201
+ }
202
+ finally {
203
+ await db.close();
204
+ }
205
+ },
206
+ }),
207
+ },
208
+ {
209
+ name: 'submit',
210
+ description: 'Submit a backfill plan to a managed job backend (e.g. ObsessionDB) instead of running it locally',
211
+ flags: SUBMIT_FLAGS,
212
+ optionsSchema: submitOptionsSchema,
213
+ flagMapping: SUBMIT_FLAG_MAP,
214
+ run: async (context) => wrapPluginRun({
215
+ command: 'submit',
216
+ label: 'Backfill submit',
217
+ jsonMode: context.jsonMode,
218
+ print: context.print,
219
+ configErrorClass: BackfillConfigError,
220
+ fn: async () => {
221
+ // Reaching this handler means no managed backend intercepted the
222
+ // command. The ObsessionDB plugin handles `submit` via its
223
+ // onBeforePluginCommand hook when a service is selected; without
224
+ // one there is nowhere to submit to.
225
+ throw new BackfillConfigError('backfill submit requires a managed job backend. Log in and select an ObsessionDB service ' +
226
+ '(`chkit obsessiondb login`, then `chkit obsessiondb service select`), ' +
227
+ 'or use `chkit backfill run --local` to execute the backfill against a direct connection.');
228
+ },
229
+ }),
53
230
  },
54
231
  {
55
232
  name: 'run',
56
- description: 'Execute a planned backfill with checkpointed chunk progress',
233
+ description: 'Execute a planned backfill with async query submission and polling',
57
234
  flags: RUN_FLAGS,
58
- async run({ flags, jsonMode, print, options: runtimeOptions, config, configPath }) {
59
- return wrapPluginRun({
60
- command: 'run',
61
- label: 'Backfill run',
62
- jsonMode,
63
- print,
64
- configErrorClass: BackfillConfigError,
65
- fn: async () => {
66
- const parsed = parseRunArgs(flags);
67
- const effectiveOptions = mergeOptions(base, runtimeOptions);
68
- validateBaseOptions(effectiveOptions);
69
- const output = await executeBackfillRun({
70
- planId: parsed.planId,
71
- config,
72
- configPath,
73
- options: effectiveOptions,
74
- execution: {
75
- replayDone: parsed.replayDone,
76
- replayFailed: parsed.replayFailed,
77
- forceOverlap: parsed.forceOverlap,
78
- forceCompatibility: parsed.forceCompatibility,
79
- simulation: {
80
- failChunkId: parsed.simulateFailChunk,
81
- failCount: parsed.simulateFailCount,
82
- },
83
- },
84
- });
85
- const payload = {
86
- ...runPayload(output),
87
- command: 'run',
88
- };
89
- if (jsonMode) {
90
- print(payload);
91
- }
92
- else {
93
- print(`Backfill run ${payload.planId}: ${payload.status} (done=${payload.chunkCounts.done}/${payload.chunkCounts.total})`);
94
- }
95
- return payload.ok ? 0 : 1;
96
- },
97
- });
98
- },
235
+ optionsSchema: runOptionsSchema,
236
+ flagMapping: RUN_FLAG_MAP,
237
+ run: async (context) => wrapPluginRun({
238
+ command: 'run',
239
+ label: 'Backfill run',
240
+ jsonMode: context.jsonMode,
241
+ print: context.print,
242
+ configErrorClass: BackfillConfigError,
243
+ fn: async () => {
244
+ const opts = context.options;
245
+ if (!context.config.clickhouse) {
246
+ throw new BackfillConfigError('ClickHouse connection is required for backfill execution. Configure clickhouse in your clickhouse.config.ts.');
247
+ }
248
+ return runBackfill({
249
+ planId: opts.planId,
250
+ forceEnvironment: opts.forceEnvironment,
251
+ concurrency: opts.concurrency,
252
+ pollIntervalMs: opts.pollIntervalMs,
253
+ stateDir: opts.stateDir,
254
+ configPath: context.configPath,
255
+ config: context.config,
256
+ clickhouse: context.config.clickhouse,
257
+ print: context.print,
258
+ jsonMode: context.jsonMode,
259
+ });
260
+ },
261
+ }),
99
262
  },
100
263
  {
101
264
  name: 'resume',
102
265
  description: 'Resume a backfill run from last checkpoint',
103
266
  flags: RESUME_FLAGS,
104
- async run({ flags, jsonMode, print, options: runtimeOptions, config, configPath }) {
105
- return wrapPluginRun({
106
- command: 'resume',
107
- label: 'Backfill resume',
108
- jsonMode,
109
- print,
110
- configErrorClass: BackfillConfigError,
111
- fn: async () => {
112
- const parsed = parseResumeArgs(flags);
113
- const effectiveOptions = mergeOptions(base, runtimeOptions);
114
- validateBaseOptions(effectiveOptions);
115
- const output = await resumeBackfillRun({
116
- planId: parsed.planId,
117
- config,
118
- configPath,
119
- options: effectiveOptions,
120
- execution: {
121
- replayDone: parsed.replayDone,
122
- replayFailed: parsed.replayFailed,
123
- forceOverlap: parsed.forceOverlap,
124
- forceCompatibility: parsed.forceCompatibility,
125
- },
126
- });
127
- const payload = {
128
- ...runPayload(output),
129
- command: 'resume',
130
- };
131
- if (jsonMode) {
132
- print(payload);
267
+ optionsSchema: resumeOptionsSchema,
268
+ flagMapping: RESUME_FLAG_MAP,
269
+ run: async (context) => wrapPluginRun({
270
+ command: 'resume',
271
+ label: 'Backfill resume',
272
+ jsonMode: context.jsonMode,
273
+ print: context.print,
274
+ configErrorClass: BackfillConfigError,
275
+ fn: async () => {
276
+ const opts = context.options;
277
+ if (!context.config.clickhouse) {
278
+ throw new BackfillConfigError('ClickHouse connection is required for backfill execution. Configure clickhouse in your clickhouse.config.ts.');
279
+ }
280
+ const { stateDir } = await readPlan({
281
+ planId: opts.planId,
282
+ configPath: context.configPath,
283
+ config: context.config,
284
+ stateDir: opts.stateDir,
285
+ });
286
+ const paths = backfillPaths(stateDir, opts.planId);
287
+ const existingRun = await readRun(paths.runPath);
288
+ if (!existingRun) {
289
+ throw new BackfillConfigError(`Run state not found for plan ${opts.planId}. Start with backfill run before resume.`);
290
+ }
291
+ if (existingRun.status === 'completed') {
292
+ if (context.jsonMode) {
293
+ context.print({ ok: true, noop: true, planId: opts.planId, status: 'completed', message: 'Run already completed. Nothing to resume.' });
133
294
  }
134
295
  else {
135
- print(`Backfill resume ${payload.planId}: ${payload.status} (done=${payload.chunkCounts.done}/${payload.chunkCounts.total})`);
296
+ context.print(`Backfill ${opts.planId}: already completed. Nothing to resume.`);
136
297
  }
137
- return payload.ok ? 0 : 1;
138
- },
139
- });
140
- },
298
+ return 0;
299
+ }
300
+ return runBackfill({
301
+ planId: opts.planId,
302
+ forceEnvironment: opts.forceEnvironment,
303
+ concurrency: opts.concurrency,
304
+ pollIntervalMs: opts.pollIntervalMs,
305
+ stateDir: opts.stateDir,
306
+ resumeFrom: existingRun.progress,
307
+ replayFailed: opts.replayFailed,
308
+ configPath: context.configPath,
309
+ config: context.config,
310
+ clickhouse: context.config.clickhouse,
311
+ print: context.print,
312
+ jsonMode: context.jsonMode,
313
+ });
314
+ },
315
+ }),
141
316
  },
142
317
  {
143
318
  name: 'status',
144
319
  description: 'Show checkpoint and chunk progress for a backfill run',
145
320
  flags: PLAN_ID_FLAGS,
146
- async run({ flags, jsonMode, print, options: runtimeOptions, config, configPath }) {
147
- return wrapPluginRun({
148
- command: 'status',
149
- label: 'Backfill status',
150
- jsonMode,
151
- print,
152
- configErrorClass: BackfillConfigError,
153
- fn: async () => {
154
- const parsed = parseStatusArgs(flags);
155
- const effectiveOptions = mergeOptions(base, runtimeOptions);
156
- validateBaseOptions(effectiveOptions);
157
- const summary = await getBackfillStatus({
158
- planId: parsed.planId,
159
- config,
160
- configPath,
161
- options: effectiveOptions,
162
- });
163
- const payload = statusPayload(summary);
164
- if (jsonMode) {
165
- print(payload);
166
- }
167
- else {
168
- print(`Backfill status ${payload.planId}: ${payload.status} (done=${payload.chunkCounts.done}/${payload.chunkCounts.total}, failed=${payload.chunkCounts.failed})`);
321
+ optionsSchema: statusOptionsSchema,
322
+ flagMapping: PLAN_ID_FLAG_MAP,
323
+ run: async (context) => wrapPluginRun({
324
+ command: 'status',
325
+ label: 'Backfill status',
326
+ jsonMode: context.jsonMode,
327
+ print: context.print,
328
+ configErrorClass: BackfillConfigError,
329
+ fn: async () => {
330
+ const opts = context.options;
331
+ const summary = await getBackfillStatus({
332
+ planId: opts.planId,
333
+ config: context.config,
334
+ configPath: context.configPath,
335
+ stateDir: opts.stateDir,
336
+ });
337
+ const payload = statusPayload(summary);
338
+ if (context.jsonMode) {
339
+ context.print(payload);
340
+ }
341
+ else {
342
+ let line = `Backfill status ${payload.planId}: ${payload.status} (done=${payload.chunkCounts.done}/${payload.chunkCounts.total}, failed=${payload.chunkCounts.failed}, ${payload.rowsWritten} rows written)`;
343
+ if (payload.lastError)
344
+ line += ` \u2014 ${payload.lastError}`;
345
+ context.print(line);
346
+ if (payload.status === 'completed' && payload.rowsWritten === 0) {
347
+ context.print('Warning: 0 rows written across all chunks. Verify that source data exists in the time range and passes the query\'s WHERE filters.');
169
348
  }
170
- return payload.ok ? 0 : 1;
171
- },
172
- });
173
- },
349
+ }
350
+ return payload.ok ? 0 : 1;
351
+ },
352
+ }),
174
353
  },
175
354
  {
176
355
  name: 'cancel',
177
356
  description: 'Cancel an in-progress backfill run and prevent further chunk execution',
178
357
  flags: PLAN_ID_FLAGS,
179
- async run({ flags, jsonMode, print, options: runtimeOptions, config, configPath }) {
180
- return wrapPluginRun({
181
- command: 'cancel',
182
- label: 'Backfill cancel',
183
- jsonMode,
184
- print,
185
- configErrorClass: BackfillConfigError,
186
- fn: async () => {
187
- const parsed = parseCancelArgs(flags);
188
- const effectiveOptions = mergeOptions(base, runtimeOptions);
189
- validateBaseOptions(effectiveOptions);
190
- const summary = await cancelBackfillRun({
191
- planId: parsed.planId,
192
- config,
193
- configPath,
194
- options: effectiveOptions,
195
- });
196
- const payload = cancelPayload(summary);
197
- if (jsonMode) {
198
- print(payload);
199
- }
200
- else {
201
- print(`Backfill cancel ${payload.planId}: ${payload.status} (done=${payload.chunkCounts.done}/${payload.chunkCounts.total})`);
202
- }
203
- return payload.ok ? 0 : 1;
204
- },
205
- });
206
- },
358
+ optionsSchema: statusOptionsSchema,
359
+ flagMapping: PLAN_ID_FLAG_MAP,
360
+ run: async (context) => wrapPluginRun({
361
+ command: 'cancel',
362
+ label: 'Backfill cancel',
363
+ jsonMode: context.jsonMode,
364
+ print: context.print,
365
+ configErrorClass: BackfillConfigError,
366
+ fn: async () => {
367
+ const opts = context.options;
368
+ const summary = await cancelBackfillRun({
369
+ planId: opts.planId,
370
+ config: context.config,
371
+ configPath: context.configPath,
372
+ stateDir: opts.stateDir,
373
+ });
374
+ const payload = cancelPayload(summary);
375
+ if (context.jsonMode) {
376
+ context.print(payload);
377
+ }
378
+ else {
379
+ context.print(`Backfill cancel ${payload.planId}: ${payload.status} (done=${payload.chunkCounts.done}/${payload.chunkCounts.total})`);
380
+ }
381
+ return payload.ok ? 0 : 1;
382
+ },
383
+ }),
207
384
  },
208
385
  {
209
386
  name: 'doctor',
210
387
  description: 'Provide actionable remediation steps for failed or pending backfill runs',
211
388
  flags: PLAN_ID_FLAGS,
212
- async run({ flags, jsonMode, print, options: runtimeOptions, config, configPath }) {
213
- return wrapPluginRun({
214
- command: 'doctor',
215
- label: 'Backfill doctor',
216
- jsonMode,
217
- print,
218
- configErrorClass: BackfillConfigError,
219
- fn: async () => {
220
- const parsed = parseDoctorArgs(flags);
221
- const effectiveOptions = mergeOptions(base, runtimeOptions);
222
- validateBaseOptions(effectiveOptions);
223
- const report = await getBackfillDoctorReport({
224
- planId: parsed.planId,
225
- config,
226
- configPath,
227
- options: effectiveOptions,
228
- });
229
- const payload = doctorPayload(report);
230
- if (jsonMode) {
231
- print(payload);
232
- }
233
- else {
234
- print(`Backfill doctor ${payload.planId}: ${payload.issueCodes.length === 0 ? 'ok' : payload.issueCodes.join(', ')}`);
235
- for (const recommendation of payload.recommendations) {
236
- print(`- ${recommendation}`);
237
- }
389
+ optionsSchema: statusOptionsSchema,
390
+ flagMapping: PLAN_ID_FLAG_MAP,
391
+ run: async (context) => wrapPluginRun({
392
+ command: 'doctor',
393
+ label: 'Backfill doctor',
394
+ jsonMode: context.jsonMode,
395
+ print: context.print,
396
+ configErrorClass: BackfillConfigError,
397
+ fn: async () => {
398
+ const opts = context.options;
399
+ const report = await getBackfillDoctorReport({
400
+ planId: opts.planId,
401
+ config: context.config,
402
+ configPath: context.configPath,
403
+ stateDir: opts.stateDir,
404
+ });
405
+ const payload = doctorPayload(report);
406
+ if (context.jsonMode) {
407
+ context.print(payload);
408
+ }
409
+ else {
410
+ context.print(`Backfill doctor ${payload.planId}: ${payload.issueCodes.length === 0 ? 'ok' : payload.issueCodes.join(', ')}`);
411
+ for (const recommendation of payload.recommendations) {
412
+ context.print(`- ${recommendation}`);
238
413
  }
239
- return payload.ok ? 0 : 1;
240
- },
241
- });
242
- },
414
+ }
415
+ return payload.ok ? 0 : 1;
416
+ },
417
+ }),
243
418
  },
244
419
  ],
245
420
  hooks: {
246
- onConfigLoaded({ options: runtimeOptions }) {
247
- const merged = mergeOptions(base, runtimeOptions);
248
- validateBaseOptions(merged);
421
+ onConfigLoaded() {
422
+ // Plugin-level options validated softly at load time via PluginConfigSchema.
249
423
  },
250
- async onCheck({ config, configPath, options: runtimeOptions }) {
251
- const effectiveOptions = mergeOptions(base, runtimeOptions);
252
- validateBaseOptions(effectiveOptions);
424
+ async onCheck({ config: appConfig, configPath, options }) {
425
+ const opts = CheckSchema.parse(options);
253
426
  return evaluateBackfillCheck({
254
427
  configPath,
255
- config,
256
- options: effectiveOptions,
428
+ config: appConfig,
429
+ stateDir: opts.stateDir,
430
+ failCheckOnRequiredPendingBackfill: opts.failCheckOnRequiredPendingBackfill,
257
431
  });
258
432
  },
259
433
  onCheckReport({ result, print }) {
@@ -269,7 +443,7 @@ export function createBackfillPlugin(options = {}) {
269
443
  }
270
444
  export function backfill(options = {}) {
271
445
  return {
272
- plugin: createBackfillPlugin(),
446
+ plugin: createBackfillPlugin(options),
273
447
  name: 'backfill',
274
448
  enabled: true,
275
449
  options,