@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 +112 -24
- package/codegen/codegen.mjs +125 -55
- package/package.json +1 -1
- package/src/browser-login.js +309 -0
- package/src/index.d.ts +172 -5
- package/src/index.js +117 -17
- package/src/login.js +237 -0
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
|
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`.
|
|
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.
|
|
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`
|
|
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.
|
|
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 |
|
package/codegen/codegen.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// Unified codegen tool:
|
|
3
|
-
// node codegen.mjs <
|
|
4
|
-
// node codegen.mjs check <
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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 &&
|
|
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> [
|
|
108
|
-
console.error(' node codegen.mjs check <file.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 (
|
|
120
|
-
return fs.readdirSync(p
|
|
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
|
|
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
|
|
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'),
|
|
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(`${
|
|
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
|
-
|
|
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
|
-
//
|
|
180
|
-
// can yield a different IR after a
|
|
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:
|
|
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(`${
|
|
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',
|
|
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}) —
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
513
|
-
fs.writeFileSync(path.join(outDir, '
|
|
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(
|
|
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}`)
|