@svgrid/enterprise 2.2.1 → 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +95 -81
- package/dist/cdn/svgrid-enterprise.svelte-external.js +22468 -10459
- package/dist/node/studio.js +11590 -2907
- package/package.json +31 -10
- package/src/SvAlertRuleEditor.svelte +294 -0
- package/src/SvAlertsManager.svelte +210 -0
- package/src/SvAlertsPanel.svelte +129 -0
- package/src/SvBoard.svelte +3 -1
- package/src/SvExpressionEditor.svelte +341 -0
- package/src/SvGridAlerts.dom.test.ts +113 -0
- package/src/SvGridAlerts.svelte +265 -0
- package/src/SvGridBoard.svelte +2355 -0
- package/src/SvGridEditPanel.svelte +849 -794
- package/src/SvGridScheduler.svelte +5334 -4410
- package/src/SvPivotDesigner.svelte +3 -3
- package/src/SvRecordDetail.svelte +6 -2
- package/src/SvSchedule.svelte +3 -1
- package/src/{ai-export-pdf.test.ts → ai-export-pdf.dom.test.ts} +4 -1
- package/src/{ai-export-xlsx.test.ts → ai-export-xlsx.dom.test.ts} +4 -1
- package/src/{ai-export.test.ts → ai-export.dom.test.ts} +5 -1
- package/src/alerts/alert-engine-attach.ts +165 -0
- package/src/alerts/alert-engine.test.ts +135 -0
- package/src/alerts/alert-engine.ts +260 -0
- package/src/alerts/alert-formats.test.ts +87 -0
- package/src/alerts/alert-formats.ts +77 -0
- package/src/alerts/alert-observer.test.ts +189 -0
- package/src/alerts/alert-observer.ts +208 -0
- package/src/alerts/alert-scheduler.ts +96 -0
- package/src/alerts/alert-storage.test.ts +54 -0
- package/src/alerts/alert-storage.ts +116 -0
- package/src/alerts/alert-store.svelte.ts +80 -0
- package/src/alerts/alert-types.ts +94 -0
- package/src/alerts.ts +28 -0
- package/src/board.dom.test.ts +941 -0
- package/src/board.ts +36 -0
- package/src/export.ts +6 -0
- package/src/expressions/evaluate.test.ts +111 -0
- package/src/expressions/evaluate.ts +228 -0
- package/src/expressions/expression-columns.ts +133 -0
- package/src/expressions/expression-types.ts +84 -0
- package/src/expressions/parse.test.ts +106 -0
- package/src/expressions/parse.ts +610 -0
- package/src/import.ts +2 -0
- package/src/index.ts +180 -46
- package/src/install.ts +12 -2
- package/src/pivot-enable.ts +44 -0
- package/src/scheduler-assignments.test.ts +97 -0
- package/src/scheduler-assignments.ts +134 -0
- package/src/scheduler-axis.test.ts +108 -0
- package/src/scheduler-axis.ts +238 -0
- package/src/scheduler-booking.test.ts +57 -0
- package/src/scheduler-booking.ts +63 -0
- package/src/scheduler-config.ts +179 -0
- package/src/scheduler-dependencies.test.ts +155 -0
- package/src/scheduler-dependencies.ts +223 -0
- package/src/scheduler-freebusy.test.ts +41 -0
- package/src/scheduler-freebusy.ts +36 -0
- package/src/scheduler-heatmap.test.ts +39 -0
- package/src/scheduler-heatmap.ts +55 -0
- package/src/scheduler-resource-tree.test.ts +84 -0
- package/src/scheduler-resource-tree.ts +107 -0
- package/src/scheduler-slots.test.ts +53 -0
- package/src/scheduler-slots.ts +94 -0
- package/src/scheduler-summary.test.ts +47 -0
- package/src/scheduler-summary.ts +81 -0
- package/src/sources/index.ts +1 -1
- package/src/sources/introspect-supabase.test.ts +13 -1
- package/src/sources/introspect-supabase.ts +20 -0
- package/src/studio/copilot-core.test.ts +45 -0
- package/src/studio/copilot-core.ts +65 -0
- package/src/studio/deploy-cli.test.ts +56 -0
- package/src/studio/deploy-cli.ts +100 -0
- package/src/studio/emit-project.test.ts +503 -18
- package/src/studio/emit-project.ts +829 -111
- package/src/studio/emit-schema.test.ts +17 -0
- package/src/studio/emit-schema.ts +166 -35
- package/src/studio/index.ts +25 -1
- package/src/studio/introspect-openapi.test.ts +84 -0
- package/src/studio/introspect-openapi.ts +252 -0
- package/src/studio/project.test.ts +57 -0
- package/src/studio/project.ts +193 -6
- package/src/studio/samples/crm.ts +282 -258
- package/src/studio/samples/fleet.ts +213 -186
- package/src/studio/samples/insurance.ts +223 -195
- package/src/studio/samples/inventory.ts +213 -182
- package/src/studio/samples/live-data.test.ts +99 -98
- package/src/studio/samples/live-data.ts +8 -10
- package/src/studio/samples/projects.ts +212 -190
- package/src/studio/samples/samples.test.ts +52 -0
- package/src/studio/samples/shared.ts +26 -6
- package/src/studio/samples/starter.ts +251 -0
- package/src/studio/samples/support.ts +209 -184
- package/src/studio/ui-components-surface.test.ts +185 -0
- package/src/studio/ui-components.generated.ts +5617 -0
- package/src/studio/ui-components.ts +641 -472
- package/src/sveltekit/sql-source.test.ts +13 -0
- package/src/sveltekit/sql-source.ts +11 -6
- package/src/upgrade-prompt.ts +2 -2
- package/src/watermark.ts +2 -2
- package/src/ai.test.ts +0 -522
- package/src/ai.ts +0 -1388
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
popIn,
|
|
32
32
|
createDismissableLayer,
|
|
33
33
|
createFocusTrap,
|
|
34
|
+
onScrollOutside,
|
|
34
35
|
type ColumnDef,
|
|
35
36
|
type ChartType,
|
|
36
37
|
type MenuItem,
|
|
@@ -692,9 +693,8 @@
|
|
|
692
693
|
trap.activate()
|
|
693
694
|
const layer = createDismissableLayer({ element: () => ctxPanelEl, onDismiss: closeCtx })
|
|
694
695
|
layer.activate()
|
|
695
|
-
const
|
|
696
|
-
|
|
697
|
-
return () => { layer.release(); trap.release(); window.removeEventListener('scroll', onScroll, true) }
|
|
696
|
+
const offScroll = onScrollOutside(() => ctxPanelEl, closeCtx)
|
|
697
|
+
return () => { layer.release(); trap.release(); offScroll() }
|
|
698
698
|
})
|
|
699
699
|
|
|
700
700
|
function clearWell(well: Well) {
|
|
@@ -19,7 +19,9 @@
|
|
|
19
19
|
type Section = { label: string; fields: string[] }
|
|
20
20
|
type Related = {
|
|
21
21
|
label: string
|
|
22
|
-
|
|
22
|
+
// Accept any specialized EntitySchema<T> (the type param is invariant); this is
|
|
23
|
+
// pure presentation over the schema's field metadata + plain record rows.
|
|
24
|
+
schema: EntitySchema<any>
|
|
23
25
|
rows?: ReadonlyArray<Row>
|
|
24
26
|
/** The child field pointing back at this record. */
|
|
25
27
|
foreignKey: string
|
|
@@ -46,7 +48,9 @@
|
|
|
46
48
|
loading = false,
|
|
47
49
|
height,
|
|
48
50
|
}: {
|
|
49
|
-
|
|
51
|
+
// Accept any specialized EntitySchema<T> (the type param is invariant); this is
|
|
52
|
+
// pure presentation over the schema's field metadata + plain record rows.
|
|
53
|
+
schema: EntitySchema<any>
|
|
50
54
|
rows?: ReadonlyArray<Row>
|
|
51
55
|
/** The specific record to show. Omit to self-drive off `rows`. */
|
|
52
56
|
row?: Row
|
package/src/SvSchedule.svelte
CHANGED
|
@@ -23,7 +23,9 @@
|
|
|
23
23
|
loading = false,
|
|
24
24
|
height,
|
|
25
25
|
}: {
|
|
26
|
-
|
|
26
|
+
// Accept any specialized EntitySchema<T> (the type param is invariant); this is
|
|
27
|
+
// pure presentation over the schema's field metadata + plain record rows.
|
|
28
|
+
schema: EntitySchema<any>
|
|
27
29
|
rows?: ReadonlyArray<Row>
|
|
28
30
|
/** The date / datetime field that places a row on the calendar. */
|
|
29
31
|
dateField: string
|
|
@@ -8,7 +8,8 @@ vi.mock('./export', () => ({
|
|
|
8
8
|
},
|
|
9
9
|
}))
|
|
10
10
|
|
|
11
|
-
import { aiExport, setAIProvider, mockAIProvider } from '
|
|
11
|
+
import { aiExport, setAIProvider, mockAIProvider, registerExportProvider } from '@svgrid/grid'
|
|
12
|
+
import { exportGrid } from './export'
|
|
12
13
|
import { setLicenseKey } from './license'
|
|
13
14
|
|
|
14
15
|
type Order = { country: string; company: string; product: string; quantity: number; price: number }
|
|
@@ -46,6 +47,8 @@ beforeEach(() => {
|
|
|
46
47
|
calls.length = 0
|
|
47
48
|
setLicenseKey('SVENTERPRISE-DEV-TEST')
|
|
48
49
|
setAIProvider(mockAIProvider)
|
|
50
|
+
// aiExport delegates to the registered engine - here the mocked exportGrid.
|
|
51
|
+
registerExportProvider(exportGrid)
|
|
49
52
|
})
|
|
50
53
|
|
|
51
54
|
describe('aiExport - the demo 203 "grouped PDF" query', () => {
|
|
@@ -17,7 +17,8 @@ vi.mock('./export-serialize', async (importOriginal) => {
|
|
|
17
17
|
return { ...actual, downloadBlobFile: () => {}, downloadTextFile: () => {} }
|
|
18
18
|
})
|
|
19
19
|
|
|
20
|
-
import { aiExport, setAIProvider, mockAIProvider } from '
|
|
20
|
+
import { aiExport, setAIProvider, mockAIProvider, registerExportProvider } from '@svgrid/grid'
|
|
21
|
+
import { exportGrid } from './export'
|
|
21
22
|
import { setLicenseKey } from './license'
|
|
22
23
|
|
|
23
24
|
// Mirrors the real makeOrders shape - crucially includes `inStock` (boolean)
|
|
@@ -70,6 +71,8 @@ beforeEach(() => {
|
|
|
70
71
|
captured.parts = {}
|
|
71
72
|
setLicenseKey('SVENTERPRISE-DEV-TEST')
|
|
72
73
|
setAIProvider(mockAIProvider)
|
|
74
|
+
// aiExport is free (grid) and delegates the real write to enterprise's engine.
|
|
75
|
+
registerExportProvider(exportGrid)
|
|
73
76
|
})
|
|
74
77
|
|
|
75
78
|
describe('aiExport - "export orders over $300" (xlsx default)', () => {
|
|
@@ -12,7 +12,8 @@ vi.mock('./export-serialize', async (importOriginal) => {
|
|
|
12
12
|
}
|
|
13
13
|
})
|
|
14
14
|
|
|
15
|
-
import { aiExport, aiFindAnomalies, setAIProvider, mockAIProvider } from '
|
|
15
|
+
import { aiExport, aiFindAnomalies, setAIProvider, mockAIProvider, registerExportProvider } from '@svgrid/grid'
|
|
16
|
+
import { exportGrid } from './export'
|
|
16
17
|
import { setLicenseKey } from './license'
|
|
17
18
|
|
|
18
19
|
type Row = { region: string; amount: number; name: string }
|
|
@@ -44,6 +45,9 @@ beforeEach(() => {
|
|
|
44
45
|
downloads.length = 0
|
|
45
46
|
setLicenseKey('SVENTERPRISE-DEV-TEST')
|
|
46
47
|
setAIProvider(mockAIProvider)
|
|
48
|
+
// aiExport is free (grid) and delegates the WRITE to the registered engine;
|
|
49
|
+
// register enterprise's exportGrid so the download path actually runs.
|
|
50
|
+
registerExportProvider(exportGrid)
|
|
47
51
|
})
|
|
48
52
|
afterEach(() => setAIProvider(null))
|
|
49
53
|
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Headless glue between the pure engine and the running grid.
|
|
3
|
+
*
|
|
4
|
+
* The grid's `onCellValueChange` fires only for edits made through the grid, not
|
|
5
|
+
* for streaming feeds / server pushes / a programmatic `data = ...`. Alerts must
|
|
6
|
+
* react to all of those, so we observe changes by diffing snapshots (the same
|
|
7
|
+
* approach the grid's `attachAutoSavedView` uses for state). `applyAlertEvents`
|
|
8
|
+
* routes fired events
|
|
9
|
+
* to their side effects (toast, log) and returns the visual bits (conditional
|
|
10
|
+
* formats + cells to flash) for the caller to paint. The `<SvGridAlerts>`
|
|
11
|
+
* overlay drives this reactively via `$effect`; `attachAlertEngine` offers the
|
|
12
|
+
* same as a plain-JS interval poll for non-Svelte hosts.
|
|
13
|
+
*/
|
|
14
|
+
import type { ConditionalFormat } from '@svgrid/grid/format'
|
|
15
|
+
import { toast } from '@svgrid/grid'
|
|
16
|
+
import { alertStore } from './alert-store.svelte'
|
|
17
|
+
import { toConditionalFormats } from './alert-formats'
|
|
18
|
+
import { createAlertEngine, type AlertEngine } from './alert-engine'
|
|
19
|
+
import type { AlertEvent, AlertRule } from './alert-types'
|
|
20
|
+
|
|
21
|
+
export type FlashTarget = { rowId: string; columnId?: string }
|
|
22
|
+
|
|
23
|
+
export type ApplyAlertOptions = {
|
|
24
|
+
/**
|
|
25
|
+
* Gate which fired events actually raise a toast. Every event is still logged
|
|
26
|
+
* (the badge/panel stay accurate) - this only suppresses the visible toast, so
|
|
27
|
+
* a busy live feed does not become a toast storm. Return `false` to skip the
|
|
28
|
+
* toast. Defaults to always-toast.
|
|
29
|
+
*/
|
|
30
|
+
shouldToast?: (event: AlertEvent) => boolean
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Perform the side effects of a batch of fired events and return the visual
|
|
35
|
+
* output: every event is recorded in the log (so the badge/panel stay accurate),
|
|
36
|
+
* `toast` actions raise a toast (subject to `shouldToast`), and
|
|
37
|
+
* `badge`/`highlight`/`cellFlash` actions yield formats + flash targets for the
|
|
38
|
+
* caller to apply to the grid.
|
|
39
|
+
*
|
|
40
|
+
* The whole batch lands in the log in ONE reactive write (`pushMany`) so a burst
|
|
41
|
+
* of firings from a single evaluation does not thrash the store.
|
|
42
|
+
*/
|
|
43
|
+
export function applyAlertEvents<TData = Record<string, unknown>>(
|
|
44
|
+
events: ReadonlyArray<AlertEvent>,
|
|
45
|
+
getRowId: (row: TData) => string,
|
|
46
|
+
options: ApplyAlertOptions = {},
|
|
47
|
+
): { formats: ConditionalFormat<TData>[]; flashes: FlashTarget[] } {
|
|
48
|
+
if (events.length === 0) return { formats: [], flashes: [] }
|
|
49
|
+
alertStore.pushMany(events)
|
|
50
|
+
const allow = options.shouldToast
|
|
51
|
+
for (const event of events) {
|
|
52
|
+
if (event.actions.some((a) => a.kind === 'toast') && (!allow || allow(event))) {
|
|
53
|
+
toast[event.severity](event.message)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
const flashes: FlashTarget[] = []
|
|
57
|
+
for (const event of events) {
|
|
58
|
+
if (event.rowId && event.actions.some((a) => a.kind === 'cellFlash')) {
|
|
59
|
+
flashes.push({ rowId: event.rowId, columnId: event.columnId })
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return { formats: toConditionalFormats(events, getRowId), flashes }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export type AttachAlertOptions<TData> = {
|
|
66
|
+
rules: AlertRule[]
|
|
67
|
+
getRowId: (row: TData) => string
|
|
68
|
+
/** Read the grid's current rows. */
|
|
69
|
+
getData: () => ReadonlyArray<TData>
|
|
70
|
+
getValue?: (row: TData, columnId: string) => unknown
|
|
71
|
+
locale?: string | ReadonlyArray<string>
|
|
72
|
+
/** Merge alert-driven conditional formats into the grid. */
|
|
73
|
+
applyFormats?: (formats: ConditionalFormat<TData>[]) => void
|
|
74
|
+
/** Flash a cell (e.g. call `api.flashCells`). */
|
|
75
|
+
flashCell?: (target: FlashTarget) => void
|
|
76
|
+
/** Poll interval in ms (`0` disables polling - drive `tick()` yourself). */
|
|
77
|
+
intervalMs?: number
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export type AlertAttachment<TData> = {
|
|
81
|
+
engine: AlertEngine<TData>
|
|
82
|
+
/** Run one evaluation pass against the latest data. */
|
|
83
|
+
tick(): void
|
|
84
|
+
/** Push new rules into the engine (e.g. after the user edits a rule). */
|
|
85
|
+
setRules(rules: AlertRule[]): void
|
|
86
|
+
detach(): void
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Wire the engine to a data source. On the first pass it seeds edges silently
|
|
91
|
+
* from the current snapshot (so pre-existing matches do not fire); thereafter
|
|
92
|
+
* each pass fires newly-matching rows.
|
|
93
|
+
*
|
|
94
|
+
* Perf: cloning a previous snapshot is gated on `engine.needsPrev()`. For a rule
|
|
95
|
+
* set that is purely `dataChange`/aggregate (the common case) NO clone is taken -
|
|
96
|
+
* `engine.evaluate` re-checks the current rows and its edge memory (the `active`
|
|
97
|
+
* set) fires each row exactly once when it starts matching. Only `relativeChange`
|
|
98
|
+
* rules, which compare prev -> next, pay for a snapshot. This is the non-Svelte
|
|
99
|
+
* poll host; for the reactive overlay, `createAlertObserver` adds a scheduler and
|
|
100
|
+
* an O(changed) push path. Returns `detach()` to stop.
|
|
101
|
+
*/
|
|
102
|
+
export function attachAlertEngine<TData = Record<string, unknown>>(
|
|
103
|
+
opts: AttachAlertOptions<TData>,
|
|
104
|
+
): AlertAttachment<TData> {
|
|
105
|
+
const engine = createAlertEngine<TData>({
|
|
106
|
+
rules: opts.rules,
|
|
107
|
+
getRowId: opts.getRowId,
|
|
108
|
+
getValue: opts.getValue,
|
|
109
|
+
locale: opts.locale,
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
let prev: TData[] | null = null
|
|
113
|
+
let seeded = false
|
|
114
|
+
|
|
115
|
+
const snapshot = (rows: ReadonlyArray<TData>): TData[] =>
|
|
116
|
+
rows.map((r) => (r && typeof r === 'object' ? ({ ...r } as TData) : r))
|
|
117
|
+
|
|
118
|
+
const flush = (events: AlertEvent[]) => {
|
|
119
|
+
if (events.length === 0) return
|
|
120
|
+
const { formats, flashes } = applyAlertEvents(events, opts.getRowId)
|
|
121
|
+
if (formats.length && opts.applyFormats) opts.applyFormats(formats)
|
|
122
|
+
if (opts.flashCell) flashes.forEach((f) => opts.flashCell?.(f))
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const tick = () => {
|
|
126
|
+
const rows = opts.getData()
|
|
127
|
+
const needsPrev = engine.needsPrev()
|
|
128
|
+
let events: AlertEvent[]
|
|
129
|
+
if (!seeded) {
|
|
130
|
+
// Silent seed: arm edges from the current set, fire nothing.
|
|
131
|
+
engine.evaluate(rows)
|
|
132
|
+
events = []
|
|
133
|
+
seeded = true
|
|
134
|
+
} else if (needsPrev) {
|
|
135
|
+
events = engine.evaluateTransition(prev ?? [], rows)
|
|
136
|
+
} else {
|
|
137
|
+
// No rule reads prev - the edge set dedups, so no clone/diff is needed.
|
|
138
|
+
events = engine.evaluate(rows)
|
|
139
|
+
}
|
|
140
|
+
prev = needsPrev ? snapshot(rows) : null
|
|
141
|
+
flush(events)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const interval = opts.intervalMs ?? 400
|
|
145
|
+
let timer: ReturnType<typeof setInterval> | null = null
|
|
146
|
+
if (interval > 0) {
|
|
147
|
+
tick()
|
|
148
|
+
timer = setInterval(tick, interval)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
engine,
|
|
153
|
+
tick,
|
|
154
|
+
setRules(rules) {
|
|
155
|
+
engine.setRules(rules)
|
|
156
|
+
engine.reset()
|
|
157
|
+
prev = null
|
|
158
|
+
seeded = false
|
|
159
|
+
},
|
|
160
|
+
detach() {
|
|
161
|
+
if (timer) clearInterval(timer)
|
|
162
|
+
timer = null
|
|
163
|
+
},
|
|
164
|
+
}
|
|
165
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { createAlertEngine, renderTemplate } from './alert-engine'
|
|
3
|
+
import type { AlertRule } from './alert-types'
|
|
4
|
+
|
|
5
|
+
type Row = { id: string; price: number; region: string; status: string }
|
|
6
|
+
|
|
7
|
+
const rows = (over: Partial<Row>[] = []): Row[] =>
|
|
8
|
+
[
|
|
9
|
+
{ id: 'a', price: 120, region: 'EU', status: 'open' },
|
|
10
|
+
{ id: 'b', price: 80, region: 'US', status: 'open' },
|
|
11
|
+
].map((r, i) => ({ ...r, ...(over[i] ?? {}) }))
|
|
12
|
+
|
|
13
|
+
const getRowId = (r: Row) => r.id
|
|
14
|
+
|
|
15
|
+
const rule = (over: Partial<AlertRule> = {}): AlertRule => ({
|
|
16
|
+
id: 'r1',
|
|
17
|
+
name: 'High price',
|
|
18
|
+
enabled: true,
|
|
19
|
+
severity: 'warning',
|
|
20
|
+
scope: 'row',
|
|
21
|
+
predicate: { kind: 'cmp', column: 'price', op: 'greaterThan', value: 100 },
|
|
22
|
+
trigger: { type: 'dataChange' },
|
|
23
|
+
actions: [{ kind: 'toast', message: '{region} price is {value}' }],
|
|
24
|
+
createdAt: 0,
|
|
25
|
+
...over,
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
const engine = (r: AlertRule[]) => createAlertEngine<Row>({ rules: r, getRowId, now: () => 1000 })
|
|
29
|
+
|
|
30
|
+
describe('dataChange edge firing', () => {
|
|
31
|
+
it('fires once when a row starts matching, not on every pass', () => {
|
|
32
|
+
const e = engine([rule()])
|
|
33
|
+
const first = e.evaluate(rows())
|
|
34
|
+
expect(first).toHaveLength(1)
|
|
35
|
+
expect(first[0]!.rowId).toBe('a')
|
|
36
|
+
// Re-evaluating the same data must not re-fire.
|
|
37
|
+
expect(e.evaluate(rows())).toHaveLength(0)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('re-arms after a row stops matching', () => {
|
|
41
|
+
const e = engine([rule()])
|
|
42
|
+
e.evaluate(rows())
|
|
43
|
+
// 'a' drops below threshold -> no fire, edge memory cleared.
|
|
44
|
+
expect(e.evaluate(rows([{ price: 90 }]))).toHaveLength(0)
|
|
45
|
+
// 'a' climbs back over -> fires again.
|
|
46
|
+
expect(e.evaluate(rows([{ price: 130 }]))).toHaveLength(1)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('fires on transition when a row newly matches after an edit', () => {
|
|
50
|
+
const e = engine([rule()])
|
|
51
|
+
const prev = rows([{ price: 90 }]) // 'a' below threshold
|
|
52
|
+
e.evaluate(prev)
|
|
53
|
+
const events = e.evaluateTransition(prev, rows([{ price: 130 }]))
|
|
54
|
+
expect(events).toHaveLength(1)
|
|
55
|
+
expect(events[0]!.rowId).toBe('a')
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('respects disabled rules', () => {
|
|
59
|
+
const e = engine([rule({ enabled: false })])
|
|
60
|
+
expect(e.evaluate(rows())).toHaveLength(0)
|
|
61
|
+
})
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
describe('relativeChange firing', () => {
|
|
65
|
+
it('fires when a value jumps by more than the threshold', () => {
|
|
66
|
+
const r = rule({
|
|
67
|
+
id: 'r2',
|
|
68
|
+
trigger: { type: 'relativeChange', expr: { kind: 'percentChange', column: 'price', op: '>', value: 5, abs: true } },
|
|
69
|
+
predicate: { kind: 'const', value: true },
|
|
70
|
+
})
|
|
71
|
+
const e = engine([r])
|
|
72
|
+
const prev = rows()
|
|
73
|
+
e.evaluate(prev)
|
|
74
|
+
const events = e.evaluateTransition(prev, rows([{ price: 140 }])) // +16%
|
|
75
|
+
expect(events).toHaveLength(1)
|
|
76
|
+
expect(events[0]!.columnId).toBe('price')
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('gates relativeChange on the predicate', () => {
|
|
80
|
+
const r = rule({
|
|
81
|
+
id: 'r3',
|
|
82
|
+
trigger: { type: 'relativeChange', expr: { kind: 'changed', column: 'status' } },
|
|
83
|
+
predicate: { kind: 'cmp', column: 'status', op: 'equals', value: 'closed' },
|
|
84
|
+
})
|
|
85
|
+
const e = engine([r])
|
|
86
|
+
const prev = rows()
|
|
87
|
+
// status changes open -> pending: predicate (== closed) fails -> no fire.
|
|
88
|
+
expect(e.evaluateTransition(prev, rows([{ status: 'pending' }]))).toHaveLength(0)
|
|
89
|
+
// status changes open -> closed: fires.
|
|
90
|
+
expect(e.evaluateTransition(prev, rows([{ status: 'closed' }]))).toHaveLength(1)
|
|
91
|
+
})
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
describe('aggregate scope', () => {
|
|
95
|
+
it('fires one edge event when the whole-set predicate flips true', () => {
|
|
96
|
+
const r = rule({
|
|
97
|
+
id: 'agg',
|
|
98
|
+
scope: 'aggregate',
|
|
99
|
+
predicate: { kind: 'scalarCmp', left: { kind: 'agg', fn: 'sum', column: 'price' }, op: '>', right: { kind: 'lit', value: 250 } },
|
|
100
|
+
})
|
|
101
|
+
const e = engine([r])
|
|
102
|
+
expect(e.evaluate(rows())).toHaveLength(0) // 120+80 = 200
|
|
103
|
+
const fired = e.evaluate(rows([{ price: 200 }])) // 200+80 = 280
|
|
104
|
+
expect(fired).toHaveLength(1)
|
|
105
|
+
expect(fired[0]!.rowId).toBeUndefined()
|
|
106
|
+
})
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
describe('validateEdit', () => {
|
|
110
|
+
it('vetoes an edit that trips a preventEdit validation rule', () => {
|
|
111
|
+
const r = rule({
|
|
112
|
+
id: 'v',
|
|
113
|
+
trigger: { type: 'validation' },
|
|
114
|
+
predicate: { kind: 'cmp', column: 'price', op: 'lessThan', value: 0 },
|
|
115
|
+
actions: [{ kind: 'preventEdit', message: 'Price cannot be negative' }],
|
|
116
|
+
})
|
|
117
|
+
const e = engine([r])
|
|
118
|
+
const target = rows()[0]!
|
|
119
|
+
expect(e.validateEdit(target, 'price', -5).vetoed).toBe(true)
|
|
120
|
+
expect(e.validateEdit(target, 'price', 50).vetoed).toBe(false)
|
|
121
|
+
})
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
describe('renderTemplate', () => {
|
|
125
|
+
it('fills tokens from the firing context', () => {
|
|
126
|
+
expect(
|
|
127
|
+
renderTemplate('{rule}: {region} = {value}', {
|
|
128
|
+
rule: rule(),
|
|
129
|
+
row: { region: 'EU' },
|
|
130
|
+
columnId: 'price',
|
|
131
|
+
value: 120,
|
|
132
|
+
}),
|
|
133
|
+
).toBe('High price: EU = 120')
|
|
134
|
+
})
|
|
135
|
+
})
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The pure alert engine - no DOM, no timers. It turns rules + data snapshots
|
|
3
|
+
* into `AlertEvent`s, with true-edge de-duplication so a `dataChange` rule fires
|
|
4
|
+
* once when a row starts matching (not on every re-evaluation while it keeps
|
|
5
|
+
* matching). The reactive/observing wrapper lives in `alert-engine-attach.ts`.
|
|
6
|
+
*/
|
|
7
|
+
import { evaluateChange, evaluatePredicate } from '../expressions/evaluate'
|
|
8
|
+
import { collectColumnRefs } from '../expressions/parse'
|
|
9
|
+
import type { EvalContext } from '../expressions/expression-types'
|
|
10
|
+
import type { AlertEvent, AlertRule } from './alert-types'
|
|
11
|
+
|
|
12
|
+
export type AlertEngineOptions<TData> = {
|
|
13
|
+
rules: AlertRule[]
|
|
14
|
+
getRowId: (row: TData) => string
|
|
15
|
+
/** Resolve a column id to a value (defaults to `row[id]`). */
|
|
16
|
+
getValue?: (row: TData, columnId: string) => unknown
|
|
17
|
+
locale?: string | ReadonlyArray<string>
|
|
18
|
+
/** Clock for `firedAt` (injectable for deterministic tests). */
|
|
19
|
+
now?: () => number
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type ValidateEditResult = {
|
|
23
|
+
vetoed: boolean
|
|
24
|
+
events: AlertEvent[]
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type AlertEngine<TData> = {
|
|
28
|
+
setRules(rules: AlertRule[]): void
|
|
29
|
+
getRules(): AlertRule[]
|
|
30
|
+
/** Full-snapshot pass: fires `dataChange`/`scheduled`/aggregate edges. */
|
|
31
|
+
evaluate(rows: ReadonlyArray<TData>): AlertEvent[]
|
|
32
|
+
/** Prev -> next pass: fires `relativeChange` plus `dataChange` edges. */
|
|
33
|
+
evaluateTransition(prev: ReadonlyArray<TData>, next: ReadonlyArray<TData>): AlertEvent[]
|
|
34
|
+
/** Would editing `(row, columnId) -> nextValue` trip a validation rule? */
|
|
35
|
+
validateEdit(row: TData, columnId: string, nextValue: unknown): ValidateEditResult
|
|
36
|
+
/**
|
|
37
|
+
* True iff some enabled rule reads a row's PREVIOUS value (a `relativeChange`
|
|
38
|
+
* trigger). When false, the observer can skip cloning prev-snapshots entirely
|
|
39
|
+
* - the hot-path saver for `dataChange`/aggregate-only rule sets over big feeds.
|
|
40
|
+
*/
|
|
41
|
+
needsPrev(): boolean
|
|
42
|
+
/** True iff some enabled rule is aggregate-scope (needs the full row set). */
|
|
43
|
+
needsFullSet(): boolean
|
|
44
|
+
/** Forget edge memory so matching rows can fire again. */
|
|
45
|
+
reset(): void
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const AGG_ROW_KEY = '*'
|
|
49
|
+
|
|
50
|
+
/** Fill message-template tokens from the firing context. */
|
|
51
|
+
export function renderTemplate(
|
|
52
|
+
template: string,
|
|
53
|
+
ctx: { rule: AlertRule; row?: Record<string, unknown>; columnId?: string; value?: unknown },
|
|
54
|
+
): string {
|
|
55
|
+
const { rule, row, columnId, value } = ctx
|
|
56
|
+
return template
|
|
57
|
+
.replace(/\{rule\}/g, rule.name)
|
|
58
|
+
.replace(/\{severity\}/g, rule.severity)
|
|
59
|
+
.replace(/\{column\}/g, columnId ?? '')
|
|
60
|
+
.replace(/\{value\}/g, value == null ? '' : String(value))
|
|
61
|
+
.replace(/\{row\.([\w$]+)\}/g, (_m, key: string) => fmt(row?.[key]))
|
|
62
|
+
.replace(/\{([\w$]+)\}/g, (m, key: string) => (row && key in row ? fmt(row[key]) : m))
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function fmt(v: unknown): string {
|
|
66
|
+
return v == null ? '' : String(v)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function defaultMessage(rule: AlertRule): string {
|
|
70
|
+
return rule.name
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function createAlertEngine<TData = Record<string, unknown>>(
|
|
74
|
+
options: AlertEngineOptions<TData>,
|
|
75
|
+
): AlertEngine<TData> {
|
|
76
|
+
let rules = options.rules
|
|
77
|
+
const getValue = options.getValue
|
|
78
|
+
const now = options.now ?? (() => Date.now())
|
|
79
|
+
// Edge memory: keys currently satisfying a `dataChange`/aggregate predicate.
|
|
80
|
+
const active = new Set<string>()
|
|
81
|
+
|
|
82
|
+
const ctxFor = (row: TData, rows?: ReadonlyArray<TData>, prev?: TData): EvalContext<TData> => ({
|
|
83
|
+
row,
|
|
84
|
+
prev,
|
|
85
|
+
rows,
|
|
86
|
+
getValue,
|
|
87
|
+
locale: options.locale,
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
const val = (row: TData, columnId?: string): unknown =>
|
|
91
|
+
columnId == null
|
|
92
|
+
? undefined
|
|
93
|
+
: getValue
|
|
94
|
+
? getValue(row, columnId)
|
|
95
|
+
: (row as Record<string, unknown>)?.[columnId]
|
|
96
|
+
|
|
97
|
+
// A representative column for the message `{value}`/`{column}` tokens: the
|
|
98
|
+
// rule's explicit target, else the first column its predicate references.
|
|
99
|
+
const primaryColumn = (rule: AlertRule): string | undefined =>
|
|
100
|
+
rule.columns?.[0] ?? collectColumnRefs(rule.predicate)[0]
|
|
101
|
+
|
|
102
|
+
function buildEvent(
|
|
103
|
+
rule: AlertRule,
|
|
104
|
+
opts: { row?: TData; rowId?: string; columnId?: string; value?: unknown },
|
|
105
|
+
): AlertEvent {
|
|
106
|
+
const msgAction = rule.actions.find((a) => a.message)
|
|
107
|
+
const template = msgAction?.message ?? defaultMessage(rule)
|
|
108
|
+
const message = renderTemplate(template, {
|
|
109
|
+
rule,
|
|
110
|
+
row: opts.row as Record<string, unknown> | undefined,
|
|
111
|
+
columnId: opts.columnId,
|
|
112
|
+
value: opts.value,
|
|
113
|
+
})
|
|
114
|
+
return {
|
|
115
|
+
ruleId: rule.id,
|
|
116
|
+
ruleName: rule.name,
|
|
117
|
+
severity: rule.severity,
|
|
118
|
+
scope: rule.scope,
|
|
119
|
+
triggerType: rule.trigger.type,
|
|
120
|
+
rowId: opts.rowId,
|
|
121
|
+
columnId: opts.columnId,
|
|
122
|
+
value: opts.value,
|
|
123
|
+
message,
|
|
124
|
+
actions: rule.actions,
|
|
125
|
+
firedAt: now(),
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Aggregate-scope: one edge event when the predicate over all rows flips true. */
|
|
130
|
+
function evaluateAggregate(rule: AlertRule, rows: ReadonlyArray<TData>): AlertEvent[] {
|
|
131
|
+
const key = `${rule.id}::${AGG_ROW_KEY}`
|
|
132
|
+
const first = rows[0]
|
|
133
|
+
const matches = first != null && evaluatePredicate(rule.predicate, ctxFor(first, rows))
|
|
134
|
+
if (matches && !active.has(key)) {
|
|
135
|
+
active.add(key)
|
|
136
|
+
return [buildEvent(rule, {})]
|
|
137
|
+
}
|
|
138
|
+
if (!matches) active.delete(key)
|
|
139
|
+
return []
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Row/cell dataChange: edge event when a row newly satisfies the predicate. */
|
|
143
|
+
function evaluateDataChangeRow(
|
|
144
|
+
rule: AlertRule,
|
|
145
|
+
row: TData,
|
|
146
|
+
rows: ReadonlyArray<TData>,
|
|
147
|
+
): AlertEvent[] {
|
|
148
|
+
const id = options.getRowId(row)
|
|
149
|
+
const key = `${rule.id}::${id}`
|
|
150
|
+
const matches = evaluatePredicate(rule.predicate, ctxFor(row, rows))
|
|
151
|
+
if (matches && !active.has(key)) {
|
|
152
|
+
active.add(key)
|
|
153
|
+
const source = primaryColumn(rule)
|
|
154
|
+
// Expose the column on the event only for cell scope; still surface its
|
|
155
|
+
// value for the message tokens regardless of scope.
|
|
156
|
+
const columnId = rule.scope === 'cell' ? source : undefined
|
|
157
|
+
return [buildEvent(rule, { row, rowId: id, columnId, value: val(row, source) })]
|
|
158
|
+
}
|
|
159
|
+
if (!matches) active.delete(key)
|
|
160
|
+
return []
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** relativeChange: fires whenever the change test holds for a prev->next row. */
|
|
164
|
+
function evaluateRelative(
|
|
165
|
+
rule: AlertRule,
|
|
166
|
+
row: TData,
|
|
167
|
+
prev: TData | undefined,
|
|
168
|
+
rows: ReadonlyArray<TData>,
|
|
169
|
+
): AlertEvent[] {
|
|
170
|
+
if (rule.trigger.type !== 'relativeChange' || prev == null) return []
|
|
171
|
+
const changed = evaluateChange(rule.trigger.expr, ctxFor(row, rows, prev))
|
|
172
|
+
if (!changed) return []
|
|
173
|
+
// Gate on the predicate too, so "price jumped AND price > 100" is expressible.
|
|
174
|
+
if (!evaluatePredicate(rule.predicate, ctxFor(row, rows, prev))) return []
|
|
175
|
+
const columnId = rule.trigger.expr.column
|
|
176
|
+
return [
|
|
177
|
+
buildEvent(rule, {
|
|
178
|
+
row,
|
|
179
|
+
rowId: options.getRowId(row),
|
|
180
|
+
columnId,
|
|
181
|
+
value: val(row, columnId),
|
|
182
|
+
}),
|
|
183
|
+
]
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function activeRules(): AlertRule[] {
|
|
187
|
+
return rules.filter((r) => r.enabled)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return {
|
|
191
|
+
setRules(next) {
|
|
192
|
+
rules = next
|
|
193
|
+
},
|
|
194
|
+
getRules() {
|
|
195
|
+
return rules
|
|
196
|
+
},
|
|
197
|
+
needsPrev() {
|
|
198
|
+
return rules.some((r) => r.enabled && r.trigger.type === 'relativeChange')
|
|
199
|
+
},
|
|
200
|
+
needsFullSet() {
|
|
201
|
+
return rules.some((r) => r.enabled && r.scope === 'aggregate')
|
|
202
|
+
},
|
|
203
|
+
reset() {
|
|
204
|
+
active.clear()
|
|
205
|
+
},
|
|
206
|
+
|
|
207
|
+
evaluate(rows) {
|
|
208
|
+
const events: AlertEvent[] = []
|
|
209
|
+
for (const rule of activeRules()) {
|
|
210
|
+
if (rule.scope === 'aggregate') {
|
|
211
|
+
events.push(...evaluateAggregate(rule, rows))
|
|
212
|
+
} else if (rule.trigger.type === 'dataChange' || rule.trigger.type === 'scheduled') {
|
|
213
|
+
for (const row of rows) events.push(...evaluateDataChangeRow(rule, row, rows))
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
return events
|
|
217
|
+
},
|
|
218
|
+
|
|
219
|
+
evaluateTransition(prev, next) {
|
|
220
|
+
const prevById = new Map<string, TData>()
|
|
221
|
+
for (const row of prev) prevById.set(options.getRowId(row), row)
|
|
222
|
+
const events: AlertEvent[] = []
|
|
223
|
+
for (const rule of activeRules()) {
|
|
224
|
+
if (rule.scope === 'aggregate') {
|
|
225
|
+
events.push(...evaluateAggregate(rule, next))
|
|
226
|
+
continue
|
|
227
|
+
}
|
|
228
|
+
for (const row of next) {
|
|
229
|
+
const before = prevById.get(options.getRowId(row))
|
|
230
|
+
if (rule.trigger.type === 'relativeChange') {
|
|
231
|
+
events.push(...evaluateRelative(rule, row, before, next))
|
|
232
|
+
} else if (rule.trigger.type === 'dataChange') {
|
|
233
|
+
events.push(...evaluateDataChangeRow(rule, row, next))
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
return events
|
|
238
|
+
},
|
|
239
|
+
|
|
240
|
+
validateEdit(row, columnId, nextValue) {
|
|
241
|
+
const candidate = { ...(row as Record<string, unknown>), [columnId]: nextValue } as TData
|
|
242
|
+
const events: AlertEvent[] = []
|
|
243
|
+
let vetoed = false
|
|
244
|
+
for (const rule of activeRules()) {
|
|
245
|
+
if (rule.trigger.type !== 'validation') continue
|
|
246
|
+
if (!evaluatePredicate(rule.predicate, ctxFor(candidate))) continue
|
|
247
|
+
events.push(
|
|
248
|
+
buildEvent(rule, {
|
|
249
|
+
row: candidate,
|
|
250
|
+
rowId: options.getRowId(candidate),
|
|
251
|
+
columnId,
|
|
252
|
+
value: nextValue,
|
|
253
|
+
}),
|
|
254
|
+
)
|
|
255
|
+
if (rule.actions.some((a) => a.kind === 'preventEdit')) vetoed = true
|
|
256
|
+
}
|
|
257
|
+
return { vetoed, events }
|
|
258
|
+
},
|
|
259
|
+
}
|
|
260
|
+
}
|