@svgrid/enterprise 2.0.4 → 2.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +18 -6
  2. package/dist/cdn/svgrid-enterprise.svelte-external.js +14035 -6842
  3. package/dist/node/studio.js +7888 -2459
  4. package/package.json +9 -4
  5. package/src/SvGridMasterDetail.svelte +24 -3
  6. package/src/SvGridScheduler.svelte +4410 -0
  7. package/src/SvPivotDesigner.svelte +1990 -1045
  8. package/src/SvSchemaChart.svelte +10 -9
  9. package/src/ai.test.ts +522 -522
  10. package/src/ai.ts +202 -2
  11. package/src/export.ts +7 -1
  12. package/src/index.ts +409 -384
  13. package/src/install.ts +10 -0
  14. package/src/pivot-chart.test.ts +86 -0
  15. package/src/pivot-chart.ts +112 -0
  16. package/src/scheduler.ts +37 -0
  17. package/src/scheduling.test.ts +194 -0
  18. package/src/scheduling.ts +293 -0
  19. package/src/sources/filters.ts +6 -0
  20. package/src/studio/HANDLERS-DESIGN.md +142 -0
  21. package/src/studio/cli.ts +7 -2
  22. package/src/studio/emit-project.test.ts +1447 -13
  23. package/src/studio/emit-project.ts +3995 -1273
  24. package/src/studio/emit-schema.ts +146 -29
  25. package/src/studio/index.ts +320 -195
  26. package/src/studio/project.test.ts +370 -0
  27. package/src/studio/project.ts +1146 -26
  28. package/src/studio/sample-data.ts +4 -1
  29. package/src/studio/samples/ats.ts +2 -2
  30. package/src/studio/samples/clinic.ts +4 -2
  31. package/src/studio/samples/crm.ts +16 -8
  32. package/src/studio/samples/events.ts +4 -2
  33. package/src/studio/samples/fleet.ts +4 -2
  34. package/src/studio/samples/gym.ts +4 -2
  35. package/src/studio/samples/hr.ts +3 -1
  36. package/src/studio/samples/live-data.ts +308 -308
  37. package/src/studio/samples/projects.ts +2 -2
  38. package/src/studio/samples/restaurant.ts +4 -2
  39. package/src/studio/samples/samples.test.ts +13 -5
  40. package/src/studio/samples/shared.ts +346 -305
  41. package/src/studio/samples/support.ts +3 -1
  42. package/src/studio/scaffold.test.ts +15 -1
  43. package/src/studio/scaffold.ts +16 -0
  44. package/src/studio/themes.ts +7 -0
  45. package/src/studio/ui-components.ts +472 -0
  46. package/src/sveltekit/transport.test.ts +26 -0
  47. package/src/sveltekit/transport.ts +50 -5
  48. package/dist/designer/assets/index-Dp44bTid.js +0 -939
  49. package/dist/designer/assets/index-RJp6x8tw.css +0 -1
  50. package/dist/designer/assets/jszip.min-CjMo-QGg.js +0 -2
  51. package/dist/designer/index.html +0 -13
