@synthigy/sdk 0.1.2 → 0.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 CHANGED
@@ -13,20 +13,17 @@ npm install @synthigy/sdk
13
13
  ```ts
14
14
  import { createClient } from '@synthigy/sdk'
15
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 } })
16
+ // Endpoint and identity come from `synthigy exec` (see Code generation, step 3).
17
+ const synthigy = createClient()
18
+
19
+ // Read — XSQL: the shape you write is the shape you get back
20
+ const movies = await synthigy.query(`
21
+ movie (limit 5, order by release_year desc)
22
+ xid
23
+ title
24
+ release_year
25
+ ->genres
26
+ name`)
30
27
 
31
28
  // Write — additive; doesn't disturb the movie's other ratings
32
29
  await synthigy.stack('user_rating', {
@@ -43,11 +40,12 @@ for await (const ev of w.events) {
43
40
  }
44
41
  ```
45
42
 
43
+ Run it with `synthigy exec -- node hello.ts`.
44
+
46
45
  ## What makes it Synthigy
47
46
 
48
47
  - **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.
48
+ that JSON and nothing else, written in [XSQL](./docs/XSQL.md).
51
49
 
52
50
  - **Writes are silent by default.** `sync`/`stack` answer `{ count }`, not the
53
51
  record. Mint the id up front with `newXid()` when you need it — cheaper than
