@synthigy/sdk 0.1.2 → 0.2.1
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 +128 -56
- package/codegen/lib.mjs +24 -0
- package/package.json +1 -1
- package/src/browser-login.js +310 -0
- package/src/index.d.ts +182 -5
- package/src/index.js +158 -23
- package/src/login.js +244 -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
|
|
@@ -11,32 +11,16 @@ import path from 'node:path'
|
|
|
11
11
|
import { createHash } from 'node:crypto'
|
|
12
12
|
import { execFileSync } from 'node:child_process'
|
|
13
13
|
import { createRequire } from 'node:module'
|
|
14
|
-
import { TS, tsScalar, pascal, camel } from './lib.mjs'
|
|
14
|
+
import { TS, tsScalar, pascal, camel, warnStaleSchema } 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,14 +127,21 @@ 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 })
|
|
143
|
+
} else {
|
|
144
|
+
await warnStaleSchema(ENDPOINT, schemaPath)
|
|
148
145
|
}
|
|
149
146
|
|
|
150
147
|
if (!fs.existsSync(schemaPath)) {
|
|
@@ -154,7 +151,7 @@ if (!fs.existsSync(schemaPath)) {
|
|
|
154
151
|
|
|
155
152
|
async function token() {
|
|
156
153
|
if (!SYNTHIGY_CLIENT_ID) return ''
|
|
157
|
-
const r = await fetch(`${
|
|
154
|
+
const r = await fetch(`${endpoint()}/oauth/token`, {
|
|
158
155
|
method: 'POST',
|
|
159
156
|
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
160
157
|
body: new URLSearchParams({ grant_type: 'client_credentials',
|
|
@@ -169,21 +166,37 @@ async function token() {
|
|
|
169
166
|
}
|
|
170
167
|
|
|
171
168
|
// Merge all xsql files — ops are independent, names must be unique across files.
|
|
172
|
-
|
|
169
|
+
// `@workspace` is buffer-scoped and must precede the first op, so only the
|
|
170
|
+
// first file's survives the merge — left in place it lands INSIDE the
|
|
171
|
+
// preceding file's last op and the server rejects that op at run time.
|
|
172
|
+
const dropWorkspace = (src) => src.split('\n').filter(l => !/^@workspace\b/.test(l)).join('\n')
|
|
173
|
+
const source = xsqlPaths
|
|
174
|
+
.map((p, i) => { const src = fs.readFileSync(p, 'utf8'); return i === 0 ? src : dropWorkspace(src) })
|
|
175
|
+
.join('\n\n')
|
|
176
|
+
// describe parses each file on its own — concatenated, file 1's buffer
|
|
177
|
+
// @namespace would leak into every later file and theirs would be dropped.
|
|
178
|
+
const sources = xsqlPaths.map(p => ({
|
|
179
|
+
path: path.relative(xsqlDir, p).split(path.sep).join('/'),
|
|
180
|
+
source: fs.readFileSync(p, 'utf8'),
|
|
181
|
+
}))
|
|
173
182
|
// Contract between the .xsql sources on disk and the saved IR: the IR carries
|
|
174
183
|
// the hash of the sources it was described from. Mismatch = the IR is stale.
|
|
175
184
|
const sourceHash = createHash('sha256').update(source).digest('hex')
|
|
176
185
|
|
|
177
186
|
// check mode fails FAST and OFFLINE when the sources drifted from the saved IR —
|
|
178
187
|
// no backend/credentials needed to catch "edited .xsql, forgot codegen".
|
|
179
|
-
//
|
|
180
|
-
// can yield a different IR after a
|
|
188
|
+
// A hash match still goes through the live describe-diff below when a server is
|
|
189
|
+
// configured: same sources can yield a different IR after a model change.
|
|
181
190
|
if (checkMode && fs.existsSync(irPath)) {
|
|
182
191
|
const saved = JSON.parse(fs.readFileSync(irPath, 'utf8'))
|
|
183
192
|
if (saved.sourceHash && saved.sourceHash !== sourceHash) {
|
|
184
|
-
console.error(`sources drifted from saved IR — run:
|
|
193
|
+
console.error(`sources drifted from saved IR — run: synthigy-gen ${xsqlArgs.join(' ')}`)
|
|
185
194
|
process.exit(1)
|
|
186
195
|
}
|
|
196
|
+
if (saved.sourceHash && !['SYNTHIGY_ENDPOINT', 'SYNTHIGY_TOKEN', 'SYNTHIGY_CLIENT_ID'].some(k => process.env[k])) {
|
|
197
|
+
console.log('ok — sourceHash matches saved IR (offline; set SYNTHIGY_* env for a live describe-diff)')
|
|
198
|
+
process.exit(0)
|
|
199
|
+
}
|
|
187
200
|
}
|
|
188
201
|
|
|
189
202
|
// Describe: use the cached IR only when it matches the sources on disk
|
|
@@ -200,14 +213,12 @@ if (cached && cached.sourceHash === sourceHash) {
|
|
|
200
213
|
console.log(cached.sourceHash
|
|
201
214
|
? 'sources changed since IR pull — refreshing'
|
|
202
215
|
: 'saved IR predates source hashing — refreshing')
|
|
203
|
-
// The canonical parser owns @namespace/@watch/@returns now — send source as-is.
|
|
204
|
-
const sourceForDescribe = source
|
|
205
216
|
try {
|
|
206
217
|
const tok = await token()
|
|
207
|
-
const resp = await fetch(`${
|
|
218
|
+
const resp = await fetch(`${endpoint()}/data`, {
|
|
208
219
|
method: 'POST',
|
|
209
220
|
headers: { 'content-type': 'application/json', ...(tok && { authorization: `Bearer ${tok}` }) },
|
|
210
|
-
body: JSON.stringify({ operations: [{ op: 'describe',
|
|
221
|
+
body: JSON.stringify({ operations: [{ op: 'describe', sources }] }),
|
|
211
222
|
})
|
|
212
223
|
if (!resp.ok) throw new Error(`describe ${resp.status}: ${await resp.text()}`)
|
|
213
224
|
const body = await resp.json()
|
|
@@ -223,7 +234,7 @@ if (cached && cached.sourceHash === sourceHash) {
|
|
|
223
234
|
throw new Error(`describe returned no operations: ${JSON.stringify(ir).slice(0, 200)}`)
|
|
224
235
|
} catch (e) {
|
|
225
236
|
console.error(cached
|
|
226
|
-
? `sources changed since IR pull and describe failed (${e.message}) —
|
|
237
|
+
? `sources changed since IR pull and describe failed (${e.message}) — run under \`synthigy exec\` or revert the .xsql edit`
|
|
227
238
|
: `describe failed: ${e.message}`)
|
|
228
239
|
process.exit(1)
|
|
229
240
|
}
|
|
@@ -275,7 +286,7 @@ const sdkSrc = sdkRoot.includes(`${path.sep}node_modules${path.sep}`)
|
|
|
275
286
|
const ent = (n) => schema.entities[n] ?? {}
|
|
276
287
|
const visited = new Set()
|
|
277
288
|
|
|
278
|
-
// The server owns pretty-name derivation
|
|
289
|
+
// The server owns pretty-name derivation:
|
|
279
290
|
// entity/attribute/relation `skins` in the pulled schema are the contract, not a hint.
|
|
280
291
|
// Falls back to the local splitter only for names with no schema entry (op/namespace
|
|
281
292
|
// identifiers, which are snake-authored code with no display label to preserve).
|
|
@@ -360,13 +371,11 @@ for (const n of allEntities) nsOf(entityPascal(n))
|
|
|
360
371
|
// Mutations are Layer-1 territory (schema-derived Entity.sync/stack/delete) —
|
|
361
372
|
// never emitted from XSQL. Skip LOUDLY, never silently.
|
|
362
373
|
const MUTATE_VERBS = new Set(['sync', 'stack', 'delete'])
|
|
374
|
+
const genOps = [] // generated reads, for @batch member resolution
|
|
375
|
+
const batches = []
|
|
363
376
|
for (const op of ir.operations) {
|
|
364
377
|
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`)
|
|
378
|
+
batches.push(op)
|
|
370
379
|
continue
|
|
371
380
|
}
|
|
372
381
|
if (MUTATE_VERBS.has(op.op)) {
|
|
@@ -391,6 +400,7 @@ for (const op of ir.operations) {
|
|
|
391
400
|
const ret = op.op === 'get' ? `Promise<${Type} | null>` : `Promise<${Type}[]>`
|
|
392
401
|
const methodName = camel(op.name)
|
|
393
402
|
|
|
403
|
+
genOps.push({ op, Type })
|
|
394
404
|
if (ParamsT) g.typeAliases.push(`export type ${ParamsT} = {\n${pb}\n}`)
|
|
395
405
|
g.typeAliases.push(`export type ${Type} = ${isSql ? plainResultType(op.result) : projectionType(op.entity, op.result.fields)}`)
|
|
396
406
|
if (!isSql) g.typeAliases.push(`export const ${Type}Sel = ${JSON.stringify(fieldsToSel(op.result.fields), null, 2)} as const`)
|
|
@@ -407,6 +417,69 @@ for (const op of ir.operations) {
|
|
|
407
417
|
: ` 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
418
|
}
|
|
409
419
|
|
|
420
|
+
// @batch <name>: <m1> <m2> → one exported function, ONE exec round trip. Results
|
|
421
|
+
// are keyed by member name (qualified ns/name when a bare name repeats); a failed
|
|
422
|
+
// member is a SynthigyError VALUE in its slot, never a batch-wide throw. Params
|
|
423
|
+
// are one flat object shared by every member — the Python generator's rules.
|
|
424
|
+
const opIdentity = o => `${(o.namespace ?? o.entity ?? '').toLowerCase()}/${o.name.toLowerCase()}`
|
|
425
|
+
function resolveMember(batchName, ref) {
|
|
426
|
+
const i = ref.lastIndexOf('/')
|
|
427
|
+
const ns = i >= 0 ? ref.slice(0, i).toLowerCase() : null
|
|
428
|
+
const nm = ref.slice(i + 1).toLowerCase()
|
|
429
|
+
const matches = genOps.filter(({ op }) => op.name.toLowerCase() === nm &&
|
|
430
|
+
(ns === null || (op.namespace ?? op.entity ?? '').toLowerCase() === ns))
|
|
431
|
+
if (!matches.length)
|
|
432
|
+
throw new Error(`@batch ${batchName} references unknown/ungenerated op ${ref}`)
|
|
433
|
+
if (ns === null && matches.length > 1)
|
|
434
|
+
throw new Error(`@batch ${batchName} — ${ref} is ambiguous: ${matches.map(m => opIdentity(m.op)).join(', ')} — qualify the reference`)
|
|
435
|
+
return matches[0]
|
|
436
|
+
}
|
|
437
|
+
const batchChunks = []
|
|
438
|
+
for (const b of batches) {
|
|
439
|
+
const members = b.members.map(ref => resolveMember(b.name, ref))
|
|
440
|
+
const merged = new Map()
|
|
441
|
+
for (const { op } of members)
|
|
442
|
+
for (const p of op.params ?? []) {
|
|
443
|
+
const seen = merged.get(p.name)
|
|
444
|
+
if (!seen) merged.set(p.name, { ...p })
|
|
445
|
+
else if (!p.optional) seen.optional = false
|
|
446
|
+
}
|
|
447
|
+
const params = [...merged.values()]
|
|
448
|
+
const names = members.map(({ op }) => op.name.toLowerCase())
|
|
449
|
+
const key = ({ op }) => names.filter(n => n === op.name.toLowerCase()).length > 1 ? opIdentity(op) : op.name
|
|
450
|
+
const fn = camel(b.name)
|
|
451
|
+
const B = pascal(b.name)
|
|
452
|
+
const pb = paramsBody(params)
|
|
453
|
+
const allOpt = params.every(p => p.optional)
|
|
454
|
+
const lines = []
|
|
455
|
+
if (pb) lines.push(`export type ${B}BatchParams = {\n${pb}\n}`)
|
|
456
|
+
lines.push(`export type ${B}Batch = {\n${members.map(m =>
|
|
457
|
+
` ${JSON.stringify(key(m))}: ${m.op.op === 'get' ? `${m.Type} | null` : `${m.Type}[]`} | SynthigyError`).join('\n')}\n}`)
|
|
458
|
+
const ops = members.map(({ op }) => op.op === 'sql-template'
|
|
459
|
+
? ` op.sqlTemplate(${xsqlLiteral(op.source)}, p),`
|
|
460
|
+
: ` op.query(${xsqlLiteral(`@${op.op} ${op.name}\n` + op.source)}, p, ${JSON.stringify(op.op)}),`)
|
|
461
|
+
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))}],`)
|
|
462
|
+
lines.push(`/**
|
|
463
|
+
* Batch: ${b.members.join(' + ')} — ONE request. Results are keyed by member; a
|
|
464
|
+
* failed member is a SynthigyError value in its slot, not a throw. Params are one
|
|
465
|
+
* object shared by every member.
|
|
466
|
+
*/
|
|
467
|
+
export async function ${fn}(${pb ? `params${allOpt ? '?' : ''}: ${B}BatchParams, ` : ''}opts?: ExecOpts): Promise<${B}Batch> {
|
|
468
|
+
const p = ${pb ? 'params ?? {}' : '{}'}
|
|
469
|
+
const r = await _client.exec([
|
|
470
|
+
${ops.join('\n')}
|
|
471
|
+
], opts)
|
|
472
|
+
return {
|
|
473
|
+
${res.join('\n')}
|
|
474
|
+
}
|
|
475
|
+
}`)
|
|
476
|
+
batchChunks.push(lines.join('\n\n'))
|
|
477
|
+
}
|
|
478
|
+
if (batchChunks.length) batchChunks.unshift(`function _batchResult(r: OperationResult, verb: string): unknown {
|
|
479
|
+
if (!r?.ok) return new SynthigyError(r?.error?.message ?? 'operation failed', r?.error?.code ?? 'OPERATION_FAILED')
|
|
480
|
+
return verb === 'get' ? (r.data ?? null) : (r.data ?? [])
|
|
481
|
+
}`)
|
|
482
|
+
|
|
410
483
|
// Emit one chunk per namespace: projection types + the object (reads + writes).
|
|
411
484
|
const nsChunks = []
|
|
412
485
|
for (const NS of Object.keys(nsMap).sort()) {
|
|
@@ -472,8 +545,8 @@ const aliasImports = allEntities.flatMap(n =>
|
|
|
472
545
|
).join(', ')
|
|
473
546
|
const inputNames = xsqlPaths.map(p => path.basename(p)).join(', ')
|
|
474
547
|
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}"
|
|
548
|
+
const OPS_HDR_CODE = `import type { SynthigyClient, ClientConfig, ExecOpts, WriteOpts, QueryWatch, SqlTemplateWatch${batchChunks.length ? ', OperationResult' : ''} } from "${sdkSrc}"
|
|
549
|
+
import { connect${batchChunks.length ? ', op, SynthigyError' : ''} } from "${sdkSrc}"
|
|
477
550
|
import type { ${aliasImports} } from "./schema.js"
|
|
478
551
|
|
|
479
552
|
/** What a write answers by default — writes are silent unless \`returning\`. */
|
|
@@ -490,7 +563,7 @@ let _client: SynthigyClient
|
|
|
490
563
|
* points every generated method at it. One process, one client, one token
|
|
491
564
|
* cache, one SSE. Reconnect by calling again (destroys the previous client).
|
|
492
565
|
*/
|
|
493
|
-
export function configure(config: ClientConfig): void {
|
|
566
|
+
export function configure(config: ClientConfig = {}): void {
|
|
494
567
|
_client = connect(${flags.keys === 'camel' ? '{ ...config, keyFormat: \'camel\' }' : 'config'})
|
|
495
568
|
}
|
|
496
569
|
|
|
@@ -509,9 +582,8 @@ const _toSnake = (v: unknown): unknown => {
|
|
|
509
582
|
const SCHEMA_HDR = `// AUTO-GENERATED — entity types from /schema ${schema.version ?? ''}. Do not edit.\n`
|
|
510
583
|
|
|
511
584
|
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'))
|
|
585
|
+
fs.writeFileSync(path.join(outDir, 'schema.ts'), SCHEMA_HDR + schemaChunks.join('\n\n') + '\n')
|
|
586
|
+
fs.writeFileSync(path.join(outDir, 'ops.ts'), OPS_HDR_COMMENT + OPS_HDR_CODE + [...nsChunks, ...batchChunks].join('\n\n') + '\n')
|
|
515
587
|
|
|
516
588
|
// Gate, not a builder: VALIDATE the generated TS and FAIL LOUD if it doesn't
|
|
517
589
|
// type-check — never ship broken/stale output silently (the old emit-and-swallow
|
|
@@ -536,7 +608,7 @@ const tscBin = (() => {
|
|
|
536
608
|
|
|
537
609
|
// `--ignoreConfig` needs TypeScript 6. On 5.x the flag is itself an error, and
|
|
538
610
|
// naming files explicitly already makes tsc skip tsconfig.json, so it is left off.
|
|
539
|
-
const tscMajor = Number.parseInt(
|
|
611
|
+
const tscMajor = Number.parseInt(tsVersion ?? '0', 10) || 0
|
|
540
612
|
if (tscBin) {
|
|
541
613
|
try {
|
|
542
614
|
execFileSync(tscBin, [
|
|
@@ -552,9 +624,9 @@ if (tscBin) {
|
|
|
552
624
|
process.exit(1)
|
|
553
625
|
}
|
|
554
626
|
} else {
|
|
555
|
-
process.stderr.write('codegen: tsc not found — skipping the type-check gate.\n')
|
|
627
|
+
process.stderr.write('codegen: tsc not found — skipping the type-check gate (npm install -D typescript).\n')
|
|
556
628
|
}
|
|
557
629
|
const flagSummary = [flags.keys !== 'snake' && `keys=${flags.keys}`, !flags.writes && 'no-writes'].filter(Boolean).join(' ')
|
|
558
630
|
const opCount = ir.operations.filter(o => !o.batch).length
|
|
559
631
|
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}`)
|
|
632
|
+
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/lib.mjs
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
|
|
1
3
|
// Shared codegen primitives for the TS emitters — one home for the scalar-type
|
|
2
4
|
// map + casing so the read and write emitters can't drift apart.
|
|
3
5
|
|
|
@@ -28,3 +30,25 @@ export const camel = (s) => { const p = pascal(s); return p[0].toLowerCase() + p
|
|
|
28
30
|
export const snake = (s) => words(s).join('_')
|
|
29
31
|
// kebab wire keys aren't valid TS identifiers → keep verbatim, quote when needed.
|
|
30
32
|
export const tsKey = (k) => (/^[A-Za-z_$][\w$]*$/.test(k) ? k : JSON.stringify(k))
|
|
33
|
+
|
|
34
|
+
// One line on `write` when the schema's deploy stamp is older than the model at
|
|
35
|
+
// /.well-known/synthigy. Silent on any failure: generation stays offline-capable.
|
|
36
|
+
export async function warnStaleSchema(endpoint, schemaPath, write = (s) => console.error(s)) {
|
|
37
|
+
if (!endpoint || !fs.existsSync(schemaPath)) return
|
|
38
|
+
let local, remote
|
|
39
|
+
try {
|
|
40
|
+
local = JSON.parse(fs.readFileSync(schemaPath, 'utf8')).datasets ?? {}
|
|
41
|
+
const r = await fetch(`${endpoint.replace(/\/$/, '')}/.well-known/synthigy`,
|
|
42
|
+
{ signal: AbortSignal.timeout(2000) })
|
|
43
|
+
if (!r.ok) return
|
|
44
|
+
remote = (await r.json()).datasets ?? {}
|
|
45
|
+
} catch {
|
|
46
|
+
return
|
|
47
|
+
}
|
|
48
|
+
const instant = (s) => (Number.isNaN(Date.parse(s)) ? s : Date.parse(s))
|
|
49
|
+
const changed = Object.keys(remote).filter((id) => instant(local[id]?.['deployed-at']) !== instant(remote[id]))
|
|
50
|
+
.concat(Object.keys(local).filter((id) => !(id in remote)))
|
|
51
|
+
if (changed.length) {
|
|
52
|
+
write(`warning: ${schemaPath} is stale against ${endpoint} (${changed.length} datasets changed since the pull) — re-run with --pull`)
|
|
53
|
+
}
|
|
54
|
+
}
|