@svgrid/enterprise 2.0.3 → 2.2.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 +18 -6
- package/dist/cdn/svgrid-enterprise.svelte-external.js +14025 -6836
- package/dist/node/studio.js +7889 -2460
- package/package.json +9 -4
- package/src/SvGridMasterDetail.svelte +24 -3
- package/src/SvGridScheduler.svelte +4410 -0
- package/src/SvPivotDesigner.svelte +1990 -1045
- package/src/SvSchemaChart.svelte +10 -9
- package/src/ai.test.ts +522 -522
- package/src/ai.ts +202 -2
- package/src/index.ts +409 -384
- package/src/install.ts +10 -0
- package/src/pivot-chart.test.ts +86 -0
- package/src/pivot-chart.ts +112 -0
- package/src/scheduler.ts +37 -0
- package/src/scheduling.test.ts +194 -0
- package/src/scheduling.ts +293 -0
- package/src/sources/filters.ts +6 -0
- package/src/studio/HANDLERS-DESIGN.md +142 -0
- package/src/studio/cli.ts +7 -2
- package/src/studio/emit-project.test.ts +1447 -13
- package/src/studio/emit-project.ts +3995 -1273
- package/src/studio/emit-schema.ts +146 -29
- package/src/studio/index.ts +320 -195
- package/src/studio/project.test.ts +370 -0
- package/src/studio/project.ts +1146 -26
- package/src/studio/sample-data.ts +4 -1
- package/src/studio/samples/ats.ts +2 -2
- package/src/studio/samples/clinic.ts +4 -2
- package/src/studio/samples/crm.ts +16 -8
- package/src/studio/samples/events.ts +4 -2
- package/src/studio/samples/fleet.ts +4 -2
- package/src/studio/samples/gym.ts +4 -2
- package/src/studio/samples/hr.ts +3 -1
- package/src/studio/samples/live-data.ts +308 -308
- package/src/studio/samples/projects.ts +2 -2
- package/src/studio/samples/restaurant.ts +4 -2
- package/src/studio/samples/samples.test.ts +13 -5
- package/src/studio/samples/shared.ts +346 -305
- package/src/studio/samples/support.ts +3 -1
- package/src/studio/scaffold.test.ts +15 -1
- package/src/studio/scaffold.ts +16 -0
- package/src/studio/themes.ts +7 -0
- package/src/studio/ui-components.ts +472 -0
- package/src/sveltekit/transport.test.ts +26 -0
- package/src/sveltekit/transport.ts +50 -5
- package/dist/designer/assets/index-Dp44bTid.js +0 -939
- package/dist/designer/assets/index-RJp6x8tw.css +0 -1
- package/dist/designer/assets/jszip.min-CjMo-QGg.js +0 -2
- 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
|
+
]
|
package/src/sources/filters.ts
CHANGED
|
@@ -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)
|