@@ -55,29 +53,119 @@ for await (const ev of w.events) {
55
53
  Pass `{ returning: true }` for the record; generated code types the two apart.
56
54
  - **Writes that ORMs make you hand-roll.** `stack` adds a child without
57
55
  clobbering its siblings; `slice` detaches a link without deleting the record.
58
- Plus `sync` (upsert), `delete`, `purge`. → [WIRE-FORMAT.md](./docs/WIRE-FORMAT.md)
56
+ Plus `sync` (upsert), `delete`, `purge`.
59
57
 
60
58
  - **Live data on one connection.** Open dozens of watches — they multiplex onto
61
59
  a single SSE per client. `watchQuery` for a live result set,
62
60
  `watchSqlTemplate` for live analytics, `watch` for raw record events.
63
- → [WATCH.md](./docs/WATCH.md)
64
61
 
65
62
  - **`actingAs` per call.** Every read and write can run as a specific user, with
66
63
  IAM / RBAC / row-level security enforced *server-side*. Tokens never have to
67
- reach the browser. → [AUTH.md](./docs/AUTH.md)
64
+ reach the browser. `client.login` runs the OIDC authorization-code + PKCE
65
+ exchange for your BFF and leaves sessions to you; `createBrowserLogin` does
66
+ the same for a browser app, as a public client with silent renew and tokens
67
+ in memory.
68
68
 
69
- - **Typed from your model.** `synthigy-gen` (needs `typescript` in your project) compiles your ERD + `.xsql` files
69
+ - **Typed from your model.** `synthigy-gen` compiles your ERD + `.xsql` files
70
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)
71
+ or an unselected field is a compile error. With `typescript` (any version, 7
72
+ included) in your project the generated files are type-checked before they
73
+ are written. → [Code generation](#code-generation)
74
+
75
+ ## Code generation
76
+
77
+ Write your queries in `.xsql` files and get typed functions for them. The
78
+ server compiles the queries, so the types always match what it returns.
79
+
80
+ **1. Get a server.** In your project folder:
81
+
82
+ ```bash
83
+ synthigy env init
84
+ synthigy up
85
+ ```
86
+
87
+ The first `up` prints a `/setup` link; open it and pick a database. (No
88
+ browser? `synthigy up --db sqlite` skips the wizard.) Already have a server?
89
+ Skip this step.
90
+
91
+ **2. Deploy your data model** in the modeler (or from code with `client.deploy()`, as a
92
+ client with the Dataset Developer role).
93
+
94
+ **3. Connect as your app.** Create its client once, then save it to the
95
+ project:
96
+
97
+ ```bash
98
+ synthigy iam add-client "My App" --id my-app --type confidential \
99
+ --role "Dataset Explorer" --api Synthigy --grant client_credentials --local
100
+ synthigy connect http://localhost:7887 --client-id my-app
101
+ ```
102
+
103
+ `add-client` prints the secret once; `connect` asks for it. Code is generated
104
+ for what this app is allowed to see. (`--local` works on the server's own
105
+ machine; for a remote server, create the client in the console.)
106
+
107
+ **4. Install the SDK:**
108
+
109
+ ```bash
110
+ npm install @synthigy/sdk
111
+ npm install -D typescript # optional: type-checks the generated files
112
+ ```
113
+
114
+ **5. Write a query** in `xsql/movies.xsql`:
115
+
116
+ ```
117
+ @search list
118
+ movie (release_year > ?since:int=1990, limit ?limit:int=20)
119
+ xid
120
+ title
121
+ release_year
122
+ ```
123
+
124
+ **6. Generate:**
125
+
126
+ ```bash
127
+ synthigy exec -- npx synthigy-gen xsql/ generated/
128
+ ```
129
+
130
+ This writes `generated/ops.ts` and `generated/schema.ts`, and saves `xsql/schema.json` and `xsql/ops.ir.json`
131
+ next to your queries.
132
+
133
+ **7. Use it:**
134
+
135
+ ```ts
136
+ import { configure, Movie } from './generated/ops.ts'
137
+
138
+ configure()
139
+ const movies = await Movie.list({ since: 2000 })
140
+ console.log(movies.map(m => m.title))
141
+ ```
142
+
143
+ ```bash
144
+ synthigy exec -- node app.ts
145
+ ```
146
+
147
+ `synthigy exec` gives your program the server address and the app's identity (Node 23+ runs `.ts` directly; set `"type": "module"` in `package.json`).
148
+ Without it, pass them yourself: `configure({ endpoint, clientId, clientSecret })`.
149
+
150
+ **After you edit a query**, run step 6 again. As long as the `.xsql` files are
151
+ unchanged it works offline from `xsql/ops.ir.json`; after an edit it needs the
152
+ server, and it never generates from outdated results. In CI:
153
+
154
+ ```bash
155
+ npx synthigy-gen check xsql/
156
+ ```
157
+
158
+ **What to commit:** your `.xsql` files and `xsql/ops.ir.json`.
159
+ `xsql/schema.json` is your whole data model, so commit it only in a private
160
+ repo.
161
+
162
+ `@watch` ops also get a live `watch<Name>()` method, `@batch` ops become one exported function that sends every member in one request (a failed member is a `SynthigyError` in its slot), and every entity gets typed `sync` / `stack` / `delete`.
72
163
 
73
164
  ## Docs
74
165
 
75
166
  | Doc | What's in it |
76
167
  |---|---|
77
168
  | [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
169
  | [CHANGELOG.md](./CHANGELOG.md) | Version history |
82
170
  | [synthigy/examples: movies/js](https://github.com/synthigy/examples/tree/main/movies/js) | End-to-end Datastar BFF against the published package |
83
171
  | [src/index.d.ts](./src/index.d.ts) | Full type definitions |
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
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
3
+ // node codegen.mjs <xsql...> [outDir] — describe + gen TS (saves ops.ir.json)
4
+ // node codegen.mjs check <xsql...> — re-describe, diff, exit 1 on drift
5
5
  // node codegen.mjs pull [endpoint] [out] — pull schema.json (wraps pull.mjs)
6
6
  //
7
7
  // Config via env: SYNTHIGY_ENDPOINT, SYNTHIGY_CLIENT_ID, SYNTHIGY_CLIENT_SECRET
@@ -14,29 +14,13 @@ import { createRequire } from 'node:module'
14
14
  import { TS, tsScalar, pascal, camel } from './lib.mjs'
15
15
  import { PLATFORM_AUDIENCE } from '../src/index.js'
16
16
 
17
- // typescript is an optional peer: the runtime client has no dependencies and
18
- // must stay that way, but generating .ts files needs the compiler — resolved
19
- // from the consumer's project, where a TypeScript codebase already has it.
20
- let ts
17
+ let tsVersion = null
21
18
  try {
22
- ts = createRequire(import.meta.url)('typescript')
23
- } catch {
24
- console.error('synthigy-gen: `typescript` is required to generate code and was not found.\n' +
25
- ' npm install -D typescript')
26
- process.exit(1)
27
- }
19
+ tsVersion = createRequire(import.meta.url)('typescript/package.json').version
20
+ } catch {}
28
21
 
29
22
  const here = path.dirname(new URL(import.meta.url).pathname)
30
23
 
31
- // Reformat generated TS through the compiler's own printer: correct indentation,
32
- // spacing and semicolons with zero hand-managed `\n`. The printer round-trips the
33
- // AST, so it fixes structural formatting while preserving embedded XSQL template
34
- // literals verbatim. (Realizes the "AST emission" goal cheaply; the fail-loud tsc
35
- // gate below still validates the result.)
36
- const formatTs = (code, file) =>
37
- ts.createPrinter({ newLine: ts.NewLineKind.LineFeed })
38
- .printFile(ts.createSourceFile(file, code, ts.ScriptTarget.Latest, true))
39
-
40
24
  // ── args + flags ──────────────────────────────────────────────────────────────
41
25
  if (process.argv.includes('--help') || process.argv.includes('-h')) {
42
26
  console.log(`
@@ -54,7 +38,7 @@ Flags:
54
38
  --schema <path> explicit schema.json path (skips auto-pull)
55
39
 
56
40
  Environment:
57
- SYNTHIGY_ENDPOINT server URL (default: http://localhost:7887)
41
+ SYNTHIGY_ENDPOINT server URL (required when a server is needed)
58
42
  SYNTHIGY_CLIENT_ID OAuth client ID
59
43
  SYNTHIGY_CLIENT_SECRET OAuth client secret
60
44
  SYNTHIGY_AUDIENCE client_credentials audience, when the server requires one
@@ -99,13 +83,13 @@ if (pullMode) {
99
83
  // is input. check mode has no outDir — every positional is input.
100
84
  const rawPos = checkMode ? pos.slice(1) : pos
101
85
  const lastPos = rawPos[rawPos.length - 1]
102
- const hasExplicitOut = !checkMode && lastPos && !lastPos.endsWith('.xsql')
86
+ const hasExplicitOut = !checkMode && rawPos.length > 1 && !lastPos.endsWith('.xsql')
103
87
  const outArg = hasExplicitOut ? lastPos : undefined
104
88
  const xsqlArgs = hasExplicitOut ? rawPos.slice(0, -1) : rawPos
105
89
 
106
90
  if (!xsqlArgs.length) {
107
- console.error('usage: node codegen.mjs [flags] <file.xsql> [file2.xsql ...] [outDir]')
108
- console.error(' node codegen.mjs check <file.xsql> [file2.xsql ...]')
91
+ console.error('usage: node codegen.mjs [flags] <file.xsql|dir> [...] [outDir] (dirs searched recursively)')
92
+ console.error(' node codegen.mjs check <file.xsql|dir> [...]')
109
93
  console.error(' node codegen.mjs pull [endpoint] [schema.json]')
110
94
  console.error('flags: --pull force schema refresh')
111
95
  console.error(' --keys snake|camel field casing (default: snake)')
@@ -114,14 +98,20 @@ if (!xsqlArgs.length) {
114
98
  process.exit(1)
115
99
  }
116
100
 
101
+ const isDir = p => fs.existsSync(p) && fs.statSync(p).isDirectory()
117
102
  const xsqlPaths = xsqlArgs.flatMap(a => {
118
103
  const p = path.resolve(a)
119
- if (fs.existsSync(p) && fs.statSync(p).isDirectory())
120
- return fs.readdirSync(p).filter(f => f.endsWith('.xsql')).sort().map(f => path.join(p, f))
104
+ if (isDir(p))
105
+ return fs.readdirSync(p, { recursive: true })
106
+ .map(f => f.split(path.sep).join('/'))
107
+ .filter(f => f.endsWith('.xsql') && fs.statSync(path.join(p, f)).isFile())
108
+ .sort()
109
+ .map(f => path.join(p, f))
121
110
  return [p]
122
111
  })
123
112
  if (!xsqlPaths.length) { console.error('no .xsql files found'); process.exit(1) }
124
- const xsqlDir = path.dirname(xsqlPaths[0]) // schema.json lives next to first file
113
+ const firstArg = path.resolve(xsqlArgs[0])
114
+ const xsqlDir = isDir(firstArg) ? firstArg : path.dirname(firstArg) // IR + schema.json home
125
115
  const irPath = path.join(xsqlDir, 'ops.ir.json')
126
116
  const outDir = checkMode
127
117
  ? null
@@ -137,13 +127,18 @@ const schemaPath = flags.schemaOverride ?? localSchemaPath
137
127
  const toKey = flags.keys === 'camel' ? camel : s => s
138
128
 
139
129
  // ── describe ─────────────────────────────────────────────────────────────────
140
- const ENDPOINT = process.env.SYNTHIGY_ENDPOINT ?? 'http://localhost:7887'
130
+ const ENDPOINT = process.env.SYNTHIGY_ENDPOINT
131
+ function endpoint() {
132
+ if (ENDPOINT) return ENDPOINT
133
+ console.error('NO_ENDPOINT: no endpoint — set SYNTHIGY_ENDPOINT (run under `synthigy exec`)')
134
+ process.exit(1)
135
+ }
141
136
  const { SYNTHIGY_CLIENT_ID, SYNTHIGY_CLIENT_SECRET, SYNTHIGY_AUDIENCE } = process.env
142
137
 
143
138
  // Auto-pull schema if missing or --pull requested (saved next to .xsql, cached for future runs).
144
139
  if (!flags.schemaOverride && (flags.pull || !fs.existsSync(schemaPath))) {
145
140
  execFileSync(process.execPath,
146
- [path.join(here, 'pull.mjs'), ENDPOINT, schemaPath],
141
+ [path.join(here, 'pull.mjs'), endpoint(), schemaPath],
147
142
  { stdio: 'inherit', env: process.env })
148
143
  }
149
144
 
@@ -154,7 +149,7 @@ if (!fs.existsSync(schemaPath)) {
154
149
 
155
150
  async function token() {
156
151
  if (!SYNTHIGY_CLIENT_ID) return ''
157
- const r = await fetch(`${ENDPOINT}/oauth/token`, {
152
+ const r = await fetch(`${endpoint()}/oauth/token`, {
158
153
  method: 'POST',
159
154
  headers: { 'content-type': 'application/x-www-form-urlencoded' },
160
155
  body: new URLSearchParams({ grant_type: 'client_credentials',
@@ -169,21 +164,37 @@ async function token() {
169
164
  }
170
165
 
171
166
  // Merge all xsql files — ops are independent, names must be unique across files.
172
- const source = xsqlPaths.map(p => fs.readFileSync(p, 'utf8')).join('\n\n')
167
+ // `@workspace` is buffer-scoped and must precede the first op, so only the
168
+ // first file's survives the merge — left in place it lands INSIDE the
169
+ // preceding file's last op and the server rejects that op at run time.
170
+ const dropWorkspace = (src) => src.split('\n').filter(l => !/^@workspace\b/.test(l)).join('\n')
171
+ const source = xsqlPaths
172
+ .map((p, i) => { const src = fs.readFileSync(p, 'utf8'); return i === 0 ? src : dropWorkspace(src) })
173
+ .join('\n\n')
174
+ // describe parses each file on its own — concatenated, file 1's buffer
175
+ // @namespace would leak into every later file and theirs would be dropped.
176
+ const sources = xsqlPaths.map(p => ({
177
+ path: path.relative(xsqlDir, p).split(path.sep).join('/'),
178
+ source: fs.readFileSync(p, 'utf8'),
179
+ }))
173
180
  // Contract between the .xsql sources on disk and the saved IR: the IR carries
174
181
  // the hash of the sources it was described from. Mismatch = the IR is stale.
175
182
  const sourceHash = createHash('sha256').update(source).digest('hex')
176
183
 
177
184
  // check mode fails FAST and OFFLINE when the sources drifted from the saved IR —
178
185
  // no backend/credentials needed to catch "edited .xsql, forgot codegen".
179
- // (A hash match still goes through the live describe-diff below: same sources
180
- // can yield a different IR after a server-side model change.)
186
+ // A hash match still goes through the live describe-diff below when a server is
187
+ // configured: same sources can yield a different IR after a model change.
181
188
  if (checkMode && fs.existsSync(irPath)) {
182
189
  const saved = JSON.parse(fs.readFileSync(irPath, 'utf8'))
183
190
  if (saved.sourceHash && saved.sourceHash !== sourceHash) {
184
- console.error(`sources drifted from saved IR — run: node codegen.mjs ${xsqlArgs.join(' ')}`)
191
+ console.error(`sources drifted from saved IR — run: synthigy-gen ${xsqlArgs.join(' ')}`)
185
192
  process.exit(1)
186
193
  }
194
+ if (saved.sourceHash && !['SYNTHIGY_ENDPOINT', 'SYNTHIGY_TOKEN', 'SYNTHIGY_CLIENT_ID'].some(k => process.env[k])) {
195
+ console.log('ok — sourceHash matches saved IR (offline; set SYNTHIGY_* env for a live describe-diff)')
196
+ process.exit(0)
197
+ }
187
198
  }
188
199
 
189
200
  // Describe: use the cached IR only when it matches the sources on disk
@@ -200,14 +211,12 @@ if (cached && cached.sourceHash === sourceHash) {
200
211
  console.log(cached.sourceHash
201
212
  ? 'sources changed since IR pull — refreshing'
202
213
  : 'saved IR predates source hashing — refreshing')
203
- // The canonical parser owns @namespace/@watch/@returns now — send source as-is.
204
- const sourceForDescribe = source
205
214
  try {
206
215
  const tok = await token()
207
- const resp = await fetch(`${ENDPOINT}/data`, {
216
+ const resp = await fetch(`${endpoint()}/data`, {
208
217
  method: 'POST',
209
218
  headers: { 'content-type': 'application/json', ...(tok && { authorization: `Bearer ${tok}` }) },
210
- body: JSON.stringify({ operations: [{ op: 'describe', source: sourceForDescribe }] }),
219
+ body: JSON.stringify({ operations: [{ op: 'describe', sources }] }),
211
220
  })
212
221
  if (!resp.ok) throw new Error(`describe ${resp.status}: ${await resp.text()}`)
213
222
  const body = await resp.json()
@@ -223,7 +232,7 @@ if (cached && cached.sourceHash === sourceHash) {
223
232
  throw new Error(`describe returned no operations: ${JSON.stringify(ir).slice(0, 200)}`)
224
233
  } catch (e) {
225
234
  console.error(cached
226
- ? `sources changed since IR pull and describe failed (${e.message}) — start a backend (SYNTHIGY_ENDPOINT) or revert the .xsql edit`
235
+ ? `sources changed since IR pull and describe failed (${e.message}) — run under \`synthigy exec\` or revert the .xsql edit`
227
236
  : `describe failed: ${e.message}`)
228
237
  process.exit(1)
229
238
  }
@@ -275,7 +284,7 @@ const sdkSrc = sdkRoot.includes(`${path.sep}node_modules${path.sep}`)
275
284
  const ent = (n) => schema.entities[n] ?? {}
276
285
  const visited = new Set()
277
286
 
278
- // The server owns pretty-name derivation (docs/plans/PLAN-SCHEMA-SKINS-PROJECTION.md):
287
+ // The server owns pretty-name derivation:
279
288
  // entity/attribute/relation `skins` in the pulled schema are the contract, not a hint.
280
289
  // Falls back to the local splitter only for names with no schema entry (op/namespace
281
290
  // identifiers, which are snake-authored code with no display label to preserve).
@@ -360,13 +369,11 @@ for (const n of allEntities) nsOf(entityPascal(n))
360
369
  // Mutations are Layer-1 territory (schema-derived Entity.sync/stack/delete) —
361
370
  // never emitted from XSQL. Skip LOUDLY, never silently.
362
371
  const MUTATE_VERBS = new Set(['sync', 'stack', 'delete'])
372
+ const genOps = [] // generated reads, for @batch member resolution
373
+ const batches = []
363
374
  for (const op of ir.operations) {
364
375
  if (op.batch) {
365
- // The Python and PHP generators emit a batch function; this one does not
366
- // yet. Say so — the author declared an operation and would otherwise get
367
- // nothing back with no diagnostic.
368
- console.warn(`skipped @batch ${op.name} — not generated by the JS codegen yet; ` +
369
- `call the members individually, or exec([op(...), op(...)]) for one round trip`)
376
+ batches.push(op)
370
377
  continue
371
378
  }
372
379
  if (MUTATE_VERBS.has(op.op)) {
@@ -391,6 +398,7 @@ for (const op of ir.operations) {
391
398
  const ret = op.op === 'get' ? `Promise<${Type} | null>` : `Promise<${Type}[]>`
392
399
  const methodName = camel(op.name)
393
400
 
401
+ genOps.push({ op, Type })
394
402
  if (ParamsT) g.typeAliases.push(`export type ${ParamsT} = {\n${pb}\n}`)
395
403
  g.typeAliases.push(`export type ${Type} = ${isSql ? plainResultType(op.result) : projectionType(op.entity, op.result.fields)}`)
396
404
  if (!isSql) g.typeAliases.push(`export const ${Type}Sel = ${JSON.stringify(fieldsToSel(op.result.fields), null, 2)} as const`)
@@ -407,6 +415,69 @@ for (const op of ir.operations) {
407
415
  : ` 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 }`)
408
416
  }
409
417
 
418
+ // @batch <name>: <m1> <m2> → one exported function, ONE exec round trip. Results
419
+ // are keyed by member name (qualified ns/name when a bare name repeats); a failed
420
+ // member is a SynthigyError VALUE in its slot, never a batch-wide throw. Params
421
+ // are one flat object shared by every member — the Python generator's rules.
422
+ const opIdentity = o => `${(o.namespace ?? o.entity ?? '').toLowerCase()}/${o.name.toLowerCase()}`
423
+ function resolveMember(batchName, ref) {
424
+ const i = ref.lastIndexOf('/')
425
+ const ns = i >= 0 ? ref.slice(0, i).toLowerCase() : null
426
+ const nm = ref.slice(i + 1).toLowerCase()
427
+ const matches = genOps.filter(({ op }) => op.name.toLowerCase() === nm &&
428
+ (ns === null || (op.namespace ?? op.entity ?? '').toLowerCase() === ns))
429
+ if (!matches.length)
430
+ throw new Error(`@batch ${batchName} references unknown/ungenerated op ${ref}`)
431
+ if (ns === null && matches.length > 1)
432
+ throw new Error(`@batch ${batchName} — ${ref} is ambiguous: ${matches.map(m => opIdentity(m.op)).join(', ')} — qualify the reference`)
433
+ return matches[0]
434
+ }
435
+ const batchChunks = []
436
+ for (const b of batches) {
437
+ const members = b.members.map(ref => resolveMember(b.name, ref))
438
+ const merged = new Map()
439
+ for (const { op } of members)
440
+ for (const p of op.params ?? []) {
441
+ const seen = merged.get(p.name)
442
+ if (!seen) merged.set(p.name, { ...p })
443
+ else if (!p.optional) seen.optional = false
444
+ }
445
+ const params = [...merged.values()]
446
+ const names = members.map(({ op }) => op.name.toLowerCase())
447
+ const key = ({ op }) => names.filter(n => n === op.name.toLowerCase()).length > 1 ? opIdentity(op) : op.name
448
+ const fn = camel(b.name)
449
+ const B = pascal(b.name)
450
+ const pb = paramsBody(params)
451
+ const allOpt = params.every(p => p.optional)
452
+ const lines = []
453
+ if (pb) lines.push(`export type ${B}BatchParams = {\n${pb}\n}`)
454
+ lines.push(`export type ${B}Batch = {\n${members.map(m =>
455
+ ` ${JSON.stringify(key(m))}: ${m.op.op === 'get' ? `${m.Type} | null` : `${m.Type}[]`} | SynthigyError`).join('\n')}\n}`)
456
+ const ops = members.map(({ op }) => op.op === 'sql-template'
457
+ ? ` op.sqlTemplate(${xsqlLiteral(op.source)}, p),`
458
+ : ` op.query(${xsqlLiteral(`@${op.op} ${op.name}\n` + op.source)}, p, ${JSON.stringify(op.op)}),`)
459
+ const res = members.map((m, i) => ` ${JSON.stringify(key(m))}: _batchResult(r[${i}], ${JSON.stringify(m.op.op)}) as ${B}Batch[${JSON.stringify(key(m))}],`)
460
+ lines.push(`/**
461
+ * Batch: ${b.members.join(' + ')} — ONE request. Results are keyed by member; a
462
+ * failed member is a SynthigyError value in its slot, not a throw. Params are one
463
+ * object shared by every member.
464
+ */
465
+ export async function ${fn}(${pb ? `params${allOpt ? '?' : ''}: ${B}BatchParams, ` : ''}opts?: ExecOpts): Promise<${B}Batch> {
466
+ const p = ${pb ? 'params ?? {}' : '{}'}
467
+ const r = await _client.exec([
468
+ ${ops.join('\n')}
469
+ ], opts)
470
+ return {
471
+ ${res.join('\n')}
472
+ }
473
+ }`)
474
+ batchChunks.push(lines.join('\n\n'))
475
+ }
476
+ if (batchChunks.length) batchChunks.unshift(`function _batchResult(r: OperationResult, verb: string): unknown {
477
+ if (!r?.ok) return new SynthigyError(r?.error?.message ?? 'operation failed', r?.error?.code ?? 'OPERATION_FAILED')
478
+ return verb === 'get' ? (r.data ?? null) : (r.data ?? [])
479
+ }`)
480
+
410
481
  // Emit one chunk per namespace: projection types + the object (reads + writes).
411
482
  const nsChunks = []
412
483
  for (const NS of Object.keys(nsMap).sort()) {
@@ -472,8 +543,8 @@ const aliasImports = allEntities.flatMap(n =>
472
543
  ).join(', ')
473
544
  const inputNames = xsqlPaths.map(p => path.basename(p)).join(', ')
474
545
  const OPS_HDR_COMMENT = `// AUTO-GENERATED from ${inputNames}. Do not edit.\n`
475
- const OPS_HDR_CODE = `import type { SynthigyClient, ClientConfig, ExecOpts, WriteOpts, QueryWatch, SqlTemplateWatch } from "${sdkSrc}"
476
- import { connect } from "${sdkSrc}"
546
+ const OPS_HDR_CODE = `import type { SynthigyClient, ClientConfig, ExecOpts, WriteOpts, QueryWatch, SqlTemplateWatch${batchChunks.length ? ', OperationResult' : ''} } from "${sdkSrc}"
547
+ import { connect${batchChunks.length ? ', op, SynthigyError' : ''} } from "${sdkSrc}"
477
548
  import type { ${aliasImports} } from "./schema.js"
478
549
 
479
550
  /** What a write answers by default — writes are silent unless \`returning\`. */
@@ -490,7 +561,7 @@ let _client: SynthigyClient
490
561
  * points every generated method at it. One process, one client, one token
491
562
  * cache, one SSE. Reconnect by calling again (destroys the previous client).
492
563
  */
493
- export function configure(config: ClientConfig): void {
564
+ export function configure(config: ClientConfig = {}): void {
494
565
  _client = connect(${flags.keys === 'camel' ? '{ ...config, keyFormat: \'camel\' }' : 'config'})
495
566
  }
496
567
 
@@ -509,9 +580,8 @@ const _toSnake = (v: unknown): unknown => {
509
580
  const SCHEMA_HDR = `// AUTO-GENERATED — entity types from /schema ${schema.version ?? ''}. Do not edit.\n`
510
581
 
511
582
  fs.mkdirSync(outDir, { recursive: true })
512
- // The printer drops the detached top-of-file comment, so prepend it after formatting.
513
- fs.writeFileSync(path.join(outDir, 'schema.ts'), SCHEMA_HDR + formatTs(schemaChunks.join('\n\n'), 'schema.ts'))
514
- fs.writeFileSync(path.join(outDir, 'ops.ts'), OPS_HDR_COMMENT + formatTs(OPS_HDR_CODE + nsChunks.join('\n\n'), 'ops.ts'))
583
+ fs.writeFileSync(path.join(outDir, 'schema.ts'), SCHEMA_HDR + schemaChunks.join('\n\n') + '\n')
584
+ fs.writeFileSync(path.join(outDir, 'ops.ts'), OPS_HDR_COMMENT + OPS_HDR_CODE + [...nsChunks, ...batchChunks].join('\n\n') + '\n')
515
585
 
516
586
  // Gate, not a builder: VALIDATE the generated TS and FAIL LOUD if it doesn't
517
587
  // type-check — never ship broken/stale output silently (the old emit-and-swallow
@@ -536,7 +606,7 @@ const tscBin = (() => {
536
606
 
537
607
  // `--ignoreConfig` needs TypeScript 6. On 5.x the flag is itself an error, and
538
608
  // naming files explicitly already makes tsc skip tsconfig.json, so it is left off.
539
- const tscMajor = Number.parseInt(ts.version ?? '0', 10) || 0
609
+ const tscMajor = Number.parseInt(tsVersion ?? '0', 10) || 0
540
610
  if (tscBin) {
541
611
  try {
542
612
  execFileSync(tscBin, [
@@ -552,9 +622,9 @@ if (tscBin) {
552
622
  process.exit(1)
553
623
  }
554
624
  } else {
555
- process.stderr.write('codegen: tsc not found — skipping the type-check gate.\n')
625
+ process.stderr.write('codegen: tsc not found — skipping the type-check gate (npm install -D typescript).\n')
556
626
  }
557
627
  const flagSummary = [flags.keys !== 'snake' && `keys=${flags.keys}`, !flags.writes && 'no-writes'].filter(Boolean).join(' ')
558
628
  const opCount = ir.operations.filter(o => !o.batch).length
559
629
  const inputSummary = xsqlPaths.map(p => path.basename(p)).join(' + ')
560
- console.log(`${inputSummary} → ${allEntities.length} entities (full model), ${Object.keys(nsMap).length} namespaces, ${opCount} ops${flagSummary ? ` [${flagSummary}]` : ''} → ${outDir}`)
630
+ console.log(`${inputSummary} → ${allEntities.length} entities (full model), ${Object.keys(nsMap).length} namespaces, ${opCount} ops${batches.length ? `, ${batches.length} batches` : ''}${flagSummary ? ` [${flagSummary}]` : ''} → ${outDir}`)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@synthigy/sdk",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
4
4
  "description": "Zero-dependency client for Synthigy's /data endpoint",
5
5
  "author": "Robert Geršak",
6
6
  "license": "MIT",