@synthigy/sdk 0.1.1 → 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,32 +53,121 @@ 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
- | [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 |
170
+ | [synthigy/examples: movies/js](https://github.com/synthigy/examples/tree/main/movies/js) | End-to-end Datastar BFF against the published package |
84
171
  | [src/index.d.ts](./src/index.d.ts) | Full type definitions |
85
172
 
86
173
  ## Tests
@@ -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
@@ -12,30 +12,15 @@ import { createHash } from 'node:crypto'
12
12
  import { execFileSync } from 'node:child_process'
13
13
  import { createRequire } from 'node:module'
14
14
  import { TS, tsScalar, pascal, camel } from './lib.mjs'
15
+ import { PLATFORM_AUDIENCE } from '../src/index.js'
15
16
 
16
- // typescript is an optional peer: the runtime client has no dependencies and
17
- // must stay that way, but generating .ts files needs the compiler — resolved
18
- // from the consumer's project, where a TypeScript codebase already has it.
19
- let ts
17
+ let tsVersion = null
20
18
  try {
21
- ts = createRequire(import.meta.url)('typescript')
22
- } catch {
23
- console.error('synthigy-gen: `typescript` is required to generate code and was not found.\n' +
24
- ' npm install -D typescript')
25
- process.exit(1)
26
- }
19
+ tsVersion = createRequire(import.meta.url)('typescript/package.json').version
20
+ } catch {}
27
21
 
28
22
  const here = path.dirname(new URL(import.meta.url).pathname)
29
23
 
30
- // Reformat generated TS through the compiler's own printer: correct indentation,
31
- // spacing and semicolons with zero hand-managed `\n`. The printer round-trips the
32
- // AST, so it fixes structural formatting while preserving embedded XSQL template
33
- // literals verbatim. (Realizes the "AST emission" goal cheaply; the fail-loud tsc
34
- // gate below still validates the result.)
35
- const formatTs = (code, file) =>
36
- ts.createPrinter({ newLine: ts.NewLineKind.LineFeed })
37
- .printFile(ts.createSourceFile(file, code, ts.ScriptTarget.Latest, true))
38
-
39
24
  // ── args + flags ──────────────────────────────────────────────────────────────
40
25
  if (process.argv.includes('--help') || process.argv.includes('-h')) {
41
26
  console.log(`
@@ -53,9 +38,10 @@ Flags:
53
38
  --schema <path> explicit schema.json path (skips auto-pull)
54
39
 
55
40
  Environment:
56
- SYNTHIGY_ENDPOINT server URL (default: http://localhost:7887)
41
+ SYNTHIGY_ENDPOINT server URL (required when a server is needed)
57
42
  SYNTHIGY_CLIENT_ID OAuth client ID
58
43
  SYNTHIGY_CLIENT_SECRET OAuth client secret
44
+ SYNTHIGY_AUDIENCE client_credentials audience, when the server requires one
59
45
 
60
46
  Examples:
61
47
  node codegen.mjs movies.xsql src/generated/ first run auto-pulls schema
@@ -97,13 +83,13 @@ if (pullMode) {
97
83
  // is input. check mode has no outDir — every positional is input.
98
84
  const rawPos = checkMode ? pos.slice(1) : pos
99
85
  const lastPos = rawPos[rawPos.length - 1]
100
- const hasExplicitOut = !checkMode && lastPos && !lastPos.endsWith('.xsql')
86
+ const hasExplicitOut = !checkMode && rawPos.length > 1 && !lastPos.endsWith('.xsql')
101
87
  const outArg = hasExplicitOut ? lastPos : undefined
102
88
  const xsqlArgs = hasExplicitOut ? rawPos.slice(0, -1) : rawPos
103
89
 
104
90
  if (!xsqlArgs.length) {
105
- console.error('usage: node codegen.mjs [flags] <file.xsql> [file2.xsql ...] [outDir]')
106
- 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> [...]')
107
93
  console.error(' node codegen.mjs pull [endpoint] [schema.json]')
108
94
  console.error('flags: --pull force schema refresh')
109
95
  console.error(' --keys snake|camel field casing (default: snake)')
@@ -112,14 +98,20 @@ if (!xsqlArgs.length) {
112
98
  process.exit(1)
113
99
  }
114
100
 
101
+ const isDir = p => fs.existsSync(p) && fs.statSync(p).isDirectory()
115
102
  const xsqlPaths = xsqlArgs.flatMap(a => {
116
103
  const p = path.resolve(a)
117
- if (fs.existsSync(p) && fs.statSync(p).isDirectory())
118
- 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))
119
110
  return [p]
120
111
  })
121
112
  if (!xsqlPaths.length) { console.error('no .xsql files found'); process.exit(1) }
122
- 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
123
115
  const irPath = path.join(xsqlDir, 'ops.ir.json')
124
116
  const outDir = checkMode
125
117
  ? null
@@ -135,13 +127,18 @@ const schemaPath = flags.schemaOverride ?? localSchemaPath
135
127
  const toKey = flags.keys === 'camel' ? camel : s => s
136
128
 
137
129
  // ── describe ─────────────────────────────────────────────────────────────────
138
- const ENDPOINT = process.env.SYNTHIGY_ENDPOINT ?? 'http://localhost:7887'
139
- const { SYNTHIGY_CLIENT_ID, SYNTHIGY_CLIENT_SECRET } = process.env
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
+ }
136
+ const { SYNTHIGY_CLIENT_ID, SYNTHIGY_CLIENT_SECRET, SYNTHIGY_AUDIENCE } = process.env
140
137
 
141
138
  // Auto-pull schema if missing or --pull requested (saved next to .xsql, cached for future runs).
142
139
  if (!flags.schemaOverride && (flags.pull || !fs.existsSync(schemaPath))) {
143
140
  execFileSync(process.execPath,
144
- [path.join(here, 'pull.mjs'), ENDPOINT, schemaPath],
141
+ [path.join(here, 'pull.mjs'), endpoint(), schemaPath],
145
142
  { stdio: 'inherit', env: process.env })
146
143
  }
147
144
 
@@ -152,32 +149,52 @@ if (!fs.existsSync(schemaPath)) {
152
149
 
153
150
  async function token() {
154
151
  if (!SYNTHIGY_CLIENT_ID) return ''
155
- const r = await fetch(`${ENDPOINT}/oauth/token`, {
152
+ const r = await fetch(`${endpoint()}/oauth/token`, {
156
153
  method: 'POST',
157
154
  headers: { 'content-type': 'application/x-www-form-urlencoded' },
158
155
  body: new URLSearchParams({ grant_type: 'client_credentials',
159
- client_id: SYNTHIGY_CLIENT_ID, client_secret: SYNTHIGY_CLIENT_SECRET }),
156
+ client_id: SYNTHIGY_CLIENT_ID, client_secret: SYNTHIGY_CLIENT_SECRET,
157
+ // describe is a platform-API call like any other, so it mints for the
158
+ // platform audience. Hand-rolled here rather than going through the
159
+ // client, so the default has to be named explicitly.
160
+ audience: SYNTHIGY_AUDIENCE || PLATFORM_AUDIENCE }),
160
161
  })
161
162
  if (!r.ok) throw new Error(`token ${r.status}: ${await r.text()}`)
162
163
  return (await r.json()).access_token
163
164
  }
164
165
 
165
166
  // Merge all xsql files — ops are independent, names must be unique across files.
166
- 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
+ }))
167
180
  // Contract between the .xsql sources on disk and the saved IR: the IR carries
168
181
  // the hash of the sources it was described from. Mismatch = the IR is stale.
169
182
  const sourceHash = createHash('sha256').update(source).digest('hex')
170
183
 
171
184
  // check mode fails FAST and OFFLINE when the sources drifted from the saved IR —
172
185
  // no backend/credentials needed to catch "edited .xsql, forgot codegen".
173
- // (A hash match still goes through the live describe-diff below: same sources
174
- // 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.
175
188
  if (checkMode && fs.existsSync(irPath)) {
176
189
  const saved = JSON.parse(fs.readFileSync(irPath, 'utf8'))
177
190
  if (saved.sourceHash && saved.sourceHash !== sourceHash) {
178
- 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(' ')}`)
179
192
  process.exit(1)
180
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
+ }
181
198
  }
