@synthigy/sdk 0.1.1 → 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
@@ -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
@@ -12,6 +12,7 @@ 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
  // typescript is an optional peer: the runtime client has no dependencies and
17
18
  // must stay that way, but generating .ts files needs the compiler — resolved
@@ -56,6 +57,7 @@ Environment:
56
57
  SYNTHIGY_ENDPOINT server URL (default: http://localhost:7887)
57
58
  SYNTHIGY_CLIENT_ID OAuth client ID
58
59
  SYNTHIGY_CLIENT_SECRET OAuth client secret
60
+ SYNTHIGY_AUDIENCE client_credentials audience, when the server requires one
59
61
 
60
62
  Examples:
61
63
  node codegen.mjs movies.xsql src/generated/ first run auto-pulls schema
@@ -136,7 +138,7 @@ const toKey = flags.keys === 'camel' ? camel : s => s
136
138
 
137
139
  // ── describe ─────────────────────────────────────────────────────────────────
138
140
  const ENDPOINT = process.env.SYNTHIGY_ENDPOINT ?? 'http://localhost:7887'
139
- const { SYNTHIGY_CLIENT_ID, SYNTHIGY_CLIENT_SECRET } = process.env
141
+ const { SYNTHIGY_CLIENT_ID, SYNTHIGY_CLIENT_SECRET, SYNTHIGY_AUDIENCE } = process.env
140
142
 
141
143
  // Auto-pull schema if missing or --pull requested (saved next to .xsql, cached for future runs).
142
144
  if (!flags.schemaOverride && (flags.pull || !fs.existsSync(schemaPath))) {
@@ -156,7 +158,11 @@ async function token() {
156
158
  method: 'POST',
157
159
  headers: { 'content-type': 'application/x-www-form-urlencoded' },
158
160
  body: new URLSearchParams({ grant_type: 'client_credentials',
159
- 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 }),
160
166
  })
161
167
  if (!r.ok) throw new Error(`token ${r.status}: ${await r.text()}`)
162
168
  return (await r.json()).access_token
@@ -206,7 +212,15 @@ if (cached && cached.sourceHash === sourceHash) {
206
212
  if (!resp.ok) throw new Error(`describe ${resp.status}: ${await resp.text()}`)
207
213
  const body = await resp.json()
208
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))
209
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)}`)
210
224
  } catch (e) {
211
225
  console.error(cached
212
226
  ? `sources changed since IR pull and describe failed (${e.message}) — start a backend (SYNTHIGY_ENDPOINT) or revert the .xsql edit`
@@ -251,7 +265,13 @@ fs.writeFileSync(irPath, JSON.stringify(ir, null, 2) + '\n')
251
265
  // Identity (namespace / name), @watch, and sql-template @returns results all come
252
266
  // from the IR now — the canonical XSQL parser owns them. No XSQL re-parsing here.
253
267
  const schema = JSON.parse(fs.readFileSync(schemaPath, 'utf8'))
254
- 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'
255
275
  const ent = (n) => schema.entities[n] ?? {}
256
276
  const visited = new Set()
257
277
 
@@ -341,7 +361,14 @@ for (const n of allEntities) nsOf(entityPascal(n))
341
361
  // never emitted from XSQL. Skip LOUDLY, never silently.
342
362
  const MUTATE_VERBS = new Set(['sync', 'stack', 'delete'])
343
363
  for (const op of ir.operations) {
344
- 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
+ }
345
372
  if (MUTATE_VERBS.has(op.op)) {
346
373
  console.warn(`skipped @${op.op} ${op.name} — mutations are generated from the schema; use ${entityPascal(op.entity ?? '<Entity>')}.${op.op}(data)`)
347
374
  continue
@@ -490,20 +517,42 @@ fs.writeFileSync(path.join(outDir, 'ops.ts'), OPS_HDR_COMMENT + formatTs(OPS_
490
517
  // type-check — never ship broken/stale output silently (the old emit-and-swallow
491
518
  // shipped a stale ops.js once). No .js emitted: consumers import the .ts source
492
519
  // (Node 23+ type-stripping / a bundler); the type-only `./schema.js` import erases
493
- // at runtime. `--ignoreConfig` so a consumer tsconfig can't trip TS5112.
494
- const tscBin = path.join(here, '..', 'node_modules', '.bin', 'tsc')
495
- try {
496
- execFileSync(tscBin, [
497
- '--ignoreConfig',
498
- '--module', 'nodenext', '--moduleResolution', 'nodenext',
499
- '--target', 'es2022', '--strict', '--skipLibCheck', '--noEmit',
500
- path.join(outDir, 'ops.ts'),
501
- path.join(outDir, 'schema.ts'),
502
- ], { stdio: 'pipe' })
503
- } catch (e) {
504
- process.stderr.write('codegen: generated TS failed to type-check (not shipped):\n')
505
- process.stderr.write((e.stdout ?? e.stderr ?? '').toString())
506
- 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')
507
556
  }
508
557
  const flagSummary = [flags.keys !== 'snake' && `keys=${flags.keys}`, !flags.writes && 'no-writes'].filter(Boolean).join(' ')
509
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.1",
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",
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)