@tai42/studio-sdk 13.3.0 → 15.0.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 (104) hide show
  1. package/dist/components/components.css +19 -0
  2. package/dist/components/icons.d.ts +2 -0
  3. package/dist/components/icons.d.ts.map +1 -1
  4. package/dist/components/icons.js +2 -0
  5. package/dist/components/icons.js.map +1 -1
  6. package/dist/components/templated-text-field.d.ts +98 -0
  7. package/dist/components/templated-text-field.d.ts.map +1 -0
  8. package/dist/components/templated-text-field.js +217 -0
  9. package/dist/components/templated-text-field.js.map +1 -0
  10. package/dist/hooks/useApi.d.ts +461 -59
  11. package/dist/hooks/useApi.d.ts.map +1 -1
  12. package/dist/index.d.ts +6 -2
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js +5 -2
  15. package/dist/index.js.map +1 -1
  16. package/dist/navigation/types.d.ts +2 -1
  17. package/dist/navigation/types.d.ts.map +1 -1
  18. package/dist/query-keys.d.ts +13 -0
  19. package/dist/query-keys.d.ts.map +1 -1
  20. package/dist/query-keys.js +13 -0
  21. package/dist/query-keys.js.map +1 -1
  22. package/dist/state-binding/AdapterMapping.d.ts +33 -0
  23. package/dist/state-binding/AdapterMapping.d.ts.map +1 -0
  24. package/dist/state-binding/AdapterMapping.js +205 -0
  25. package/dist/state-binding/AdapterMapping.js.map +1 -0
  26. package/dist/state-binding/BindingJqField.d.ts +43 -0
  27. package/dist/state-binding/BindingJqField.d.ts.map +1 -0
  28. package/dist/state-binding/BindingJqField.js +57 -0
  29. package/dist/state-binding/BindingJqField.js.map +1 -0
  30. package/dist/state-binding/BindingTemplatedJqField.d.ts +24 -0
  31. package/dist/state-binding/BindingTemplatedJqField.d.ts.map +1 -0
  32. package/dist/state-binding/BindingTemplatedJqField.js +7 -0
  33. package/dist/state-binding/BindingTemplatedJqField.js.map +1 -0
  34. package/dist/state-binding/InputInjectionRow.d.ts +18 -0
  35. package/dist/state-binding/InputInjectionRow.d.ts.map +1 -0
  36. package/dist/state-binding/InputInjectionRow.js +55 -0
  37. package/dist/state-binding/InputInjectionRow.js.map +1 -0
  38. package/dist/state-binding/StateAttachRow.d.ts +27 -0
  39. package/dist/state-binding/StateAttachRow.d.ts.map +1 -0
  40. package/dist/state-binding/StateAttachRow.js +102 -0
  41. package/dist/state-binding/StateAttachRow.js.map +1 -0
  42. package/dist/state-binding/StateBindingEditor.d.ts +37 -0
  43. package/dist/state-binding/StateBindingEditor.d.ts.map +1 -0
  44. package/dist/state-binding/StateBindingEditor.js +54 -0
  45. package/dist/state-binding/StateBindingEditor.js.map +1 -0
  46. package/dist/state-binding/StateBindingSection.d.ts +14 -0
  47. package/dist/state-binding/StateBindingSection.d.ts.map +1 -0
  48. package/dist/state-binding/StateBindingSection.js +17 -0
  49. package/dist/state-binding/StateBindingSection.js.map +1 -0
  50. package/dist/state-binding/SubjectScopeFields.d.ts +20 -0
  51. package/dist/state-binding/SubjectScopeFields.d.ts.map +1 -0
  52. package/dist/state-binding/SubjectScopeFields.js +8 -0
  53. package/dist/state-binding/SubjectScopeFields.js.map +1 -0
  54. package/dist/state-binding/UpdateRow.d.ts +20 -0
  55. package/dist/state-binding/UpdateRow.d.ts.map +1 -0
  56. package/dist/state-binding/UpdateRow.js +89 -0
  57. package/dist/state-binding/UpdateRow.js.map +1 -0
  58. package/dist/state-binding/adapter.d.ts +93 -0
  59. package/dist/state-binding/adapter.d.ts.map +1 -0
  60. package/dist/state-binding/adapter.js +251 -0
  61. package/dist/state-binding/adapter.js.map +1 -0
  62. package/dist/state-binding/build-catalog.d.ts +19 -0
  63. package/dist/state-binding/build-catalog.d.ts.map +1 -0
  64. package/dist/state-binding/build-catalog.js +43 -0
  65. package/dist/state-binding/build-catalog.js.map +1 -0
  66. package/dist/state-binding/catalog.d.ts +26 -0
  67. package/dist/state-binding/catalog.d.ts.map +1 -0
  68. package/dist/state-binding/catalog.js +71 -0
  69. package/dist/state-binding/catalog.js.map +1 -0
  70. package/dist/state-binding/index.d.ts +33 -0
  71. package/dist/state-binding/index.d.ts.map +1 -0
  72. package/dist/state-binding/index.js +20 -0
  73. package/dist/state-binding/index.js.map +1 -0
  74. package/dist/state-binding/templated-text-catalog.d.ts +21 -0
  75. package/dist/state-binding/templated-text-catalog.d.ts.map +1 -0
  76. package/dist/state-binding/templated-text-catalog.js +48 -0
  77. package/dist/state-binding/templated-text-catalog.js.map +1 -0
  78. package/dist/state-binding/types.d.ts +78 -0
  79. package/dist/state-binding/types.d.ts.map +1 -0
  80. package/dist/state-binding/types.js +2 -0
  81. package/dist/state-binding/types.js.map +1 -0
  82. package/package.json +5 -5
  83. package/src/components/components.css +19 -0
  84. package/src/components/icons.tsx +8 -0
  85. package/src/components/templated-text-field.tsx +508 -0
  86. package/src/index.ts +76 -1
  87. package/src/navigation/types.ts +4 -1
  88. package/src/query-keys.ts +15 -0
  89. package/src/state-binding/AdapterMapping.tsx +468 -0
  90. package/src/state-binding/BindingJqField.tsx +146 -0
  91. package/src/state-binding/BindingTemplatedJqField.tsx +73 -0
  92. package/src/state-binding/InputInjectionRow.tsx +157 -0
  93. package/src/state-binding/README.md +46 -0
  94. package/src/state-binding/StateAttachRow.tsx +289 -0
  95. package/src/state-binding/StateBindingEditor.tsx +166 -0
  96. package/src/state-binding/StateBindingSection.tsx +37 -0
  97. package/src/state-binding/SubjectScopeFields.tsx +57 -0
  98. package/src/state-binding/UpdateRow.tsx +227 -0
  99. package/src/state-binding/adapter.ts +276 -0
  100. package/src/state-binding/build-catalog.ts +59 -0
  101. package/src/state-binding/catalog.ts +87 -0
  102. package/src/state-binding/index.ts +67 -0
  103. package/src/state-binding/templated-text-catalog.ts +69 -0
  104. package/src/state-binding/types.ts +91 -0
