@malloy-publisher/sdk 0.0.227 → 0.0.229

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 (62) hide show
  1. package/dist/{ServerProvider-BnQo3hhW.es.js → ServerProvider-BdQK_q3C.es.js} +780 -715
  2. package/dist/ServerProvider-n4SO1thp.cjs.js +1 -0
  3. package/dist/client/api.d.ts +79 -1
  4. package/dist/client/index.cjs.js +1 -1
  5. package/dist/client/index.es.js +1 -1
  6. package/dist/components/Materializations/CreateEnvMaterializationDialog.d.ts +14 -0
  7. package/dist/components/Materializations/EnvironmentMaterializations.d.ts +7 -0
  8. package/dist/components/Materializations/MaterializationDetailDialog.d.ts +2 -1
  9. package/dist/components/Materializations/MaterializationRunsList.d.ts +5 -1
  10. package/dist/components/Materializations/ScheduleCard.d.ts +20 -0
  11. package/dist/components/Materializations/SectionLabel.d.ts +10 -0
  12. package/dist/components/Materializations/SetScheduleDialog.d.ts +11 -0
  13. package/dist/components/Materializations/TriggerChip.d.ts +10 -0
  14. package/dist/components/Materializations/cron.d.ts +23 -0
  15. package/dist/components/Materializations/index.d.ts +1 -0
  16. package/dist/components/Materializations/utils.d.ts +9 -0
  17. package/dist/components/Notebook/Notebook.d.ts +6 -1
  18. package/dist/components/Notebook/NotebookCell.d.ts +3 -1
  19. package/dist/components/RenderedResult/ResultContainer.d.ts +3 -1
  20. package/dist/components/RenderedResult/renderLogs.d.ts +6 -0
  21. package/dist/components/ResultsDialog.d.ts +3 -1
  22. package/dist/constants/docLinks.d.ts +6 -0
  23. package/dist/{core-CuGbFNhB.cjs.js → core-BJUrppec.cjs.js} +1 -1
  24. package/dist/{core-vVgoO8IR.es.js → core-Coi3caGs.es.js} +1 -1
  25. package/dist/index-DWcuaQOW.cjs.js +309 -0
  26. package/dist/{index-Br214Q_W.es.js → index-mbDUet0T.es.js} +34625 -25301
  27. package/dist/index.cjs.js +1 -1
  28. package/dist/index.d.ts +1 -0
  29. package/dist/index.es.js +54 -52
  30. package/dist/utils/queryClient.d.ts +5 -0
  31. package/package.json +3 -1
  32. package/src/components/Environment/AddPackageDialog.tsx +2 -1
  33. package/src/components/Environment/Environment.tsx +8 -0
  34. package/src/components/Home/Home.tsx +5 -4
  35. package/src/components/Materializations/CreateEnvMaterializationDialog.tsx +176 -0
  36. package/src/components/Materializations/EnvironmentMaterializations.tsx +264 -0
  37. package/src/components/Materializations/ManifestView.tsx +2 -3
  38. package/src/components/Materializations/MaterializationDetailDialog.tsx +121 -95
  39. package/src/components/Materializations/MaterializationRunsList.tsx +37 -0
  40. package/src/components/Materializations/Materializations.tsx +86 -1
  41. package/src/components/Materializations/ScheduleCard.tsx +228 -0
  42. package/src/components/Materializations/SectionLabel.tsx +27 -0
  43. package/src/components/Materializations/SetScheduleDialog.tsx +159 -0
  44. package/src/components/Materializations/TriggerChip.tsx +41 -0
  45. package/src/components/Materializations/cron.ts +121 -0
  46. package/src/components/Materializations/index.ts +1 -0
  47. package/src/components/Materializations/utils.ts +14 -0
  48. package/src/components/Model/ModelCell.tsx +4 -0
  49. package/src/components/Notebook/Notebook.tsx +14 -1
  50. package/src/components/Notebook/NotebookCell.tsx +103 -2
  51. package/src/components/Package/Package.tsx +14 -3
  52. package/src/components/QueryResult/QueryResult.tsx +7 -1
  53. package/src/components/RenderedResult/RenderedResult.tsx +153 -51
  54. package/src/components/RenderedResult/ResultContainer.tsx +40 -1
  55. package/src/components/RenderedResult/renderLogs.spec.ts +55 -0
  56. package/src/components/RenderedResult/renderLogs.ts +40 -0
  57. package/src/components/ResultsDialog.tsx +4 -0
  58. package/src/constants/docLinks.ts +17 -0
  59. package/src/index.ts +1 -0
  60. package/src/utils/queryClient.ts +14 -0
  61. package/dist/ServerProvider-CCKhmHeT.cjs.js +0 -1
  62. package/dist/index-CK7FeJKR.cjs.js +0 -307
