@synthigy/sdk 0.1.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/LICENSE +21 -0
- package/README.md +102 -0
- package/codegen/codegen.mjs +499 -0
- package/codegen/lib.mjs +30 -0
- package/codegen/pull.mjs +47 -0
- package/package.json +63 -0
- package/src/index.d.ts +1108 -0
- package/src/index.js +2535 -0
- package/src/watch-query.js +533 -0
- package/src/watch.js +915 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Robert Geršak
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# `@synthigy/sdk`
|
|
2
|
+
|
|
3
|
+
TypeScript / JavaScript client for [Synthigy](https://github.com/synthigy/synthigy).
|
|
4
|
+
Zero dependencies. Typed reads, structured writes, live data on one connection,
|
|
5
|
+
and per-call server-enforced permissions.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @synthigy/sdk
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Hello world
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
import { createClient } from '@synthigy/sdk'
|
|
15
|
+
|
|
16
|
+
const synthigy = createClient({
|
|
17
|
+
endpoint: 'http://localhost:7887',
|
|
18
|
+
clientId: process.env.SYNTHIGY_CLIENT_ID,
|
|
19
|
+
clientSecret: process.env.SYNTHIGY_CLIENT_SECRET,
|
|
20
|
+
// The audience model is opt-in: a token naming none is identity-only and
|
|
21
|
+
// /data rejects it. Your server publishes this at /.well-known/synthigy
|
|
22
|
+
// (auth.oidc.audience). Defaults to $SYNTHIGY_AUDIENCE on Node. See docs/AUTH.md.
|
|
23
|
+
audience: 'https://synthigy.com',
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
// Read — you get back exactly the shape you ask for
|
|
27
|
+
const movies = await synthigy.search('Movie',
|
|
28
|
+
{ _limit: 5, _order_by: [['release_year', 'desc']] },
|
|
29
|
+
{ title: null, release_year: null, genres: { name: null } })
|
|
30
|
+
|
|
31
|
+
// Write — additive; doesn't disturb the movie's other ratings
|
|
32
|
+
await synthigy.stack('user_rating', {
|
|
33
|
+
value: 5, timestamp: new Date().toISOString(),
|
|
34
|
+
movie: { xid: movies[0].xid },
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
// Live — snapshot + keep-current, multiplexed onto one SSE
|
|
38
|
+
const w = synthigy.watchSqlTemplate(
|
|
39
|
+
`SELECT count(*) AS n FROM {user_rating}`, {}, { entities: ['user_rating'] })
|
|
40
|
+
await w.ready()
|
|
41
|
+
for await (const ev of w.events) {
|
|
42
|
+
if (ev.type === 'result/changed') console.log('ratings:', ev.after[0].n)
|
|
43
|
+
}
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## What makes it Synthigy
|
|
47
|
+
|
|
48
|
+
- **The query *is* the result shape.** Ask for fields and relations; get back
|
|
49
|
+
that JSON and nothing else. Two surfaces for the same engine: an
|
|
50
|
+
[XSQL](./docs/XSQL.md) string, or a [wire](./docs/WIRE-FORMAT.md) selection object.
|
|
51
|
+
|
|
52
|
+
- **Writes are silent by default.** `sync`/`stack` answer `{ count }`, not the
|
|
53
|
+
record. Mint the id up front with `newXid()` when you need it — cheaper than
|
|
54
|
+
the echo, and a retried write upserts the same row instead of duplicating it.
|
|
55
|
+
Pass `{ returning: true }` for the record; generated code types the two apart.
|
|
56
|
+
- **Writes that ORMs make you hand-roll.** `stack` adds a child without
|
|
57
|
+
clobbering its siblings; `slice` detaches a link without deleting the record.
|
|
58
|
+
Plus `sync` (upsert), `delete`, `purge`. → [WIRE-FORMAT.md](./docs/WIRE-FORMAT.md)
|
|
59
|
+
|
|
60
|
+
- **Live data on one connection.** Open dozens of watches — they multiplex onto
|
|
61
|
+
a single SSE per client. `watchQuery` for a live result set,
|
|
62
|
+
`watchSqlTemplate` for live analytics, `watch` for raw record events.
|
|
63
|
+
→ [WATCH.md](./docs/WATCH.md)
|
|
64
|
+
|
|
65
|
+
- **`actingAs` per call.** Every read and write can run as a specific user, with
|
|
66
|
+
IAM / RBAC / row-level security enforced *server-side*. Tokens never have to
|
|
67
|
+
reach the browser. → [AUTH.md](./docs/AUTH.md)
|
|
68
|
+
|
|
69
|
+
- **Typed from your model.** `synthigy-gen` compiles your ERD + `.xsql` files
|
|
70
|
+
into typed methods whose result types follow the selection — a typo'd entity
|
|
71
|
+
or an unselected field is a compile error. → [codegen](./docs/WATCH.md#codegen)
|
|
72
|
+
|
|
73
|
+
## Docs
|
|
74
|
+
|
|
75
|
+
| Doc | What's in it |
|
|
76
|
+
|---|---|
|
|
77
|
+
| [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
|
+
| [CHANGELOG.md](./CHANGELOG.md) | Version history |
|
|
82
|
+
| [examples/datastar-movies/](./examples/datastar-movies/) | End-to-end BFF exercising every primitive |
|
|
83
|
+
| [examples/movies-live.mjs](./examples/movies-live.mjs) | Minimal live-watch demo |
|
|
84
|
+
| [src/index.d.ts](./src/index.d.ts) | Full type definitions |
|
|
85
|
+
|
|
86
|
+
## Tests
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
node --test 'test/*.test.js' # unit
|
|
90
|
+
SYNTHIGY_ENDPOINT=http://localhost:7887 \
|
|
91
|
+
SYNTHIGY_CLIENT_ID=… SYNTHIGY_CLIENT_SECRET=… \
|
|
92
|
+
node --test 'test/integration.test.js' # integration (skips without a server)
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## License
|
|
96
|
+
|
|
97
|
+
MIT — see [LICENSE](LICENSE).
|
|
98
|
+
|
|
99
|
+
The SDKs are deliberately permissive: they are client libraries you embed in
|
|
100
|
+
your own application. The Synthigy **engine** is separate, and is fair-code
|
|
101
|
+
under the Sustainable Use License.
|
|
102
|
+
|
|
@@ -0,0 +1,499 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Unified codegen tool:
|
|
3
|
+
// node codegen.mjs <file.xsql> [outDir] — describe + gen TS (saves .ir.json)
|
|
4
|
+
// node codegen.mjs check <file.xsql> — re-describe, diff, exit 1 on drift
|
|
5
|
+
// node codegen.mjs pull [endpoint] [out] — pull schema.json (wraps pull.mjs)
|
|
6
|
+
//
|
|
7
|
+
// Config via env: SYNTHIGY_ENDPOINT, SYNTHIGY_CLIENT_ID, SYNTHIGY_CLIENT_SECRET
|
|
8
|
+
|
|
9
|
+
import fs from 'node:fs'
|
|
10
|
+
import path from 'node:path'
|
|
11
|
+
import { createHash } from 'node:crypto'
|
|
12
|
+
import { execFileSync } from 'node:child_process'
|
|
13
|
+
import ts from 'typescript'
|
|
14
|
+
import { TS, tsScalar, pascal, camel } from './lib.mjs'
|
|
15
|
+
|
|
16
|
+
const here = path.dirname(new URL(import.meta.url).pathname)
|
|
17
|
+
|
|
18
|
+
// Reformat generated TS through the compiler's own printer: correct indentation,
|
|
19
|
+
// spacing and semicolons with zero hand-managed `\n`. The printer round-trips the
|
|
20
|
+
// AST, so it fixes structural formatting while preserving embedded XSQL template
|
|
21
|
+
// literals verbatim. (Realizes the "AST emission" goal cheaply; the fail-loud tsc
|
|
22
|
+
// gate below still validates the result.)
|
|
23
|
+
const formatTs = (code, file) =>
|
|
24
|
+
ts.createPrinter({ newLine: ts.NewLineKind.LineFeed })
|
|
25
|
+
.printFile(ts.createSourceFile(file, code, ts.ScriptTarget.Latest, true))
|
|
26
|
+
|
|
27
|
+
// ── args + flags ──────────────────────────────────────────────────────────────
|
|
28
|
+
if (process.argv.includes('--help') || process.argv.includes('-h')) {
|
|
29
|
+
console.log(`
|
|
30
|
+
synthigy-codegen — generate TypeScript types from XSQL operations
|
|
31
|
+
|
|
32
|
+
Usage:
|
|
33
|
+
codegen.mjs [flags] <a.xsql> [b.xsql ...] [outDir] generate TS from one or more xsql files
|
|
34
|
+
codegen.mjs check [flags] <a.xsql> [b.xsql ...] re-describe, diff IR, exit 1 on drift
|
|
35
|
+
codegen.mjs pull [endpoint] [out] pull schema.json only
|
|
36
|
+
|
|
37
|
+
Flags:
|
|
38
|
+
--pull force schema refresh before generating
|
|
39
|
+
--keys snake|camel field key casing in generated types (default: snake)
|
|
40
|
+
--no-writes omit sync / stack / delete methods
|
|
41
|
+
--schema <path> explicit schema.json path (skips auto-pull)
|
|
42
|
+
|
|
43
|
+
Environment:
|
|
44
|
+
SYNTHIGY_ENDPOINT server URL (default: http://localhost:7887)
|
|
45
|
+
SYNTHIGY_CLIENT_ID OAuth client ID
|
|
46
|
+
SYNTHIGY_CLIENT_SECRET OAuth client secret
|
|
47
|
+
|
|
48
|
+
Examples:
|
|
49
|
+
node codegen.mjs movies.xsql src/generated/ first run auto-pulls schema
|
|
50
|
+
node codegen.mjs --pull movies.xsql src/generated/ force schema refresh + generate
|
|
51
|
+
node codegen.mjs check movies.xsql CI drift check
|
|
52
|
+
node codegen.mjs pull http://localhost:7887 pull schema only
|
|
53
|
+
`.trim())
|
|
54
|
+
process.exit(0)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Flags (--key val / --flag) extracted first; positionals drive existing logic.
|
|
58
|
+
const flags = { keys: 'snake', writes: true, schemaOverride: null, pull: false }
|
|
59
|
+
const pos = []
|
|
60
|
+
for (let i = 2; i < process.argv.length; i++) {
|
|
61
|
+
const a = process.argv[i]
|
|
62
|
+
if (a === '--keys' && process.argv[i+1]) { flags.keys = process.argv[++i]; continue }
|
|
63
|
+
else if (a === '--no-writes') { flags.writes = false; continue }
|
|
64
|
+
else if (a === '--schema' && process.argv[i+1]) { flags.schemaOverride = path.resolve(process.argv[++i]); continue }
|
|
65
|
+
else if (a === '--pull') { flags.pull = true; continue }
|
|
66
|
+
pos.push(a)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const VALID_KEYS = new Set(['snake', 'camel'])
|
|
70
|
+
if (!VALID_KEYS.has(flags.keys)) {
|
|
71
|
+
console.error(`--keys must be snake or camel (got: ${flags.keys})`)
|
|
72
|
+
process.exit(1)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const checkMode = pos[0] === 'check'
|
|
76
|
+
const pullMode = pos[0] === 'pull'
|
|
77
|
+
|
|
78
|
+
if (pullMode) {
|
|
79
|
+
execFileSync(process.execPath, [path.join(here, 'pull.mjs'), ...pos.slice(1)],
|
|
80
|
+
{ stdio: 'inherit', env: process.env })
|
|
81
|
+
process.exit(0)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Positionals: last non-.xsql arg is outDir; everything before it (files or dirs)
|
|
85
|
+
// is input. check mode has no outDir — every positional is input.
|
|
86
|
+
const rawPos = checkMode ? pos.slice(1) : pos
|
|
87
|
+
const lastPos = rawPos[rawPos.length - 1]
|
|
88
|
+
const hasExplicitOut = !checkMode && lastPos && !lastPos.endsWith('.xsql')
|
|
89
|
+
const outArg = hasExplicitOut ? lastPos : undefined
|
|
90
|
+
const xsqlArgs = hasExplicitOut ? rawPos.slice(0, -1) : rawPos
|
|
91
|
+
|
|
92
|
+
if (!xsqlArgs.length) {
|
|
93
|
+
console.error('usage: node codegen.mjs [flags] <file.xsql> [file2.xsql ...] [outDir]')
|
|
94
|
+
console.error(' node codegen.mjs check <file.xsql> [file2.xsql ...]')
|
|
95
|
+
console.error(' node codegen.mjs pull [endpoint] [schema.json]')
|
|
96
|
+
console.error('flags: --pull force schema refresh')
|
|
97
|
+
console.error(' --keys snake|camel field casing (default: snake)')
|
|
98
|
+
console.error(' --no-writes omit sync/stack/delete methods')
|
|
99
|
+
console.error(' --schema <path> explicit schema.json path')
|
|
100
|
+
process.exit(1)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const xsqlPaths = xsqlArgs.flatMap(a => {
|
|
104
|
+
const p = path.resolve(a)
|
|
105
|
+
if (fs.existsSync(p) && fs.statSync(p).isDirectory())
|
|
106
|
+
return fs.readdirSync(p).filter(f => f.endsWith('.xsql')).sort().map(f => path.join(p, f))
|
|
107
|
+
return [p]
|
|
108
|
+
})
|
|
109
|
+
if (!xsqlPaths.length) { console.error('no .xsql files found'); process.exit(1) }
|
|
110
|
+
const xsqlDir = path.dirname(xsqlPaths[0]) // schema.json lives next to first file
|
|
111
|
+
const irPath = path.join(xsqlDir, 'ops.ir.json')
|
|
112
|
+
const outDir = checkMode
|
|
113
|
+
? null
|
|
114
|
+
: outArg ? path.resolve(outArg) : path.join(xsqlDir, 'generated')
|
|
115
|
+
|
|
116
|
+
// Schema path: explicit override > local schema.json (next to first xsql).
|
|
117
|
+
// Auto-pull happens below once ENDPOINT is known.
|
|
118
|
+
const localSchemaPath = path.join(xsqlDir, 'schema.json')
|
|
119
|
+
const schemaPath = flags.schemaOverride ?? localSchemaPath
|
|
120
|
+
|
|
121
|
+
// Key transform: applies to field names in generated TS only.
|
|
122
|
+
// XSQL source + wire format are always snake; the SDK re-cases results at runtime.
|
|
123
|
+
const toKey = flags.keys === 'camel' ? camel : s => s
|
|
124
|
+
|
|
125
|
+
// ── describe ─────────────────────────────────────────────────────────────────
|
|
126
|
+
const ENDPOINT = process.env.SYNTHIGY_ENDPOINT ?? 'http://localhost:7887'
|
|
127
|
+
const { SYNTHIGY_CLIENT_ID, SYNTHIGY_CLIENT_SECRET } = process.env
|
|
128
|
+
|
|
129
|
+
// Auto-pull schema if missing or --pull requested (saved next to .xsql, cached for future runs).
|
|
130
|
+
if (!flags.schemaOverride && (flags.pull || !fs.existsSync(schemaPath))) {
|
|
131
|
+
execFileSync(process.execPath,
|
|
132
|
+
[path.join(here, 'pull.mjs'), ENDPOINT, schemaPath],
|
|
133
|
+
{ stdio: 'inherit', env: process.env })
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (!fs.existsSync(schemaPath)) {
|
|
137
|
+
console.error(`schema.json not found — set SYNTHIGY_ENDPOINT + credentials`)
|
|
138
|
+
process.exit(1)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function token() {
|
|
142
|
+
if (!SYNTHIGY_CLIENT_ID) return ''
|
|
143
|
+
const r = await fetch(`${ENDPOINT}/oauth/token`, {
|
|
144
|
+
method: 'POST',
|
|
145
|
+
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
146
|
+
body: new URLSearchParams({ grant_type: 'client_credentials',
|
|
147
|
+
client_id: SYNTHIGY_CLIENT_ID, client_secret: SYNTHIGY_CLIENT_SECRET }),
|
|
148
|
+
})
|
|
149
|
+
if (!r.ok) throw new Error(`token ${r.status}: ${await r.text()}`)
|
|
150
|
+
return (await r.json()).access_token
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Merge all xsql files — ops are independent, names must be unique across files.
|
|
154
|
+
const source = xsqlPaths.map(p => fs.readFileSync(p, 'utf8')).join('\n\n')
|
|
155
|
+
// Contract between the .xsql sources on disk and the saved IR: the IR carries
|
|
156
|
+
// the hash of the sources it was described from. Mismatch = the IR is stale.
|
|
157
|
+
const sourceHash = createHash('sha256').update(source).digest('hex')
|
|
158
|
+
|
|
159
|
+
// check mode fails FAST and OFFLINE when the sources drifted from the saved IR —
|
|
160
|
+
// no backend/credentials needed to catch "edited .xsql, forgot codegen".
|
|
161
|
+
// (A hash match still goes through the live describe-diff below: same sources
|
|
162
|
+
// can yield a different IR after a server-side model change.)
|
|
163
|
+
if (checkMode && fs.existsSync(irPath)) {
|
|
164
|
+
const saved = JSON.parse(fs.readFileSync(irPath, 'utf8'))
|
|
165
|
+
if (saved.sourceHash && saved.sourceHash !== sourceHash) {
|
|
166
|
+
console.error(`sources drifted from saved IR — run: node codegen.mjs ${xsqlArgs.join(' ')}`)
|
|
167
|
+
process.exit(1)
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Describe: use the cached IR only when it matches the sources on disk
|
|
172
|
+
// (avoids needing credentials on every run). Stale/legacy-unhashed IR
|
|
173
|
+
// auto-refreshes; NEVER emit from an IR that disagrees with the sources.
|
|
174
|
+
let ir
|
|
175
|
+
const cached = !flags.pull && !checkMode && fs.existsSync(irPath)
|
|
176
|
+
? JSON.parse(fs.readFileSync(irPath, 'utf8')) : null
|
|
177
|
+
if (cached && cached.sourceHash === sourceHash) {
|
|
178
|
+
ir = cached
|
|
179
|
+
console.log(`using cached IR (${ir.operations?.length ?? 0} ops, in sync with sources) — use --pull to refresh`)
|
|
180
|
+
} else {
|
|
181
|
+
if (cached)
|
|
182
|
+
console.log(cached.sourceHash
|
|
183
|
+
? 'sources changed since IR pull — refreshing'
|
|
184
|
+
: 'saved IR predates source hashing — refreshing')
|
|
185
|
+
// The canonical parser owns @namespace/@watch/@returns now — send source as-is.
|
|
186
|
+
const sourceForDescribe = source
|
|
187
|
+
try {
|
|
188
|
+
const tok = await token()
|
|
189
|
+
const resp = await fetch(`${ENDPOINT}/data`, {
|
|
190
|
+
method: 'POST',
|
|
191
|
+
headers: { 'content-type': 'application/json', ...(tok && { authorization: `Bearer ${tok}` }) },
|
|
192
|
+
body: JSON.stringify({ operations: [{ op: 'describe', source: sourceForDescribe }] }),
|
|
193
|
+
})
|
|
194
|
+
if (!resp.ok) throw new Error(`describe ${resp.status}: ${await resp.text()}`)
|
|
195
|
+
const body = await resp.json()
|
|
196
|
+
const r0 = (body.results ?? body.result ?? [body.data ?? body])[0] ?? body
|
|
197
|
+
ir = r0.result ?? r0.data ?? r0
|
|
198
|
+
} catch (e) {
|
|
199
|
+
console.error(cached
|
|
200
|
+
? `sources changed since IR pull and describe failed (${e.message}) — start a backend (SYNTHIGY_ENDPOINT) or revert the .xsql edit`
|
|
201
|
+
: `describe failed: ${e.message}`)
|
|
202
|
+
process.exit(1)
|
|
203
|
+
}
|
|
204
|
+
ir = { sourceHash, ...ir }
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// ── check mode ────────────────────────────────────────────────────────────────
|
|
208
|
+
if (checkMode) {
|
|
209
|
+
if (!fs.existsSync(irPath)) {
|
|
210
|
+
console.error(`no saved IR at ${irPath} — run: node codegen.mjs ${xsqlArgs.join(' ')}`)
|
|
211
|
+
process.exit(1)
|
|
212
|
+
}
|
|
213
|
+
const savedIr = JSON.parse(fs.readFileSync(irPath, 'utf8'))
|
|
214
|
+
const freshStr = JSON.stringify(ir, null, 2)
|
|
215
|
+
if (JSON.stringify(savedIr, null, 2) === freshStr) {
|
|
216
|
+
console.log(`ok — IR unchanged (${ir.operations?.length ?? 0} ops)`)
|
|
217
|
+
process.exit(0)
|
|
218
|
+
}
|
|
219
|
+
// identity = (namespace ?? entity, name) — bare names repeat across entities
|
|
220
|
+
// (movie/list, user_rating/list), so a name-only match diffs the wrong pairs.
|
|
221
|
+
const opId = o => o.batch ? `@batch ${o.name}` : `${(o.namespace ?? o.entity ?? '').toLowerCase()}/${o.name.toLowerCase()}`
|
|
222
|
+
const added = ir.operations.filter(o => !savedIr.operations?.find(s => opId(s) === opId(o)))
|
|
223
|
+
const removed = savedIr.operations?.filter(o => !ir.operations.find(s => opId(s) === opId(o))) ?? []
|
|
224
|
+
const changed = ir.operations.filter(o => {
|
|
225
|
+
const s = savedIr.operations?.find(s => opId(s) === opId(o))
|
|
226
|
+
return s && JSON.stringify(s) !== JSON.stringify(o)
|
|
227
|
+
})
|
|
228
|
+
if (added.length) console.log(`+ added: ${added.map(opId).join(', ')}`)
|
|
229
|
+
if (removed.length) console.log(`- removed: ${removed.map(opId).join(', ')}`)
|
|
230
|
+
if (changed.length) console.log(`~ changed: ${changed.map(opId).join(', ')}`)
|
|
231
|
+
console.error(`IR drifted — run: node codegen.mjs ${xsqlArgs.join(' ')}`)
|
|
232
|
+
process.exit(1)
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// ── save IR ───────────────────────────────────────────────────────────────────
|
|
236
|
+
fs.writeFileSync(irPath, JSON.stringify(ir, null, 2) + '\n')
|
|
237
|
+
|
|
238
|
+
// ── gen ───────────────────────────────────────────────────────────────────────
|
|
239
|
+
// Identity (namespace / name), @watch, and sql-template @returns results all come
|
|
240
|
+
// from the IR now — the canonical XSQL parser owns them. No XSQL re-parsing here.
|
|
241
|
+
const schema = JSON.parse(fs.readFileSync(schemaPath, 'utf8'))
|
|
242
|
+
const sdkSrc = path.relative(outDir, path.join(here, '..', 'src')) + '/index.js'
|
|
243
|
+
const ent = (n) => schema.entities[n] ?? {}
|
|
244
|
+
const visited = new Set()
|
|
245
|
+
|
|
246
|
+
// The server owns pretty-name derivation (docs/plans/PLAN-SCHEMA-SKINS-PROJECTION.md):
|
|
247
|
+
// entity/attribute/relation `skins` in the pulled schema are the contract, not a hint.
|
|
248
|
+
// Falls back to the local splitter only for names with no schema entry (op/namespace
|
|
249
|
+
// identifiers, which are snake-authored code with no display label to preserve).
|
|
250
|
+
const entityPascal = (n) => ent(n).skins?.pascal ?? pascal(n)
|
|
251
|
+
const alias = (n) => `_${entityPascal(n)}`
|
|
252
|
+
|
|
253
|
+
function projectionType(entityName, fields) {
|
|
254
|
+
visited.add(entityName)
|
|
255
|
+
const A = alias(entityName)
|
|
256
|
+
// scalars become a Pick over the entity; relations + aggregate maps become
|
|
257
|
+
// extra object members. `_count`/`_agg` (kind "map") must NOT enter the Pick —
|
|
258
|
+
// they aren't entity attributes — so filter them out of scalars.
|
|
259
|
+
const scalars = fields.filter(f => f.kind !== 'relation' && f.kind !== 'map').map(f => toKey(f.key))
|
|
260
|
+
const rels = fields.filter(f => f.kind === 'relation')
|
|
261
|
+
const maps = fields.filter(f => f.kind === 'map')
|
|
262
|
+
const pick = scalars.length ? `Pick<${A}, ${scalars.map(k => JSON.stringify(k)).join(' | ')}>` : null
|
|
263
|
+
const relLines = rels.map(r => {
|
|
264
|
+
const target = ent(entityName).relations?.[r.key]?.to ?? r.key // schema lookup stays snake
|
|
265
|
+
const inner = projectionType(target, r.fields)
|
|
266
|
+
const t = r.cardinality === 'many' ? `(${inner})[]` : `(${inner})`
|
|
267
|
+
return ` ${toKey(r.key)}?: ${t}`
|
|
268
|
+
})
|
|
269
|
+
// _count → Record<string, number>; _agg → Record<string, unknown> (honest maps,
|
|
270
|
+
// not a precise nested type — the wire keys are alias-dependent).
|
|
271
|
+
const mapLines = maps.map(m => ` ${m.key}?: Record<string, ${TS[m.value] ?? 'unknown'}>`)
|
|
272
|
+
const objLines = [...relLines, ...mapLines]
|
|
273
|
+
if (!objLines.length) return pick ?? '{}'
|
|
274
|
+
return `${pick ? pick + ' & ' : ''}{\n${objLines.join('\n')}\n}`
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Escape a string for use inside a TS template literal.
|
|
278
|
+
const xsqlLiteral = (s) => '`' + s.replace(/`/g, '\\`').replace(/\$\{/g, '\\${') + '`'
|
|
279
|
+
|
|
280
|
+
// Build a wire selection object from IR fields — used by watchQuery / sdk.get.
|
|
281
|
+
function fieldsToSel(fields) {
|
|
282
|
+
const obj = {}
|
|
283
|
+
for (const f of fields) {
|
|
284
|
+
if (f.kind === 'map') continue // _count/_agg have no plain wire-selection form
|
|
285
|
+
obj[f.key] = f.kind === 'relation' ? fieldsToSel(f.fields) : null
|
|
286
|
+
}
|
|
287
|
+
return obj
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function paramsBody(params) {
|
|
291
|
+
if (!params?.length) return null
|
|
292
|
+
return params.map(p => ` ${p.name}${p.optional ? '?' : ''}: ${TS[p.type] ?? 'string'}${p.array ? '[]' : ''}`).join('\n')
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// sql-template result type — a flat object from the IR's `result.fields` (which
|
|
296
|
+
// the server synthesized from @returns). No fields / no @returns → Record<unknown>.
|
|
297
|
+
// `nullable === false` ⇒ non-null column; otherwise nullable (`| null`).
|
|
298
|
+
function plainResultType(result) {
|
|
299
|
+
if (!result?.fields?.length) return 'Record<string, unknown>'
|
|
300
|
+
const lines = result.fields.map(f =>
|
|
301
|
+
` ${toKey(f.key)}: ${TS[f.type] ?? 'unknown'}${f.nullable === false ? '' : ' | null'}`)
|
|
302
|
+
return `{\n${lines.join('\n')}\n}`
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Group every read + sql-template op by its declared @namespace (the OOP facade /
|
|
306
|
+
// FP module). Identity is (namespace, name) — both come from the IR; the emitter
|
|
307
|
+
// does only case-transforms, no string surgery.
|
|
308
|
+
const writeData = flags.keys === 'camel'
|
|
309
|
+
? '(Array.isArray(data) ? data.map(d => _toSnake(d)) : _toSnake(data))'
|
|
310
|
+
: 'data'
|
|
311
|
+
// ── Layer 1 (model, 1:1) + Layer 2 (XSQL op overlay) ────────────────────────
|
|
312
|
+
// Layer 1: EVERY entity in the IAM-filtered schema → a base namespace with
|
|
313
|
+
// sync/stack/delete (typed via `${E}Write`). Layer 2: declared XSQL ops add their
|
|
314
|
+
// typed read/sql-template/watch methods onto the matching @namespace. They merge:
|
|
315
|
+
// `Movie` ends up with writes (from the model) AND its declared reads.
|
|
316
|
+
const allEntities = Object.keys(schema.entities).sort()
|
|
317
|
+
const entityByPascal = {}
|
|
318
|
+
for (const n of allEntities) entityByPascal[entityPascal(n)] = n
|
|
319
|
+
|
|
320
|
+
// namespace registry: NS(pascal) → { writeEntity(snake|null), typeAliases[], readMethods[] }
|
|
321
|
+
const nsMap = {}
|
|
322
|
+
const nsOf = (NS) => (nsMap[NS] ??= { writeEntity: entityByPascal[NS] ?? null, typeAliases: [], readMethods: [] })
|
|
323
|
+
|
|
324
|
+
// Layer 1 — seed a base namespace for every entity (write methods emitted below).
|
|
325
|
+
for (const n of allEntities) nsOf(entityPascal(n))
|
|
326
|
+
|
|
327
|
+
// Layer 2 — overlay declared ops as typed methods on their @namespace.
|
|
328
|
+
// Mutations are Layer-1 territory (schema-derived Entity.sync/stack/delete) —
|
|
329
|
+
// never emitted from XSQL. Skip LOUDLY, never silently.
|
|
330
|
+
const MUTATE_VERBS = new Set(['sync', 'stack', 'delete'])
|
|
331
|
+
for (const op of ir.operations) {
|
|
332
|
+
if (op.batch) continue
|
|
333
|
+
if (MUTATE_VERBS.has(op.op)) {
|
|
334
|
+
console.warn(`skipped @${op.op} ${op.name} — mutations are generated from the schema; use ${entityPascal(op.entity ?? '<Entity>')}.${op.op}(data)`)
|
|
335
|
+
continue
|
|
336
|
+
}
|
|
337
|
+
// Namespace defaults to the op's ROOT ENTITY (the model is the source of truth) —
|
|
338
|
+
// `@search list` over `movie` → Movie.list(). @namespace is only needed when there
|
|
339
|
+
// is no root entity (a raw sql-template) or to deliberately group elsewhere.
|
|
340
|
+
const nsName = op.namespace ?? op.entity
|
|
341
|
+
if (!nsName)
|
|
342
|
+
throw new Error(`op "${op.name}": a sql-template with no root entity needs @namespace`)
|
|
343
|
+
const NS = entityPascal(nsName)
|
|
344
|
+
const g = nsOf(NS)
|
|
345
|
+
const isSql = op.op === 'sql-template'
|
|
346
|
+
const pb = paramsBody(op.params)
|
|
347
|
+
const allOptional = !op.params?.length || op.params.every(p => p.optional)
|
|
348
|
+
const Type = `${NS}${pascal(op.name)}` // MovieList, MovieGenreList, DashboardStats…
|
|
349
|
+
const ParamsT = pb ? `${Type}Params` : null
|
|
350
|
+
const pArg = ParamsT ? `params${allOptional ? '?' : ''}: ${ParamsT}, opts?: ExecOpts` : `opts?: ExecOpts`
|
|
351
|
+
const pPass = op.params?.length ? `params ?? {}` : '{}'
|
|
352
|
+
const ret = op.op === 'get' ? `Promise<${Type} | null>` : `Promise<${Type}[]>`
|
|
353
|
+
const methodName = camel(op.name)
|
|
354
|
+
|
|
355
|
+
if (ParamsT) g.typeAliases.push(`export type ${ParamsT} = {\n${pb}\n}`)
|
|
356
|
+
g.typeAliases.push(`export type ${Type} = ${isSql ? plainResultType(op.result) : projectionType(op.entity, op.result.fields)}`)
|
|
357
|
+
if (!isSql) g.typeAliases.push(`export const ${Type}Sel = ${JSON.stringify(fieldsToSel(op.result.fields), null, 2)} as const`)
|
|
358
|
+
|
|
359
|
+
// @description → JSDoc on the generated method (multiline-safe).
|
|
360
|
+
const jsdoc = op.description
|
|
361
|
+
? ` /**\n${op.description.split('\n').map(l => ` * ${l.replace(/\*\//g, '* /')}`).join('\n')}\n */\n`
|
|
362
|
+
: ''
|
|
363
|
+
g.readMethods.push(jsdoc + (isSql
|
|
364
|
+
? ` ${methodName}(${pArg}): Promise<${Type}[]> {\n return _client.sqlTemplate<${Type}>(${xsqlLiteral(op.source)}, ${pPass}, opts)\n }`
|
|
365
|
+
: ` ${methodName}(${pArg}): ${ret} {\n return _client.query<${Type}>(${xsqlLiteral(`@${op.op} ${op.name}\n` + op.source)}, ${pPass}, { op: ${JSON.stringify(op.op)}, ...opts })\n }`))
|
|
366
|
+
if (op.watch) g.readMethods.push(isSql
|
|
367
|
+
? ` watch${pascal(methodName)}(${pArg}): SqlTemplateWatch<${Type}> {\n return _client.watchSqlTemplate<${Type}>(${xsqlLiteral(op.source)}, ${pPass}, { entities: ${JSON.stringify(Array.isArray(op.watch) ? op.watch : [])}, searchOpts: opts })\n }`
|
|
368
|
+
: ` 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 }`)
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// Emit one chunk per namespace: projection types + the object (reads + writes).
|
|
372
|
+
const nsChunks = []
|
|
373
|
+
for (const NS of Object.keys(nsMap).sort()) {
|
|
374
|
+
const g = nsMap[NS]
|
|
375
|
+
const we = g.writeEntity
|
|
376
|
+
// Writes are SILENT by default — the server answers { count }. `returning`
|
|
377
|
+
// opts in to the echo, and the return type follows it: the `O extends
|
|
378
|
+
// { returning: true }` conditional resolves per call site, so a default call
|
|
379
|
+
// has no `.xid` and a returning call has no `.count`.
|
|
380
|
+
const retT = `O extends { returning: true } ? Echo<D> : WriteCount`
|
|
381
|
+
const gen = `D extends ${NS}Write | ${NS}Write[], O extends WriteOpts | undefined = undefined`
|
|
382
|
+
const wd = `${writeData} as Record<string,unknown> | Record<string,unknown>[]`
|
|
383
|
+
const writeMethods = (flags.writes && we) ? [
|
|
384
|
+
` sync<${gen}>(data: D, opts?: O): Promise<${retT}> {\n return _client.sync<${retT}>(${JSON.stringify(we)}, ${wd}, opts)\n }`,
|
|
385
|
+
` stack<${gen}>(data: D, opts?: O): Promise<${retT}> {\n return _client.stack<${retT}>(${JSON.stringify(we)}, ${wd}, opts)\n }`,
|
|
386
|
+
` delete(xid: string, opts?: ExecOpts): Promise<unknown> {\n return _client.delete(${JSON.stringify(we)}, { xid }, opts)\n }`,
|
|
387
|
+
] : []
|
|
388
|
+
const methods = [...g.readMethods, ...writeMethods]
|
|
389
|
+
nsChunks.push([
|
|
390
|
+
...g.typeAliases,
|
|
391
|
+
// entity-backed namespaces: `Movie` is also the read row type. Facades (Dashboard) value-only.
|
|
392
|
+
...(we ? [`export type ${NS} = ${alias(we)}`] : []),
|
|
393
|
+
`export const ${NS} = {\n${methods.join(',\n')}\n}`,
|
|
394
|
+
].join('\n\n'))
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// schema.ts — read + write interface for EVERY entity in the model
|
|
398
|
+
const scalarType = (attr) => {
|
|
399
|
+
const base = tsScalar(attr)
|
|
400
|
+
return attr.nullable === false ? base : `${base} | null`
|
|
401
|
+
}
|
|
402
|
+
const schemaChunks = []
|
|
403
|
+
for (const name of allEntities) { // 1:1 with the model — every entity
|
|
404
|
+
const e = ent(name)
|
|
405
|
+
const rels = e.relations ?? {}
|
|
406
|
+
// schema-attached field key, from the same skins contract as entityPascal — falls
|
|
407
|
+
// back to the local splitter only when this field has no schema entry.
|
|
408
|
+
const key = (skinned, k) => skinned?.skins?.[flags.keys] ?? toKey(k)
|
|
409
|
+
const lines = [' xid: string'] // READ shape — every field present
|
|
410
|
+
const wlines = [' xid?: string'] // WRITE shape — all optional; xid set = update target
|
|
411
|
+
for (const [k, a] of Object.entries(e.attributes ?? {})) {
|
|
412
|
+
if (k === 'xid') continue // identity already emitted above (non-null); skip the nullable attribute dup
|
|
413
|
+
if (rels[k]) continue
|
|
414
|
+
lines.push(` ${key(a, k)}: ${scalarType(a)}`)
|
|
415
|
+
const wt = tsScalar(a, true) // write scalar (e.g. hashed → string, not never)
|
|
416
|
+
if (wt !== 'never') wlines.push(` ${key(a, k)}?: ${a.nullable === false ? wt : `${wt} | null`}`)
|
|
417
|
+
}
|
|
418
|
+
for (const [k, r] of Object.entries(rels)) {
|
|
419
|
+
if (!schema.entities[r.to]) continue // relation target filtered out of schema → skip
|
|
420
|
+
lines.push(` ${key(r, k)}?: ${r.cardinality === 'many' ? `${entityPascal(r.to)}[]` : entityPascal(r.to)}`)
|
|
421
|
+
// WRITE relation: a LINK to an existing record ({ xid }) OR a nested write.
|
|
422
|
+
// This is the FK-reference the wire actually accepts — not the full read entity.
|
|
423
|
+
const link = `{ xid: string } | ${entityPascal(r.to)}Write`
|
|
424
|
+
wlines.push(` ${key(r, k)}?: ${r.cardinality === 'many' ? `(${link})[]` : link}`)
|
|
425
|
+
}
|
|
426
|
+
schemaChunks.push(`export interface ${entityPascal(name)} {\n${lines.join('\n')}\n}`)
|
|
427
|
+
if (flags.writes) schemaChunks.push(`export interface ${entityPascal(name)}Write {\n${wlines.join('\n')}\n}`)
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// ops.ts — import every entity's read type (aliased) + write type from schema.ts.
|
|
431
|
+
const aliasImports = allEntities.flatMap(n =>
|
|
432
|
+
flags.writes ? [`${entityPascal(n)} as ${alias(n)}`, `${entityPascal(n)}Write`] : [`${entityPascal(n)} as ${alias(n)}`]
|
|
433
|
+
).join(', ')
|
|
434
|
+
const inputNames = xsqlPaths.map(p => path.basename(p)).join(', ')
|
|
435
|
+
const OPS_HDR_COMMENT = `// AUTO-GENERATED from ${inputNames}. Do not edit.\n`
|
|
436
|
+
const OPS_HDR_CODE = `import type { SynthigyClient, ClientConfig, ExecOpts, WriteOpts, QueryWatch, SqlTemplateWatch } from "${sdkSrc}"
|
|
437
|
+
import { connect } from "${sdkSrc}"
|
|
438
|
+
import type { ${aliasImports} } from "./schema.js"
|
|
439
|
+
|
|
440
|
+
/** What a write answers by default — writes are silent unless \`returning\`. */
|
|
441
|
+
export type WriteCount = { count: number }
|
|
442
|
+
|
|
443
|
+
/** What a write answers under \`{ returning: true }\`, cardinality-mirrored. */
|
|
444
|
+
export type Echo<D> = D extends any[] ? (D[number] & { xid: string })[] : D & { xid: string }
|
|
445
|
+
|
|
446
|
+
let _client: SynthigyClient
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Call once at startup. Installs the process-wide SDK client via \`connect\` —
|
|
450
|
+
* the SAME single client the bare verbs (\`search\`, \`sync\`, …) run on — and
|
|
451
|
+
* points every generated method at it. One process, one client, one token
|
|
452
|
+
* cache, one SSE. Reconnect by calling again (destroys the previous client).
|
|
453
|
+
*/
|
|
454
|
+
export function configure(config: ClientConfig): void {
|
|
455
|
+
_client = connect(${flags.keys === 'camel' ? '{ ...config, keyFormat: \'camel\' }' : 'config'})
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/** Return the underlying SDK client — use for calls not covered by generated ops. */
|
|
459
|
+
export function client(): SynthigyClient { return _client }
|
|
460
|
+
${flags.keys === 'camel' && flags.writes ? `
|
|
461
|
+
// Wire writes always use snake_case — transform before sending.
|
|
462
|
+
const _toSnake = (v: unknown): unknown => {
|
|
463
|
+
if (Array.isArray(v)) return v.map(_toSnake)
|
|
464
|
+
if (v !== null && typeof v === 'object')
|
|
465
|
+
return Object.fromEntries(Object.entries(v as Record<string, unknown>)
|
|
466
|
+
.map(([k, val]) => [k.replace(/([a-z])([A-Z])/g, '$1_$2').toLowerCase(), _toSnake(val)]))
|
|
467
|
+
return v
|
|
468
|
+
}` : ''}
|
|
469
|
+
`
|
|
470
|
+
const SCHEMA_HDR = `// AUTO-GENERATED — entity types from /schema ${schema.version ?? ''}. Do not edit.\n`
|
|
471
|
+
|
|
472
|
+
fs.mkdirSync(outDir, { recursive: true })
|
|
473
|
+
// The printer drops the detached top-of-file comment, so prepend it after formatting.
|
|
474
|
+
fs.writeFileSync(path.join(outDir, 'schema.ts'), SCHEMA_HDR + formatTs(schemaChunks.join('\n\n'), 'schema.ts'))
|
|
475
|
+
fs.writeFileSync(path.join(outDir, 'ops.ts'), OPS_HDR_COMMENT + formatTs(OPS_HDR_CODE + nsChunks.join('\n\n'), 'ops.ts'))
|
|
476
|
+
|
|
477
|
+
// Gate, not a builder: VALIDATE the generated TS and FAIL LOUD if it doesn't
|
|
478
|
+
// type-check — never ship broken/stale output silently (the old emit-and-swallow
|
|
479
|
+
// shipped a stale ops.js once). No .js emitted: consumers import the .ts source
|
|
480
|
+
// (Node 23+ type-stripping / a bundler); the type-only `./schema.js` import erases
|
|
481
|
+
// at runtime. `--ignoreConfig` so a consumer tsconfig can't trip TS5112.
|
|
482
|
+
const tscBin = path.join(here, '..', 'node_modules', '.bin', 'tsc')
|
|
483
|
+
try {
|
|
484
|
+
execFileSync(tscBin, [
|
|
485
|
+
'--ignoreConfig',
|
|
486
|
+
'--module', 'nodenext', '--moduleResolution', 'nodenext',
|
|
487
|
+
'--target', 'es2022', '--strict', '--skipLibCheck', '--noEmit',
|
|
488
|
+
path.join(outDir, 'ops.ts'),
|
|
489
|
+
path.join(outDir, 'schema.ts'),
|
|
490
|
+
], { stdio: 'pipe' })
|
|
491
|
+
} catch (e) {
|
|
492
|
+
process.stderr.write('codegen: generated TS failed to type-check (not shipped):\n')
|
|
493
|
+
process.stderr.write((e.stdout ?? e.stderr ?? '').toString())
|
|
494
|
+
process.exit(1)
|
|
495
|
+
}
|
|
496
|
+
const flagSummary = [flags.keys !== 'snake' && `keys=${flags.keys}`, !flags.writes && 'no-writes'].filter(Boolean).join(' ')
|
|
497
|
+
const opCount = ir.operations.filter(o => !o.batch).length
|
|
498
|
+
const inputSummary = xsqlPaths.map(p => path.basename(p)).join(' + ')
|
|
499
|
+
console.log(`${inputSummary} → ${allEntities.length} entities (full model), ${Object.keys(nsMap).length} namespaces, ${opCount} ops${flagSummary ? ` [${flagSummary}]` : ''} → ${outDir}`)
|
package/codegen/lib.mjs
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// Shared codegen primitives for the TS emitters — one home for the scalar-type
|
|
2
|
+
// map + casing so the read and write emitters can't drift apart.
|
|
3
|
+
|
|
4
|
+
// scalar/enum/ref type → TS. `write` flips the read-only asymmetry: a `hashed`
|
|
5
|
+
// field is write-only — `never` in a read result, but a plain `string` you set
|
|
6
|
+
// on input.
|
|
7
|
+
export const TS = {
|
|
8
|
+
int: 'number', float: 'number', string: 'string', boolean: 'boolean',
|
|
9
|
+
timestamp: 'string', uuid: 'string', json: 'unknown', transit: 'unknown',
|
|
10
|
+
encrypted: 'string',
|
|
11
|
+
// `?name:order` — a "column [asc|desc], …" spec string; the server
|
|
12
|
+
// validates columns against the param's restriction set at bind time.
|
|
13
|
+
order: 'string',
|
|
14
|
+
}
|
|
15
|
+
export const tsScalar = (attr, write = false) => {
|
|
16
|
+
if (!attr) return 'unknown'
|
|
17
|
+
if (attr.type === 'enum') return (attr.enum ?? []).map((v) => JSON.stringify(v)).join(' | ') || 'string'
|
|
18
|
+
if (attr.type === 'hashed') return write ? 'string' : 'never' // write-only: input string, never read
|
|
19
|
+
return TS[attr.type] ?? 'string' // reference types (type === entity name) → FK id string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// casing
|
|
23
|
+
export const words = (s) => s.split(/[-_\s]+/).filter(Boolean)
|
|
24
|
+
export const pascal = (s) => words(s).map((w) => w[0].toUpperCase() + w.slice(1)).join('')
|
|
25
|
+
export const camel = (s) => { const p = pascal(s); return p[0].toLowerCase() + p.slice(1) }
|
|
26
|
+
// snake_case — the wire-native form. Valid TS identifier, no quoting, and the
|
|
27
|
+
// server's DEFAULT response casing, so generated reads need no key_format walk.
|
|
28
|
+
export const snake = (s) => words(s).join('_')
|
|
29
|
+
// kebab wire keys aren't valid TS identifiers → keep verbatim, quote when needed.
|
|
30
|
+
export const tsKey = (k) => (/^[A-Za-z_$][\w$]*$/.test(k) ? k : JSON.stringify(k))
|