@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
@@ -0,0 +1,87 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { rulesToConditionalFormats, toConditionalFormats } from './alert-formats'
3
+ import type { AlertEvent, AlertRule } from './alert-types'
4
+
5
+ type Row = { id: string; price: number }
6
+ const getRowId = (r: Row) => r.id
7
+
8
+ const event = (over: Partial<AlertEvent> = {}): AlertEvent => ({
9
+ ruleId: 'r1',
10
+ ruleName: 'High',
11
+ severity: 'warning',
12
+ scope: 'row',
13
+ triggerType: 'dataChange',
14
+ rowId: 'a',
15
+ message: 'hi',
16
+ actions: [{ kind: 'highlight', style: { background: '#fee2e2', color: '#991b1b' } }],
17
+ firedAt: 0,
18
+ ...over,
19
+ })
20
+
21
+ describe('toConditionalFormats', () => {
22
+ it('turns a highlight action into a row-matching RuleFormat', () => {
23
+ const formats = toConditionalFormats([event()], getRowId)
24
+ expect(formats).toHaveLength(1)
25
+ const f = formats[0]!
26
+ expect(f.type).toBe('rule')
27
+ if (f.type === 'rule') {
28
+ expect(f.background).toBe('#fee2e2')
29
+ expect(f.color).toBe('#991b1b')
30
+ expect(f.when({ value: 120, row: { id: 'a', price: 120 } })).toBe(true)
31
+ expect(f.when({ value: 80, row: { id: 'b', price: 80 } })).toBe(false)
32
+ }
33
+ })
34
+
35
+ it('scopes to the event column when no explicit columns', () => {
36
+ const formats = toConditionalFormats([event({ columnId: 'price' })], getRowId)
37
+ const f = formats[0]!
38
+ if (f.type === 'rule') expect(f.columns).toEqual(['price'])
39
+ })
40
+
41
+ it('honours explicit action columns', () => {
42
+ const formats = toConditionalFormats(
43
+ [event({ actions: [{ kind: 'badge', columns: ['price', 'id'], style: { background: '#fff' } }] })],
44
+ getRowId,
45
+ )
46
+ const f = formats[0]!
47
+ if (f.type === 'rule') expect(f.columns).toEqual(['price', 'id'])
48
+ })
49
+
50
+ it('ignores toast/log/flash-only events (no styling)', () => {
51
+ expect(toConditionalFormats([event({ actions: [{ kind: 'toast' }, { kind: 'log' }] })], getRowId)).toHaveLength(0)
52
+ })
53
+
54
+ it('skips events without a row id', () => {
55
+ expect(toConditionalFormats([event({ rowId: undefined })], getRowId)).toHaveLength(0)
56
+ })
57
+ })
58
+
59
+ describe('rulesToConditionalFormats', () => {
60
+ const rule = (over: Partial<AlertRule> = {}): AlertRule => ({
61
+ id: 'r1',
62
+ name: 'High',
63
+ enabled: true,
64
+ severity: 'warning',
65
+ scope: 'row',
66
+ predicate: { kind: 'cmp', column: 'price', op: 'greaterThan', value: 100 },
67
+ trigger: { type: 'dataChange' },
68
+ actions: [{ kind: 'highlight', style: { background: '#fee2e2' } }],
69
+ createdAt: 0,
70
+ ...over,
71
+ })
72
+
73
+ it('emits one live predicate-driven format per styling rule', () => {
74
+ const formats = rulesToConditionalFormats<Row>([rule()])
75
+ expect(formats).toHaveLength(1)
76
+ const f = formats[0]!
77
+ if (f.type === 'rule') {
78
+ expect(f.when({ value: 0, row: { id: 'a', price: 120 } })).toBe(true)
79
+ expect(f.when({ value: 0, row: { id: 'b', price: 80 } })).toBe(false)
80
+ }
81
+ })
82
+
83
+ it('ignores disabled rules and non-styling rules', () => {
84
+ expect(rulesToConditionalFormats<Row>([rule({ enabled: false })])).toHaveLength(0)
85
+ expect(rulesToConditionalFormats<Row>([rule({ actions: [{ kind: 'toast' }] })])).toHaveLength(0)
86
+ })
87
+ })
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Bridge fired alert events to the grid's conditional-formatting engine. A
3
+ * `badge` / `highlight` action becomes a `RuleFormat` whose `when` matches the
4
+ * row that fired, so `resolveCellFormat` paints it - the same painter the grid
5
+ * already uses for color scales and data bars. No new rendering path.
6
+ */
7
+ import type { ConditionalFormat } from '@svgrid/grid/format'
8
+ import { evaluatePredicate } from '../expressions/evaluate'
9
+ import type { AlertEvent, AlertRule } from './alert-types'
10
+
11
+ /** Which action kinds paint onto cells. */
12
+ const STYLING_KINDS = new Set(['badge', 'highlight'])
13
+
14
+ export type StylingContext<TData> = {
15
+ getValue?: (row: TData, columnId: string) => unknown
16
+ locale?: string | ReadonlyArray<string>
17
+ }
18
+
19
+ /**
20
+ * Persistent styling: one self-contained `RuleFormat` per enabled rule that has
21
+ * a `badge`/`highlight` action. The format's `when` runs the rule predicate
22
+ * directly, so the styling stays live as data changes (rows light up while they
23
+ * match and clear when they stop) without any row-id bookkeeping. One format
24
+ * per rule keeps the array tiny regardless of row count.
25
+ */
26
+ export function rulesToConditionalFormats<TData = Record<string, unknown>>(
27
+ rules: ReadonlyArray<AlertRule>,
28
+ ctx: StylingContext<TData> = {},
29
+ ): ConditionalFormat<TData>[] {
30
+ const formats: ConditionalFormat<TData>[] = []
31
+ for (const rule of rules) {
32
+ if (!rule.enabled) continue
33
+ for (const action of rule.actions) {
34
+ if (!STYLING_KINDS.has(action.kind)) continue
35
+ const columns = action.columns ?? rule.columns
36
+ const style = action.style ?? {}
37
+ formats.push({
38
+ type: 'rule',
39
+ ...(columns ? { columns } : {}),
40
+ when: ({ row }: { value: unknown; row: TData }) =>
41
+ evaluatePredicate(rule.predicate, { row, getValue: ctx.getValue, locale: ctx.locale }),
42
+ ...(style.background ? { background: style.background } : {}),
43
+ ...(style.color ? { color: style.color } : {}),
44
+ })
45
+ }
46
+ }
47
+ return formats
48
+ }
49
+
50
+ /**
51
+ * Turn active alert events into conditional formats. Events whose actions only
52
+ * toast/log/flash contribute nothing here. Matching is by row id, so the format
53
+ * follows the row through sort/filter rather than a fixed index.
54
+ */
55
+ export function toConditionalFormats<TData = Record<string, unknown>>(
56
+ events: ReadonlyArray<AlertEvent>,
57
+ getRowId: (row: TData) => string,
58
+ ): ConditionalFormat<TData>[] {
59
+ const formats: ConditionalFormat<TData>[] = []
60
+ for (const event of events) {
61
+ if (event.rowId == null) continue
62
+ const rowId = event.rowId
63
+ for (const action of event.actions) {
64
+ if (!STYLING_KINDS.has(action.kind)) continue
65
+ const columns = action.columns ?? (event.columnId ? [event.columnId] : undefined)
66
+ const style = action.style ?? {}
67
+ formats.push({
68
+ type: 'rule',
69
+ ...(columns ? { columns } : {}),
70
+ when: ({ row }: { value: unknown; row: TData }) => getRowId(row) === rowId,
71
+ ...(style.background ? { background: style.background } : {}),
72
+ ...(style.color ? { color: style.color } : {}),
73
+ })
74
+ }
75
+ }
76
+ return formats
77
+ }
@@ -0,0 +1,189 @@
1
+ import { describe, expect, it, vi } from 'vitest'
2
+ import { createAlertEngine } from './alert-engine'
3
+ import { createAlertObserver } from './alert-observer'
4
+ import { createAlertScheduler } from './alert-scheduler'
5
+ import type { AlertEvent, AlertRule } from './alert-types'
6
+
7
+ type Row = { id: string; price: number; region: string; status: string }
8
+
9
+ const seedRows = (over: Partial<Row>[] = []): Row[] =>
10
+ [
11
+ { id: 'a', price: 120, region: 'EU', status: 'open' },
12
+ { id: 'b', price: 80, region: 'US', status: 'open' },
13
+ ].map((r, i) => ({ ...r, ...(over[i] ?? {}) }))
14
+
15
+ const getRowId = (r: Row) => r.id
16
+
17
+ const dataChangeRule: AlertRule = {
18
+ id: 'r1',
19
+ name: 'High price',
20
+ enabled: true,
21
+ severity: 'warning',
22
+ scope: 'row',
23
+ predicate: { kind: 'cmp', column: 'price', op: 'greaterThan', value: 100 },
24
+ trigger: { type: 'dataChange' },
25
+ actions: [{ kind: 'toast', message: '{region} at {value}' }],
26
+ createdAt: 0,
27
+ }
28
+
29
+ const relativeRule: AlertRule = {
30
+ id: 'r2',
31
+ name: 'Jumped',
32
+ enabled: true,
33
+ severity: 'info',
34
+ scope: 'row',
35
+ predicate: { kind: 'const', value: true },
36
+ trigger: { type: 'relativeChange', expr: { kind: 'percentChange', column: 'price', op: '>', value: 5, abs: true } },
37
+ actions: [{ kind: 'toast', message: 'moved' }],
38
+ createdAt: 0,
39
+ }
40
+
41
+ function harness(rules: AlertRule[]) {
42
+ const engine = createAlertEngine<Row>({ rules, getRowId, now: () => 1000 })
43
+ const events: AlertEvent[] = []
44
+ const observer = createAlertObserver<Row>({
45
+ engine,
46
+ getRowId,
47
+ schedule: 'sync', // deterministic: runs inline
48
+ onEvents: (batch) => events.push(...batch),
49
+ })
50
+ return { engine, observer, events }
51
+ }
52
+
53
+ describe('engine.needsPrev / needsFullSet', () => {
54
+ it('needsPrev is true only when a relativeChange rule is enabled', () => {
55
+ expect(createAlertEngine<Row>({ rules: [dataChangeRule], getRowId }).needsPrev()).toBe(false)
56
+ expect(createAlertEngine<Row>({ rules: [relativeRule], getRowId }).needsPrev()).toBe(true)
57
+ expect(
58
+ createAlertEngine<Row>({ rules: [{ ...relativeRule, enabled: false }], getRowId }).needsPrev(),
59
+ ).toBe(false)
60
+ })
61
+
62
+ it('needsFullSet is true only when an aggregate-scope rule is enabled', () => {
63
+ expect(createAlertEngine<Row>({ rules: [dataChangeRule], getRowId }).needsFullSet()).toBe(false)
64
+ const agg: AlertRule = { ...dataChangeRule, id: 'agg', scope: 'aggregate' }
65
+ expect(createAlertEngine<Row>({ rules: [agg], getRowId }).needsFullSet()).toBe(true)
66
+ })
67
+ })
68
+
69
+ describe('createAlertObserver push mode', () => {
70
+ it('seed is silent - pre-existing matches do not fire', () => {
71
+ const { observer, events } = harness([dataChangeRule])
72
+ observer.seed(seedRows()) // 'a' already > 100
73
+ expect(events).toHaveLength(0)
74
+ })
75
+
76
+ it('pushChanged fires only for the rows it is given, once on crossing', () => {
77
+ const { observer, events } = harness([dataChangeRule])
78
+ observer.seed(seedRows([{ price: 90 }])) // 'a' starts below threshold
79
+ // 'b' unchanged and below threshold; push 'a' now crossing.
80
+ observer.pushChanged([{ id: 'a', price: 130, region: 'EU', status: 'open' }])
81
+ expect(events).toHaveLength(1)
82
+ expect(events[0]!.rowId).toBe('a')
83
+ // Pushing it again while it stays above must not re-fire (edge memory).
84
+ observer.pushChanged([{ id: 'a', price: 131, region: 'EU', status: 'open' }])
85
+ expect(events).toHaveLength(1)
86
+ })
87
+
88
+ it('does not evaluate rows that were never pushed', () => {
89
+ const { observer, events } = harness([dataChangeRule])
90
+ observer.seed(seedRows([{ price: 90 }, { price: 95 }])) // both below
91
+ // Only 'b' is pushed, crossing the line; 'a' is untouched.
92
+ observer.pushChanged([{ id: 'b', price: 150, region: 'US', status: 'open' }])
93
+ expect(events).toHaveLength(1)
94
+ expect(events[0]!.rowId).toBe('b')
95
+ })
96
+
97
+ it('relativeChange push sees prev via the maintained snapshot', () => {
98
+ const { observer, events } = harness([relativeRule])
99
+ const start = seedRows()
100
+ observer.seed(start)
101
+ // 'a' 120 -> 140 is +16% -> fires.
102
+ observer.pushChanged([{ id: 'a', price: 140, region: 'EU', status: 'open' }])
103
+ expect(events).toHaveLength(1)
104
+ expect(events[0]!.columnId).toBe('price')
105
+ })
106
+ })
107
+
108
+ describe('createAlertObserver scales O(changed), not O(total)', () => {
109
+ it('a push over a 100k seed touches only the changed rows', () => {
110
+ // Seed 100k rows, then push a handful. The evaluation must not re-scan the
111
+ // whole set - we assert by counting getRowId calls after the seed.
112
+ let calls = 0
113
+ const countingId = (r: Row) => {
114
+ calls++
115
+ return r.id
116
+ }
117
+ const engine = createAlertEngine<Row>({ rules: [dataChangeRule], getRowId: countingId, now: () => 1000 })
118
+ const events: AlertEvent[] = []
119
+ const observer = createAlertObserver<Row>({
120
+ engine,
121
+ getRowId: countingId,
122
+ schedule: 'sync',
123
+ onEvents: (b) => events.push(...b),
124
+ })
125
+
126
+ const big: Row[] = Array.from({ length: 100_000 }, (_, i) => ({
127
+ id: `r${i}`,
128
+ price: 10, // all below the >100 threshold
129
+ region: 'EU',
130
+ status: 'open',
131
+ }))
132
+ observer.seed(big)
133
+ calls = 0 // ignore the one-time seed cost; measure only the push
134
+
135
+ observer.pushChanged([{ id: 'r5', price: 130, region: 'EU', status: 'open' }])
136
+ expect(events).toHaveLength(1)
137
+ expect(events[0]!.rowId).toBe('r5')
138
+ // O(changed): a single changed row costs a bounded handful of id lookups,
139
+ // nowhere near the 100k total.
140
+ expect(calls).toBeLessThan(50)
141
+ })
142
+ })
143
+
144
+ describe('createAlertObserver scan (watch) mode', () => {
145
+ it('index-diffs the array and evaluates only changed rows', () => {
146
+ const { observer, events } = harness([dataChangeRule])
147
+ let data: Row[] = seedRows([{ price: 90 }]) // 'a' below
148
+ observer.scan(() => data) // first scan seeds silently
149
+ expect(events).toHaveLength(0)
150
+ // Immutable replace of 'a' only (Svelte-5 style).
151
+ data = [{ ...data[0]!, price: 130 }, data[1]!]
152
+ observer.scan(() => data)
153
+ expect(events).toHaveLength(1)
154
+ expect(events[0]!.rowId).toBe('a')
155
+ })
156
+ })
157
+
158
+ describe('createScheduler', () => {
159
+ it('sync mode runs immediately', () => {
160
+ const s = createAlertScheduler('sync')
161
+ const fn = vi.fn()
162
+ s.schedule(fn)
163
+ expect(fn).toHaveBeenCalledTimes(1)
164
+ expect(s.pending).toBe(false)
165
+ })
166
+
167
+ it('coalesces multiple schedules and flush runs the latest once', () => {
168
+ const s = createAlertScheduler('raf') // no rAF in node -> setTimeout fallback, but flush is sync
169
+ const a = vi.fn()
170
+ const b = vi.fn()
171
+ s.schedule(a)
172
+ s.schedule(b)
173
+ expect(s.pending).toBe(true)
174
+ s.flush()
175
+ // Latest wins - only the most recent callback runs, exactly once.
176
+ expect(a).not.toHaveBeenCalled()
177
+ expect(b).toHaveBeenCalledTimes(1)
178
+ expect(s.pending).toBe(false)
179
+ })
180
+
181
+ it('cancel drops the pending callback', () => {
182
+ const s = createAlertScheduler('raf')
183
+ const fn = vi.fn()
184
+ s.schedule(fn)
185
+ s.cancel()
186
+ s.flush()
187
+ expect(fn).not.toHaveBeenCalled()
188
+ })
189
+ })
@@ -0,0 +1,208 @@
1
+ /**
2
+ * alert-observer - the observation layer that turns data movement into engine
3
+ * passes WITHOUT ever blocking the grid's render frame, and in O(changed) rather
4
+ * than O(all rows).
5
+ *
6
+ * Two ways to feed it, both funnelling through the same coalescing scheduler so
7
+ * at most one evaluation runs per frame, post-paint:
8
+ *
9
+ * - PUSH (`pushChanged`) - the consumer already knows which rows changed (a
10
+ * streaming feed, a transaction, the flagship demo's own tick loop). We
11
+ * evaluate ONLY those rows. Cost is O(rows that changed) at any dataset size.
12
+ *
13
+ * - SCAN (`scan`) - the zero-effort fallback for a plain `data = ...` where no
14
+ * change signal exists. We index-diff the new array against the previous one
15
+ * (Svelte 5 replaces changed rows immutably, so unchanged rows keep their
16
+ * reference) and evaluate only the mismatches. Still O(all rows) to FIND the
17
+ * changes, but deferred to after paint so it never lengthens the grid's frame.
18
+ *
19
+ * Cloning of previous row values is gated on `engine.needsPrev()`: for a rule set
20
+ * that is purely `dataChange`/aggregate (the common case), we keep NO prev
21
+ * snapshots at all - the biggest GC saver on large live feeds. Full-set passes
22
+ * (aggregate scope, or a row-count change) run on the same deferred path.
23
+ *
24
+ * Pure TypeScript - no Svelte, no DOM. The `<SvGridAlerts>` overlay wires an
25
+ * instance to a scheduler and an `onEvents` sink; `attachAlertEngine` wraps it
26
+ * for non-Svelte hosts.
27
+ */
28
+ import type { AlertEngine } from './alert-engine'
29
+ import type { AlertEvent, AlertRule } from './alert-types'
30
+ import { createAlertScheduler, type AlertScheduler, type AlertSchedulerMode } from './alert-scheduler'
31
+
32
+ export type AlertObserverOptions<TData> = {
33
+ engine: AlertEngine<TData>
34
+ getRowId: (row: TData) => string
35
+ /** Fired events to route to side effects (toast/log/formats). Batched per run. */
36
+ onEvents: (events: AlertEvent[]) => void
37
+ /** Scheduling strategy (default `raf`), or a custom scheduler for tests. */
38
+ schedule?: AlertSchedulerMode | AlertScheduler
39
+ }
40
+
41
+ export type AlertObserver<TData> = {
42
+ /** Silent full seed - arms edges from the current set so pre-existing matches
43
+ * do not fire. Call on mount and after a rule change (in push mode). */
44
+ seed(rows: ReadonlyArray<TData>): void
45
+ /** Queue changed rows for a deferred, O(changed) evaluation (push mode). */
46
+ pushChanged(rows: ReadonlyArray<TData>): void
47
+ /** Schedule a deferred index-diff scan of the latest full row set (watch mode). */
48
+ scan(getRows: () => ReadonlyArray<TData>): void
49
+ /** Swap the rule set; edge memory is reset and a fresh seed is required. */
50
+ setRules(rules: AlertRule[]): void
51
+ /** Run any pending evaluation immediately (e.g. before a snapshot/test). */
52
+ flush(): void
53
+ /** Stop: cancel pending work and release retained snapshots. */
54
+ detach(): void
55
+ }
56
+
57
+ const clone = <TData>(r: TData): TData =>
58
+ r && typeof r === 'object' ? ({ ...(r as Record<string, unknown>) } as TData) : r
59
+
60
+ export function createAlertObserver<TData = Record<string, unknown>>(
61
+ opts: AlertObserverOptions<TData>,
62
+ ): AlertObserver<TData> {
63
+ const { engine, getRowId, onEvents } = opts
64
+ const scheduler: AlertScheduler =
65
+ opts.schedule && typeof opts.schedule === 'object'
66
+ ? opts.schedule
67
+ : createAlertScheduler(opts.schedule ?? 'raf')
68
+
69
+ // Carry-over between runs. Only populated when a rule actually needs it.
70
+ let seeded = false
71
+ let prevRows: ReadonlyArray<TData> = [] // last array seen by scan (reference diff)
72
+ let prevSnapById = new Map<string, TData>() // cloned prev VALUES (only if needsPrev)
73
+ let fullById: Map<string, TData> | null = null // full current set (only if needsFullSet)
74
+
75
+ // Pending work, accumulated between scheduler runs.
76
+ const pushQueue = new Map<string, TData>()
77
+ let scanSource: (() => ReadonlyArray<TData>) | null = null
78
+
79
+ function snapshotAll(rows: ReadonlyArray<TData>) {
80
+ prevSnapById = new Map()
81
+ for (const row of rows) prevSnapById.set(getRowId(row), clone(row))
82
+ }
83
+
84
+ function rebuildFullIndex(rows: ReadonlyArray<TData>) {
85
+ fullById = new Map()
86
+ for (const row of rows) fullById.set(getRowId(row), row)
87
+ }
88
+
89
+ function seedNow(rows: ReadonlyArray<TData>) {
90
+ engine.reset()
91
+ engine.evaluate(rows) // arm dataChange/aggregate edges; discard the events
92
+ seeded = true
93
+ prevRows = rows
94
+ if (engine.needsPrev()) snapshotAll(rows)
95
+ else prevSnapById = new Map()
96
+ if (engine.needsFullSet()) rebuildFullIndex(rows)
97
+ else fullById = null
98
+ }
99
+
100
+ function emit(events: AlertEvent[]) {
101
+ if (events.length) onEvents(events)
102
+ }
103
+
104
+ /** PUSH run: evaluate exactly the queued rows (or the full set for aggregates). */
105
+ function runPush() {
106
+ const next = [...pushQueue.values()]
107
+ pushQueue.clear()
108
+ if (next.length === 0) return
109
+ const needsPrev = engine.needsPrev()
110
+
111
+ if (engine.needsFullSet()) {
112
+ // Aggregates need every row; evaluate over the maintained full index.
113
+ const rows = fullById ? [...fullById.values()] : next
114
+ const prev = needsPrev ? [...prevSnapById.values()] : []
115
+ const events = engine.evaluateTransition(prev, rows)
116
+ if (needsPrev) snapshotAll(rows)
117
+ emit(events)
118
+ return
119
+ }
120
+
121
+ const prev = needsPrev ? next.map((r) => prevSnapById.get(getRowId(r)) ?? r) : []
122
+ if (needsPrev) for (const r of next) prevSnapById.set(getRowId(r), clone(r))
123
+ emit(engine.evaluateTransition(prev, next))
124
+ }
125
+
126
+ /** SCAN run: index-diff the latest full array and evaluate the mismatches. */
127
+ function runScan(rows: ReadonlyArray<TData>) {
128
+ if (!seeded) {
129
+ seedNow(rows)
130
+ return
131
+ }
132
+ const needsPrev = engine.needsPrev()
133
+ const needsFull = engine.needsFullSet() || rows.length !== prevRows.length
134
+
135
+ if (needsFull) {
136
+ const prev = needsPrev ? [...prevSnapById.values()] : []
137
+ const events = engine.evaluateTransition(prev, rows)
138
+ prevRows = rows
139
+ if (needsPrev) snapshotAll(rows)
140
+ if (engine.needsFullSet()) rebuildFullIndex(rows)
141
+ emit(events)
142
+ return
143
+ }
144
+
145
+ // Fast path: index-aligned reference diff (unchanged rows keep their ref).
146
+ const changedNext: TData[] = []
147
+ const changedPrev: TData[] = []
148
+ for (let i = 0; i < rows.length; i++) {
149
+ if (rows[i] !== prevRows[i]) {
150
+ const row = rows[i] as TData
151
+ changedNext.push(row)
152
+ if (needsPrev) changedPrev.push(prevSnapById.get(getRowId(row)) ?? row)
153
+ }
154
+ }
155
+ prevRows = rows
156
+ if (changedNext.length === 0) return
157
+ if (needsPrev) for (const r of changedNext) prevSnapById.set(getRowId(r), clone(r))
158
+ emit(engine.evaluateTransition(changedPrev, changedNext))
159
+ }
160
+
161
+ function run() {
162
+ // A consumer drives one mode at a time; be defensive and honour whichever
163
+ // has pending work (scan takes precedence as it carries the full set).
164
+ if (scanSource) {
165
+ const src = scanSource
166
+ scanSource = null
167
+ runScan(src())
168
+ }
169
+ if (pushQueue.size) runPush()
170
+ }
171
+
172
+ return {
173
+ seed(rows) {
174
+ // Seeding is a one-off; do it synchronously so edges are armed before the
175
+ // first data event, then let subsequent evaluations defer.
176
+ seedNow(rows)
177
+ },
178
+ pushChanged(rows) {
179
+ if (fullById) for (const r of rows) fullById.set(getRowId(r), r)
180
+ for (const r of rows) pushQueue.set(getRowId(r), r)
181
+ if (!seeded) seeded = true // push implies the consumer is driving; edges arm lazily
182
+ scheduler.schedule(run)
183
+ },
184
+ scan(getRows) {
185
+ scanSource = getRows
186
+ scheduler.schedule(run)
187
+ },
188
+ setRules(rules) {
189
+ engine.setRules(rules)
190
+ engine.reset()
191
+ // Force a fresh silent seed before anything fires under the new rules.
192
+ seeded = false
193
+ prevSnapById = new Map()
194
+ fullById = null
195
+ pushQueue.clear()
196
+ },
197
+ flush() {
198
+ scheduler.flush()
199
+ },
200
+ detach() {
201
+ scheduler.cancel()
202
+ prevSnapById = new Map()
203
+ fullById = null
204
+ pushQueue.clear()
205
+ scanSource = null
206
+ },
207
+ }
208
+ }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * alert-scheduler - decouples WHEN alert evaluation runs from the reactive flush
3
+ * that mutates the grid's data. The whole performance premise of the rewrite is
4
+ * that alert work must never lengthen the frame that paints the grid: on a data
5
+ * change we schedule one evaluation for AFTER paint (`requestAnimationFrame`) and
6
+ * coalesce a burst of updates into a single run.
7
+ *
8
+ * Modes:
9
+ * - `raf` (default) - run once on the next animation frame, post-paint.
10
+ * - `idle` - run when the browser is idle (`requestIdleCallback`), best for
11
+ * non-urgent watch scans; falls back to `raf` where unavailable.
12
+ * - `sync` - run immediately (deterministic tests, or hosts with no rAF).
13
+ *
14
+ * The scheduler is intentionally tiny and dependency-free: it holds at most one
15
+ * pending callback (the latest wins), so repeated `schedule()` calls within a
16
+ * frame collapse to a single run. The observer accumulates the changed-row queue
17
+ * itself, so "latest wins" loses no work.
18
+ */
19
+ export type AlertSchedulerMode = 'raf' | 'idle' | 'sync'
20
+
21
+ export type AlertScheduler = {
22
+ /** Queue `fn` to run once on the next tick (coalesced). */
23
+ schedule(fn: () => void): void
24
+ /** Run any pending callback now, synchronously. No-op if nothing is pending. */
25
+ flush(): void
26
+ /** Drop any pending callback without running it. */
27
+ cancel(): void
28
+ /** Whether a callback is currently queued. */
29
+ readonly pending: boolean
30
+ }
31
+
32
+ type RafHost = {
33
+ requestAnimationFrame?: (cb: () => void) => number
34
+ cancelAnimationFrame?: (handle: number) => void
35
+ requestIdleCallback?: (cb: () => void) => number
36
+ cancelIdleCallback?: (handle: number) => void
37
+ }
38
+
39
+ const host: RafHost = typeof globalThis !== 'undefined' ? (globalThis as RafHost) : {}
40
+
41
+ function raf(cb: () => void): () => void {
42
+ if (typeof host.requestAnimationFrame === 'function') {
43
+ const id = host.requestAnimationFrame(cb)
44
+ return () => host.cancelAnimationFrame?.(id)
45
+ }
46
+ const id = setTimeout(cb, 16)
47
+ return () => clearTimeout(id)
48
+ }
49
+
50
+ function idle(cb: () => void): () => void {
51
+ if (typeof host.requestIdleCallback === 'function') {
52
+ const id = host.requestIdleCallback(cb)
53
+ return () => host.cancelIdleCallback?.(id)
54
+ }
55
+ return raf(cb)
56
+ }
57
+
58
+ export function createAlertScheduler(mode: AlertSchedulerMode = 'raf'): AlertScheduler {
59
+ let pendingFn: (() => void) | null = null
60
+ let cancelHandle: (() => void) | null = null
61
+
62
+ const request = mode === 'idle' ? idle : raf
63
+
64
+ const run = () => {
65
+ cancelHandle = null
66
+ const fn = pendingFn
67
+ pendingFn = null
68
+ fn?.()
69
+ }
70
+
71
+ return {
72
+ schedule(fn) {
73
+ if (mode === 'sync') {
74
+ fn()
75
+ return
76
+ }
77
+ // Latest wins - the observer keeps the accumulated queue, so replacing the
78
+ // callback never drops queued rows; it just avoids double-running a frame.
79
+ pendingFn = fn
80
+ if (!cancelHandle) cancelHandle = request(run)
81
+ },
82
+ flush() {
83
+ if (!pendingFn) return
84
+ cancelHandle?.()
85
+ run()
86
+ },
87
+ cancel() {
88
+ cancelHandle?.()
89
+ cancelHandle = null
90
+ pendingFn = null
91
+ },
92
+ get pending() {
93
+ return pendingFn != null
94
+ },
95
+ }
96
+ }