@dbx-tools/shared-genie 0.1.9

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 ADDED
@@ -0,0 +1,113 @@
1
+ # @dbx-tools/shared-genie
2
+
3
+ Browser-safe Genie schemas, event vocabulary, and snapshot diff helpers.
4
+
5
+ Import this package when a UI, test, or server component needs to validate Genie
6
+ wire payloads or derive high-level events from raw Genie message snapshots
7
+ without importing the Databricks SDK. The Node driver that calls Genie is
8
+ [`@dbx-tools/node-genie`](../../node/genie).
9
+
10
+ Key features:
11
+
12
+ - Runtime schemas for Genie messages, responses, spaces, attachments, and
13
+ statuses, including fields observed on the live wire.
14
+ - A flat `GenieChatEvent` vocabulary for thinking, SQL, rows, result, status,
15
+ and error rendering.
16
+ - Snapshot diff helpers that emit only newly observed semantic events.
17
+ - Attachment and status helpers for UI display logic.
18
+ - Browser-safe types that allow clients to validate Genie payloads without
19
+ bundling Databricks SDK runtime code.
20
+
21
+ ## Why Not Just AppKit Genie Types?
22
+
23
+ Use AppKit's Genie plugin and UI types when you are building directly against
24
+ the native AppKit Genie routes. Use this package when you need a browser-safe
25
+ event vocabulary independent of AppKit transport:
26
+
27
+ - validating raw Genie messages captured from the Databricks SDK;
28
+ - replaying persisted snapshots into semantic events;
29
+ - sharing a flat event union between `@dbx-tools/node-genie`,
30
+ `@dbx-tools/node-appkit-mastra`, and custom UI/tests;
31
+ - detecting attachment/status shapes that are present on the live wire but not
32
+ always convenient in generated SDK types.
33
+
34
+ ## Validate Genie Wire Payloads
35
+
36
+ ```ts
37
+ import { genieModel } from "@dbx-tools/shared-genie";
38
+
39
+ const message = genieModel.GenieMessageSchema.parse(rawMessage);
40
+ const attachments = genieModel.GenieResponseSchema.parse(rawMessage);
41
+ ```
42
+
43
+ The schemas extend generated Databricks SDK Genie types with fields observed on
44
+ the wire, including query thoughts, attachment discriminators, and serialized
45
+ space data.
46
+
47
+ ## Handle Typed Chat Events
48
+
49
+ ```ts
50
+ import { type GenieChatEvent } from "@dbx-tools/shared-genie";
51
+
52
+ function render(event: GenieChatEvent) {
53
+ switch (event.type) {
54
+ case "thinking":
55
+ return showThought(event.thought_type, event.text);
56
+ case "query":
57
+ return showSql(event.sql);
58
+ case "result":
59
+ return markDone(event.status);
60
+ }
61
+ }
62
+ ```
63
+
64
+ The event union is flat: every variant has `type` plus its own fields and shared
65
+ location fields such as `space_id`, `conversation_id`, and `message_id`.
66
+
67
+ ## Derive Events From Snapshots
68
+
69
+ ```ts
70
+ import { event } from "@dbx-tools/shared-genie";
71
+
72
+ for (const evt of event.eventsFromMessage(current, previous, spaceId)) {
73
+ render(evt);
74
+ }
75
+ ```
76
+
77
+ Use `eventsFromMessage()` when replaying persisted Genie messages, testing UI
78
+ event handling, or building a custom polling loop. Individual detectors are also
79
+ exported for targeted checks: `detectStatus`, `detectThinking`, `detectQuery`,
80
+ `detectRows`, and the other event-specific helpers.
81
+
82
+ ## Work With Status And Attachments
83
+
84
+ ```ts
85
+ import { genieModel } from "@dbx-tools/shared-genie";
86
+
87
+ if (genieModel.isTerminalStatus(message.status)) {
88
+ console.log(genieModel.humanizeStatus(message.status));
89
+ }
90
+
91
+ const kind = genieModel.detectAttachmentType(message.attachments?.[0]);
92
+ ```
93
+
94
+ `humanizeStatus()` is the shared display-label function. `detectAttachmentType()`
95
+ lets consumers switch on `query`, `text`, or `suggested_questions` instead of
96
+ probing optional fields.
97
+
98
+ ## Event Model
99
+
100
+ The event detector compares the current Genie message with the previous snapshot
101
+ from the same turn. This keeps streaming UIs from re-rendering duplicate thought
102
+ or query fragments while still allowing full history replay from persisted raw
103
+ messages. Consumers can use the full `eventsFromMessage()` helper or individual
104
+ detectors when a test needs to assert one specific behavior.
105
+
106
+ ## Modules
107
+
108
+ - `genieModel` - Genie schemas, status helpers, attachment helpers, and event
109
+ union schemas/types.
110
+ - `event` - event detector factory, individual detectors, and
111
+ `eventsFromMessage()`.
112
+
113
+ Server-side streaming is in [`@dbx-tools/node-genie`](../../node/genie).
package/index.ts ADDED
@@ -0,0 +1,7 @@
1
+ // GENERATED by projen watch - DO NOT EDIT.
2
+ // Regenerated from the exporting modules in ./src.
3
+ // Hand edits are overwritten on the next watch; this file is read-only.
4
+
5
+ export * as event from "./src/event";
6
+ export * as genieModel from "./src/genie-model";
7
+ export type { MessageStatus, GenieThoughtType, GenieThought, KnownAttachmentType, AttachmentType, GenieQueryAttachment, GenieAttachment, GenieMessage, GenieResponse, GenieSpace, TerminalStatus, GenieChatLocation, QuestionEvent, MessageEvent, StatusEvent, AttachmentEvent, ThinkingEvent, TextEvent, QueryEvent, StatementEvent, RowsEvent, SuggestedQuestionsEvent, ResultEvent, GenieChatEvent, GenieChatEventType, GenieChatEventFields } from "./src/genie-model";
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "@dbx-tools/shared-genie",
3
+ "repository": {
4
+ "type": "git",
5
+ "url": "git+https://github.com/reggie-db/dbx-tools.git",
6
+ "directory": "workspaces/shared/genie"
7
+ },
8
+ "devDependencies": {
9
+ "@types/node": "^24.6.0",
10
+ "tsx": "^4.23.0",
11
+ "typescript": "^5.9.3"
12
+ },
13
+ "dependencies": {
14
+ "zod": "^4.3.6",
15
+ "@dbx-tools/shared-core": "0.1.9",
16
+ "@dbx-tools/shared-sdk-model": "0.1.9"
17
+ },
18
+ "main": "index.ts",
19
+ "license": "UNLICENSED",
20
+ "version": "0.1.9",
21
+ "types": "index.ts",
22
+ "type": "module",
23
+ "exports": {
24
+ ".": "./index.ts",
25
+ "./package.json": "./package.json"
26
+ },
27
+ "dbxToolsConfig": {
28
+ "tags": [
29
+ "shared"
30
+ ]
31
+ },
32
+ "//": "~~ Generated by projen. To modify, edit .projenrc.js and run \"pnpm exec projen\".",
33
+ "scripts": {
34
+ "build": "projen build",
35
+ "compile": "projen compile",
36
+ "default": "projen default",
37
+ "package": "projen package",
38
+ "post-compile": "projen post-compile",
39
+ "pre-compile": "projen pre-compile",
40
+ "test": "projen test",
41
+ "watch": "projen watch",
42
+ "projen": "projen"
43
+ }
44
+ }
package/src/event.ts ADDED
@@ -0,0 +1,350 @@
1
+ /**
2
+ * Pure event-detection for `genieEventChat`. Given two
3
+ * `GenieMessage` snapshots (current + prior) and the surrounding
4
+ * `space_id`, derive the semantic deltas (status transitions, new
5
+ * attachments, new thoughts, SQL emission, warehouse submission,
6
+ * row-count progress, follow-up suggestions, text deltas) and
7
+ * yield them as typed {@link GenieChatEvent}s.
8
+ *
9
+ * Detectors are built through a single typed factory that ties
10
+ * each event's name to its scope (whole-message vs per-attachment)
11
+ * and payload shape, so a misnamed event or a payload that doesn't
12
+ * match its event fails to compile. An orchestrator walks the
13
+ * snapshot diff and yields every detector that fires in a stable
14
+ * order (status first, then per-attachment field deltas) so a
15
+ * subscriber that logs events as they arrive sees a sensible
16
+ * sequence.
17
+ *
18
+ * The module is intentionally pure: no `EventEmitter`, no `this`,
19
+ * no I/O, no module-level mutable state. The `message` and
20
+ * `result` events are NOT derived here - they belong to the chat
21
+ * lifecycle layer (the `genieEventChat` driver) because they track
22
+ * per-yield / per-turn-completion semantics rather than a
23
+ * field-level snapshot diff.
24
+ */
25
+
26
+ import {
27
+ detectAttachmentType,
28
+ type GenieAttachment,
29
+ type GenieChatEvent,
30
+ type GenieChatEventFields,
31
+ type GenieChatEventType,
32
+ type GenieChatLocation,
33
+ type GenieMessage,
34
+ type GenieThought,
35
+ } from "./genie-model";
36
+
37
+ /* ----------------------------- contract ---------------------------- */
38
+
39
+ /**
40
+ * What a single detector call returns: zero (`undefined`), one
41
+ * (fields object), or many (`fields[]`) events of the same type.
42
+ * Each result is the variant's payload fields **without** the
43
+ * `type` discriminator - the orchestrator stamps `type` when it
44
+ * yields the event.
45
+ */
46
+ type DetectorResult<T extends GenieChatEventType> =
47
+ GenieChatEventFields<T> | GenieChatEventFields<T>[] | undefined;
48
+
49
+ /**
50
+ * Where in the wire shape a given event is derived from. Drives
51
+ * which arguments `detect` receives. `"message"` events watch
52
+ * `GenieMessage` itself; `"attachment"` events watch one slot of
53
+ * `message.attachments[]`. `"lifecycle"` events (`message`,
54
+ * `result`) are emitted by the chat driver directly and can't be
55
+ * built with {@link eventDetector} - they have no diff signature.
56
+ */
57
+ interface DetectorScope {
58
+ // Message-scoped: top-level field diff on `GenieMessage`.
59
+ status: "message";
60
+ // Per-attachment: field diff on one `GenieAttachment` slot.
61
+ attachment: "attachment";
62
+ thinking: "attachment";
63
+ text: "attachment";
64
+ query: "attachment";
65
+ statement: "attachment";
66
+ rows: "attachment";
67
+ suggested_questions: "attachment";
68
+ // Lifecycle: not derived via diff, handled by the chat driver.
69
+ question: "lifecycle";
70
+ message: "lifecycle";
71
+ result: "lifecycle";
72
+ }
73
+
74
+ /**
75
+ * `detect` callback signature for a given event type. Resolved
76
+ * from {@link DetectorScope}: `"message"` events get the top-level
77
+ * snapshot triple, `"attachment"` events get the per-slot quad,
78
+ * `"lifecycle"` events resolve to `never` (no diff-based detector
79
+ * exists for them).
80
+ */
81
+ type DetectFn<T extends GenieChatEventType> = DetectorScope[T] extends "message"
82
+ ? (
83
+ current: GenieMessage,
84
+ previous: GenieMessage | undefined,
85
+ space_id: string,
86
+ ) => DetectorResult<T>
87
+ : DetectorScope[T] extends "attachment"
88
+ ? (
89
+ current: GenieAttachment,
90
+ previous: GenieAttachment | undefined,
91
+ location: GenieChatLocation,
92
+ index: number,
93
+ ) => DetectorResult<T>
94
+ : never;
95
+
96
+ /**
97
+ * Typed detector for one event in the {@link GenieChatEvent}
98
+ * union. The `type` field is the event name; `detect`'s signature
99
+ * is picked from {@link DetectorScope} based on `T`.
100
+ */
101
+ interface EventDetector<T extends GenieChatEventType> {
102
+ readonly type: T;
103
+ detect: DetectFn<T>;
104
+ }
105
+
106
+ /* ----------------------- detector factory ----------------------- */
107
+
108
+ /**
109
+ * Build an {@link EventDetector}. Pass the event name as the
110
+ * literal first arg and the matching `detect` callback as the
111
+ * second. TS infers `T` from the literal, narrows `detect`'s
112
+ * signature accordingly, and types the return as
113
+ * `EventDetector<T>`.
114
+ *
115
+ * Build-time guarantees:
116
+ *
117
+ * - `eventDetector("status2", ...)` fails - the name isn't in
118
+ * {@link GenieChatEvent}.
119
+ * - `eventDetector("status", attachmentArgsCallback)` fails -
120
+ * `"status"` is message-scoped, so `detect` must take
121
+ * `(GenieMessage, GenieMessage | undefined, string)`.
122
+ * - Returning a `ThinkingEvent`-shaped fields object from a
123
+ * `"status"` detector fails - the return type is constrained
124
+ * to `DetectorResult<"status">`.
125
+ *
126
+ * Lifecycle event names (`"message"`, `"result"`) resolve `detect`
127
+ * to `never` and won't compile, which is intentional: those have
128
+ * no diff signature and are emitted directly by the chat driver.
129
+ */
130
+ export function eventDetector<T extends GenieChatEventType>(
131
+ type: T,
132
+ detect: DetectFn<T>,
133
+ ): EventDetector<T> {
134
+ return { type, detect };
135
+ }
136
+
137
+ /* ---------------------------- detectors ---------------------------- */
138
+
139
+ /** Top-level `message.status` transitioned. */
140
+ export const detectStatus = eventDetector("status", (current, previous, space_id) => {
141
+ if (!current.status || current.status === previous?.status) return;
142
+ return {
143
+ status: current.status,
144
+ previous_status: previous?.status,
145
+ space_id,
146
+ conversation_id: current.conversation_id,
147
+ message_id: current.message_id,
148
+ };
149
+ });
150
+
151
+ /** First time we see an attachment slot. */
152
+ export const detectAttachmentAdded = eventDetector(
153
+ "attachment",
154
+ (current, previous, location, index) => {
155
+ if (previous) return;
156
+ return {
157
+ ...location,
158
+ index,
159
+ attachment_type: detectAttachmentType(current),
160
+ };
161
+ },
162
+ );
163
+
164
+ /**
165
+ * One emit per new `(thought_type, content)` tuple on a query
166
+ * attachment. Value-based set diff: Genie can mutate existing
167
+ * thought slots in place (e.g. re-typing index 0 from
168
+ * `DATA_SOURCING` to `DESCRIPTION` while re-appending the
169
+ * original at index 1), so positional / append-only diff would
170
+ * miss re-types and double-count re-orders.
171
+ */
172
+ export const detectThinking = eventDetector("thinking", (current, previous, location) => {
173
+ const currThoughts = current.query?.thoughts ?? [];
174
+ if (currThoughts.length === 0) return;
175
+ const seen = new Set((previous?.query?.thoughts ?? []).map(thoughtKey));
176
+ const out: GenieChatEventFields<"thinking">[] = [];
177
+ for (const t of currThoughts) {
178
+ const key = thoughtKey(t);
179
+ if (seen.has(key)) continue;
180
+ // Defensive: dedupe within a single snapshot in case Genie
181
+ // ever ships the same thought twice in one `thoughts[]`.
182
+ seen.add(key);
183
+ out.push({ ...location, text: t.content, thought_type: t.thought_type });
184
+ }
185
+ return out;
186
+ });
187
+
188
+ /** Text-attachment `content` appeared or changed. */
189
+ export const detectText = eventDetector("text", (current, previous, location) => {
190
+ const curr = current.text?.content;
191
+ const prev = previous?.text?.content;
192
+ if (curr === undefined || curr === prev) return;
193
+ return { ...location, text: curr };
194
+ });
195
+
196
+ /** SQL transitioned undefined -> string, or changed. */
197
+ export const detectQuery = eventDetector("query", (current, previous, location) => {
198
+ const curr = current.query?.query;
199
+ const prev = previous?.query?.query;
200
+ if (!curr || curr === prev) return;
201
+ return {
202
+ ...location,
203
+ sql: curr,
204
+ title: current.query?.title,
205
+ description: current.query?.description,
206
+ };
207
+ });
208
+
209
+ /** Warehouse-statement id assigned. */
210
+ export const detectStatement = eventDetector("statement", (current, previous, location) => {
211
+ const curr = current.query?.statement_id;
212
+ const prev = previous?.query?.statement_id;
213
+ if (!curr || curr === prev) return;
214
+ return { ...location, statement_id: curr };
215
+ });
216
+
217
+ /**
218
+ * `row_count` changed - fires on every transition including the
219
+ * initial `undefined -> 0` and the post-execution `0 -> N`.
220
+ * Carries the statement id when available for correlation.
221
+ */
222
+ export const detectRows = eventDetector("rows", (current, previous, location) => {
223
+ const curr = current.query?.query_result_metadata?.row_count;
224
+ const prev = previous?.query?.query_result_metadata?.row_count;
225
+ if (curr === undefined || curr === prev) return;
226
+ return {
227
+ ...location,
228
+ row_count: curr,
229
+ previous_row_count: prev,
230
+ statement_id: current.query?.statement_id ?? previous?.query?.statement_id,
231
+ };
232
+ });
233
+
234
+ /**
235
+ * Follow-up suggested-questions array appeared or changed.
236
+ * Compares JSON-stringified arrays so a length-preserving content
237
+ * rewrite still fires.
238
+ */
239
+ export const detectSuggestedQuestions = eventDetector(
240
+ "suggested_questions",
241
+ (current, previous, location) => {
242
+ const curr = current.suggested_questions?.questions;
243
+ const prev = previous?.suggested_questions?.questions;
244
+ if (!curr || curr.length === 0) return;
245
+ if (JSON.stringify(curr) === JSON.stringify(prev)) return;
246
+ return { ...location, questions: curr };
247
+ },
248
+ );
249
+
250
+ /* --------------------------- orchestrator --------------------------- */
251
+
252
+ /**
253
+ * Walk the diff between `current` and `previous` and yield every
254
+ * derived event the snapshot produced. Detector order mirrors
255
+ * Genie's wire ordering (status first, then per-attachment field
256
+ * deltas) so a subscriber that simply logs events as they arrive
257
+ * sees them in a sensible sequence.
258
+ *
259
+ * Caller responsibilities (not handled here):
260
+ *
261
+ * - Yield `{ type: "message", message: current }` BEFORE
262
+ * calling this, once per poll yield.
263
+ * - Yield `{ type: "result", ... }` AFTER calling this when
264
+ * `isTerminalStatus(current.status)` - per-turn lifecycle,
265
+ * not a per-snapshot field diff.
266
+ * - Decide what counts as a "fresh turn" and pass `undefined`
267
+ * for `previous` on turn boundaries, so anonymous-attachment
268
+ * state from a prior turn doesn't bleed in.
269
+ *
270
+ * Sync generator: the diff is pure CPU work, no awaits. Use
271
+ * `yield*` from an async generator to splice the events into a
272
+ * stream.
273
+ */
274
+ export function* eventsFromMessage(
275
+ current: GenieMessage,
276
+ previous: GenieMessage | undefined,
277
+ space_id: string,
278
+ ): Generator<GenieChatEvent, void, void> {
279
+ // Stamp `type` onto each detector result and yield it. Returning
280
+ // a typed generator keeps the `yield*` callsite tidy. The double
281
+ // cast (`unknown` -> `GenieChatEvent`) is needed because the
282
+ // generic merge `{type: T} & GenieChatEventFields<T>` doesn't
283
+ // structurally narrow back to a discriminated-union member -
284
+ // each detector's runtime output is shaped correctly by
285
+ // construction, so the cast is sound.
286
+ function* emit<T extends GenieChatEventType>(
287
+ detector: EventDetector<T>,
288
+ result: DetectorResult<T>,
289
+ ): Generator<GenieChatEvent, void, void> {
290
+ if (result === undefined) return;
291
+ if (Array.isArray(result)) {
292
+ for (const fields of result) {
293
+ yield { type: detector.type, ...fields } as unknown as GenieChatEvent;
294
+ }
295
+ } else {
296
+ yield { type: detector.type, ...result } as unknown as GenieChatEvent;
297
+ }
298
+ }
299
+
300
+ // Message-scoped detectors run once per snapshot.
301
+ yield* emit(detectStatus, detectStatus.detect(current, previous, space_id));
302
+
303
+ // Per-attachment detectors run once per attachment slot.
304
+ const currAtts = current.attachments ?? [];
305
+ const prevAtts = previous?.attachments ?? [];
306
+ for (let i = 0; i < currAtts.length; i++) {
307
+ const curr = currAtts[i]!;
308
+ const prev = matchPrevAttachment(curr, prevAtts, i);
309
+ const location: GenieChatLocation = {
310
+ space_id,
311
+ conversation_id: current.conversation_id,
312
+ message_id: current.message_id,
313
+ attachment_id: curr.attachment_id,
314
+ };
315
+ yield* emit(detectAttachmentAdded, detectAttachmentAdded.detect(curr, prev, location, i));
316
+ yield* emit(detectThinking, detectThinking.detect(curr, prev, location, i));
317
+ yield* emit(detectText, detectText.detect(curr, prev, location, i));
318
+ yield* emit(detectQuery, detectQuery.detect(curr, prev, location, i));
319
+ yield* emit(detectStatement, detectStatement.detect(curr, prev, location, i));
320
+ yield* emit(detectRows, detectRows.detect(curr, prev, location, i));
321
+ yield* emit(detectSuggestedQuestions, detectSuggestedQuestions.detect(curr, prev, location, i));
322
+ }
323
+ }
324
+
325
+ /* ----------------------------- helpers ----------------------------- */
326
+
327
+ /**
328
+ * Find the prior version of `curr` in `prevAtts`. Attachments
329
+ * with ids match by id (Genie keeps ids stable across polls);
330
+ * anonymous attachments (Genie's main-answer text doesn't get
331
+ * one) match positionally against an anonymous prev at the same
332
+ * index, so they don't accidentally bind to an id'd predecessor
333
+ * that happened to share the slot.
334
+ */
335
+ function matchPrevAttachment(
336
+ curr: GenieAttachment,
337
+ prevAtts: GenieAttachment[],
338
+ i: number,
339
+ ): GenieAttachment | undefined {
340
+ if (curr.attachment_id) {
341
+ return prevAtts.find((a) => a.attachment_id === curr.attachment_id);
342
+ }
343
+ const p = prevAtts[i];
344
+ return p && !p.attachment_id ? p : undefined;
345
+ }
346
+
347
+ /** Stable key for {@link detectThinking}'s value-based set diff. */
348
+ function thoughtKey(t: GenieThought): string {
349
+ return `${t.thought_type}|${t.content}`;
350
+ }