@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 +113 -26
- package/codegen/codegen.mjs +185 -66
- package/codegen/pull.mjs +7 -1
- package/package.json +1 -1
- package/src/browser-login.js +309 -0
- package/src/index.d.ts +199 -5
- package/src/index.js +166 -21
- 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,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`.
|
|
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
|
-
| [examples
|
|
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
|
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
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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 (
|
|
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 &&
|
|
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> [
|
|
106
|
-
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> [...]')
|
|
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 (
|
|
118
|
-
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))
|
|
119
110
|
return [p]
|
|
120
111
|
})
|
|
121
112
|
if (!xsqlPaths.length) { console.error('no .xsql files found'); process.exit(1) }
|
|
122
|
-
const
|
|
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
|
|
139
|
-
|
|
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'),
|
|
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(`${
|
|
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
|
-
|
|
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
|
-
//
|
|
174
|
-
// 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.
|
|
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:
|
|
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(`${
|
|
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',
|
|
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}) —
|
|
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
|
-
|
|
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
|
|
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)
|
|
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
|
-
|
|
486
|
-
fs.writeFileSync(path.join(outDir, '
|
|
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.
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
path.join(
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
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
|
|
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
|
}
|