@@ -9,7 +9,11 @@ import {
9
9
  } from "@mui/material";
10
10
  import { useQueryClient } from "@tanstack/react-query";
11
11
  import React, { useState } from "react";
12
- import { Materialization, MaterializationActionActionEnum } from "../../client";
12
+ import {
13
+ Materialization,
14
+ MaterializationActionActionEnum,
15
+ PackageScopeEnum,
16
+ } from "../../client";
13
17
  import {
14
18
  useMutationWithApiError,
15
19
  useQueryWithApiError,
@@ -21,6 +25,7 @@ import { useServer } from "../ServerProvider";
21
25
  import CreateMaterializationDialog from "./CreateMaterializationDialog";
22
26
  import MaterializationDetailDialog from "./MaterializationDetailDialog";
23
27
  import MaterializationRunsList from "./MaterializationRunsList";
28
+ import ScheduleCard from "./ScheduleCard";
24
29
  import { isActiveStatus } from "./utils";
25
30
 
26
31
  const MATERIALIZATION_POLL_MS = 3000;
@@ -144,6 +149,60 @@ export default function Materializations({
144
149
  },
145
150
  });
146
151
 
152
+ const currentPackage = packageQuery.data?.data;
153
+
154
+ // Edit the package's materialization.schedule (persisted to publisher.json).
155
+ // A schedule is legal only on a version-scoped package, so enabling one also
156
+ // sets scope: version; clearing (null) leaves scope untouched — scope is an
157
+ // explicit control (updateScope below), so clearing a schedule no longer
158
+ // strands scope: version with no way back. The running scheduler re-arms from
159
+ // the new cron on its next tick — no reload needed.
160
+ const updateSchedule = useMutationWithApiError({
161
+ mutationFn: (schedule: string | null) =>
162
+ apiClients.packages.updatePackage(environmentName, packageName, {
163
+ name: packageName,
164
+ // updatePackage overwrites description from the body — carry the
165
+ // current value through so a schedule edit doesn't drop it.
166
+ description: currentPackage?.description,
167
+ ...(schedule ? { scope: PackageScopeEnum.Version } : {}),
168
+ materialization: { schedule },
169
+ }),
170
+ onSuccess(_data, schedule) {
171
+ setNotificationMessage(
172
+ schedule ? "Schedule updated" : "Schedule cleared",
173
+ );
174
+ queryClient.invalidateQueries({
175
+ queryKey: ["package", environmentName, packageName],
176
+ });
177
+ },
178
+ onError(error) {
179
+ setNotificationMessage(error.message);
180
+ },
181
+ });
182
+
183
+ // Set the persist scope explicitly (package | version). Independent of the
184
+ // schedule so the version flip a schedule requires can be undone after the
185
+ // schedule is cleared. The server rejects scope: package while a schedule is
186
+ // still set (publish-gate Rule 2), so the UI only offers this when no
187
+ // schedule is active.
188
+ const updateScope = useMutationWithApiError({
189
+ mutationFn: (scope: PackageScopeEnum) =>
190
+ apiClients.packages.updatePackage(environmentName, packageName, {
191
+ name: packageName,
192
+ description: currentPackage?.description,
193
+ scope,
194
+ }),
195
+ onSuccess() {
196
+ setNotificationMessage("Scope updated");
197
+ queryClient.invalidateQueries({
198
+ queryKey: ["package", environmentName, packageName],
199
+ });
200
+ },
201
+ onError(error) {
202
+ setNotificationMessage(error.message);
203
+ },
204
+ });
205
+
147
206
  const materializations = (listQuery.data?.data ?? []) as Materialization[];
148
207
  const selected =
149
208
  materializations.find((row) => row.id === selectedId) ?? null;
