@synthigy/sdk 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Robert Geršak
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,102 @@
1
+ # `@synthigy/sdk`
2
+
3
+ TypeScript / JavaScript client for [Synthigy](https://github.com/synthigy/synthigy).
4
+ Zero dependencies. Typed reads, structured writes, live data on one connection,
5
+ and per-call server-enforced permissions.
6
+
7
+ ```bash
8
+ npm install @synthigy/sdk
9
+ ```
10
+
11
+ ## Hello world
12
+
13
+ ```ts
14
+ import { createClient } from '@synthigy/sdk'
15
+
16
+ const synthigy = createClient({
17
+ endpoint: 'http://localhost:7887',
18
+ clientId: process.env.SYNTHIGY_CLIENT_ID,
19
+ clientSecret: process.env.SYNTHIGY_CLIENT_SECRET,
20
+ // The audience model is opt-in: a token naming none is identity-only and
21
+ // /data rejects it. Your server publishes this at /.well-known/synthigy
22
+ // (auth.oidc.audience). Defaults to $SYNTHIGY_AUDIENCE on Node. See docs/AUTH.md.
23
+ audience: 'https://synthigy.com',
24
+ })
25
+
26
+ // Read — you get back exactly the shape you ask for
27
+ const movies = await synthigy.search('Movie',
28
+ { _limit: 5, _order_by: [['release_year', 'desc']] },
29
+ { title: null, release_year: null, genres: { name: null } })
30
+
31
+ // Write — additive; doesn't disturb the movie's other ratings
32
+ await synthigy.stack('user_rating', {
33
+ value: 5, timestamp: new Date().toISOString(),
34
+ movie: { xid: movies[0].xid },
35
+ })
36
+
37
+ // Live — snapshot + keep-current, multiplexed onto one SSE
38
+ const w = synthigy.watchSqlTemplate(
39
+ `SELECT count(*) AS n FROM {user_rating}`, {}, { entities: ['user_rating'] })
40
+ await w.ready()
41
+ for await (const ev of w.events) {
42
+ if (ev.type === 'result/changed') console.log('ratings:', ev.after[0].n)
43
+ }
44
+ ```
45
+
46
+ ## What makes it Synthigy
47
+
48
+ - **The query *is* the result shape.** Ask for fields and relations; get back
49
+ that JSON and nothing else. Two surfaces for the same engine: an
50
+ [XSQL](./docs/XSQL.md) string, or a [wire](./docs/WIRE-FORMAT.md) selection object.
51
+
52
+ - **Writes are silent by default.** `sync`/`stack` answer `{ count }`, not the
53
+ record. Mint the id up front with `newXid()` when you need it — cheaper than
54
+ the echo, and a retried write upserts the same row instead of duplicating it.
55
+ Pass `{ returning: true }` for the record; generated code types the two apart.
56
+ - **Writes that ORMs make you hand-roll.** `stack` adds a child without
57
+ clobbering its siblings; `slice` detaches a link without deleting the record.
58
+ Plus `sync` (upsert), `delete`, `purge`. → [WIRE-FORMAT.md](./docs/WIRE-FORMAT.md)
59
+
60
+ - **Live data on one connection.** Open dozens of watches — they multiplex onto
61
+ a single SSE per client. `watchQuery` for a live result set,
62
+ `watchSqlTemplate` for live analytics, `watch` for raw record events.
63
+ → [WATCH.md](./docs/WATCH.md)
64
+
65
+ - **`actingAs` per call.** Every read and write can run as a specific user, with
66
+ IAM / RBAC / row-level security enforced *server-side*. Tokens never have to
67
+ reach the browser. → [AUTH.md](./docs/AUTH.md)
68
+
69
+ - **Typed from your model.** `synthigy-gen` compiles your ERD + `.xsql` files
70
+ into typed methods whose result types follow the selection — a typo'd entity
71
+ or an unselected field is a compile error. → [codegen](./docs/WATCH.md#codegen)
72
+
73
+ ## Docs
74
+
75
+ | Doc | What's in it |
76
+ |---|---|
77
+ | [XSQL.md](./docs/XSQL.md) | The query language: shapes, params, aggregates, op files, sql-templates |
78
+ | [WIRE-FORMAT.md](./docs/WIRE-FORMAT.md) | JSON selections, args/operators, result conventions, the five write primitives |
79
+ | [WATCH.md](./docs/WATCH.md) | `watch` / `watchQuery` / `watchSqlTemplate` / `observe`, lifecycle, and codegen |
80
+ | [AUTH.md](./docs/AUTH.md) | OAuth client registration, `actingAs`, BFF / SPA / native patterns |
81
+ | [CHANGELOG.md](./CHANGELOG.md) | Version history |
82
+ | [examples/datastar-movies/](./examples/datastar-movies/) | End-to-end BFF exercising every primitive |
83
+ | [examples/movies-live.mjs](./examples/movies-live.mjs) | Minimal live-watch demo |
84
+ | [src/index.d.ts](./src/index.d.ts) | Full type definitions |
85
+
86
+ ## Tests
87
+
88
+ ```bash
89
+ node --test 'test/*.test.js' # unit
90
+ SYNTHIGY_ENDPOINT=http://localhost:7887 \
91
+ SYNTHIGY_CLIENT_ID=… SYNTHIGY_CLIENT_SECRET=… \
92
+ node --test 'test/integration.test.js' # integration (skips without a server)
93
+ ```
94
+
95
+ ## License
96
+
97
+ MIT — see [LICENSE](LICENSE).
98
+
99
+ The SDKs are deliberately permissive: they are client libraries you embed in
100
+ your own application. The Synthigy **engine** is separate, and is fair-code
101
+ under the Sustainable Use License.
102
+
@@ -0,0 +1,499 @@
1
+ #!/usr/bin/env node
2
+ // Unified codegen tool:
3
+ // node codegen.mjs <file.xsql> [outDir] — describe + gen TS (saves .ir.json)
4
+ // node codegen.mjs check <file.xsql> — re-describe, diff, exit 1 on drift
5
+ // node codegen.mjs pull [endpoint] [out] — pull schema.json (wraps pull.mjs)
6
+ //
7
+ // Config via env: SYNTHIGY_ENDPOINT, SYNTHIGY_CLIENT_ID, SYNTHIGY_CLIENT_SECRET
8
+
9
+ import fs from 'node:fs'
10
+ import path from 'node:path'
11
+ import { createHash } from 'node:crypto'
12
+ import { execFileSync } from 'node:child_process'
13
+ import ts from 'typescript'
14
+ import { TS, tsScalar, pascal, camel } from './lib.mjs'
15
+
16
+ const here = path.dirname(new URL(import.meta.url).pathname)
17
+
18
+ // Reformat generated TS through the compiler's own printer: correct indentation,
19
+ // spacing and semicolons with zero hand-managed `\n`. The printer round-trips the
20
+ // AST, so it fixes structural formatting while preserving embedded XSQL template
21
+ // literals verbatim. (Realizes the "AST emission" goal cheaply; the fail-loud tsc
22
+ // gate below still validates the result.)
23
+ const formatTs = (code, file) =>
24
+ ts.createPrinter({ newLine: ts.NewLineKind.LineFeed })
25
+ .printFile(ts.createSourceFile(file, code, ts.ScriptTarget.Latest, true))
26
+
27
+ // ── args + flags ──────────────────────────────────────────────────────────────
28
+ if (process.argv.includes('--help') || process.argv.includes('-h')) {
29
+ console.log(`
30
+ synthigy-codegen — generate TypeScript types from XSQL operations
31
+
32
+ Usage:
33
+ codegen.mjs [flags] <a.xsql> [b.xsql ...] [outDir] generate TS from one or more xsql files
34
+ codegen.mjs check [flags] <a.xsql> [b.xsql ...] re-describe, diff IR, exit 1 on drift
35
+ codegen.mjs pull [endpoint] [out] pull schema.json only
36
+
37
+ Flags:
38
+ --pull force schema refresh before generating
39
+ --keys snake|camel field key casing in generated types (default: snake)
40
+ --no-writes omit sync / stack / delete methods
41
+ --schema <path> explicit schema.json path (skips auto-pull)
42
+
43
+ Environment:
44
+ SYNTHIGY_ENDPOINT server URL (default: http://localhost:7887)
45
+ SYNTHIGY_CLIENT_ID OAuth client ID
46
+ SYNTHIGY_CLIENT_SECRET OAuth client secret
47
+
48
+ Examples:
49
+ node codegen.mjs movies.xsql src/generated/ first run auto-pulls schema
50
+ node codegen.mjs --pull movies.xsql src/generated/ force schema refresh + generate
51
+ node codegen.mjs check movies.xsql CI drift check
52
+ node codegen.mjs pull http://localhost:7887 pull schema only
53
+ `.trim())
54
+ process.exit(0)
55
+ }
56
+
57
+ // Flags (--key val / --flag) extracted first; positionals drive existing logic.
58
+ const flags = { keys: 'snake', writes: true, schemaOverride: null, pull: false }
59
+ const pos = []
60
+ for (let i = 2; i < process.argv.length; i++) {
61
+ const a = process.argv[i]
62
+ if (a === '--keys' && process.argv[i+1]) { flags.keys = process.argv[++i]; continue }
63
+ else if (a === '--no-writes') { flags.writes = false; continue }
64
+ else if (a === '--schema' && process.argv[i+1]) { flags.schemaOverride = path.resolve(process.argv[++i]); continue }
65
+ else if (a === '--pull') { flags.pull = true; continue }
66
+ pos.push(a)
67
+ }
68
+
69
+ const VALID_KEYS = new Set(['snake', 'camel'])
70
+ if (!VALID_KEYS.has(flags.keys)) {
71
+ console.error(`--keys must be snake or camel (got: ${flags.keys})`)
72
+ process.exit(1)
73
+ }
74
+
75
+ const checkMode = pos[0] === 'check'
76
+ const pullMode = pos[0] === 'pull'
77
+
78
+ if (pullMode) {
79
+ execFileSync(process.execPath, [path.join(here, 'pull.mjs'), ...pos.slice(1)],
80
+ { stdio: 'inherit', env: process.env })
81
+ process.exit(0)
82
+ }
83
+
84
+ // Positionals: last non-.xsql arg is outDir; everything before it (files or dirs)
85
+ // is input. check mode has no outDir — every positional is input.
86
+ const rawPos = checkMode ? pos.slice(1) : pos
87
+ const lastPos = rawPos[rawPos.length - 1]
88
+ const hasExplicitOut = !checkMode && lastPos && !lastPos.endsWith('.xsql')
89
+ const outArg = hasExplicitOut ? lastPos : undefined
90
+ const xsqlArgs = hasExplicitOut ? rawPos.slice(0, -1) : rawPos
91
+
92
+ if (!xsqlArgs.length) {
93
+ console.error('usage: node codegen.mjs [flags] <file.xsql> [file2.xsql ...] [outDir]')
94
+ console.error(' node codegen.mjs check <file.xsql> [file2.xsql ...]')
95
+ console.error(' node codegen.mjs pull [endpoint] [schema.json]')
96
+ console.error('flags: --pull force schema refresh')
97
+ console.error(' --keys snake|camel field casing (default: snake)')
98
+ console.error(' --no-writes omit sync/stack/delete methods')
99
+ console.error(' --schema <path> explicit schema.json path')
100
+ process.exit(1)
101
+ }
102
+
103
+ const xsqlPaths = xsqlArgs.flatMap(a => {
104
+ const p = path.resolve(a)
105
+ if (fs.existsSync(p) && fs.statSync(p).isDirectory())
106
+ return fs.readdirSync(p).filter(f => f.endsWith('.xsql')).sort().map(f => path.join(p, f))
107
+ return [p]
108
+ })
109
+ if (!xsqlPaths.length) { console.error('no .xsql files found'); process.exit(1) }
110
+ const xsqlDir = path.dirname(xsqlPaths[0]) // schema.json lives next to first file
111
+ const irPath = path.join(xsqlDir, 'ops.ir.json')
112
+ const outDir = checkMode
113
+ ? null
114
+ : outArg ? path.resolve(outArg) : path.join(xsqlDir, 'generated')
115
+
116
+ // Schema path: explicit override > local schema.json (next to first xsql).
117
+ // Auto-pull happens below once ENDPOINT is known.
118
+ const localSchemaPath = path.join(xsqlDir, 'schema.json')
119
+ const schemaPath = flags.schemaOverride ?? localSchemaPath
120
+
121
+ // Key transform: applies to field names in generated TS only.
122
+ // XSQL source + wire format are always snake; the SDK re-cases results at runtime.
123
+ const toKey = flags.keys === 'camel' ? camel : s => s
124
+
125
+ // ── describe ─────────────────────────────────────────────────────────────────
126
+ const ENDPOINT = process.env.SYNTHIGY_ENDPOINT ?? 'http://localhost:7887'
127
+ const { SYNTHIGY_CLIENT_ID, SYNTHIGY_CLIENT_SECRET } = process.env
128
+
129
+ // Auto-pull schema if missing or --pull requested (saved next to .xsql, cached for future runs).
130
+ if (!flags.schemaOverride && (flags.pull || !fs.existsSync(schemaPath))) {
131
+ execFileSync(process.execPath,
132
+ [path.join(here, 'pull.mjs'), ENDPOINT, schemaPath],
133
+ { stdio: 'inherit', env: process.env })
134
+ }
135
+
136
+ if (!fs.existsSync(schemaPath)) {
137
+ console.error(`schema.json not found — set SYNTHIGY_ENDPOINT + credentials`)
138
+ process.exit(1)
139
+ }
140
+
141
+ async function token() {
142
+ if (!SYNTHIGY_CLIENT_ID) return ''
143
+ const r = await fetch(`${ENDPOINT}/oauth/token`, {
144
+ method: 'POST',
145
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
146
+ body: new URLSearchParams({ grant_type: 'client_credentials',
147
+ client_id: SYNTHIGY_CLIENT_ID, client_secret: SYNTHIGY_CLIENT_SECRET }),
148
+ })
149
+ if (!r.ok) throw new Error(`token ${r.status}: ${await r.text()}`)
150
+ return (await r.json()).access_token
151
+ }
152
+
153
+ // Merge all xsql files — ops are independent, names must be unique across files.
154
+ const source = xsqlPaths.map(p => fs.readFileSync(p, 'utf8')).join('\n\n')
155
+ // Contract between the .xsql sources on disk and the saved IR: the IR carries
156
+ // the hash of the sources it was described from. Mismatch = the IR is stale.
157
+ const sourceHash = createHash('sha256').update(source).digest('hex')
158
+
159
+ // check mode fails FAST and OFFLINE when the sources drifted from the saved IR —
160
+ // no backend/credentials needed to catch "edited .xsql, forgot codegen".
161
+ // (A hash match still goes through the live describe-diff below: same sources
162
+ // can yield a different IR after a server-side model change.)
163
+ if (checkMode && fs.existsSync(irPath)) {
164
+ const saved = JSON.parse(fs.readFileSync(irPath, 'utf8'))
165
+ if (saved.sourceHash && saved.sourceHash !== sourceHash) {
166
+ console.error(`sources drifted from saved IR — run: node codegen.mjs ${xsqlArgs.join(' ')}`)
167
+ process.exit(1)
168
+ }
169
+ }
170
+
171
+ // Describe: use the cached IR only when it matches the sources on disk
172
+ // (avoids needing credentials on every run). Stale/legacy-unhashed IR
173
+ // auto-refreshes; NEVER emit from an IR that disagrees with the sources.
174
+ let ir
175
+ const cached = !flags.pull && !checkMode && fs.existsSync(irPath)
176
+ ? JSON.parse(fs.readFileSync(irPath, 'utf8')) : null
177
+ if (cached && cached.sourceHash === sourceHash) {
178
+ ir = cached
179
+ console.log(`using cached IR (${ir.operations?.length ?? 0} ops, in sync with sources) — use --pull to refresh`)
180
+ } else {
181
+ if (cached)
182
+ console.log(cached.sourceHash
183
+ ? 'sources changed since IR pull — refreshing'
184
+ : 'saved IR predates source hashing — refreshing')
185
+ // The canonical parser owns @namespace/@watch/@returns now — send source as-is.
186
+ const sourceForDescribe = source
187
+ try {
188
+ const tok = await token()
189
+ const resp = await fetch(`${ENDPOINT}/data`, {
190
+ method: 'POST',
191
+ headers: { 'content-type': 'application/json', ...(tok && { authorization: `Bearer ${tok}` }) },
192
+ body: JSON.stringify({ operations: [{ op: 'describe', source: sourceForDescribe }] }),
193
+ })
194
+ if (!resp.ok) throw new Error(`describe ${resp.status}: ${await resp.text()}`)
195
+ const body = await resp.json()
196
+ const r0 = (body.results ?? body.result ?? [body.data ?? body])[0] ?? body
197
+ ir = r0.result ?? r0.data ?? r0
198
+ } catch (e) {
199
+ console.error(cached
200
+ ? `sources changed since IR pull and describe failed (${e.message}) — start a backend (SYNTHIGY_ENDPOINT) or revert the .xsql edit`
201
+ : `describe failed: ${e.message}`)
202
+ process.exit(1)
203
+ }
204
+ ir = { sourceHash, ...ir }
205
+ }
206
+
207
+ // ── check mode ────────────────────────────────────────────────────────────────
208
+ if (checkMode) {
209
+ if (!fs.existsSync(irPath)) {
210
+ console.error(`no saved IR at ${irPath} — run: node codegen.mjs ${xsqlArgs.join(' ')}`)
211
+ process.exit(1)
212
+ }
213
+ const savedIr = JSON.parse(fs.readFileSync(irPath, 'utf8'))
214
+ const freshStr = JSON.stringify(ir, null, 2)
215
+ if (JSON.stringify(savedIr, null, 2) === freshStr) {
216
+ console.log(`ok — IR unchanged (${ir.operations?.length ?? 0} ops)`)
217
+ process.exit(0)
218
+ }
219
+ // identity = (namespace ?? entity, name) — bare names repeat across entities
220
+ // (movie/list, user_rating/list), so a name-only match diffs the wrong pairs.
221
+ const opId = o => o.batch ? `@batch ${o.name}` : `${(o.namespace ?? o.entity ?? '').toLowerCase()}/${o.name.toLowerCase()}`
222
+ const added = ir.operations.filter(o => !savedIr.operations?.find(s => opId(s) === opId(o)))
223
+ const removed = savedIr.operations?.filter(o => !ir.operations.find(s => opId(s) === opId(o))) ?? []
224
+ const changed = ir.operations.filter(o => {
225
+ const s = savedIr.operations?.find(s => opId(s) === opId(o))
226
+ return s && JSON.stringify(s) !== JSON.stringify(o)
227
+ })
228
+ if (added.length) console.log(`+ added: ${added.map(opId).join(', ')}`)
229
+ if (removed.length) console.log(`- removed: ${removed.map(opId).join(', ')}`)
230
+ if (changed.length) console.log(`~ changed: ${changed.map(opId).join(', ')}`)
231
+ console.error(`IR drifted — run: node codegen.mjs ${xsqlArgs.join(' ')}`)
232
+ process.exit(1)
233
+ }
234
+
235
+ // ── save IR ───────────────────────────────────────────────────────────────────
236
+ fs.writeFileSync(irPath, JSON.stringify(ir, null, 2) + '\n')
237
+
238
+ // ── gen ───────────────────────────────────────────────────────────────────────
239
+ // Identity (namespace / name), @watch, and sql-template @returns results all come
240
+ // from the IR now — the canonical XSQL parser owns them. No XSQL re-parsing here.
241
+ const schema = JSON.parse(fs.readFileSync(schemaPath, 'utf8'))
242
+ const sdkSrc = path.relative(outDir, path.join(here, '..', 'src')) + '/index.js'
243
+ const ent = (n) => schema.entities[n] ?? {}
244
+ const visited = new Set()
245
+
246
+ // The server owns pretty-name derivation (docs/plans/PLAN-SCHEMA-SKINS-PROJECTION.md):
247
+ // entity/attribute/relation `skins` in the pulled schema are the contract, not a hint.
248
+ // Falls back to the local splitter only for names with no schema entry (op/namespace
249
+ // identifiers, which are snake-authored code with no display label to preserve).
250
+ const entityPascal = (n) => ent(n).skins?.pascal ?? pascal(n)
251
+ const alias = (n) => `_${entityPascal(n)}`
252
+
253
+ function projectionType(entityName, fields) {
254
+ visited.add(entityName)
255
+ const A = alias(entityName)
256
+ // scalars become a Pick over the entity; relations + aggregate maps become
257
+ // extra object members. `_count`/`_agg` (kind "map") must NOT enter the Pick —
258
+ // they aren't entity attributes — so filter them out of scalars.
259
+ const scalars = fields.filter(f => f.kind !== 'relation' && f.kind !== 'map').map(f => toKey(f.key))
260
+ const rels = fields.filter(f => f.kind === 'relation')
261
+ const maps = fields.filter(f => f.kind === 'map')
262
+ const pick = scalars.length ? `Pick<${A}, ${scalars.map(k => JSON.stringify(k)).join(' | ')}>` : null
263
+ const relLines = rels.map(r => {
264
+ const target = ent(entityName).relations?.[r.key]?.to ?? r.key // schema lookup stays snake
265
+ const inner = projectionType(target, r.fields)
266
+ const t = r.cardinality === 'many' ? `(${inner})[]` : `(${inner})`
267
+ return ` ${toKey(r.key)}?: ${t}`
268
+ })
269
+ // _count → Record<string, number>; _agg → Record<string, unknown> (honest maps,
270
+ // not a precise nested type — the wire keys are alias-dependent).
271
+ const mapLines = maps.map(m => ` ${m.key}?: Record<string, ${TS[m.value] ?? 'unknown'}>`)
272
+ const objLines = [...relLines, ...mapLines]
273
+ if (!objLines.length) return pick ?? '{}'
274
+ return `${pick ? pick + ' & ' : ''}{\n${objLines.join('\n')}\n}`
275
+ }
276
+
277
+ // Escape a string for use inside a TS template literal.
278
+ const xsqlLiteral = (s) => '`' + s.replace(/`/g, '\\`').replace(/\$\{/g, '\\${') + '`'
279
+
280
+ // Build a wire selection object from IR fields — used by watchQuery / sdk.get.
281
+ function fieldsToSel(fields) {
282
+ const obj = {}
283
+ for (const f of fields) {
284
+ if (f.kind === 'map') continue // _count/_agg have no plain wire-selection form
285
+ obj[f.key] = f.kind === 'relation' ? fieldsToSel(f.fields) : null
286
+ }
287
+ return obj
288
+ }
289
+
290
+ function paramsBody(params) {
291
+ if (!params?.length) return null
292
+ return params.map(p => ` ${p.name}${p.optional ? '?' : ''}: ${TS[p.type] ?? 'string'}${p.array ? '[]' : ''}`).join('\n')
293
+ }
294
+
295
+ // sql-template result type — a flat object from the IR's `result.fields` (which
296
+ // the server synthesized from @returns). No fields / no @returns → Record<unknown>.
297
+ // `nullable === false` ⇒ non-null column; otherwise nullable (`| null`).
298
+ function plainResultType(result) {
299
+ if (!result?.fields?.length) return 'Record<string, unknown>'
300
+ const lines = result.fields.map(f =>
301
+ ` ${toKey(f.key)}: ${TS[f.type] ?? 'unknown'}${f.nullable === false ? '' : ' | null'}`)
302
+ return `{\n${lines.join('\n')}\n}`
303
+ }
304
+
305
+ // Group every read + sql-template op by its declared @namespace (the OOP facade /
306
+ // FP module). Identity is (namespace, name) — both come from the IR; the emitter
307
+ // does only case-transforms, no string surgery.
308
+ const writeData = flags.keys === 'camel'
309
+ ? '(Array.isArray(data) ? data.map(d => _toSnake(d)) : _toSnake(data))'
310
+ : 'data'
311
+ // ── Layer 1 (model, 1:1) + Layer 2 (XSQL op overlay) ────────────────────────
312
+ // Layer 1: EVERY entity in the IAM-filtered schema → a base namespace with
313
+ // sync/stack/delete (typed via `${E}Write`). Layer 2: declared XSQL ops add their
314
+ // typed read/sql-template/watch methods onto the matching @namespace. They merge:
315
+ // `Movie` ends up with writes (from the model) AND its declared reads.
316
+ const allEntities = Object.keys(schema.entities).sort()
317
+ const entityByPascal = {}
318
+ for (const n of allEntities) entityByPascal[entityPascal(n)] = n
319
+
320
+ // namespace registry: NS(pascal) → { writeEntity(snake|null), typeAliases[], readMethods[] }
321
+ const nsMap = {}
322
+ const nsOf = (NS) => (nsMap[NS] ??= { writeEntity: entityByPascal[NS] ?? null, typeAliases: [], readMethods: [] })
323
+
324
+ // Layer 1 — seed a base namespace for every entity (write methods emitted below).
325
+ for (const n of allEntities) nsOf(entityPascal(n))
326
+
327
+ // Layer 2 — overlay declared ops as typed methods on their @namespace.
328
+ // Mutations are Layer-1 territory (schema-derived Entity.sync/stack/delete) —
329
+ // never emitted from XSQL. Skip LOUDLY, never silently.
330
+ const MUTATE_VERBS = new Set(['sync', 'stack', 'delete'])
331
+ for (const op of ir.operations) {
332
+ if (op.batch) continue
333
+ if (MUTATE_VERBS.has(op.op)) {
334
+ console.warn(`skipped @${op.op} ${op.name} — mutations are generated from the schema; use ${entityPascal(op.entity ?? '<Entity>')}.${op.op}(data)`)
335
+ continue
336
+ }
337
+ // Namespace defaults to the op's ROOT ENTITY (the model is the source of truth) —
338
+ // `@search list` over `movie` → Movie.list(). @namespace is only needed when there
339
+ // is no root entity (a raw sql-template) or to deliberately group elsewhere.
340
+ const nsName = op.namespace ?? op.entity
341
+ if (!nsName)
342
+ throw new Error(`op "${op.name}": a sql-template with no root entity needs @namespace`)
343
+ const NS = entityPascal(nsName)
344
+ const g = nsOf(NS)
345
+ const isSql = op.op === 'sql-template'
346
+ const pb = paramsBody(op.params)
347
+ const allOptional = !op.params?.length || op.params.every(p => p.optional)
348
+ const Type = `${NS}${pascal(op.name)}` // MovieList, MovieGenreList, DashboardStats…
349
+ const ParamsT = pb ? `${Type}Params` : null
350
+ const pArg = ParamsT ? `params${allOptional ? '?' : ''}: ${ParamsT}, opts?: ExecOpts` : `opts?: ExecOpts`
351
+ const pPass = op.params?.length ? `params ?? {}` : '{}'
352
+ const ret = op.op === 'get' ? `Promise<${Type} | null>` : `Promise<${Type}[]>`
353
+ const methodName = camel(op.name)
354
+
355
+ if (ParamsT) g.typeAliases.push(`export type ${ParamsT} = {\n${pb}\n}`)
356
+ g.typeAliases.push(`export type ${Type} = ${isSql ? plainResultType(op.result) : projectionType(op.entity, op.result.fields)}`)
357
+ if (!isSql) g.typeAliases.push(`export const ${Type}Sel = ${JSON.stringify(fieldsToSel(op.result.fields), null, 2)} as const`)
358
+
359
+ // @description → JSDoc on the generated method (multiline-safe).
360
+ const jsdoc = op.description
361
+ ? ` /**\n${op.description.split('\n').map(l => ` * ${l.replace(/\*\//g, '* /')}`).join('\n')}\n */\n`
362
+ : ''
363
+ g.readMethods.push(jsdoc + (isSql
364
+ ? ` ${methodName}(${pArg}): Promise<${Type}[]> {\n return _client.sqlTemplate<${Type}>(${xsqlLiteral(op.source)}, ${pPass}, opts)\n }`
365
+ : ` ${methodName}(${pArg}): ${ret} {\n return _client.query<${Type}>(${xsqlLiteral(`@${op.op} ${op.name}\n` + op.source)}, ${pPass}, { op: ${JSON.stringify(op.op)}, ...opts })\n }`))
366
+ if (op.watch) g.readMethods.push(isSql
367
+ ? ` watch${pascal(methodName)}(${pArg}): SqlTemplateWatch<${Type}> {\n return _client.watchSqlTemplate<${Type}>(${xsqlLiteral(op.source)}, ${pPass}, { entities: ${JSON.stringify(Array.isArray(op.watch) ? op.watch : [])}, searchOpts: opts })\n }`
368
+ : ` watch${pascal(methodName)}(${pArg}): QueryWatch<${Type}> {\n return _client.watchQuery.xsql<${Type}>(${xsqlLiteral(`@${op.op} ${op.name}\n` + op.source)}, ${pPass}, { entity: ${JSON.stringify(op.entity)}, op: ${JSON.stringify(op.op)}, searchOpts: opts })\n }`)
369
+ }
370
+
371
+ // Emit one chunk per namespace: projection types + the object (reads + writes).
372
+ const nsChunks = []
373
+ for (const NS of Object.keys(nsMap).sort()) {
374
+ const g = nsMap[NS]
375
+ const we = g.writeEntity
376
+ // Writes are SILENT by default — the server answers { count }. `returning`
377
+ // opts in to the echo, and the return type follows it: the `O extends
378
+ // { returning: true }` conditional resolves per call site, so a default call
379
+ // has no `.xid` and a returning call has no `.count`.
380
+ const retT = `O extends { returning: true } ? Echo<D> : WriteCount`
381
+ const gen = `D extends ${NS}Write | ${NS}Write[], O extends WriteOpts | undefined = undefined`
382
+ const wd = `${writeData} as Record<string,unknown> | Record<string,unknown>[]`
383
+ const writeMethods = (flags.writes && we) ? [
384
+ ` sync<${gen}>(data: D, opts?: O): Promise<${retT}> {\n return _client.sync<${retT}>(${JSON.stringify(we)}, ${wd}, opts)\n }`,
385
+ ` stack<${gen}>(data: D, opts?: O): Promise<${retT}> {\n return _client.stack<${retT}>(${JSON.stringify(we)}, ${wd}, opts)\n }`,
386
+ ` delete(xid: string, opts?: ExecOpts): Promise<unknown> {\n return _client.delete(${JSON.stringify(we)}, { xid }, opts)\n }`,
387
+ ] : []
388
+ const methods = [...g.readMethods, ...writeMethods]
389
+ nsChunks.push([
390
+ ...g.typeAliases,
391
+ // entity-backed namespaces: `Movie` is also the read row type. Facades (Dashboard) value-only.
392
+ ...(we ? [`export type ${NS} = ${alias(we)}`] : []),
393
+ `export const ${NS} = {\n${methods.join(',\n')}\n}`,
394
+ ].join('\n\n'))
395
+ }
396
+
397
+ // schema.ts — read + write interface for EVERY entity in the model
398
+ const scalarType = (attr) => {
399
+ const base = tsScalar(attr)
400
+ return attr.nullable === false ? base : `${base} | null`
401
+ }
402
+ const schemaChunks = []
403
+ for (const name of allEntities) { // 1:1 with the model — every entity
404
+ const e = ent(name)
405
+ const rels = e.relations ?? {}
406
+ // schema-attached field key, from the same skins contract as entityPascal — falls
407
+ // back to the local splitter only when this field has no schema entry.
408
+ const key = (skinned, k) => skinned?.skins?.[flags.keys] ?? toKey(k)
409
+ const lines = [' xid: string'] // READ shape — every field present
410
+ const wlines = [' xid?: string'] // WRITE shape — all optional; xid set = update target
411
+ for (const [k, a] of Object.entries(e.attributes ?? {})) {
412
+ if (k === 'xid') continue // identity already emitted above (non-null); skip the nullable attribute dup
413
+ if (rels[k]) continue
414
+ lines.push(` ${key(a, k)}: ${scalarType(a)}`)
415
+ const wt = tsScalar(a, true) // write scalar (e.g. hashed → string, not never)
416
+ if (wt !== 'never') wlines.push(` ${key(a, k)}?: ${a.nullable === false ? wt : `${wt} | null`}`)
417
+ }
418
+ for (const [k, r] of Object.entries(rels)) {
419
+ if (!schema.entities[r.to]) continue // relation target filtered out of schema → skip
420
+ lines.push(` ${key(r, k)}?: ${r.cardinality === 'many' ? `${entityPascal(r.to)}[]` : entityPascal(r.to)}`)
421
+ // WRITE relation: a LINK to an existing record ({ xid }) OR a nested write.
422
+ // This is the FK-reference the wire actually accepts — not the full read entity.
423
+ const link = `{ xid: string } | ${entityPascal(r.to)}Write`
424
+ wlines.push(` ${key(r, k)}?: ${r.cardinality === 'many' ? `(${link})[]` : link}`)
425
+ }
426
+ schemaChunks.push(`export interface ${entityPascal(name)} {\n${lines.join('\n')}\n}`)
427
+ if (flags.writes) schemaChunks.push(`export interface ${entityPascal(name)}Write {\n${wlines.join('\n')}\n}`)
428
+ }
429
+
430
+ // ops.ts — import every entity's read type (aliased) + write type from schema.ts.
431
+ const aliasImports = allEntities.flatMap(n =>
432
+ flags.writes ? [`${entityPascal(n)} as ${alias(n)}`, `${entityPascal(n)}Write`] : [`${entityPascal(n)} as ${alias(n)}`]
433
+ ).join(', ')
434
+ const inputNames = xsqlPaths.map(p => path.basename(p)).join(', ')
435
+ const OPS_HDR_COMMENT = `// AUTO-GENERATED from ${inputNames}. Do not edit.\n`
436
+ const OPS_HDR_CODE = `import type { SynthigyClient, ClientConfig, ExecOpts, WriteOpts, QueryWatch, SqlTemplateWatch } from "${sdkSrc}"
437
+ import { connect } from "${sdkSrc}"
438
+ import type { ${aliasImports} } from "./schema.js"
439
+
440
+ /** What a write answers by default — writes are silent unless \`returning\`. */
441
+ export type WriteCount = { count: number }
442
+
443
+ /** What a write answers under \`{ returning: true }\`, cardinality-mirrored. */
444
+ export type Echo<D> = D extends any[] ? (D[number] & { xid: string })[] : D & { xid: string }
445
+
446
+ let _client: SynthigyClient
447
+
448
+ /**
449
+ * Call once at startup. Installs the process-wide SDK client via \`connect\` —
450
+ * the SAME single client the bare verbs (\`search\`, \`sync\`, …) run on — and
451
+ * points every generated method at it. One process, one client, one token
452
+ * cache, one SSE. Reconnect by calling again (destroys the previous client).
453
+ */
454
+ export function configure(config: ClientConfig): void {
455
+ _client = connect(${flags.keys === 'camel' ? '{ ...config, keyFormat: \'camel\' }' : 'config'})
456
+ }
457
+
458
+ /** Return the underlying SDK client — use for calls not covered by generated ops. */
459
+ export function client(): SynthigyClient { return _client }
460
+ ${flags.keys === 'camel' && flags.writes ? `
461
+ // Wire writes always use snake_case — transform before sending.
462
+ const _toSnake = (v: unknown): unknown => {
463
+ if (Array.isArray(v)) return v.map(_toSnake)
464
+ if (v !== null && typeof v === 'object')
465
+ return Object.fromEntries(Object.entries(v as Record<string, unknown>)
466
+ .map(([k, val]) => [k.replace(/([a-z])([A-Z])/g, '$1_$2').toLowerCase(), _toSnake(val)]))
467
+ return v
468
+ }` : ''}
469
+ `
470
+ const SCHEMA_HDR = `// AUTO-GENERATED — entity types from /schema ${schema.version ?? ''}. Do not edit.\n`
471
+
472
+ fs.mkdirSync(outDir, { recursive: true })
473
+ // The printer drops the detached top-of-file comment, so prepend it after formatting.
474
+ fs.writeFileSync(path.join(outDir, 'schema.ts'), SCHEMA_HDR + formatTs(schemaChunks.join('\n\n'), 'schema.ts'))
475
+ fs.writeFileSync(path.join(outDir, 'ops.ts'), OPS_HDR_COMMENT + formatTs(OPS_HDR_CODE + nsChunks.join('\n\n'), 'ops.ts'))
476
+
477
+ // Gate, not a builder: VALIDATE the generated TS and FAIL LOUD if it doesn't
478
+ // type-check — never ship broken/stale output silently (the old emit-and-swallow
479
+ // shipped a stale ops.js once). No .js emitted: consumers import the .ts source
480
+ // (Node 23+ type-stripping / a bundler); the type-only `./schema.js` import erases
481
+ // at runtime. `--ignoreConfig` so a consumer tsconfig can't trip TS5112.
482
+ const tscBin = path.join(here, '..', 'node_modules', '.bin', 'tsc')
483
+ try {
484
+ execFileSync(tscBin, [
485
+ '--ignoreConfig',
486
+ '--module', 'nodenext', '--moduleResolution', 'nodenext',
487
+ '--target', 'es2022', '--strict', '--skipLibCheck', '--noEmit',
488
+ path.join(outDir, 'ops.ts'),
489
+ path.join(outDir, 'schema.ts'),
490
+ ], { stdio: 'pipe' })
491
+ } catch (e) {
492
+ process.stderr.write('codegen: generated TS failed to type-check (not shipped):\n')
493
+ process.stderr.write((e.stdout ?? e.stderr ?? '').toString())
494
+ process.exit(1)
495
+ }
496
+ const flagSummary = [flags.keys !== 'snake' && `keys=${flags.keys}`, !flags.writes && 'no-writes'].filter(Boolean).join(' ')
497
+ const opCount = ir.operations.filter(o => !o.batch).length
498
+ const inputSummary = xsqlPaths.map(p => path.basename(p)).join(' + ')
499
+ console.log(`${inputSummary} → ${allEntities.length} entities (full model), ${Object.keys(nsMap).length} namespaces, ${opCount} ops${flagSummary ? ` [${flagSummary}]` : ''} → ${outDir}`)
@@ -0,0 +1,30 @@
1
+ // Shared codegen primitives for the TS emitters — one home for the scalar-type
2
+ // map + casing so the read and write emitters can't drift apart.
3
+
4
+ // scalar/enum/ref type → TS. `write` flips the read-only asymmetry: a `hashed`
5
+ // field is write-only — `never` in a read result, but a plain `string` you set
6
+ // on input.
7
+ export const TS = {
8
+ int: 'number', float: 'number', string: 'string', boolean: 'boolean',
9
+ timestamp: 'string', uuid: 'string', json: 'unknown', transit: 'unknown',
10
+ encrypted: 'string',
11
+ // `?name:order` — a "column [asc|desc], …" spec string; the server
12
+ // validates columns against the param's restriction set at bind time.
13
+ order: 'string',
14
+ }
15
+ export const tsScalar = (attr, write = false) => {
16
+ if (!attr) return 'unknown'
17
+ if (attr.type === 'enum') return (attr.enum ?? []).map((v) => JSON.stringify(v)).join(' | ') || 'string'
18
+ if (attr.type === 'hashed') return write ? 'string' : 'never' // write-only: input string, never read
19
+ return TS[attr.type] ?? 'string' // reference types (type === entity name) → FK id string
20
+ }
21
+
22
+ // casing
23
+ export const words = (s) => s.split(/[-_\s]+/).filter(Boolean)
24
+ export const pascal = (s) => words(s).map((w) => w[0].toUpperCase() + w.slice(1)).join('')
25
+ export const camel = (s) => { const p = pascal(s); return p[0].toLowerCase() + p.slice(1) }
26
+ // snake_case — the wire-native form. Valid TS identifier, no quoting, and the
27
+ // server's DEFAULT response casing, so generated reads need no key_format walk.
28
+ export const snake = (s) => words(s).join('_')
29
+ // kebab wire keys aren't valid TS identifiers → keep verbatim, quote when needed.
30
+ export const tsKey = (k) => (/^[A-Za-z_$][\w$]*$/.test(k) ? k : JSON.stringify(k))