@synthigy/sdk 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -66,7 +66,7 @@ for await (const ev of w.events) {
66
66
  IAM / RBAC / row-level security enforced *server-side*. Tokens never have to
67
67
  reach the browser. → [AUTH.md](./docs/AUTH.md)
68
68
 
69
- - **Typed from your model.** `synthigy-gen` compiles your ERD + `.xsql` files
69
+ - **Typed from your model.** `synthigy-gen` (needs `typescript` in your project) compiles your ERD + `.xsql` files
70
70
  into typed methods whose result types follow the selection — a typo'd entity
71
71
  or an unselected field is a compile error. → [codegen](./docs/WATCH.md#codegen)
72
72
 
@@ -79,8 +79,7 @@ for await (const ev of w.events) {
79
79
  | [WATCH.md](./docs/WATCH.md) | `watch` / `watchQuery` / `watchSqlTemplate` / `observe`, lifecycle, and codegen |
80
80
  | [AUTH.md](./docs/AUTH.md) | OAuth client registration, `actingAs`, BFF / SPA / native patterns |
81
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 |
82
+ | [synthigy/examples: movies/js](https://github.com/synthigy/examples/tree/main/movies/js) | End-to-end Datastar BFF against the published package |
84
83
  | [src/index.d.ts](./src/index.d.ts) | Full type definitions |
85
84
 
86
85
  ## Tests
@@ -10,8 +10,21 @@ import fs from 'node:fs'
10
10
  import path from 'node:path'
11
11
  import { createHash } from 'node:crypto'
12
12
  import { execFileSync } from 'node:child_process'
13
- import ts from 'typescript'
13
+ import { createRequire } from 'node:module'
14
14
  import { TS, tsScalar, pascal, camel } from './lib.mjs'
15
+ import { PLATFORM_AUDIENCE } from '../src/index.js'
16
+
17
+ // typescript is an optional peer: the runtime client has no dependencies and
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
21
+ try {
22
+ ts = createRequire(import.meta.url)('typescript')
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
+ }
15
28
 
16
29
  const here = path.dirname(new URL(import.meta.url).pathname)
17
30
 
@@ -44,6 +57,7 @@ Environment:
44
57
  SYNTHIGY_ENDPOINT server URL (default: http://localhost:7887)
45
58
  SYNTHIGY_CLIENT_ID OAuth client ID
46
59
  SYNTHIGY_CLIENT_SECRET OAuth client secret
60
+ SYNTHIGY_AUDIENCE client_credentials audience, when the server requires one
47
61
 
48
62
  Examples:
49
63
  node codegen.mjs movies.xsql src/generated/ first run auto-pulls schema
@@ -124,7 +138,7 @@ const toKey = flags.keys === 'camel' ? camel : s => s
124
138
 
125
139
  // ── describe ─────────────────────────────────────────────────────────────────
126
140
  const ENDPOINT = process.env.SYNTHIGY_ENDPOINT ?? 'http://localhost:7887'
127
- const { SYNTHIGY_CLIENT_ID, SYNTHIGY_CLIENT_SECRET } = process.env
141
+ const { SYNTHIGY_CLIENT_ID, SYNTHIGY_CLIENT_SECRET, SYNTHIGY_AUDIENCE } = process.env
128
142
 
129
143
  // Auto-pull schema if missing or --pull requested (saved next to .xsql, cached for future runs).
130
144
  if (!flags.schemaOverride && (flags.pull || !fs.existsSync(schemaPath))) {
@@ -144,7 +158,11 @@ async function token() {
144
158
  method: 'POST',
145
159
  headers: { 'content-type': 'application/x-www-form-urlencoded' },
146
160
  body: new URLSearchParams({ grant_type: 'client_credentials',
147
- client_id: SYNTHIGY_CLIENT_ID, client_secret: SYNTHIGY_CLIENT_SECRET }),
161
+ client_id: SYNTHIGY_CLIENT_ID, client_secret: SYNTHIGY_CLIENT_SECRET,
162
+ // describe is a platform-API call like any other, so it mints for the
163
+ // platform audience. Hand-rolled here rather than going through the
164
+ // client, so the default has to be named explicitly.
165
+ audience: SYNTHIGY_AUDIENCE || PLATFORM_AUDIENCE }),
148
166
  })
149
167
  if (!r.ok) throw new Error(`token ${r.status}: ${await r.text()}`)
150
168
  return (await r.json()).access_token
@@ -194,7 +212,15 @@ if (cached && cached.sourceHash === sourceHash) {
194
212
  if (!resp.ok) throw new Error(`describe ${resp.status}: ${await resp.text()}`)
195
213
  const body = await resp.json()
196
214
  const r0 = (body.results ?? body.result ?? [body.data ?? body])[0] ?? body
215
+ // A describe that fails answers 200 with {ok:false, error} in the result.
216
+ // Reading .operations off that yields "not iterable" and throws the
217
+ // server's diagnostic away — which is the only thing that says WHICH
218
+ // attribute the templates got wrong.
219
+ const err = r0?.error ?? (r0?.ok === false ? { message: 'describe failed' } : null)
220
+ if (err) throw new Error(err.message ?? JSON.stringify(err))
197
221
  ir = r0.result ?? r0.data ?? r0
222
+ if (!Array.isArray(ir?.operations))
223
+ throw new Error(`describe returned no operations: ${JSON.stringify(ir).slice(0, 200)}`)
198
224
  } catch (e) {
199
225
  console.error(cached
200
226
  ? `sources changed since IR pull and describe failed (${e.message}) — start a backend (SYNTHIGY_ENDPOINT) or revert the .xsql edit`
@@ -239,7 +265,13 @@ fs.writeFileSync(irPath, JSON.stringify(ir, null, 2) + '\n')
239
265
  // Identity (namespace / name), @watch, and sql-template @returns results all come
240
266
  // from the IR now — the canonical XSQL parser owns them. No XSQL re-parsing here.
241
267
  const schema = JSON.parse(fs.readFileSync(schemaPath, 'utf8'))
242
- const sdkSrc = path.relative(outDir, path.join(here, '..', 'src')) + '/index.js'
268
+ // Installed from npm -> import the package by name; a relative path out of
269
+ // node_modules is correct only for one install layout. Inside this repo the
270
+ // relative path is still what works, since there is no package to resolve.
271
+ const sdkRoot = path.join(here, '..')
272
+ const sdkSrc = sdkRoot.includes(`${path.sep}node_modules${path.sep}`)
273
+ ? '@synthigy/sdk'
274
+ : path.relative(outDir, path.join(sdkRoot, 'src')) + '/index.js'
243
275
  const ent = (n) => schema.entities[n] ?? {}
244
276
  const visited = new Set()
245
277
 
@@ -329,7 +361,14 @@ for (const n of allEntities) nsOf(entityPascal(n))
329
361
  // never emitted from XSQL. Skip LOUDLY, never silently.
330
362
  const MUTATE_VERBS = new Set(['sync', 'stack', 'delete'])
331
363
  for (const op of ir.operations) {
332
- if (op.batch) continue
364
+ if (op.batch) {
365
+ // The Python and PHP generators emit a batch function; this one does not
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`)
370
+ continue
371
+ }
333
372
  if (MUTATE_VERBS.has(op.op)) {
334
373
  console.warn(`skipped @${op.op} ${op.name} — mutations are generated from the schema; use ${entityPascal(op.entity ?? '<Entity>')}.${op.op}(data)`)
335
374
  continue
@@ -478,20 +517,42 @@ fs.writeFileSync(path.join(outDir, 'ops.ts'), OPS_HDR_COMMENT + formatTs(OPS_
478
517
  // type-check — never ship broken/stale output silently (the old emit-and-swallow
479
518
  // shipped a stale ops.js once). No .js emitted: consumers import the .ts source
480
519
  // (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)
520
+ // at runtime.
521
+ // Locate tsc through the SAME resolution that found the `typescript` module
522
+ // above, so a hoisted install — the normal consumer layout — is found. A path
523
+ // built from `here` only exists when typescript is nested under this package.
524
+ const tscBin = (() => {
525
+ try {
526
+ const lib = createRequire(import.meta.url).resolve('typescript')
527
+ const bin = path.join(path.dirname(lib), '..', 'bin', 'tsc')
528
+ if (fs.existsSync(bin)) return bin
529
+ } catch {}
530
+ for (const c of [path.join(here, '..', 'node_modules', '.bin', 'tsc'),
531
+ path.join(process.cwd(), 'node_modules', '.bin', 'tsc')]) {
532
+ if (fs.existsSync(c)) return c
533
+ }
534
+ return null
535
+ })()
536
+
537
+ // `--ignoreConfig` needs TypeScript 6. On 5.x the flag is itself an error, and
538
+ // naming files explicitly already makes tsc skip tsconfig.json, so it is left off.
539
+ const tscMajor = Number.parseInt(ts.version ?? '0', 10) || 0
540
+ if (tscBin) {
541
+ try {
542
+ execFileSync(tscBin, [
543
+ ...(tscMajor >= 6 ? ['--ignoreConfig'] : []),
544
+ '--module', 'nodenext', '--moduleResolution', 'nodenext',
545
+ '--target', 'es2022', '--strict', '--skipLibCheck', '--noEmit',
546
+ path.join(outDir, 'ops.ts'),
547
+ path.join(outDir, 'schema.ts'),
548
+ ], { stdio: 'pipe' })
549
+ } catch (e) {
550
+ process.stderr.write('codegen: generated TS failed to type-check (not shipped):\n')
551
+ process.stderr.write((e.stdout ?? e.stderr ?? '').toString())
552
+ process.exit(1)
553
+ }
554
+ } else {
555
+ process.stderr.write('codegen: tsc not found — skipping the type-check gate.\n')
495
556
  }
496
557
  const flagSummary = [flags.keys !== 'snake' && `keys=${flags.keys}`, !flags.writes && 'no-writes'].filter(Boolean).join(' ')
497
558
  const opCount = ir.operations.filter(o => !o.batch).length
package/codegen/pull.mjs CHANGED
@@ -26,11 +26,17 @@ if (!endpoint) {
26
26
  process.exit(1)
27
27
  }
28
28
 
29
- const { SYNTHIGY_TOKEN, SYNTHIGY_CLIENT_ID, SYNTHIGY_CLIENT_SECRET } = process.env
29
+ const { SYNTHIGY_TOKEN, SYNTHIGY_CLIENT_ID, SYNTHIGY_CLIENT_SECRET,
30
+ SYNTHIGY_AUDIENCE } = process.env
30
31
  const config = { endpoint }
31
32
  if (SYNTHIGY_CLIENT_ID && SYNTHIGY_CLIENT_SECRET) {
32
33
  config.clientId = SYNTHIGY_CLIENT_ID
33
34
  config.clientSecret = SYNTHIGY_CLIENT_SECRET
35
+ // A server can require an explicit client_credentials audience for /data and
36
+ // /schema. Without it the mint still succeeds and the token is then refused,
37
+ // so the symptom is a 401 on schema rather than anything about auth config.
38
+ // A static SYNTHIGY_TOKEN already has its audience baked in.
39
+ if (SYNTHIGY_AUDIENCE) config.audience = SYNTHIGY_AUDIENCE
34
40
  } else {
35
41
  config.token = SYNTHIGY_TOKEN ?? '' // '' = authless (bare server)
36
42
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@synthigy/sdk",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Zero-dependency client for Synthigy's /data endpoint",
5
5
  "author": "Robert Geršak",
6
6
  "license": "MIT",
@@ -50,6 +50,14 @@
50
50
  "realtime",
51
51
  "xsql"
52
52
  ],
53
+ "peerDependencies": {
54
+ "typescript": ">=5"
55
+ },
56
+ "peerDependenciesMeta": {
57
+ "typescript": {
58
+ "optional": true
59
+ }
60
+ },
53
61
  "devDependencies": {
54
62
  "typescript": "^6.0.3"
55
63
  },
package/src/index.d.ts CHANGED
@@ -1,3 +1,6 @@
1
+ /** The platform API this SDK is a client of; the default audience for every mint. */
2
+ export const PLATFORM_AUDIENCE: "https://synthigy.com"
3
+
1
4
  // ============================================================================
2
5
  // Selection
3
6
  // ============================================================================
@@ -79,6 +82,13 @@ export interface TreeExecOpts extends ExecOpts {
79
82
  childrenKey?: string
80
83
  }
81
84
 
85
+ /** What `deploy()` acks with — `dataset` is the xid `destroy()` takes. */
86
+ export interface DeployAck {
87
+ deployed: boolean
88
+ version: string
89
+ dataset: string
90
+ }
91
+
82
92
  export type OperationResult<T = unknown> =
83
93
  | { ok: true; data: T }
84
94
  | { ok: false; error: { message: string; code: string } }
@@ -608,6 +618,19 @@ export class SynthigyClient {
608
618
  */
609
619
  deployedModel(opts?: ExecOpts): Promise<Record<string, unknown>>
610
620
 
621
+ /**
622
+ * Deploy a dataset version from a modeler export — pass the export file's
623
+ * contents verbatim, the server decodes it. Requires `dataset:deploy`.
624
+ * The ack carries the dataset xid `destroy()` takes.
625
+ */
626
+ deploy(exportContents: string, opts?: ExecOpts): Promise<DeployAck>
627
+
628
+ /**
629
+ * Destroy a dataset — every version, table and row. Requires
630
+ * `dataset:delete`. Idempotent.
631
+ */
632
+ destroy(datasetXid: string, opts?: ExecOpts): Promise<boolean>
633
+
611
634
  /**
612
635
  * Fetch the runtime (enhanced) ERD model via `op: "runtime-model"` —
613
636
  * the model as the running system actually emits it, with audit
@@ -1014,6 +1037,8 @@ export const op: {
1014
1037
  sqlTemplate(template: string, params?: Record<string, unknown> | unknown[], opts?: { cached?: boolean }): Operation
1015
1038
  deployedModel(): Operation
1016
1039
  runtimeModel(): Operation
1040
+ deploy(exportContents: string): Operation
1041
+ destroy(datasetXid: string): Operation
1017
1042
  }
1018
1043
 
1019
1044
  // ============================================================================
@@ -1087,6 +1112,8 @@ export { _delete as delete }
1087
1112
 
1088
1113
  export const schema: SynthigyClient['schema']
1089
1114
  export const lint: SynthigyClient['lint']
1115
+ export const deploy: SynthigyClient['deploy']
1116
+ export const destroy: SynthigyClient['destroy']
1090
1117
  export const deployedModel: SynthigyClient['deployedModel']
1091
1118
  export const runtimeModel: SynthigyClient['runtimeModel']
1092
1119
  export const token: SynthigyClient['token']
package/src/index.js CHANGED
@@ -733,6 +733,15 @@ const RETRYABLE_CATEGORIES = new Set(['network', 'rate_limit', 'internal'])
733
733
  * - .requestId— request ID for cross-correlation with server logs
734
734
  * - .status — HTTP status code (for transport-level errors)
735
735
  */
736
+ /**
737
+ * The platform API this SDK is a client of. `/data`, `/schema`, `/history`,
738
+ * `/logs` and subscriptions all require a token bound to it. It identifies the
739
+ * API, never a deployment, so it is the same string on localhost and in
740
+ * production — which is exactly why it is a constant here rather than
741
+ * something every caller configures.
742
+ */
743
+ export const PLATFORM_AUDIENCE = 'https://synthigy.com'
744
+
736
745
  export class SynthigyError extends Error {
737
746
  constructor(message, code, details, extras) {
738
747
  super(message)
@@ -870,12 +879,16 @@ class SynthigyClient {
870
879
  // [sub, client_id] isn't torn down between watches. Replaces the
871
880
  // earlier "dummy keepalive watch" workaround.
872
881
  this._keepAlive = config.keepAlive === true
873
- // The platform's audience model is opt-in: a client_credentials mint
874
- // naming no audience resolves to the identity-only OIDC audience, which
875
- // /data rejects. Bound once here, applied to every mint this client makes.
882
+ // A client_credentials mint naming no audience resolves to the
883
+ // identity-only OIDC audience, which /data rejects. This SDK is the client
884
+ // for the platform API, so PLATFORM_AUDIENCE is what it asks for — there
885
+ // is nothing for a caller to configure, and nowhere to look the value up
886
+ // if there were: the server does not advertise it in discovery. Minting a
887
+ // token for some OTHER API is a per-call argument on token(), not a
888
+ // property of the client. The env var stays only as an escape hatch.
876
889
  this._defaultAudience = config.audience
877
890
  ?? (typeof process !== 'undefined' ? process.env?.SYNTHIGY_AUDIENCE : undefined)
878
- ?? undefined
891
+ ?? PLATFORM_AUDIENCE
879
892
 
880
893
  if (config.token !== undefined && config.token !== null) {
881
894
  // Static token mode — empty string allowed for unauth / dev servers.
@@ -1863,6 +1876,34 @@ class SynthigyClient {
1863
1876
  return result.data
1864
1877
  }
1865
1878
 
1879
+ /**
1880
+ * Deploy a dataset version from a modeler export. Pass the export file's
1881
+ * contents verbatim — the server decodes it. Requires `dataset:deploy`.
1882
+ *
1883
+ * @param {string} exportContents - Contents of a `<Dataset>@<version>.json` export
1884
+ * @param {Object} [opts] - Options ({ actingAs })
1885
+ * @returns {Promise<{deployed: boolean, version: string, dataset: string}>}
1886
+ */
1887
+ async deploy(exportContents, opts) {
1888
+ const [result] = await this.exec([{ op: 'deploy', data: exportContents }], opts)
1889
+ if (!result.ok) throw _errorFromServer(result.error, undefined, result._requestId)
1890
+ return result.data
1891
+ }
1892
+
1893
+ /**
1894
+ * Destroy a dataset: every version, every table, every row. Requires
1895
+ * `dataset:delete`. Idempotent — a dataset that is already gone is not an error.
1896
+ *
1897
+ * @param {string} datasetXid - The dataset xid (`deploy()` returns it)
1898
+ * @param {Object} [opts] - Options ({ actingAs })
1899
+ * @returns {Promise<boolean>}
1900
+ */
1901
+ async destroy(datasetXid, opts) {
1902
+ const [result] = await this.exec([{ op: 'delete', entity: 'dataset', data: { xid: datasetXid } }], opts)
1903
+ if (!result.ok) throw _errorFromServer(result.error, undefined, result._requestId)
1904
+ return result.data
1905
+ }
1906
+
1866
1907
  /**
1867
1908
  * Fetch the runtime ERD model — the deployed model augmented with:
1868
1909
  * - `xid` / `euuid` identity attrs on every entity
@@ -2387,6 +2428,8 @@ export const op = {
2387
2428
  sqlTemplate: (template, params) => ({ op: 'sql-template', template, params: params || [] }),
2388
2429
  deployedModel: () => ({ op: 'deployed-model' }),
2389
2430
  runtimeModel: () => ({ op: 'runtime-model' }),
2431
+ deploy: (exportContents) => ({ op: 'deploy', data: exportContents }),
2432
+ destroy: (datasetXid) => ({ op: 'delete', entity: 'dataset', data: { xid: datasetXid } }),
2390
2433
  }
2391
2434
 
2392
2435
  // ============================================================================
@@ -2512,6 +2555,8 @@ export const schema = (...a) => _dflt().schema(...a)
2512
2555
  export const lint = (...a) => _dflt().lint(...a)
2513
2556
  export const onboard = (...a) => _dflt().onboard(...a)
2514
2557
  export const onboardComplete = (...a) => _dflt().onboardComplete(...a)
2558
+ export const deploy = (...a) => _dflt().deploy(...a)
2559
+ export const destroy = (...a) => _dflt().destroy(...a)
2515
2560
  export const deployedModel = (...a) => _dflt().deployedModel(...a)
2516
2561
  export const runtimeModel = (...a) => _dflt().runtimeModel(...a)
2517
2562
  export const token = (...a) => _dflt().token(...a)