@@ -0,0 +1,57 @@
1
+ /**
2
+ * The subject + scope of one attached state: the SUBJECT (required) yields either a
3
+ * bare record-key string or a full subject object; the optional SCOPE is a boolean
4
+ * jq predicate — when it is false the state is skipped for the run. Each is an
5
+ * authored templated-text value (inline jq or a stored template that renders to it).
6
+ */
7
+ import type { ReactNode } from 'react';
8
+
9
+ import { BindingTemplatedJqField } from './BindingTemplatedJqField';
10
+ import type { TemplateJqSuggestion } from './BindingJqField';
11
+ import type { TemplatedText, TemplatedTextCatalog } from './types';
12
+
13
+ export interface SubjectScopeFieldsProps {
14
+ readonly subjectExpr: TemplatedText;
15
+ readonly scopeExpr: TemplatedText | null;
16
+ readonly onSubjectChange: (value: TemplatedText) => void;
17
+ readonly onScopeChange: (value: TemplatedText | null) => void;
18
+ readonly subjectError?: string;
19
+ readonly suggestions?: readonly TemplateJqSuggestion[];
20
+ readonly templates?: TemplatedTextCatalog;
21
+ }
22
+
23
+ export function SubjectScopeFields({
24
+ subjectExpr,
25
+ scopeExpr,
26
+ onSubjectChange,
27
+ onScopeChange,
28
+ subjectError,
29
+ suggestions,
30
+ templates,
31
+ }: SubjectScopeFieldsProps): ReactNode {
32
+ return (
33
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--tai-space-2)' }}>
34
+ <BindingTemplatedJqField
35
+ label="Subject"
36
+ required
37
+ description="The record key this binding reads and updates: a key string, or a full subject object."
38
+ value={subjectExpr}
39
+ error={subjectError}
40
+ suggestions={suggestions}
41
+ templates={templates}
42
+ placeholder=".subject_id"
43
+ onChange={(next) => {
44
+ onSubjectChange(next ?? { content: '' });
45
+ }}
46
+ />
47
+ <BindingTemplatedJqField
48
+ label="Scope"
49
+ description="Optional. A jq condition; when it is false this state is skipped for the run."
50
+ value={scopeExpr}
51
+ suggestions={suggestions}
52
+ templates={templates}
53
+ onChange={onScopeChange}
54
+ />
55
+ </div>
56
+ );
57
+ }
@@ -0,0 +1,227 @@
1
+ /**
2
+ * The update list of one attached state. Each update applies AFTER the run: either
3
+ * a template `update` jq (picked by name, whose declared input is authored through
4
+ * the adapter mapping) or a custom jq over `{ record, output, input }` that authors
5
+ * the whole op batch itself. An optional op-id expression carries idempotency.
6
+ */
7
+ import type { ReactNode } from 'react';
8
+
9
+ import { Badge } from '../components/badge';
10
+ import { Button } from '../components/primitives';
11
+ import { Field } from '../components/field';
12
+ import { Select } from '../components/select';
13
+ import { TemplatedTextField } from '../components/templated-text-field';
14
+ import { CloseIcon } from '../components/icons';
15
+ import { AdapterMapping } from './AdapterMapping';
16
+ import { BindingTemplatedJqField } from './BindingTemplatedJqField';
17
+ import type { TemplateJqSuggestion } from './BindingJqField';
18
+ import { findByRef, type ResolvedTemplateJq } from './catalog';
19
+ import type { BindingSourceSchemas, StateUpdate, TemplatedTextCatalog } from './types';
20
+
21
+ const CUSTOM = '__custom__';
22
+
23
+ function optionLabel(entry: ResolvedTemplateJq): string {
24
+ return entry.description !== undefined && entry.description !== ''
25
+ ? `${entry.ref} — ${entry.description}`
26
+ : entry.ref;
27
+ }
28
+
29
+ function writesLabel(entry: ResolvedTemplateJq | undefined): string | null {
30
+ if (entry?.writes === undefined || entry.writes.length === 0) return null;
31
+ return entry.writes.map((path) => path.join('.')).join(', ');
32
+ }
33
+
34
+ function UpdateRowEditor({
35
+ update,
36
+ index,
37
+ updateJq,
38
+ sources,
39
+ suggestions,
40
+ templates,
41
+ onChange,
42
+ onRemove,
43
+ }: {
44
+ readonly update: StateUpdate;
45
+ readonly index: number;
46
+ readonly updateJq: readonly ResolvedTemplateJq[];
47
+ readonly sources?: BindingSourceSchemas;
48
+ readonly suggestions?: readonly TemplateJqSuggestion[];
49
+ readonly templates?: TemplatedTextCatalog;
50
+ readonly onChange: (update: StateUpdate) => void;
51
+ readonly onRemove: () => void;
52
+ }): ReactNode {
53
+ const isCustom = update.jq !== null;
54
+ const selectValue = isCustom ? CUSTOM : (update.template_jq ?? '');
55
+ const resolved =
56
+ update.template_jq !== null ? findByRef(update.template_jq, updateJq) : undefined;
57
+ const writes = writesLabel(resolved);
58
+
59
+ return (
60
+ <div
61
+ data-testid={`update-row-${String(index)}`}
62
+ style={{
63
+ display: 'flex',
64
+ flexDirection: 'column',
65
+ gap: 'var(--tai-space-2)',
66
+ padding: 'var(--tai-space-2)',
67
+ border: '1px solid var(--tai-color-border)',
68
+ borderRadius: 'var(--tai-radius-md)',
69
+ }}
70
+ >
71
+ <div
72
+ style={{
73
+ display: 'flex',
74
+ gap: 'var(--tai-space-2)',
75
+ alignItems: 'center',
76
+ flexWrap: 'wrap',
77
+ }}
78
+ >
79
+ <Field label={`Update source ${String(index + 1)}`} hideLabel>
80
+ <Select
81
+ placeholder="Choose a template jq"
82
+ value={selectValue}
83
+ onValueChange={(next) => {
84
+ if (next === CUSTOM)
85
+ onChange({
86
+ template_jq: null,
87
+ jq: { content: '' },
88
+ adapter: null,
89
+ op_id: update.op_id,
90
+ });
91
+ else onChange({ template_jq: next, jq: null, adapter: null, op_id: update.op_id });
92
+ }}
93
+ groups={[
94
+ {
95
+ label: 'Template jq',
96
+ options: updateJq.map((entry) => ({ value: entry.ref, label: optionLabel(entry) })),
97
+ },
98
+ { label: 'Custom', options: [{ value: CUSTOM, label: 'Custom jq' }] },
99
+ ]}
100
+ />
101
+ </Field>
102
+ {writes !== null ? <Badge variant="neutral">{`writes: ${writes}`}</Badge> : null}
103
+ <Button
104
+ type="button"
105
+ variant="ghost"
106
+ aria-label={`Remove update ${String(index + 1)}`}
107
+ onClick={onRemove}
108
+ >
109
+ <CloseIcon aria-hidden="true" />
110
+ </Button>
111
+ </div>
112
+
113
+ {isCustom ? (
114
+ <BindingTemplatedJqField
115
+ label={`Custom update jq ${String(index + 1)}`}
116
+ required
117
+ description="A jq over `{ record, output, input }` returning a template-relative op batch."
118
+ value={update.jq}
119
+ onChange={(next) => {
120
+ onChange({
121
+ template_jq: null,
122
+ jq: next ?? { content: '' },
123
+ adapter: null,
124
+ op_id: update.op_id,
125
+ });
126
+ }}
127
+ suggestions={suggestions}
128
+ templates={templates}
129
+ />
130
+ ) : (
131
+ <TemplatedTextField
132
+ // Remount on a template change so the mapping rebuilds from the newly
133
+ // declared input (and re-seeds its shown default) instead of stale rows.
134
+ key={resolved?.ref ?? '__none__'}
135
+ label="Adapter"
136
+ value={update.adapter}
137
+ templates={templates?.templates}
138
+ templatesLoading={templates?.loading}
139
+ templatesError={templates?.error}
140
+ onTemplatesRetry={templates?.onRetry}
141
+ storageAbsent={templates?.storageAbsent}
142
+ storagePresenceLoading={templates?.storagePresenceLoading}
143
+ onChange={(next) => {
144
+ onChange({ ...update, adapter: next });
145
+ }}
146
+ renderInline={({ value: inlineValue, onChange: onInline }) => (
147
+ <AdapterMapping
148
+ declaredInput={resolved?.params ?? []}
149
+ value={inlineValue}
150
+ sources={sources}
151
+ suggestions={suggestions}
152
+ onChange={(adapterJq) => {
153
+ onInline(adapterJq ?? '');
154
+ }}
155
+ />
156
+ )}
157
+ />
158
+ )}
159
+
160
+ <BindingTemplatedJqField
161
+ label="Op id"
162
+ description="Optional — an idempotency-key expression."
163
+ value={update.op_id}
164
+ onChange={(next) => {
165
+ onChange({ ...update, op_id: next });
166
+ }}
167
+ suggestions={suggestions}
168
+ templates={templates}
169
+ />
170
+ </div>
171
+ );
172
+ }
173
+
174
+ export interface UpdateListProps {
175
+ readonly updates: readonly StateUpdate[];
176
+ readonly onChange: (updates: readonly StateUpdate[]) => void;
177
+ readonly updateJq: readonly ResolvedTemplateJq[];
178
+ readonly sources?: BindingSourceSchemas;
179
+ readonly suggestions?: readonly TemplateJqSuggestion[];
180
+ readonly templates?: TemplatedTextCatalog;
181
+ }
182
+
183
+ export function UpdateList({
184
+ updates,
185
+ onChange,
186
+ updateJq,
187
+ sources,
188
+ suggestions,
189
+ templates,
190
+ }: UpdateListProps): ReactNode {
191
+ const add = (): void => {
192
+ const first = updateJq[0];
193
+ const next: StateUpdate =
194
+ first !== undefined
195
+ ? { template_jq: first.ref, jq: null, adapter: null, op_id: null }
196
+ : { template_jq: null, jq: { content: '' }, adapter: null, op_id: null };
197
+ onChange([...updates, next]);
198
+ };
199
+
200
+ return (
201
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--tai-space-2)' }}>
202
+ {updates.map((update, index) => (
203
+ <UpdateRowEditor
204
+ // Rows are positional; the index is their stable identity within a save.
205
+ key={index}
206
+ update={update}
207
+ index={index}
208
+ updateJq={updateJq}
209
+ sources={sources}
210
+ suggestions={suggestions}
211
+ templates={templates}
212
+ onChange={(next) => {
213
+ onChange(updates.map((current, position) => (position === index ? next : current)));
214
+ }}
215
+ onRemove={() => {
216
+ onChange(updates.filter((_, position) => position !== index));
217
+ }}
218
+ />
219
+ ))}
220
+ <div>
221
+ <Button type="button" variant="ghost" onClick={add}>
222
+ Add update
223
+ </Button>
224
+ </div>
225
+ </div>
226
+ );
227
+ }
@@ -0,0 +1,276 @@
1
+ /**
2
+ * The adapter compiler — a PURE function set, no React, no jq engine.
3
+ *
4
+ * A named template `update` jq declares an input object (its `params` / declared
5
+ * input keys). A binding must map the run's OUTPUT (and the run's INPUT) into that
6
+ * object; that mapping is the `adapter` — a single jq program over `{ output, input }`
7
+ * that CONSTRUCTS the update jq's declared input (which the update jq then reads as
8
+ * `.input` alongside the record).
9
+ *
10
+ * The editor authors the adapter as a ROW form: one row per declared input key,
11
+ * each row picking one of three value sources — a picked field (from the run's
12
+ * output / input schema), a hardcoded literal, or a raw jq expression. The whole
13
+ * form compiles here into ONE adapter jq; each row's own jq fragment is shown on demand.
14
+ *
15
+ * Compilation never throws: an invalid literal or an empty field surfaces as a
16
+ * loud, human error the editor renders in a `role="alert"`, never a silent empty
17
+ * object.
18
+ */
19
+
20
+ /** Which root of `{ output, input }` a picked field reads. */
21
+ export type FieldRoot = 'output' | 'input';
22
+
23
+ /** A picked field: a path into `output` or `input`. */
24
+ export interface FieldSource {
25
+ readonly kind: 'field';
26
+ readonly root: FieldRoot;
27
+ readonly path: readonly string[];
28
+ }
29
+
30
+ /** A hardcoded literal, authored as JSON text (`"a"`, `42`, `true`, `{…}`). */
31
+ export interface LiteralSource {
32
+ readonly kind: 'literal';
33
+ readonly json: string;
34
+ }
35
+
36
+ /** A raw jq expression over `{ output, input }`. */
37
+ export interface JqSource {
38
+ readonly kind: 'jq';
39
+ readonly expr: string;
40
+ }
41
+
42
+ /** The three value sources one mapping row may carry — exactly one at a time. */
43
+ export type MappingSource = FieldSource | LiteralSource | JqSource;
44
+
45
+ /** One mapping row: a target key of the update jq's declared input + its value source. */
46
+ export interface MappingRow {
47
+ readonly target: string;
48
+ readonly source: MappingSource;
49
+ }
50
+
51
+ /** A compile outcome: the jq program, or a human error to surface loudly. */
52
+ export type AdapterCompileResult =
53
+ { readonly ok: true; readonly jq: string } | { readonly ok: false; readonly error: string };
54
+
55
+ const IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/;
56
+
57
+ /** An object key rendered for jq: a bare identifier, or a quoted string otherwise. */
58
+ export function jqKey(key: string): string {
59
+ return IDENTIFIER.test(key) ? key : JSON.stringify(key);
60
+ }
61
+
62
+ /** A picked field rendered as a jq path, e.g. `.output.user.name` / `.input["odd key"]`. */
63
+ export function fieldPathToJq(root: FieldRoot, path: readonly string[]): string {
64
+ let out = `.${root}`;
65
+ for (const segment of path) {
66
+ out += IDENTIFIER.test(segment) ? `.${segment}` : `[${JSON.stringify(segment)}]`;
67
+ }
68
+ return out;
69
+ }
70
+
71
+ /** The jq fragment ONE row's value compiles to — the row's "show jq". */
72
+ export function rowValueJq(source: MappingSource): AdapterCompileResult {
73
+ switch (source.kind) {
74
+ case 'field':
75
+ return { ok: true, jq: fieldPathToJq(source.root, source.path) };
76
+ case 'literal': {
77
+ const text = source.json.trim();
78
+ if (text === '') return { ok: false, error: 'Enter a value.' };
79
+ let parsed: unknown;
80
+ try {
81
+ parsed = JSON.parse(text) as unknown;
82
+ } catch {
83
+ return { ok: false, error: 'This is not valid JSON.' };
84
+ }
85
+ // A parsed JSON value is a valid jq literal verbatim.
86
+ return { ok: true, jq: JSON.stringify(parsed) };
87
+ }
88
+ case 'jq': {
89
+ const expr = source.expr.trim();
90
+ if (expr === '') return { ok: false, error: 'Enter a jq expression.' };
91
+ return { ok: true, jq: expr };
92
+ }
93
+ }
94
+ }
95
+
96
+ /**
97
+ * Compile a mapping form into ONE adapter jq that constructs the update jq's
98
+ * declared input over `{ output, input }`. An empty form compiles to the
99
+ * empty object `{}`; a blank target, a duplicate target, or an invalid source is a
100
+ * loud error.
101
+ */
102
+ export function compileAdapter(rows: readonly MappingRow[]): AdapterCompileResult {
103
+ if (rows.length === 0) return { ok: true, jq: '{}' };
104
+ const seen = new Set<string>();
105
+ const parts: string[] = [];
106
+ for (const row of rows) {
107
+ const target = row.target.trim();
108
+ if (target === '') return { ok: false, error: 'Every mapping row needs a target field.' };
109
+ if (seen.has(target)) return { ok: false, error: `The field '${target}' is mapped twice.` };
110
+ seen.add(target);
111
+ const value = rowValueJq(row.source);
112
+ if (!value.ok) return { ok: false, error: `${target}: ${value.error}` };
113
+ parts.push(`${jqKey(target)}: (${value.jq})`);
114
+ }
115
+ return { ok: true, jq: `{ ${parts.join(', ')} }` };
116
+ }
117
+
118
+ /** One row per declared input key, each defaulting to a picked `output` field. */
119
+ export function defaultRowsForInput(params: readonly string[]): MappingRow[] {
120
+ return params.map((target) => ({
121
+ target,
122
+ source: { kind: 'field', root: 'output', path: [] },
123
+ }));
124
+ }
125
+
126
+ // -- The round-trip: recover a mapping form from a STORED adapter jq --------------
127
+ //
128
+ // `compileAdapter` emits a canonical, PARSEABLE shape so a stored adapter reopens as
129
+ // the form it was authored in (never UI-only state): the object
130
+ // `{ <key>: (<value>), … }` where `<key>` is a bare identifier or a JSON string and
131
+ // `<value>` is one of — a field path `.output…` / `.input…`, a JSON literal, or a raw
132
+ // jq expression. `parseAdapter` inverts it; `parse(compileAdapter(rows).jq) === rows`
133
+ // for every row kind whose literal is already canonical JSON. A shape it does not
134
+ // recognise returns `null` — the editor falls back to the raw-jq escape hatch.
135
+
136
+ const IDENTIFIER_HEAD = /^([A-Za-z_][A-Za-z0-9_]*)/;
137
+ const BRACKET_SEGMENT = /^\[("(?:[^"\\]|\\.)*")\]/;
138
+ const ADAPTER_KEY = /^("(?:[^"\\]|\\.)*"|[A-Za-z_][A-Za-z0-9_]*)\s*:\s*\(/;
139
+
140
+ /** Recover a field source from a pure path expression, or `null` if it is not one. */
141
+ export function parseFieldPath(value: string): FieldSource | null {
142
+ const head = /^\.(output|input)/.exec(value);
143
+ if (head === null) return null;
144
+ const root = head[1] as FieldRoot;
145
+ let rest = value.slice(head[0].length);
146
+ const path: string[] = [];
147
+ while (rest.length > 0) {
148
+ if (rest.startsWith('.')) {
149
+ const segment = IDENTIFIER_HEAD.exec(rest.slice(1));
150
+ if (segment?.[1] === undefined) return null;
151
+ path.push(segment[1]);
152
+ rest = rest.slice(1 + segment[0].length);
153
+ } else if (rest.startsWith('[')) {
154
+ const bracket = BRACKET_SEGMENT.exec(rest);
155
+ if (bracket?.[1] === undefined) return null;
156
+ path.push(JSON.parse(bracket[1]) as string);
157
+ rest = rest.slice(bracket[0].length);
158
+ } else {
159
+ // Trailing content (e.g. ` + 1`) means this is a jq expression, not a path.
160
+ return null;
161
+ }
162
+ }
163
+ return { kind: 'field', root, path };
164
+ }
165
+
166
+ /** Classify one recovered value into its source (field, else JSON literal, else jq). */
167
+ function parseValue(value: string): MappingSource {
168
+ const field = parseFieldPath(value);
169
+ if (field !== null) return field;
170
+ try {
171
+ const parsed = JSON.parse(value) as unknown;
172
+ return { kind: 'literal', json: JSON.stringify(parsed) };
173
+ } catch {
174
+ return { kind: 'jq', expr: value };
175
+ }
176
+ }
177
+
178
+ /** Find the index of the paren that closes the `(` at `open`, respecting jq strings. */
179
+ function matchingParen(text: string, open: number): number {
180
+ let depth = 0;
181
+ let inString = false;
182
+ for (let i = open; i < text.length; i += 1) {
183
+ const ch = text[i];
184
+ if (inString) {
185
+ if (ch === '\\') i += 1;
186
+ else if (ch === '"') inString = false;
187
+ continue;
188
+ }
189
+ if (ch === '"') inString = true;
190
+ else if (ch === '(') depth += 1;
191
+ else if (ch === ')') {
192
+ depth -= 1;
193
+ if (depth === 0) return i;
194
+ }
195
+ }
196
+ return -1;
197
+ }
198
+
199
+ /** Recover the mapping rows from a stored adapter jq, or `null` if it is not the generated shape. */
200
+ export function parseAdapter(jq: string): MappingRow[] | null {
201
+ const trimmed = jq.trim();
202
+ if (trimmed === '{}') return [];
203
+ if (!trimmed.startsWith('{') || !trimmed.endsWith('}')) return null;
204
+ let inner = trimmed.slice(1, -1).trim();
205
+ const rows: MappingRow[] = [];
206
+ while (inner.length > 0) {
207
+ const keyMatch = ADAPTER_KEY.exec(inner);
208
+ if (keyMatch?.[1] === undefined) return null;
209
+ const rawKey = keyMatch[1];
210
+ const target = rawKey.startsWith('"') ? (JSON.parse(rawKey) as string) : rawKey;
211
+ const open = keyMatch[0].length - 1;
212
+ const close = matchingParen(inner, open);
213
+ if (close === -1) return null;
214
+ const value = inner.slice(open + 1, close).trim();
215
+ rows.push({ target, source: parseValue(value) });
216
+ inner = inner.slice(close + 1).trim();
217
+ if (inner.startsWith(',')) inner = inner.slice(1).trim();
218
+ else if (inner.length > 0) return null;
219
+ }
220
+ return rows;
221
+ }
222
+
223
+ // -- The canonical single-expression form: `tjq_<callName>(<adapter object>)` --------
224
+ //
225
+ // When a template update rides inside ONE jq field (rather than the structured
226
+ // `template_jq` + `adapter` pair), it is written as the call `tjq_<callName>({ … })`.
227
+ // A jq name is `[a-z][a-z0-9_]*` (double underscores legal); a tai42 template name is a
228
+ // SLUG (`[a-z][a-z0-9-]*` with no leading, trailing, or consecutive hyphens). So the
229
+ // qualified call name is `enc(template) + "__" + name`, where `enc` maps each `-` to
230
+ // `_`. BECAUSE the slug has no consecutive or trailing hyphens, `enc(template)` never
231
+ // contains `__` and never ends in `_` — so the FIRST `__` in the call name is the
232
+ // unambiguous template/name boundary. Resolution back to a catalog ref is therefore
233
+ // injective, but done catalog-driven (`resolveCallName`, `catalog.ts`) rather than by
234
+ // string splitting, since a jq name may itself contain `__` after that boundary. A jq
235
+ // field whose expression is EXACTLY this generated call reopens as the mapping form;
236
+ // any other expression reopens as raw jq.
237
+
238
+ const TEMPLATE_CALL = /^tjq_([a-z][a-z0-9_]*)\(([\s\S]*)\)$/;
239
+
240
+ /**
241
+ * Encode a template-name slug for a jq identifier: each `-` becomes `_`. Under the slug
242
+ * invariant (no consecutive or trailing hyphens) the result carries no `__` and no
243
+ * trailing `_`, which is what makes the first `__` in a call name the template boundary.
244
+ */
245
+ export function encodeTemplateSegment(template: string): string {
246
+ return template.replace(/-/g, '_');
247
+ }
248
+
249
+ /** The jq call-name of a catalog ref: `template.name` → `enc(template)__name`; a bare `name` unchanged. */
250
+ function refToCallName(ref: string): string {
251
+ const dot = ref.indexOf('.');
252
+ if (dot === -1) return ref;
253
+ return `${encodeTemplateSegment(ref.slice(0, dot))}__${ref.slice(dot + 1)}`;
254
+ }
255
+
256
+ /**
257
+ * The single-expression form of a named template update built from a mapping form:
258
+ * `tjq_<enc(template)>__<name>({…})` for a qualified ref, `tjq_<name>({…})` for a bare
259
+ * one (see the module note on the `__` boundary invariant).
260
+ */
261
+ export function generateTemplateCall(ref: string, rows: readonly MappingRow[]): string {
262
+ const compiled = compileAdapter(rows);
263
+ return `tjq_${refToCallName(ref)}(${compiled.ok ? compiled.jq : '{}'})`;
264
+ }
265
+
266
+ /**
267
+ * Recover `{ callName, adapter }` from a `tjq_<callName>(<adapter>)` call, or `null`
268
+ * otherwise. `callName` is the RAW jq identifier — NEVER split here (a jq name may carry
269
+ * its own `__` past the template boundary); resolve it to a catalog ref with
270
+ * `resolveCallName`.
271
+ */
272
+ export function parseTemplateCall(expr: string): { callName: string; adapter: string } | null {
273
+ const match = TEMPLATE_CALL.exec(expr.trim());
274
+ if (match?.[1] === undefined || match[2] === undefined) return null;
275
+ return { callName: match[1], adapter: match[2].trim() };
276
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Map the api-client's list shapes into the editor's catalogs, so every door screen
3
+ * feeds `StateBindingEditor` the same way. Pure data transforms; the api-client types
4
+ * are imported type-only (this package keeps no runtime edge to the client).
5
+ */
6
+ import type { StateListItem, StateTemplateListItem } from '@tai42/api-client';
7
+
8
+ import type {
9
+ BindingStateOption,
10
+ BindingTemplateJqOption,
11
+ BindingTemplateOption,
12
+ SchemaFieldPath,
13
+ } from './types';
14
+
15
+ /** The template catalog (with each template's template jq) from `listStateTemplates()`. */
16
+ export function templatesCatalogFromList(
17
+ items: readonly StateTemplateListItem[],
18
+ ): BindingTemplateOption[] {
19
+ return items.map((item) => {
20
+ const templateJq: BindingTemplateJqOption[] =
21
+ item.template_jq === null
22
+ ? []
23
+ : Object.entries(item.template_jq).map(([name, jq]) => ({
24
+ name,
25
+ purpose: jq.purpose,
26
+ description: jq.description,
27
+ params: jq.params,
28
+ writes: jq.writes,
29
+ }));
30
+ return { name: item.name, description: item.description, templateJq };
31
+ });
32
+ }
33
+
34
+ /** The states catalog from `listStates()`; per-state attachments are resolved lazily elsewhere. */
35
+ export function statesCatalogFromList(states: readonly StateListItem[]): BindingStateOption[] {
36
+ return states.map((state) => ({ name: state.name }));
37
+ }
38
+
39
+ /**
40
+ * Flatten a JSON schema's `properties` into selectable field paths (top-level and
41
+ * every nested object level), each labelled with its dotted path — the picker source
42
+ * for the adapter's `output`/`input` roots. A non-object or property-less schema
43
+ * yields no fields (the field control then falls back to a free path input).
44
+ */
45
+ export function fieldPathsFromSchema(schema: unknown): SchemaFieldPath[] {
46
+ const out: SchemaFieldPath[] = [];
47
+ const walk = (node: unknown, prefix: readonly string[]): void => {
48
+ if (typeof node !== 'object' || node === null) return;
49
+ const props = (node as { properties?: unknown }).properties;
50
+ if (typeof props !== 'object' || props === null) return;
51
+ for (const [key, child] of Object.entries(props as Record<string, unknown>)) {
52
+ const path = [...prefix, key];
53
+ out.push({ path, label: path.join('.') });
54
+ walk(child, path);
55
+ }
56
+ };
57
+ walk(schema, []);
58
+ return out;
59
+ }