@@ -0,0 +1,293 @@
1
+ /**
2
+ * Scheduling - client-side, cron-driven automation for grid actions.
3
+ *
4
+ * Two everyday jobs that normally need a backend job runner - "email me this
5
+ * report every weekday at 17:30" and "remind the desk at 09:00 to reconcile" -
6
+ * are, in a long-lived data app, just a timer plus an action you already have:
7
+ * an export (`api.exportCsv` / `@svgrid/enterprise` Excel/PDF) or an alert
8
+ * (`toast`). This module supplies the missing middle: a pure cron matcher and a
9
+ * small runtime that fires your callback when a schedule comes due.
10
+ *
11
+ * Everything here is pure and injectable (`now`), so the matching logic is
12
+ * unit-testable without wall-clock flakiness, and the render/app layer just
13
+ * wires `onFire` to whatever action it wants.
14
+ *
15
+ * ```ts
16
+ * import { createScheduler } from '@svgrid/enterprise'
17
+ * import { toast } from '@svgrid/grid'
18
+ *
19
+ * const scheduler = createScheduler({
20
+ * schedules: [
21
+ * { id: 'eod', name: 'End-of-day CSV', cron: '30 17 * * 1-5' },
22
+ * { id: 'standup', name: 'Stand-up reminder', cron: '0 9 * * *' },
23
+ * ],
24
+ * onFire(schedule) {
25
+ * if (schedule.id === 'eod') api.exportCsv({ filename: 'eod' })
26
+ * else toast.info(`${schedule.name}`)
27
+ * },
28
+ * })
29
+ * scheduler.start() // stop() on teardown
30
+ * ```
31
+ *
32
+ * Caveat, by design: schedules run entirely in the browser tab, so the app has
33
+ * to be open when a schedule is due. Pair with a server job for guaranteed
34
+ * delivery; use this for the far more common "the dashboard is always up on the
35
+ * wall" case where a backend cron is overkill.
36
+ */
37
+
38
+ /** A field in the range [min, max] that a parsed cron sub-expression matches. */
39
+ type CronField = { min: number; max: number; match: (n: number) => boolean }
40
+
41
+ export type Schedule = {
42
+ /** Stable id - used to dedupe fires and to key your `onFire` switch. */
43
+ id: string
44
+ /** Human label shown in a schedules panel / toast. */
45
+ name?: string
46
+ /**
47
+ * Recurring trigger: a standard 5-field cron expression
48
+ * `"minute hour day-of-month month day-of-week"`. Ignored when `runAt` is set.
49
+ * Supports `*`, lists (`1,15`), ranges (`1-5`), and steps (`*​/15`, `9-17/2`).
50
+ * Day-of-week is `0-6` with Sunday `0` (`7` also accepted for Sunday).
51
+ */
52
+ cron?: string
53
+ /**
54
+ * One-off trigger: an ISO datetime string. Fires a single time when the
55
+ * wall clock reaches that minute, then never again. Takes precedence over
56
+ * `cron` when both are present.
57
+ */
58
+ runAt?: string
59
+ /** Set `false` to keep the definition but stop it firing. Default `true`. */
60
+ enabled?: boolean
61
+ }
62
+
63
+ const RANGES = {
64
+ minute: [0, 59],
65
+ hour: [0, 23],
66
+ dom: [1, 31],
67
+ month: [1, 12],
68
+ dow: [0, 6],
69
+ } as const
70
+
71
+ /** Parse one cron field (e.g. `*​/15`, `1-5`, `0,30`) into a matcher. */
72
+ function parseField(raw: string, min: number, max: number): CronField {
73
+ const allowed = new Set<number>()
74
+ for (const part of raw.split(',')) {
75
+ const [rangePart, stepPart] = part.split('/')
76
+ const step = stepPart ? parseInt(stepPart, 10) : 1
77
+ if (!Number.isFinite(step) || step < 1) {
78
+ throw new Error(`Invalid cron step in "${raw}"`)
79
+ }
80
+ let lo: number
81
+ let hi: number
82
+ if (rangePart === '*' || rangePart === '') {
83
+ lo = min
84
+ hi = max
85
+ } else if (rangePart!.includes('-')) {
86
+ const [a, b] = rangePart!.split('-')
87
+ lo = parseInt(a!, 10)
88
+ hi = parseInt(b!, 10)
89
+ } else {
90
+ lo = parseInt(rangePart!, 10)
91
+ hi = lo
92
+ }
93
+ if (!Number.isFinite(lo) || !Number.isFinite(hi)) {
94
+ throw new Error(`Invalid cron field "${raw}"`)
95
+ }
96
+ for (let n = lo; n <= hi; n += step) {
97
+ if (n >= min && n <= max) allowed.add(n)
98
+ }
99
+ }
100
+ return { min, max, match: (n) => allowed.has(n) }
101
+ }
102
+
103
+ type ParsedCron = {
104
+ minute: CronField
105
+ hour: CronField
106
+ dom: CronField
107
+ month: CronField
108
+ dow: CronField
109
+ /** True when BOTH day-of-month and day-of-week are restricted (not `*`). */
110
+ domAndDowRestricted: boolean
111
+ }
112
+
113
+ /**
114
+ * Parse a 5-field cron expression. Throws on the wrong field count or an
115
+ * unparseable field, so a bad schedule surfaces at setup, not silently at 3am.
116
+ */
117
+ export function parseCron(expr: string): ParsedCron {
118
+ const fields = expr.trim().split(/\s+/)
119
+ if (fields.length !== 5) {
120
+ throw new Error(
121
+ `Cron needs 5 fields "min hour dom month dow", got ${fields.length}: "${expr}"`,
122
+ )
123
+ }
124
+ const [min, hour, dom, month, dow] = fields
125
+ // Day-of-week 7 is an alias for Sunday (0); normalize before parsing.
126
+ const dowNorm = dow!.replace(/7/g, '0')
127
+ return {
128
+ minute: parseField(min!, ...RANGES.minute),
129
+ hour: parseField(hour!, ...RANGES.hour),
130
+ dom: parseField(dom!, ...RANGES.dom),
131
+ month: parseField(month!, ...RANGES.month),
132
+ dow: parseField(dowNorm, ...RANGES.dow),
133
+ domAndDowRestricted: dom !== '*' && dow !== '*',
134
+ }
135
+ }
136
+
137
+ /** Whether a parsed cron matches a specific `Date` to the minute. */
138
+ export function cronMatchesParsed(parsed: ParsedCron, date: Date): boolean {
139
+ if (!parsed.minute.match(date.getMinutes())) return false
140
+ if (!parsed.hour.match(date.getHours())) return false
141
+ if (!parsed.month.match(date.getMonth() + 1)) return false
142
+ const domOk = parsed.dom.match(date.getDate())
143
+ const dowOk = parsed.dow.match(date.getDay())
144
+ // Standard cron semantics: when both day fields are restricted the match is
145
+ // the UNION (fires if either matches); otherwise plain AND.
146
+ return parsed.domAndDowRestricted ? domOk || dowOk : domOk && dowOk
147
+ }
148
+
149
+ /** Whether a cron expression matches a `Date` to the minute. */
150
+ export function cronMatches(expr: string, date: Date): boolean {
151
+ return cronMatchesParsed(parseCron(expr), date)
152
+ }
153
+
154
+ /** Two dates fall in the same wall-clock minute. */
155
+ function sameMinute(a: Date, b: Date): boolean {
156
+ return (
157
+ a.getFullYear() === b.getFullYear() &&
158
+ a.getMonth() === b.getMonth() &&
159
+ a.getDate() === b.getDate() &&
160
+ a.getHours() === b.getHours() &&
161
+ a.getMinutes() === b.getMinutes()
162
+ )
163
+ }
164
+
165
+ /** Whether a schedule is due to fire during the minute containing `date`. */
166
+ export function isScheduleDue(schedule: Schedule, date: Date): boolean {
167
+ if (schedule.enabled === false) return false
168
+ if (schedule.runAt) {
169
+ const at = new Date(schedule.runAt)
170
+ return !Number.isNaN(at.getTime()) && sameMinute(at, date)
171
+ }
172
+ if (schedule.cron) return cronMatches(schedule.cron, date)
173
+ return false
174
+ }
175
+
176
+ /**
177
+ * The next `Date` (to the minute) at or after `from` that `schedule` fires,
178
+ * or `null` if it never will again (past one-off, or no match within a year).
179
+ * Handy for a "next run" column in a schedules panel.
180
+ */
181
+ export function nextRun(schedule: Schedule, from: Date): Date | null {
182
+ if (schedule.enabled === false) return null
183
+ if (schedule.runAt) {
184
+ const at = new Date(schedule.runAt)
185
+ if (Number.isNaN(at.getTime())) return null
186
+ // Compare at minute granularity - a runAt in the current minute is "now".
187
+ const floor = new Date(from)
188
+ floor.setSeconds(0, 0)
189
+ return at.getTime() >= floor.getTime() ? at : null
190
+ }
191
+ if (!schedule.cron) return null
192
+ const parsed = parseCron(schedule.cron)
193
+ const cursor = new Date(from)
194
+ cursor.setSeconds(0, 0)
195
+ // Scan minute-by-minute for up to ~366 days (527040 minutes).
196
+ for (let i = 0; i < 527_040; i += 1) {
197
+ if (cronMatchesParsed(parsed, cursor)) return new Date(cursor)
198
+ cursor.setMinutes(cursor.getMinutes() + 1)
199
+ }
200
+ return null
201
+ }
202
+
203
+ export type SchedulerOptions = {
204
+ /** The schedules to run. Read once per tick, so you may mutate the array. */
205
+ schedules: ReadonlyArray<Schedule>
206
+ /** Called when a schedule comes due. Receives the schedule + the fire time. */
207
+ onFire: (schedule: Schedule, firedAt: Date) => void
208
+ /**
209
+ * Clock source, injectable for tests. Default `() => new Date()`.
210
+ */
211
+ now?: () => Date
212
+ /**
213
+ * How often to check, in ms. Default 30_000 (twice a minute) so a fire is
214
+ * never more than ~30s late; per-minute dedupe keeps it firing once.
215
+ */
216
+ intervalMs?: number
217
+ }
218
+
219
+ export type Scheduler = {
220
+ /** Begin ticking. No-op if already started. */
221
+ start: () => void
222
+ /** Stop ticking and clear the timer. */
223
+ stop: () => void
224
+ /**
225
+ * Run one check against `at` (default now) and fire anything due. Exposed for
226
+ * tests and for a "run due now" button; the internal tick calls this too.
227
+ */
228
+ tick: (at?: Date) => void
229
+ /** Upcoming fire time per schedule id, from `at` (default now). */
230
+ upcoming: (at?: Date) => Array<{ id: string; at: Date | null }>
231
+ }
232
+
233
+ /**
234
+ * A client-side scheduler: ticks on an interval, fires `onFire` for every
235
+ * schedule due in the current minute, and guarantees at most one fire per
236
+ * schedule per minute (and exactly one, ever, for a one-off).
237
+ */
238
+ export function createScheduler(options: SchedulerOptions): Scheduler {
239
+ const now = options.now ?? (() => new Date())
240
+ const intervalMs = options.intervalMs ?? 30_000
241
+ // Per-schedule guard: the minute-key we last fired, so a twice-a-minute tick
242
+ // (or a manual tick) never double-fires the same minute. One-offs are marked
243
+ // done so they never fire again even across minutes.
244
+ const lastFiredKey = new Map<string, string>()
245
+ const oneOffDone = new Set<string>()
246
+ let timer: ReturnType<typeof setInterval> | null = null
247
+
248
+ const minuteKey = (d: Date) =>
249
+ `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}-${d.getHours()}-${d.getMinutes()}`
250
+
251
+ function tick(at: Date = now()): void {
252
+ const key = minuteKey(at)
253
+ for (const schedule of options.schedules) {
254
+ if (schedule.enabled === false) continue
255
+ const oneOff = !!schedule.runAt
256
+ if (oneOff && oneOffDone.has(schedule.id)) continue
257
+ if (lastFiredKey.get(schedule.id) === key) continue
258
+ if (!isScheduleDue(schedule, at)) continue
259
+ lastFiredKey.set(schedule.id, key)
260
+ if (oneOff) oneOffDone.add(schedule.id)
261
+ options.onFire(schedule, at)
262
+ }
263
+ }
264
+
265
+ return {
266
+ start() {
267
+ if (timer != null) return
268
+ timer = setInterval(() => tick(), intervalMs)
269
+ },
270
+ stop() {
271
+ if (timer != null) {
272
+ clearInterval(timer)
273
+ timer = null
274
+ }
275
+ },
276
+ tick,
277
+ upcoming(at: Date = now()) {
278
+ return options.schedules.map((s) => ({ id: s.id, at: nextRun(s, at) }))
279
+ },
280
+ }
281
+ }
282
+
283
+ /** A few common cron expressions, for docs, presets, and pickers. */
284
+ export const CRON_PRESETS: ReadonlyArray<{ label: string; cron: string }> = [
285
+ { label: 'Every minute', cron: '* * * * *' },
286
+ { label: 'Every 15 minutes', cron: '*/15 * * * *' },
287
+ { label: 'Hourly, on the hour', cron: '0 * * * *' },
288
+ { label: 'Every weekday at 09:00', cron: '0 9 * * 1-5' },
289
+ { label: 'Weekdays at 17:30', cron: '30 17 * * 1-5' },
290
+ { label: 'Daily at midnight', cron: '0 0 * * *' },
291
+ { label: 'Monday mornings at 08:00', cron: '0 8 * * 1' },
292
+ { label: 'First of the month at 06:00', cron: '0 6 1 * *' },
293
+ ]
@@ -53,6 +53,12 @@ export function normalizeFilters(model: ServerFilterModel | undefined): Normaliz
53
53
  case 'lessThan':