@@ -213,6 +272,32 @@ export default function Materializations({
213
272
  </Stack>
214
273
  </Box>
215
274
 
275
+ {packageQuery.isSuccess && (
276
+ <ScheduleCard
277
+ schedule={currentPackage?.materialization?.schedule ?? null}
278
+ scope={
279
+ currentPackage?.scope === PackageScopeEnum.Version
280
+ ? "version"
281
+ : "package"
282
+ }
283
+ manifestLocation={currentPackage?.manifestLocation ?? null}
284
+ hasFreshness={Boolean(
285
+ currentPackage?.materialization?.freshness,
286
+ )}
287
+ mutable={mutable}
288
+ isSubmitting={updateSchedule.isPending}
289
+ isScopeMutating={updateScope.isPending}
290
+ onSubmit={(schedule) => updateSchedule.mutateAsync(schedule)}
291
+ onScopeChange={(scope) =>
292
+ updateScope.mutateAsync(
293
+ scope === "version"
294
+ ? PackageScopeEnum.Version
295
+ : PackageScopeEnum.Package,
296
+ )
297
+ }
298
+ />
299
+ )}
300
+
216
301
  <Box sx={{ mb: 6 }}>
217
302
  <Typography
218
303
  variant="h6"
@@ -0,0 +1,228 @@
1
+ import ScheduleIcon from "@mui/icons-material/Schedule";
2
+ import {
3
+ Box,
4
+ Card,
5
+ CardContent,
6
+ Chip,
7
+ Stack,
8
+ ToggleButton,
9
+ ToggleButtonGroup,
10
+ Tooltip,
11
+ Typography,
12
+ } from "@mui/material";
13
+ import { MONO_FONT_FAMILY } from "../styles";
14
+ import { describeCron, formatNextRun } from "./cron";
15
+ import SetScheduleDialog from "./SetScheduleDialog";
16
+
17
+ type Scope = "package" | "version";
18
+
19
+ type ScheduleCardProps = {
20
+ /** The package's cron, or null when no schedule is declared. */
21
+ schedule: string | null;
22
+ /** The package's persist scope. */
23
+ scope: Scope;
24
+ /** When set, the package is control-plane-driven (orchestrated). */
25
+ manifestLocation: string | null;
26
+ /** True when the package declares a freshness policy (schedule N/A). */
27
+ hasFreshness: boolean;
28
+ mutable: boolean;
29
+ isSubmitting: boolean;
30
+ /** True while a scope change is in flight. */
31
+ isScopeMutating?: boolean;
32
+ onSubmit: (schedule: string | null) => Promise<unknown>;
33
+ /** Persist a new scope (package | version). */
34
+ onScopeChange?: (scope: Scope) => Promise<unknown>;
35
+ };
36
+
37
+ export default function ScheduleCard({
38
+ schedule,
39
+ scope,
40
+ manifestLocation,
41
+ hasFreshness,
42
+ mutable,
43
+ isSubmitting,
44
+ isScopeMutating,
45
+ onSubmit,
46
+ onScopeChange,
47
+ }: ScheduleCardProps) {
48
+ const orchestrated = Boolean(manifestLocation);
49
+ const info = schedule ? describeCron(schedule) : null;
50
+ // A schedule requires version scope, so scope is locked while one is set —
51
+ // the server would reject scope: package (publish-gate Rule 2). Clearing the
52
+ // schedule unlocks it, giving an explicit way back to package scope.
53
+ const scopeLocked = Boolean(schedule);
54
+
55
+ return (
56
+ <Card variant="outlined" sx={{ borderRadius: 2, mb: 6 }}>
57
+ <CardContent>
58
+ <Stack
59
+ direction="row"
60
+ justifyContent="space-between"
61
+ alignItems="flex-start"
62
+ spacing={2}
63
+ >
64
+ <Box sx={{ minWidth: 0 }}>
65
+ <Stack
66
+ direction="row"
67
+ alignItems="center"
68
+ spacing={1}
69
+ sx={{ mb: 1 }}
70
+ >
71
+ <ScheduleIcon fontSize="small" color="action" />
72
+ <Typography
73
+ variant="h6"
74
+ sx={{ fontWeight: 600, letterSpacing: "-0.025em" }}
75
+ >
76
+ Schedule
77
+ </Typography>
78
+ </Stack>
79
+
80
+ {orchestrated ? (
81
+ <Box>
82
+ <Chip
83
+ size="small"
84
+ color="default"
85
+ variant="outlined"
86
+ label="Control-plane managed"
87
+ sx={{ mb: 1 }}
88
+ />
89
+ <Typography variant="body2" color="text.secondary">
90
+ Refresh is driven by the control plane; the built-in
91
+ scheduler does not fire this package.
92
+ </Typography>
93
+ </Box>
94
+ ) : info ? (
95
+ <Box>
96
+ <Stack
97
+ direction="row"
98
+ alignItems="center"
99
+ spacing={1}
100
+ sx={{ mb: 0.5, flexWrap: "wrap" }}
101
+ >
102
+ <Chip
103
+ size="small"
104
+ color="success"
105
+ variant="outlined"
106
+ icon={<ScheduleIcon fontSize="small" />}
107
+ label="Active"
108
+ />
109
+ <Box
110
+ component="code"
111
+ sx={{
112
+ fontFamily: MONO_FONT_FAMILY,
113
+ fontSize: "0.8125rem",
114
+ px: 0.75,
115
+ py: 0.25,
116
+ borderRadius: 1,
117
+ bgcolor: "action.hover",
118
+ }}
119
+ >
120
+ {schedule}
121
+ </Box>
122
+ </Stack>
123
+ <Typography variant="body2">
124
+ {info.valid
125
+ ? info.description
126
+ : "Unrecognized cron expression."}
127
+ </Typography>
128
+ {info.valid && (
129
+ <Typography variant="body2" color="text.secondary">
130
+ Next run: {formatNextRun(info.nextRun)}
131
+ </Typography>
132
+ )}
133
+ </Box>
134
+ ) : hasFreshness ? (
135
+ <Typography variant="body2" color="text.secondary">
136
+ This package uses a freshness policy; a cron schedule is
137
+ not applicable.
138
+ </Typography>
139
+ ) : (
140
+ <Typography variant="body2" color="text.secondary">
141
+ On-demand only — no schedule set.
142
+ </Typography>
143
+ )}
144
+
145
+ {!orchestrated && (
146
+ <Box sx={{ mt: 2 }}>
147
+ <Typography
148
+ variant="caption"
149
+ color="text.secondary"
150
+ sx={{ display: "block", mb: 0.5 }}
151
+ >
152
+ Scope
153
+ </Typography>
154
+ <Tooltip
155
+ title={
156
+ scopeLocked
157
+ ? "A schedule requires version scope. Clear the schedule to change it."
158
+ : ""
159
+ }
160
+ >
161
+ <span>
162
+ <ToggleButtonGroup
163
+ size="small"
164
+ exclusive
165
+ value={scope}
166
+ disabled={
167
+ !mutable ||
168
+ !onScopeChange ||
169
+ scopeLocked ||
170
+ Boolean(isScopeMutating)
171
+ }
172
+ onChange={(_e, next: Scope | null) => {
173
+ if (next && next !== scope) {
174
+ onScopeChange?.(next);
175
+ }
176
+ }}
177
+ >
178
+ <ToggleButton value="package">
179
+ package
180
+ </ToggleButton>
181
+ <ToggleButton value="version">
182
+ version
183
+ </ToggleButton>
184
+ </ToggleButtonGroup>
185
+ </span>
186
+ </Tooltip>
187
+ <Typography
188
+ variant="caption"
189
+ color="text.secondary"
190
+ sx={{ display: "block", mt: 0.5 }}
191
+ >
192
+ {scope === "version"
193
+ ? "Materialized artifacts are per version (required for a schedule)."
194
+ : "Materialized artifacts are shared across versions."}
195
+ </Typography>
196
+ </Box>
197
+ )}
198
+
199
+ <Typography
200
+ variant="caption"
201
+ color="text.secondary"
202
+ sx={{ display: "block", mt: 1.5 }}
203
+ >
204
+ Declared in <code>publisher.json</code>.
205
+ {!orchestrated && (
206
+ <>
207
+ {" "}
208
+ In a hosted deployment, a change takes effect on your
209
+ next publish.
210
+ </>
211
+ )}
212
+ </Typography>
213
+ </Box>
214
+
215
+ {mutable && !orchestrated && (
216
+ <SetScheduleDialog
217
+ currentSchedule={schedule}
218
+ isSubmitting={isSubmitting}
219
+ disabled={hasFreshness}
220
+ disabledReason="This package declares a freshness policy; a schedule and freshness are mutually exclusive."
221
+ onSubmit={onSubmit}
222
+ />
223
+ )}
224
+ </Stack>
225
+ </CardContent>
226
+ </Card>
227
+ );
228
+ }
@@ -0,0 +1,27 @@
1
+ import { Typography } from "@mui/material";
2
+ import { ReactNode } from "react";
3
+
4
+ /**
5
+ * Small uppercase section heading for consistent hierarchy across the
6
+ * materialization views (detail dialog, manifest view). One source of truth for
7
+ * the style so the headings can't drift. Renders as an <h6> so it is a real
8
+ * heading (role="heading") — the caption styling is purely visual.
9
+ */
10
+ export default function SectionLabel({ children }: { children: ReactNode }) {
11
+ return (
12
+ <Typography
13
+ variant="caption"
14
+ component="h6"
15
+ sx={{
16
+ display: "block",
17
+ textTransform: "uppercase",
18
+ letterSpacing: "0.06em",
19
+ fontWeight: 600,
20
+ color: "text.secondary",
21
+ mb: 1,
22
+ }}
23
+ >
24
+ {children}
25
+ </Typography>
26
+ );
27
+ }
@@ -0,0 +1,159 @@
1
+ import ScheduleIcon from "@mui/icons-material/Schedule";
2
+ import {
3
+ Alert,
4
+ Box,
5
+ Button,
6
+ Dialog,
7
+ DialogActions,
8
+ DialogContent,
9
+ DialogContentText,
10
+ DialogTitle,
11
+ TextField,
12
+ Tooltip,
13
+ Typography,
14
+ } from "@mui/material";
15
+ import { useState } from "react";
16
+ import { MONO_FONT_FAMILY } from "../styles";
17
+ import { describeCron, formatNextRun } from "./cron";
18
+
19
+ type SetScheduleDialogProps = {
20
+ /** The current cron, or null when no schedule is set. */
21
+ currentSchedule: string | null;
22
+ isSubmitting: boolean;
23
+ disabled?: boolean;
24
+ disabledReason?: string;
25
+ /** Persist a new cron, or null to clear the schedule. */
26
+ onSubmit: (schedule: string | null) => Promise<unknown>;
27
+ };
28
+
29
+ const DEFAULT_CRON = "0 6 * * *";
30
+
31
+ export default function SetScheduleDialog({
32
+ currentSchedule,
33
+ isSubmitting,
34
+ disabled,
35
+ disabledReason,
36
+ onSubmit,
37
+ }: SetScheduleDialogProps) {
38
+ const [open, setOpen] = useState(false);
39
+ const [expr, setExpr] = useState(currentSchedule ?? DEFAULT_CRON);
40
+
41
+ const handleOpen = () => {
42
+ setExpr(currentSchedule ?? DEFAULT_CRON);
43
+ setOpen(true);
44
+ };
45
+ const handleClose = () => setOpen(false);
46
+
47
+ const info = describeCron(expr);
48
+
49
+ const submit = async (schedule: string | null) => {
50
+ try {
51
+ await onSubmit(schedule);
52
+ setOpen(false);
53
+ } catch {
54
+ // The caller surfaces the error via its Snackbar; keep the dialog
55
+ // open so the user can correct the cron and retry.
56
+ }
57
+ };
58
+
59
+ const button = (
60
+ <span>
61
+ <Button
62
+ variant="outlined"
63
+ size="small"
64
+ startIcon={<ScheduleIcon />}
65
+ onClick={handleOpen}
66
+ disabled={disabled}
67
+ aria-label={currentSchedule ? "Edit schedule" : "Set schedule"}
68
+ >
69
+ {currentSchedule ? "Edit schedule" : "Set schedule"}
70
+ </Button>
71
+ </span>
72
+ );
73
+
74
+ return (
75
+ <>
76
+ {disabled && disabledReason ? (
77
+ <Tooltip title={disabledReason}>{button}</Tooltip>
78
+ ) : (
79
+ button
80
+ )}
81
+
82
+ <Dialog
83
+ open={open}
84
+ onClose={handleClose}
85
+ maxWidth="xs"
86
+ fullWidth
87
+ aria-labelledby="set-schedule-title"
88
+ >
89
+ <DialogTitle id="set-schedule-title">
90
+ {currentSchedule ? "Edit schedule" : "Set schedule"}
91
+ </DialogTitle>
92
+ <DialogContent>
93
+ <DialogContentText sx={{ mb: 2 }}>
94
+ The publisher rebuilds the materializations in this package on
95
+ this cadence. Enter a 5-field UNIX cron, evaluated in UTC. In
96
+ a hosted deployment, the change takes effect on your next
97
+ publish.
98
+ </DialogContentText>
99
+ <TextField
100
+ autoFocus
101
+ fullWidth
102
+ label="Cron expression"
103
+ value={expr}
104
+ onChange={(event) => setExpr(event.target.value)}
105
+ error={expr.trim() !== "" && !info.valid}
106
+ slotProps={{
107
+ htmlInput: { style: { fontFamily: MONO_FONT_FAMILY } },
108
+ }}
109
+ />
110
+ <Box sx={{ mt: 1.5, minHeight: 48 }}>
111
+ {info.valid ? (
112
+ <>
113
+ <Typography variant="body2">
114
+ {info.description}
115
+ </Typography>
116
+ <Typography variant="body2" color="text.secondary">
117
+ Next run: {formatNextRun(info.nextRun)}
118
+ </Typography>
119
+ </>
120
+ ) : (
121
+ <Typography variant="body2" color="error">
122
+ {info.error}
123
+ </Typography>
124
+ )}
125
+ </Box>
126
+ <Alert severity="info" sx={{ mt: 2 }}>
127
+ A schedule runs only on version-scoped packages, so saving one
128
+ sets <code>scope: version</code> for this package.
129
+ </Alert>
130
+ </DialogContent>
131
+ <DialogActions sx={{ justifyContent: "space-between", px: 3 }}>
132
+ <Box>
133
+ {currentSchedule && (
134
+ <Button
135
+ color="error"
136
+ loading={isSubmitting}
137
+ onClick={() => submit(null)}
138
+ >
139
+ Clear schedule
140
+ </Button>
141
+ )}
142
+ </Box>
143
+ <Box>
144
+ <Button onClick={handleClose}>Cancel</Button>
145
+ <Button
146
+ variant="contained"
147
+ loading={isSubmitting}
148
+ disabled={!info.valid}
149
+ onClick={() => submit(expr.trim())}
150
+ sx={{ ml: 1 }}
151
+ >
152
+ Save
153
+ </Button>
154
+ </Box>
155
+ </DialogActions>
156
+ </Dialog>
157
+ </>
158
+ );
159
+ }
@@ -0,0 +1,41 @@
1
+ import ScheduleIcon from "@mui/icons-material/Schedule";
2
+ import TouchAppIcon from "@mui/icons-material/TouchApp";
3
+ import { Chip, Tooltip } from "@mui/material";
4
+ import { MaterializationMetadata, triggerLabel } from "./utils";
5
+
6
+ /**
7
+ * Chip showing how a run was initiated — Scheduled (the standalone scheduler
8
+ * fired the package's cron) vs Manual (an API/UI create) — with a tooltip. One
9
+ * source of truth for the color/icon/label so the runs list and the detail
10
+ * dialog can never drift.
11
+ */
12
+ export default function TriggerChip({
13
+ meta,
14
+ }: {
15
+ meta: MaterializationMetadata;
16
+ }) {
17
+ const scheduled = meta.trigger === "SCHEDULER";
18
+ return (
19
+ <Tooltip
20
+ title={
21
+ scheduled
22
+ ? "Fired by the materialization schedule"
23
+ : "Triggered manually"
24
+ }
25
+ >
26
+ <Chip
27
+ size="small"
28
+ variant="outlined"
29
+ color={scheduled ? "info" : "default"}
30
+ icon={
31
+ scheduled ? (
32
+ <ScheduleIcon fontSize="small" />
33
+ ) : (
34
+ <TouchAppIcon fontSize="small" />
35
+ )
36
+ }
37
+ label={triggerLabel(meta)}
38
+ />
39
+ </Tooltip>
40
+ );
41
+ }
@@ -0,0 +1,121 @@
1
+ import { CronExpressionParser } from "cron-parser";
2
+ import cronstrue from "cronstrue";
3
+
4
+ export interface CronInfo {
5
+ valid: boolean;
6
+ /** Human-readable summary, e.g. "Every minute" (empty when invalid). */
7
+ description: string;
8
+ /** Next fire instant in UTC (null when invalid). */
9
+ nextRun: Date | null;
10
+ /** Reason the expression was rejected (set only when `valid` is false). */
11
+ error?: string;
12
+ }
13
+
14
+ /** Valid 3-letter month and day-of-week names in UNIX cron. */
15
+ const UNIX_CRON_NAMES = new Set([
16
+ "JAN",
17
+ "FEB",
18
+ "MAR",
19
+ "APR",
20
+ "MAY",
21
+ "JUN",
22
+ "JUL",
23
+ "AUG",
24
+ "SEP",
25
+ "OCT",
26
+ "NOV",
27
+ "DEC",
28
+ "SUN",
29
+ "MON",
30
+ "TUE",
31
+ "WED",
32
+ "THU",
33
+ "FRI",
34
+ "SAT",
35
+ ]);
36
+
37
+ /**
38
+ * Reject cron-parser / Quartz extensions absent from plain UNIX cron (`L`, `W`,
39
+ * `#`, `?`). Mirrors the server's `CronEvaluator.isPlainUnixGrammar` so the UI
40
+ * preview flags the same expressions the server (and the control plane) reject,
41
+ * rather than describing a cron that would silently never arm after publish.
42
+ */
43
+ function isPlainUnixGrammar(expr: string): boolean {
44
+ for (const field of expr.trim().split(/\s+/)) {
45
+ if (/[#?]/.test(field)) return false;
46
+ for (const token of field.split(/[,\-/]/)) {
47
+ if (token === "" || token === "*") continue;
48
+ if (
49
+ /[A-Za-z]/.test(token) &&
50
+ !UNIX_CRON_NAMES.has(token.toUpperCase())
51
+ ) {
52
+ return false;
53
+ }
54
+ }
55
+ }
56
+ return true;
57
+ }
58
+
59
+ /**
60
+ * Describe a 5-field UNIX cron for display: a human-readable summary (cronstrue)
61
+ * and the next fire instant (cron-parser, in UTC — matching the publisher's
62
+ * server-side scheduler so the UI shows the same instants it will fire at).
63
+ *
64
+ * The expression is required to be exactly 5 fields and plain UNIX grammar,
65
+ * mirroring the server's `CronEvaluator`: cron-parser also accepts an optional
66
+ * leading seconds field (6 fields) and extensions (L/W/#/?), but the manifest
67
+ * contract is plain 5-field UNIX cron, so those are reported invalid rather
68
+ * than silently reinterpreted (or accepted here yet rejected after publish).
69
+ */
70
+ export function describeCron(expr: string): CronInfo {
71
+ const trimmed = (expr ?? "").trim();
72
+ if (trimmed.split(/\s+/).filter(Boolean).length !== 5) {
73
+ return {
74
+ valid: false,
75
+ description: "",
76
+ nextRun: null,
77
+ error: "Expected a 5-field UNIX cron: minute hour day-of-month month day-of-week.",
78
+ };
79
+ }
80
+ if (!isPlainUnixGrammar(trimmed)) {
81
+ return {
82
+ valid: false,
83
+ description: "",
84
+ nextRun: null,
85
+ error: "Unsupported cron syntax: L, W, #, and ? extensions are not allowed (plain 5-field UNIX cron only).",
86
+ };
87
+ }
88
+ try {
89
+ const description = cronstrue.toString(trimmed, {
90
+ use24HourTimeFormat: true,
91
+ verbose: false,
92
+ });
93
+ const nextRun = CronExpressionParser.parse(trimmed, { tz: "UTC" })
94
+ .next()
95
+ .toDate();
96
+ return { valid: true, description, nextRun };
97
+ } catch (e) {
98
+ return {
99
+ valid: false,
100
+ description: "",
101
+ nextRun: null,
102
+ error: e instanceof Error ? e.message : "Invalid cron expression.",
103
+ };
104
+ }
105
+ }
106
+
107
+ /** Absolute UTC label for a next-run instant, e.g. "Jul 14, 2026, 06:00 UTC". */
108
+ export function formatNextRun(d: Date | null): string {
109
+ if (!d) return "—";
110
+ return (
111
+ d.toLocaleString(undefined, {
112
+ year: "numeric",
113
+ month: "short",
114
+ day: "numeric",
115
+ hour: "2-digit",
116
+ minute: "2-digit",
117
+ timeZone: "UTC",
118
+ hour12: false,
119
+ }) + " UTC"
120
+ );
121
+ }
@@ -1 +1,2 @@
1
1
  export { default as Materializations } from "./Materializations";
2
+ export { default as EnvironmentMaterializations } from "./EnvironmentMaterializations";