182
199
 
183
200
  // Describe: use the cached IR only when it matches the sources on disk
@@ -194,22 +211,28 @@ if (cached && cached.sourceHash === sourceHash) {
194
211
  console.log(cached.sourceHash
195
212
  ? 'sources changed since IR pull — refreshing'
196
213
  : 'saved IR predates source hashing — refreshing')
197
- // The canonical parser owns @namespace/@watch/@returns now — send source as-is.
198
- const sourceForDescribe = source
199
214
  try {
200
215
  const tok = await token()
201
- const resp = await fetch(`${ENDPOINT}/data`, {
216
+ const resp = await fetch(`${endpoint()}/data`, {
202
217
  method: 'POST',
203
218
  headers: { 'content-type': 'application/json', ...(tok && { authorization: `Bearer ${tok}` }) },
204
- body: JSON.stringify({ operations: [{ op: 'describe', source: sourceForDescribe }] }),
219
+ body: JSON.stringify({ operations: [{ op: 'describe', sources }] }),
205
220
  })
206
221
  if (!resp.ok) throw new Error(`describe ${resp.status}: ${await resp.text()}`)
207
222
  const body = await resp.json()
208
223
  const r0 = (body.results ?? body.result ?? [body.data ?? body])[0] ?? body
224
+ // A describe that fails answers 200 with {ok:false, error} in the result.
225
+ // Reading .operations off that yields "not iterable" and throws the
226
+ // server's diagnostic away — which is the only thing that says WHICH
227
+ // attribute the templates got wrong.
228
+ const err = r0?.error ?? (r0?.ok === false ? { message: 'describe failed' } : null)
229
+ if (err) throw new Error(err.message ?? JSON.stringify(err))
209
230
  ir = r0.result ?? r0.data ?? r0
231
+ if (!Array.isArray(ir?.operations))
232
+ throw new Error(`describe returned no operations: ${JSON.stringify(ir).slice(0, 200)}`)
210
233
  } catch (e) {
211
234
  console.error(cached
212
- ? `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`
213
236
  : `describe failed: ${e.message}`)
214
237
  process.exit(1)
215
238
  }
@@ -251,11 +274,17 @@ fs.writeFileSync(irPath, JSON.stringify(ir, null, 2) + '\n')
251
274
  // Identity (namespace / name), @watch, and sql-template @returns results all come
252
275
  // from the IR now — the canonical XSQL parser owns them. No XSQL re-parsing here.
253
276
  const schema = JSON.parse(fs.readFileSync(schemaPath, 'utf8'))
254
- const sdkSrc = path.relative(outDir, path.join(here, '..', 'src')) + '/index.js'
277
+ // Installed from npm -> import the package by name; a relative path out of
278
+ // node_modules is correct only for one install layout. Inside this repo the
279
+ // relative path is still what works, since there is no package to resolve.
280
+ const sdkRoot = path.join(here, '..')
281
+ const sdkSrc = sdkRoot.includes(`${path.sep}node_modules${path.sep}`)
282
+ ? '@synthigy/sdk'
283
+ : path.relative(outDir, path.join(sdkRoot, 'src')) + '/index.js'
255
284
  const ent = (n) => schema.entities[n] ?? {}
256
285
  const visited = new Set()
257
286
 
258
- // The server owns pretty-name derivation (docs/plans/PLAN-SCHEMA-SKINS-PROJECTION.md):
287
+ // The server owns pretty-name derivation:
259
288
  // entity/attribute/relation `skins` in the pulled schema are the contract, not a hint.
260
289
  // Falls back to the local splitter only for names with no schema entry (op/namespace
261
290
  // identifiers, which are snake-authored code with no display label to preserve).
@@ -340,8 +369,13 @@ for (const n of allEntities) nsOf(entityPascal(n))
340
369
  // Mutations are Layer-1 territory (schema-derived Entity.sync/stack/delete) —
341
370
  // never emitted from XSQL. Skip LOUDLY, never silently.
342
371
  const MUTATE_VERBS = new Set(['sync', 'stack', 'delete'])
372
+ const genOps = [] // generated reads, for @batch member resolution
373
+ const batches = []
343
374
  for (const op of ir.operations) {
344
- if (op.batch) continue
375
+ if (op.batch) {
376
+ batches.push(op)
377
+ continue
378
+ }
345
379
  if (MUTATE_VERBS.has(op.op)) {
346
380
  console.warn(`skipped @${op.op} ${op.name} — mutations are generated from the schema; use ${entityPascal(op.entity ?? '<Entity>')}.${op.op}(data)`)
347
381
  continue
@@ -364,6 +398,7 @@ for (const op of ir.operations) {
364
398
  const ret = op.op === 'get' ? `Promise<${Type} | null>` : `Promise<${Type}[]>`
365
399
  const methodName = camel(op.name)
366
400
 
401
+ genOps.push({ op, Type })
367
402
  if (ParamsT) g.typeAliases.push(`export type ${ParamsT} = {\n${pb}\n}`)
368
403
  g.typeAliases.push(`export type ${Type} = ${isSql ? plainResultType(op.result) : projectionType(op.entity, op.result.fields)}`)
369
404
  if (!isSql) g.typeAliases.push(`export const ${Type}Sel = ${JSON.stringify(fieldsToSel(op.result.fields), null, 2)} as const`)
@@ -380,6 +415,69 @@ for (const op of ir.operations) {
380
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 }`)
381
416
  }
382
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
+
383
481
  // Emit one chunk per namespace: projection types + the object (reads + writes).
384
482
  const nsChunks = []
385
483
  for (const NS of Object.keys(nsMap).sort()) {
@@ -445,8 +543,8 @@ const aliasImports = allEntities.flatMap(n =>
445
543
  ).join(', ')
446
544
  const inputNames = xsqlPaths.map(p => path.basename(p)).join(', ')
447
545
  const OPS_HDR_COMMENT = `// AUTO-GENERATED from ${inputNames}. Do not edit.\n`
448
- const OPS_HDR_CODE = `import type { SynthigyClient, ClientConfig, ExecOpts, WriteOpts, QueryWatch, SqlTemplateWatch } from "${sdkSrc}"
449
- 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}"
450
548
  import type { ${aliasImports} } from "./schema.js"
451
549
 
452
550
  /** What a write answers by default — writes are silent unless \`returning\`. */
@@ -463,7 +561,7 @@ let _client: SynthigyClient
463
561
  * points every generated method at it. One process, one client, one token
464
562
  * cache, one SSE. Reconnect by calling again (destroys the previous client).
465
563
  */
466
- export function configure(config: ClientConfig): void {
564
+ export function configure(config: ClientConfig = {}): void {
467
565
  _client = connect(${flags.keys === 'camel' ? '{ ...config, keyFormat: \'camel\' }' : 'config'})
468
566
  }
469
567
 
@@ -482,30 +580,51 @@ const _toSnake = (v: unknown): unknown => {
482
580
  const SCHEMA_HDR = `// AUTO-GENERATED — entity types from /schema ${schema.version ?? ''}. Do not edit.\n`
483
581
 
484
582
  fs.mkdirSync(outDir, { recursive: true })
485
- // The printer drops the detached top-of-file comment, so prepend it after formatting.
486
- fs.writeFileSync(path.join(outDir, 'schema.ts'), SCHEMA_HDR + formatTs(schemaChunks.join('\n\n'), 'schema.ts'))
487
- 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')
488
585
 
489
586
  // Gate, not a builder: VALIDATE the generated TS and FAIL LOUD if it doesn't
490
587
  // type-check — never ship broken/stale output silently (the old emit-and-swallow
491
588
  // shipped a stale ops.js once). No .js emitted: consumers import the .ts source
492
589
  // (Node 23+ type-stripping / a bundler); the type-only `./schema.js` import erases
493
- // at runtime. `--ignoreConfig` so a consumer tsconfig can't trip TS5112.
494
- const tscBin = path.join(here, '..', 'node_modules', '.bin', 'tsc')
495
- try {
496
- execFileSync(tscBin, [
497
- '--ignoreConfig',
498
- '--module', 'nodenext', '--moduleResolution', 'nodenext',
499
- '--target', 'es2022', '--strict', '--skipLibCheck', '--noEmit',
500
- path.join(outDir, 'ops.ts'),
501
- path.join(outDir, 'schema.ts'),
502
- ], { stdio: 'pipe' })
503
- } catch (e) {
504
- process.stderr.write('codegen: generated TS failed to type-check (not shipped):\n')
505
- process.stderr.write((e.stdout ?? e.stderr ?? '').toString())
506
- process.exit(1)
590
+ // at runtime.
591
+ // Locate tsc through the SAME resolution that found the `typescript` module
592
+ // above, so a hoisted install — the normal consumer layout — is found. A path
593
+ // built from `here` only exists when typescript is nested under this package.
594
+ const tscBin = (() => {
595
+ try {
596
+ const lib = createRequire(import.meta.url).resolve('typescript')
597
+ const bin = path.join(path.dirname(lib), '..', 'bin', 'tsc')
598
+ if (fs.existsSync(bin)) return bin
599
+ } catch {}
600
+ for (const c of [path.join(here, '..', 'node_modules', '.bin', 'tsc'),
601
+ path.join(process.cwd(), 'node_modules', '.bin', 'tsc')]) {
602
+ if (fs.existsSync(c)) return c
603
+ }
604
+ return null
605
+ })()
606
+
607
+ // `--ignoreConfig` needs TypeScript 6. On 5.x the flag is itself an error, and
608
+ // naming files explicitly already makes tsc skip tsconfig.json, so it is left off.
609
+ const tscMajor = Number.parseInt(tsVersion ?? '0', 10) || 0
610
+ if (tscBin) {
611
+ try {
612
+ execFileSync(tscBin, [
613
+ ...(tscMajor >= 6 ? ['--ignoreConfig'] : []),
614
+ '--module', 'nodenext', '--moduleResolution', 'nodenext',
615
+ '--target', 'es2022', '--strict', '--skipLibCheck', '--noEmit',
616
+ path.join(outDir, 'ops.ts'),
617
+ path.join(outDir, 'schema.ts'),
618
+ ], { stdio: 'pipe' })
619
+ } catch (e) {
620
+ process.stderr.write('codegen: generated TS failed to type-check (not shipped):\n')
621
+ process.stderr.write((e.stdout ?? e.stderr ?? '').toString())
622
+ process.exit(1)
623
+ }
624
+ } else {
625
+ process.stderr.write('codegen: tsc not found — skipping the type-check gate (npm install -D typescript).\n')
507
626
  }
508
627
  const flagSummary = [flags.keys !== 'snake' && `keys=${flags.keys}`, !flags.writes && 'no-writes'].filter(Boolean).join(' ')
509
628
  const opCount = ir.operations.filter(o => !o.batch).length
510
629
  const inputSummary = xsqlPaths.map(p => path.basename(p)).join(' + ')
511
- 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/codegen/pull.mjs CHANGED
@@ -26,11 +26,17 @@ if (!endpoint) {
26
26
  process.exit(1)
27
27
  }
28
28
 
29
- const { SYNTHIGY_TOKEN, SYNTHIGY_CLIENT_ID, SYNTHIGY_CLIENT_SECRET } = process.env
29
+ const { SYNTHIGY_TOKEN, SYNTHIGY_CLIENT_ID, SYNTHIGY_CLIENT_SECRET,
30
+ SYNTHIGY_AUDIENCE } = process.env
30
31
  const config = { endpoint }
31
32
  if (SYNTHIGY_CLIENT_ID && SYNTHIGY_CLIENT_SECRET) {
32
33
  config.clientId = SYNTHIGY_CLIENT_ID
33
34
  config.clientSecret = SYNTHIGY_CLIENT_SECRET
35
+ // A server can require an explicit client_credentials audience for /data and
36
+ // /schema. Without it the mint still succeeds and the token is then refused,
37
+ // so the symptom is a 401 on schema rather than anything about auth config.
38
+ // A static SYNTHIGY_TOKEN already has its audience baked in.
39
+ if (SYNTHIGY_AUDIENCE) config.audience = SYNTHIGY_AUDIENCE
34
40
  } else {
35
41
  config.token = SYNTHIGY_TOKEN ?? '' // '' = authless (bare server)
36
42
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@synthigy/sdk",
3
- "version": "0.1.1",
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",