@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.
Files changed (101) hide show
  1. package/README.md +95 -81
  2. package/dist/cdn/svgrid-enterprise.svelte-external.js +22468 -10459
  3. package/dist/node/studio.js +11590 -2907
  4. package/package.json +31 -10
  5. package/src/SvAlertRuleEditor.svelte +294 -0
  6. package/src/SvAlertsManager.svelte +210 -0
  7. package/src/SvAlertsPanel.svelte +129 -0
  8. package/src/SvBoard.svelte +3 -1
  9. package/src/SvExpressionEditor.svelte +341 -0
  10. package/src/SvGridAlerts.dom.test.ts +113 -0
  11. package/src/SvGridAlerts.svelte +265 -0
  12. package/src/SvGridBoard.svelte +2355 -0
  13. package/src/SvGridEditPanel.svelte +849 -794
  14. package/src/SvGridScheduler.svelte +5334 -4410
  15. package/src/SvPivotDesigner.svelte +3 -3
  16. package/src/SvRecordDetail.svelte +6 -2
  17. package/src/SvSchedule.svelte +3 -1
  18. package/src/{ai-export-pdf.test.ts → ai-export-pdf.dom.test.ts} +4 -1
  19. package/src/{ai-export-xlsx.test.ts → ai-export-xlsx.dom.test.ts} +4 -1
  20. package/src/{ai-export.test.ts → ai-export.dom.test.ts} +5 -1
  21. package/src/alerts/alert-engine-attach.ts +165 -0
  22. package/src/alerts/alert-engine.test.ts +135 -0
  23. package/src/alerts/alert-engine.ts +260 -0
  24. package/src/alerts/alert-formats.test.ts +87 -0
  25. package/src/alerts/alert-formats.ts +77 -0
  26. package/src/alerts/alert-observer.test.ts +189 -0
  27. package/src/alerts/alert-observer.ts +208 -0
  28. package/src/alerts/alert-scheduler.ts +96 -0
  29. package/src/alerts/alert-storage.test.ts +54 -0
  30. package/src/alerts/alert-storage.ts +116 -0
  31. package/src/alerts/alert-store.svelte.ts +80 -0
  32. package/src/alerts/alert-types.ts +94 -0
  33. package/src/alerts.ts +28 -0
  34. package/src/board.dom.test.ts +941 -0
  35. package/src/board.ts +36 -0
  36. package/src/export.ts +6 -0
  37. package/src/expressions/evaluate.test.ts +111 -0
  38. package/src/expressions/evaluate.ts +228 -0
  39. package/src/expressions/expression-columns.ts +133 -0
  40. package/src/expressions/expression-types.ts +84 -0
  41. package/src/expressions/parse.test.ts +106 -0
  42. package/src/expressions/parse.ts +610 -0
  43. package/src/import.ts +2 -0
  44. package/src/index.ts +180 -46
  45. package/src/install.ts +12 -2
  46. package/src/pivot-enable.ts +44 -0
  47. package/src/scheduler-assignments.test.ts +97 -0
  48. package/src/scheduler-assignments.ts +134 -0
  49. package/src/scheduler-axis.test.ts +108 -0
  50. package/src/scheduler-axis.ts +238 -0
  51. package/src/scheduler-booking.test.ts +57 -0
  52. package/src/scheduler-booking.ts +63 -0
  53. package/src/scheduler-config.ts +179 -0
  54. package/src/scheduler-dependencies.test.ts +155 -0
  55. package/src/scheduler-dependencies.ts +223 -0
  56. package/src/scheduler-freebusy.test.ts +41 -0
  57. package/src/scheduler-freebusy.ts +36 -0
  58. package/src/scheduler-heatmap.test.ts +39 -0
  59. package/src/scheduler-heatmap.ts +55 -0
  60. package/src/scheduler-resource-tree.test.ts +84 -0
  61. package/src/scheduler-resource-tree.ts +107 -0
  62. package/src/scheduler-slots.test.ts +53 -0
  63. package/src/scheduler-slots.ts +94 -0
  64. package/src/scheduler-summary.test.ts +47 -0
  65. package/src/scheduler-summary.ts +81 -0
  66. package/src/sources/index.ts +1 -1
  67. package/src/sources/introspect-supabase.test.ts +13 -1
  68. package/src/sources/introspect-supabase.ts +20 -0
  69. package/src/studio/copilot-core.test.ts +45 -0
  70. package/src/studio/copilot-core.ts +65 -0
  71. package/src/studio/deploy-cli.test.ts +56 -0
  72. package/src/studio/deploy-cli.ts +100 -0
  73. package/src/studio/emit-project.test.ts +503 -18
  74. package/src/studio/emit-project.ts +829 -111
  75. package/src/studio/emit-schema.test.ts +17 -0
  76. package/src/studio/emit-schema.ts +166 -35
  77. package/src/studio/index.ts +25 -1
  78. package/src/studio/introspect-openapi.test.ts +84 -0
  79. package/src/studio/introspect-openapi.ts +252 -0
  80. package/src/studio/project.test.ts +57 -0
  81. package/src/studio/project.ts +193 -6
  82. package/src/studio/samples/crm.ts +282 -258
  83. package/src/studio/samples/fleet.ts +213 -186
  84. package/src/studio/samples/insurance.ts +223 -195
  85. package/src/studio/samples/inventory.ts +213 -182
  86. package/src/studio/samples/live-data.test.ts +99 -98
  87. package/src/studio/samples/live-data.ts +8 -10
  88. package/src/studio/samples/projects.ts +212 -190
  89. package/src/studio/samples/samples.test.ts +52 -0
  90. package/src/studio/samples/shared.ts +26 -6
  91. package/src/studio/samples/starter.ts +251 -0
  92. package/src/studio/samples/support.ts +209 -184
  93. package/src/studio/ui-components-surface.test.ts +185 -0
  94. package/src/studio/ui-components.generated.ts +5617 -0
  95. package/src/studio/ui-components.ts +641 -472
  96. package/src/sveltekit/sql-source.test.ts +13 -0
  97. package/src/sveltekit/sql-source.ts +11 -6
  98. package/src/upgrade-prompt.ts +2 -2
  99. package/src/watermark.ts +2 -2
  100. package/src/ai.test.ts +0 -522
  101. 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 onScroll = () => closeCtx()
696
- window.addEventListener('scroll', onScroll, true)
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
- schema: EntitySchema
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
- schema: EntitySchema
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
@@ -23,7 +23,9 @@
23
23
  loading = false,
24
24
  height,
25
25
  }: {
26
- schema: EntitySchema
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 './ai'
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 './ai'
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 './ai'
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
+ }