54
54
  if (value) predicates.push({ column, op: 'lt', value })
55
55
  break
56
+ // The grid's client-side filter row supports a wider operator set
57
+ // (notContains, notEquals, endsWith, regex, in, notIn, isNotBlank).
58
+ // Server data sources don't translate those yet - they fall through to
59
+ // `contains` below so a backend query stays valid and predictable
60
+ // rather than silently dropping the predicate. Widen this switch when
61
+ // per-backend support for the extra operators lands.
56
62
  case 'contains':
57
63
  default:
58
64
  if (value) predicates.push({ column, op: 'contains', value })
@@ -0,0 +1,142 @@
1
+ # Studio: design-time + your own code ("handlers")
2
+
3
+ Status: **Contract + tiered code-behind shipped.** The whole screen is scriptable,
4
+ not just the grid. Read this before adding code to the generator or the designer.
5
+
6
+ ## What ships today (the tiered `ctx`)
7
+
8
+ Every block on a code-enabled screen becomes a named, typed member of the page
9
+ `ctx`, tiered by how much API it has. `screenHandles()` in `emit-project.ts` is the
10
+ single source of truth the page wiring, the `PageContext` type, and the
11
+ `handlers.ts` manifest all agree on.
12
+
13
+ - **Tier 1 - the Grid (`ctx.grid`)**: the real, full `SvGridApi`, typed to the
14
+ entity's row (`SvGridApi<any, Customers>`). exportCsv(), setFilter(), addRow(), ...
15
+ - **Tier 2 - data-viz blocks (`ctx.chart1`, `ctx.kpi1`, `ctx.gauge1`, ...)**: a
16
+ reactive `DataHandle`. By default it mirrors the screen dataset; `setData(rows)`
17
+ feeds the block its own rows, `.rows` reads them, `clear()` follows the dataset
18
+ again. Kinds: chart, kpi, gauge, pivot, tree, dashboard (top-level blocks).
19
+ - **Tier 3 - UI components (`ctx.button1`, ...)**: a typed `Handle` (e.g.
20
+ `ButtonHandle = Handle & { setVariant('primary' | ...): void; onclick: ... }`),
21
+ now wired on entity screens too (not just freestanding).
22
+ - **Batteries**: `ctx.data` (the screen dataset: `setRows` on a freestanding
23
+ data-grid, `reload()` on an entity grid), `ctx.goto(path)`, `ctx.params`.
24
+
25
+ Lifecycle slots: **`onLoad(ctx)`** on mount and **`onDestroy(ctx)`** on unmount
26
+ (both structured, per-slot bodies in the model's `handlerBodies`). The advanced
27
+ `handlersSource` escape hatch still overrides the whole file verbatim.
28
+
29
+ The rest of this doc is the original Phase-0/1 contract, kept for the round-trip
30
+ rules (still authoritative) - the generated-shape examples below predate the tiers.
31
+
32
+ ## Goal
33
+
34
+ Make Studio the #1 way a Svelte dev builds a SvelteKit app with our components -
35
+ the Grid especially. You design a screen visually (empty page + the UI toolbox),
36
+ then write real code behind it. The output is clean, idiomatic, **ejectable**
37
+ SvelteKit that the user owns. Not WinForms/WebForms code-behind: Svelte already
38
+ unifies markup + `<script>`, so we don't split a partial class - we give user
39
+ logic a safe, un-clobberable home and wire the design to it.
40
+
41
+ ## The round-trip contract (the make-or-break)
42
+
43
+ Two kinds of file exist in a generated app:
44
+
45
+ | File | Owner | On re-generate |
46
+ | --- | --- | --- |
47
+ | `+page.svelte`, `+server.ts`, `src/lib/*` (generated) | **Studio** | Overwritten from the model |
48
+ | `src/routes/<route>/handlers.ts` (companion) | **You** | **Never overwritten** - scaffolded once, then yours forever |
49
+
50
+ **Companion-file-only** is the model (chosen over managed-markers) because it works
51
+ identically in the browser "Generate" path and the CLI - the browser can't merge
52
+ against files it can't see, so we never put user code in a file the generator
53
+ rewrites. The generated page *imports* the companion; it never edits it.
54
+
55
+ Enforcement: a companion `GeneratedFile` carries `userOwned: true`, and every
56
+ in-place writer runs it through the shared `skipUserOwned(file, exists)` predicate
57
+ (`scaffold.ts`) and **skips it if it already exists** - wired into both the CLI
58
+ regenerate (`cli.ts writeAll`) and the designer's bundle write
59
+ (`designer-server.ts writeBundle`). The browser zip includes the stub only as a
60
+ starting point; extracting over an existing project is the user's choice.
61
+
62
+ ## Schema (model)
63
+
64
+ On `Screen`:
65
+
66
+ ```ts
67
+ code?: boolean // this screen has a handlers.ts companion
68
+ events?: EventBinding[] // wiring from a lifecycle/DOM event to a handler name
69
+
70
+ type ScreenEvent = 'load' // Phase 1: screen lifecycle. Per-block DOM events land next.
71
+ type EventBinding = { on: ScreenEvent; handler: string }
72
+ ```
73
+
74
+ Phase 2+ extends `EventBinding['on']` and moves bindings onto blocks
75
+ (`grid.onRowClick`, `form.onSubmit`, component `onclick`), all resolving to a
76
+ function name exported from the same screen companion.
77
+
78
+ ## Generated shape (Phase 1)
79
+
80
+ For a screen with `code: true` and `events: [{ on: 'load', handler: 'load' }]`:
81
+
82
+ `src/routes/<route>/handlers.ts` (userOwned, create-once):
83
+
84
+ ```ts
85
+ // Your code. SvGrid Studio scaffolds this file once and never overwrites it.
86
+ import type { RowData } from '@svgrid/grid'
87
+
88
+ /** Runs when the page mounts. Return the rows to render, fetch data, set state. */
89
+ export async function load(): Promise<RowData[]> {
90
+ // TODO: fetch or compute your rows.
91
+ return []
92
+ }
93
+ ```
94
+
95
+ `src/routes/<route>/+page.svelte` (Studio-owned) imports and wires it:
96
+
97
+ ```svelte
98
+ <script lang="ts">
99
+ import { onMount } from 'svelte'
100
+ import * as handlers from './handlers'
101
+ let rows = $state<unknown[]>([])
102
+ onMount(async () => { rows = await handlers.load() })
103
+ </script>
104
+ ```
105
+
106
+ The Grid (hero) becomes usable on an empty page by binding its rows to `handlers.load()`
107
+ output - no entity/CRUD scaffolding required. That is the flagship of this feature.
108
+
109
+ ## Why this is safe
110
+
111
+ - User code is only ever in `handlers.ts`, which the generator treats as write-once.
112
+ - The generated page depends on the companion by import; a missing/renamed handler
113
+ is a compile error the user sees immediately (no silent breakage).
114
+ - Everything compiles to plain SvelteKit; `npm run dev` works with zero Studio runtime.
115
+
116
+ ## Non-goals (Phase 1)
117
+
118
+ - **No design-time execution** of user code. The designer preview stays declarative
119
+ with seed data; `handlers.ts` runs only in the generated app. (Revisit later via
120
+ the sandboxed playground runner.)
121
+ - **No in-browser type-checking / IntelliSense** beyond the editor + generated types.
122
+
123
+ ## Increment plan
124
+
125
+ 1. **Foundation** [done]: schema + companion emission (`userOwned`), page wiring,
126
+ emit tests. Freestanding screens first.
127
+ 2. **Grid-on-empty-page** [done] + toolbox component handles.
128
+ 3. **Designer Design | Code toggle** [done]: edit `handlers.ts` in-panel.
129
+ 4. **Tiered ctx across all screens** [done]: `screenHandles()` unifies grid /
130
+ data-viz (`DataHandle.setData`) / typed component handles on both entity and
131
+ freestanding screens; `ctx.data` / `ctx.goto` / `ctx.params` batteries;
132
+ `onDestroy` lifecycle slot; typed component handle aliases.
133
+ 5. **Write-once enforced** [done]: `skipUserOwned` in both in-place writers.
134
+
135
+ ### Next
136
+ - Designer Code view: surface the `onDestroy` slot tab + refresh the "ctx gives
137
+ you" reference / completions for data handles + batteries.
138
+ - Per-block **event slots** beyond lifecycle: component `onclick`, grid
139
+ `onRowClick(ctx, row)`, chart `onSelect/onDrill` resolving to named handlers.
140
+ - Data handles for tabs-nested viz blocks (today only top-level blocks get one).
141
+ - Surface non-grid kit component controllers via an `onReady` callback (upgrade
142
+ Tier 3 components to Tier 1 rich APIs where a headless core exists).
package/src/studio/cli.ts CHANGED
@@ -12,7 +12,7 @@
12
12
  import type { EntitySchema } from '../schema.js'
13
13
  import { introspectDrizzle, introspectDrizzleAll, introspectJson } from './introspect.js'
14
14
  import { introspectPrisma, introspectPrismaAll } from './introspect-prisma.js'
15
- import { mergeManaged, scaffold, type ScaffoldOptions } from './scaffold.js'
15
+ import { mergeManaged, scaffold, skipUserOwned, type ScaffoldOptions } from './scaffold.js'
16
16
  import { scaffoldApp, type ScaffoldAppOptions } from './scaffold-app.js'
17
17
  import { verifyScaffold, type VerifyResult } from './verify.js'
18
18
 
@@ -75,10 +75,15 @@ export async function resolveSchemas(from: string, io: StudioIO): Promise<Entity
75
75
  }
76
76
 
77
77
  /** Merge-write a set of generated files, preserving user edits outside managed regions. */
78
- async function writeAll(files: { path: string; contents: string }[], io: StudioIO): Promise<string[]> {
78
+ async function writeAll(files: { path: string; contents: string; userOwned?: boolean }[], io: StudioIO): Promise<string[]> {
79
79
  const written: string[] = []
80
80
  for (const file of files) {
81
81
  const existing = await io.readFile(file.path)
82
+ // User-owned companions (a screen's `handlers.ts`) carry no managed markers -
83
+ // they're scaffolded once and then the developer's code forever. Skip if it
84
+ // already exists so a regenerate never clobbers hand-written logic. See
85
+ // HANDLERS-DESIGN.md (the round-trip contract).
86
+ if (skipUserOwned(file, existing != null)) continue
82
87
  // Regenerating? Replace only the managed region, keep the user's edits.
83
88
  await io.writeFile(file.path, mergeManaged(existing, file.contents))
84
89
  written.push(file.path)