@synthigy/sdk 0.2.1 → 0.2.3

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
@@ -60,7 +60,9 @@ Run it with `synthigy exec -- node hello.ts`.
60
60
  `watchSqlTemplate` for live analytics, `watch` for raw record events.
61
61
 
62
62
  - **`actingAs` per call.** Every read and write can run as a specific user, with
63
- IAM / RBAC / row-level security enforced *server-side*. Tokens never have to
63
+ IAM / RBAC / row-level security enforced *server-side*; `whoami({ actingAs })`
64
+ shows who a call runs as — roles, superuser, whether this client may act as
65
+ users. Tokens never have to
64
66
  reach the browser. `client.login` runs the OIDC authorization-code + PKCE
65
67
  exchange for your BFF and leaves sessions to you; `createBrowserLogin` does
66
68
  the same for a browser app, as a public client with silent renew and tokens
@@ -85,7 +87,7 @@ synthigy up
85
87
  ```
86
88
 
87
89
  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?
90
+ browser? `synthigy up --bundle sqlite` skips the wizard.) Already have a server?
89
91
  Skip this step.
90
92
 
91
93
  **2. Deploy your data model** in the modeler (or from code with `client.deploy()`, as a
@@ -166,6 +168,7 @@ repo.
166
168
  | Doc | What's in it |
167
169
  |---|---|
168
170
  | [XSQL.md](./docs/XSQL.md) | The query language: shapes, params, aggregates, op files, sql-templates |
171
+ | [FILES.md](./docs/FILES.md) | Upload, download, list, search, share and version files |
169
172
  | [CHANGELOG.md](./CHANGELOG.md) | Version history |
170
173
  | [synthigy/examples: movies/js](https://github.com/synthigy/examples/tree/main/movies/js) | End-to-end Datastar BFF against the published package |
171
174
  | [src/index.d.ts](./src/index.d.ts) | Full type definitions |
@@ -177,13 +180,19 @@ node --test 'test/*.test.js' # unit
177
180
  SYNTHIGY_ENDPOINT=http://localhost:7887 \
178
181
  SYNTHIGY_CLIENT_ID=… SYNTHIGY_CLIENT_SECRET=… \
179
182
  node --test 'test/integration.test.js' # integration (skips without a server)
183
+ SYNTHIGY_FILES_TEST_ENDPOINT=http://localhost:7887 \
184
+ SYNTHIGY_FILES_TEST_{ADMIN,USER,READER}_{ID,SECRET}=… \
185
+ npm run test:files # live files (skips without the endpoint)
180
186
  ```
181
187
 
188
+ The live files suite needs three client-credentials clients whose service users
189
+ hold File Admin, File User and File Reader. `npm test` never runs it.
190
+
182
191
  ## License
183
192
 
184
193
  MIT — see [LICENSE](LICENSE).
185
194
 
186
195
  The SDKs are deliberately permissive: they are client libraries you embed in
187
- your own application. The Synthigy **engine** is separate, and is fair-code
188
- under the Sustainable Use License.
196
+ your own application. The Synthigy **engine** is separate, and is source-available
197
+ under the Business Source License 1.1.
189
198
 
@@ -2,7 +2,6 @@
2
2
  // Unified codegen tool:
3
3
  // node codegen.mjs <xsql...> [outDir] — describe + gen TS (saves ops.ir.json)
4
4
  // node codegen.mjs check <xsql...> — re-describe, diff, exit 1 on drift
5
- // node codegen.mjs pull [endpoint] [out] — pull schema.json (wraps pull.mjs)
6
5
  //
7
6
  // Config via env: SYNTHIGY_ENDPOINT, SYNTHIGY_CLIENT_ID, SYNTHIGY_CLIENT_SECRET
8
7
 
@@ -29,10 +28,9 @@ synthigy-codegen — generate TypeScript types from XSQL operations
29
28
  Usage:
30
29
  codegen.mjs [flags] <a.xsql> [b.xsql ...] [outDir] generate TS from one or more xsql files
31
30
  codegen.mjs check [flags] <a.xsql> [b.xsql ...] re-describe, diff IR, exit 1 on drift
32
- codegen.mjs pull [endpoint] [out] pull schema.json only
33
31
 
34
32
  Flags:
35
- --pull force schema refresh before generating
33
+ --pull refresh schema.json beside the .xsql before generating
36
34
  --keys snake|camel field key casing in generated types (default: snake)
37
35
  --no-writes omit sync / stack / delete methods
38
36
  --schema <path> explicit schema.json path (skips auto-pull)
@@ -47,7 +45,6 @@ Examples:
47
45
  node codegen.mjs movies.xsql src/generated/ first run auto-pulls schema
48
46
  node codegen.mjs --pull movies.xsql src/generated/ force schema refresh + generate
49
47
  node codegen.mjs check movies.xsql CI drift check
50
- node codegen.mjs pull http://localhost:7887 pull schema only
51
48
  `.trim())
52
49
  process.exit(0)
53
50
  }
@@ -71,13 +68,6 @@ if (!VALID_KEYS.has(flags.keys)) {
71
68
  }
72
69
 
73
70
  const checkMode = pos[0] === 'check'
74
- const pullMode = pos[0] === 'pull'
75
-
76
- if (pullMode) {
77
- execFileSync(process.execPath, [path.join(here, 'pull.mjs'), ...pos.slice(1)],
78
- { stdio: 'inherit', env: process.env })
79
- process.exit(0)
80
- }
81
71
 
82
72
  // Positionals: last non-.xsql arg is outDir; everything before it (files or dirs)
83
73
  // is input. check mode has no outDir — every positional is input.
@@ -90,7 +80,6 @@ const xsqlArgs = hasExplicitOut ? rawPos.slice(0, -1) : rawPos
90
80
  if (!xsqlArgs.length) {
91
81
  console.error('usage: node codegen.mjs [flags] <file.xsql|dir> [...] [outDir] (dirs searched recursively)')
92
82
  console.error(' node codegen.mjs check <file.xsql|dir> [...]')
93
- console.error(' node codegen.mjs pull [endpoint] [schema.json]')
94
83
  console.error('flags: --pull force schema refresh')
95
84
  console.error(' --keys snake|camel field casing (default: snake)')
96
85
  console.error(' --no-writes omit sync/stack/delete methods')
@@ -99,6 +88,8 @@ if (!xsqlArgs.length) {
99
88
  }
100
89
 
101
90
  const isDir = p => fs.existsSync(p) && fs.statSync(p).isDirectory()
91
+ const missing = xsqlArgs.filter(a => !fs.existsSync(path.resolve(a)))
92
+ if (missing.length) { console.error(`no such .xsql file or directory: ${missing.join(', ')}`); process.exit(1) }
102
93
  const xsqlPaths = xsqlArgs.flatMap(a => {
103
94
  const p = path.resolve(a)
104
95
  if (isDir(p))
package/codegen/pull.mjs CHANGED
@@ -21,8 +21,9 @@ import path from 'node:path'
21
21
  import { createClient } from '../src/index.js'
22
22
 
23
23
  const endpoint = process.argv[2] ?? process.env.SYNTHIGY_ENDPOINT
24
- if (!endpoint) {
25
- console.error('usage: node pull.mjs <endpoint> (or set SYNTHIGY_ENDPOINT)')
24
+ const out = process.argv[3]
25
+ if (!endpoint || !out) {
26
+ console.error('usage: node pull.mjs <endpoint> <schema.json>')
26
27
  process.exit(1)
27
28
  }
28
29
 
@@ -41,8 +42,7 @@ if (SYNTHIGY_CLIENT_ID && SYNTHIGY_CLIENT_SECRET) {
41
42
  config.token = SYNTHIGY_TOKEN ?? '' // '' = authless (bare server)
42
43
  }
43
44
 
44
- const here = path.dirname(new URL(import.meta.url).pathname)
45
- const outPath = process.argv[3] ? path.resolve(process.argv[3]) : path.join(here, 'schema.json')
45
+ const outPath = path.resolve(out)
46
46
 
47
47
  const client = createClient(config)
48
48
  const schema = await client.schema()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@synthigy/sdk",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "description": "Zero-dependency client for Synthigy's /data endpoint",
5
5
  "author": "Robert Geršak",
6
6
  "license": "MIT",
@@ -37,6 +37,7 @@
37
37
  "test": "node --test test/*.test.js",
38
38
  "pretest:live": "npm run typecheck",
39
39
  "test:live": "node --test test/integration.test.js",
40
+ "test:files": "node --test test/files.live.test.js",
40
41
  "codegen": "node codegen/codegen.mjs"
41
42
  },
42
43
  "keywords": [
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Browser login as a PUBLIC client: authorization code + PKCE, silent renew
3
- * through a hidden iframe, tokens in memory only. The same flow Synthigy's own
4
- * tools use (the Clojure SDK's `synthigy.client.login`).
3
+ * through a hidden iframe, tokens in memory only. Full-page redirect only:
4
+ * Synthigy's own tools sign in by popup (the Clojure SDK's `synthigy.client.login`).
5
5
  *
6
6
  * The server-side, confidential flow is `client.login` (login.js). This one
7
7
  * holds no secret: the code exchange is authenticated by the PKCE verifier.
package/src/index.d.ts CHANGED
@@ -200,6 +200,182 @@ export interface HistorySinceOpts {
200
200
  track?: 'entity' | 'relation'
201
201
  }
202
202
 
203
+ // ============================================================================
204
+ // Files — bytes behind /files, rows behind /data
205
+ // ============================================================================
206
+
207
+ /** A file as the files endpoints answer it. */
208
+ export interface FileInfo {
209
+ xid: string
210
+ name: string
211
+ size: number
212
+ /** SHA-256 of the bytes, hex; also the download ETag. */
213
+ hash: string
214
+ status: 'READY' | 'UPLOADED' | 'FAILED' | 'PENDING' | string | null
215
+ content_type: string | null
216
+ uploaded_at: string | null
217
+ /** Who uploaded the current version. */
218
+ uploaded_by: { xid: string; name: string | null } | null
219
+ folder: { xid: string; name?: string; path?: string } | null
220
+ /** Loads without a token: its folder, or a folder above it, is Public. */
221
+ public: boolean
222
+ }
223
+
224
+ export interface FileVersion {
225
+ /** Pass to `download({ version })` or `restore`. */
226
+ id: number
227
+ at: string
228
+ /** Xid of the user who wrote this version. */
229
+ by: string | null
230
+ hash: string | null
231
+ size: number | null
232
+ content_type: string | null
233
+ current: boolean
234
+ }
235
+
236
+ /** Anything fetch can send: Blob/File, ArrayBuffer, typed array, string, or a stream. */
237
+ export type FileBody =
238
+ | Blob | ArrayBuffer | ArrayBufferView | string
239
+ | ReadableStream<Uint8Array> | AsyncIterable<Uint8Array>
240
+
241
+ /** A file by path ('/site/img/logo.png'), by xid, or the file itself. */
242
+ export type FileRef = string | { xid: string }
243
+
244
+ /** A folder by path ('/site/img/', '/' is ROOT), by xid, or the folder itself. */
245
+ export type FolderRef = string | { xid: string; path?: string }
246
+
247
+ export interface FolderInfo {
248
+ xid: string
249
+ name: string
250
+ path: string
251
+ /** Parent folder xid (ROOT's for a top-level folder); null only on ROOT itself. */
252
+ parent: string | null
253
+ /** Whether you may upload here, create subfolders, rename and share. */
254
+ writable: boolean
255
+ /** Files here load without a token: this folder's own Public, else the nearest folder above that has one. */
256
+ public: boolean
257
+ /** Seconds browsers and CDNs may keep the files, set the same way; null: they check every time. */
258
+ cache: number | null
259
+ /** What is set on this folder itself (null: inherited); `folder(path, settings)` writes these. */
260
+ settings: { public: boolean | null; cache: number | null }
261
+ }
262
+
263
+ export interface FilePutOpts {
264
+ /** Defaults to the Blob's type, else text/plain for strings, else application/octet-stream. */
265
+ contentType?: string
266
+ /** Refuse with FILE_CHANGED unless the stored hash still matches (a hash, or the file). */
267
+ ifMatch?: string | { hash: string }
268
+ signal?: AbortSignal
269
+ }
270
+
271
+ export type FileDownloadAs = 'blob' | 'arrayBuffer' | 'text' | 'json' | 'stream' | 'response'
272
+
273
+ export interface FileDownloadOpts<A extends FileDownloadAs = 'blob'> {
274
+ as?: A
275
+ /** Inclusive byte range; `[start]` reads to the end, `[undefined, n]` the last n bytes. */
276
+ range?: [number | undefined, number?] | { start?: number; end?: number }
277
+ /** A hash you already hold: answers null instead of the bytes when unchanged. */
278
+ ifNoneMatch?: string
279
+ /** A kept version (its id, or the version itself) instead of the current bytes. */
280
+ version?: number | { id: number }
281
+ signal?: AbortSignal
282
+ }
283
+
284
+ export type FileDownloadResult<A extends FileDownloadAs> =
285
+ A extends 'arrayBuffer' ? ArrayBuffer
286
+ : A extends 'text' ? string
287
+ : A extends 'json' ? unknown
288
+ : A extends 'stream' ? ReadableStream<Uint8Array> | null
289
+ : A extends 'response' ? Response
290
+ : Blob
291
+
292
+ export interface FileListOpts {
293
+ /** Names holding these words in order, any case; a space stands for anything between. */
294
+ q?: string
295
+ /** Files per page: default 100, at most 1000. */
296
+ limit?: number
297
+ offset?: number
298
+ }
299
+
300
+ export interface FileListing {
301
+ files: FileInfo[]
302
+ folders: FolderInfo[]
303
+ }
304
+
305
+ export interface FileSearchOpts {
306
+ /** Search only below this folder. */
307
+ folder?: FolderRef
308
+ /** Results per page: default 100, at most 1000. */
309
+ limit?: number
310
+ /** Skip this many results; with `truncated`, page through the rest. */
311
+ offset?: number
312
+ signal?: AbortSignal
313
+ }
314
+ export type FileSearchResult =
315
+ | (FolderInfo & { type: 'folder' })
316
+ /** `path` is null for a file in no folder or in a folder you cannot see; such a file matches by name. */
317
+ | (FileInfo & { type: 'file'; path: string | null })
318
+ export interface FileSearch {
319
+ /** Shortest path first. */
320
+ results: FileSearchResult[]
321
+ /** More results after this page. */
322
+ truncated: boolean
323
+ }
324
+ export interface FolderSettings {
325
+ /** Files here, and in folders below without their own value, load without a token; null: inherit. */
326
+ public?: boolean | null
327
+ /** Seconds browsers and CDNs may keep the files; null: inherit. */
328
+ cache?: number | null
329
+ }
330
+
331
+ export type FolderAccess = 'read' | 'write' | 'delete'
332
+
333
+ export type FolderAccessLists = Record<FolderAccess, {
334
+ users: { xid: string; name: string | null }[]
335
+ groups: { xid: string; name: string | null }[]
336
+ }>
337
+
338
+ /** Users and groups by xid, or the records. */
339
+ export interface Grantees {
340
+ users?: (string | { xid: string })[]
341
+ groups?: (string | { xid: string })[]
342
+ }
343
+
344
+ export interface FilesApi {
345
+ /** A path creates or replaces the file there (its folder must exist); an xid or file replaces it; `{ name }` makes a new file in no folder. */
346
+ put(target: string | { xid: string } | { name: string }, data: FileBody, opts?: FilePutOpts): Promise<FileInfo>
347
+ /** The bytes; null when `ifNoneMatch` still matches. */
348
+ download<A extends FileDownloadAs = 'blob'>(file: FileRef, opts?: FileDownloadOpts<A>): Promise<FileDownloadResult<A> | null>
349
+ /** The file, or null when it is not there or you can't see it. */
350
+ info(file: FileRef): Promise<FileInfo | null>
351
+ /** A folder's files and subfolders; `null`: files in no folder; nothing: every file you can see, newest first. */
352
+ list(folder?: FolderRef | null, opts?: FileListOpts): Promise<FileListing>
353
+ /** Folders and files whose paths hold the words of `q` in order ('site logo' finds /site/img/logo.png), shortest path first. */
354
+ search(q: string, opts?: FileSearchOpts): Promise<FileSearch>
355
+ /** Delete a file, or an empty folder (a path ending in / or the folder itself). */
356
+ delete(target: FileRef | FolderRef): Promise<void>
357
+ /** Where the file loads from, without a request; `inline` opens it in the browser instead of downloading. */
358
+ url(file: FileRef, opts?: { inline?: boolean }): string
359
+ /** Rename or move; for a file, a `to` ending in / keeps the name. */
360
+ move(from: FileRef | FolderRef, to: string): Promise<FileInfo | FolderInfo>
361
+ /** The folder at `path`, made with every missing folder above it; `settings` sets its Public and Cache. */
362
+ folder(path: string, settings?: FolderSettings): Promise<FolderInfo>
363
+ /** Give users and groups an access on a folder; writers of the folder may. */
364
+ share(folder: FolderRef, access: FolderAccess, to: Grantees): Promise<void>
365
+ /** Take an access away. */
366
+ unshare(folder: FolderRef, access: FolderAccess, from: Grantees): Promise<void>
367
+ /** Who holds read, write and delete. */
368
+ access(folder: FolderRef): Promise<FolderAccessLists>
369
+ /** The kept versions (the last ten), newest first. */
370
+ versions(file: FileRef): Promise<FileVersion[]>
371
+ /** Make a kept version current again (VERSION_EXPIRED when it is gone). */
372
+ restore(file: FileRef, version: number | { id: number }): Promise<FileInfo>
373
+ /** Browser: a blob: URL of the bytes, downloaded with your token; call revoke when done. */
374
+ objectUrl(file: FileRef, opts?: { signal?: AbortSignal }): Promise<{ url: string; revoke(): void }>
375
+ /** Server: answer a browser's request with the file (Range, If-None-Match, ETag and type pass through). */
376
+ serve(file: FileRef, request?: Request | { method?: string; headers?: Record<string, string> }, opts?: { inline?: boolean }): Promise<Response>
377
+ }
378
+
203
379
  // ============================================================================
204
380
  // Lint
205
381
  // ============================================================================
@@ -289,7 +465,7 @@ export interface Schema {
289
465
  * Schema resolution (typed throws ride along the engine's existing
290
466
  * walks; ex-`INTERNAL_ERROR`):
291
467
  * UNKNOWN_OP, UNKNOWN_ENTITY, UNKNOWN_ATTRIBUTE, UNKNOWN_OPERATOR,
292
- * MISSING_ON, MISSING_ROOT, XSQL_PARSE_ERROR,
468
+ * MISSING_ON, MISSING_ROOT, UNKNOWN_TREE_RELATION, XSQL_PARSE_ERROR,
293
469
  * PARAM_MISSING, PARAM_TYPE_MISMATCH
294
470
  *
295
471
  * DB:
@@ -317,18 +493,63 @@ export type SynthigyErrorCode =
317
493
  | 'INVALID_BODY' | 'NO_OPERATIONS' | 'BAD_OP' | 'MISSING_ENTITY'
318
494
  | 'BAD_ARGS_SHAPE'
319
495
  | 'UNKNOWN_OP' | 'UNKNOWN_ENTITY' | 'UNKNOWN_ATTRIBUTE' | 'UNKNOWN_OPERATOR'
320
- | 'MISSING_ON' | 'MISSING_ROOT' | 'XSQL_PARSE_ERROR'
496
+ | 'MISSING_ON' | 'MISSING_ROOT' | 'UNKNOWN_TREE_RELATION' | 'XSQL_PARSE_ERROR'
321
497
  | 'PARAM_MISSING' | 'PARAM_TYPE_MISMATCH'
322
498
  | 'FK_VIOLATION' | 'UNIQUE_VIOLATION' | 'NOT_NULL_VIOLATION'
323
499
  | 'CHECK_VIOLATION' | 'TYPE_CAST_FAILURE' | 'TIMEOUT'
324
500
  | 'HTTP_ERROR' | 'OPERATION_ERROR' | 'INTERNAL_ERROR'
501
+ | 'FILE_NOT_FOUND' | 'FOLDER_NOT_FOUND' | 'FILE_NAME_TAKEN' | 'FILE_CHANGED'
502
+ | 'FILE_NOT_READY' | 'FILE_TOO_LARGE' | 'NAME_REQUIRED' | 'UNSUPPORTED_MEDIA_TYPE'
503
+ | 'RANGE_NOT_SATISFIABLE' | 'FILES_UNAVAILABLE' | 'AUDIT_UNAVAILABLE'
504
+ | 'FOLDER_NOT_EMPTY' | 'FOLDER_NAME_TAKEN' | 'FOLDER_CYCLE' | 'INVALID_NAME' | 'INVALID_ACCESS'
505
+ | 'VERSION_EXPIRED' | 'INVALID_PATH' | 'INVALID_SETTINGS' | 'INVALID_QUERY'
506
+ | 'METHOD_NOT_ALLOWED' | 'NOT_FOUND' | 'ENGINE_OWNED' | 'INVALID_SLICE'
325
507
  | (string & {})
326
508
 
509
+ /** `whoami()` — the identity a call runs as. `client` is null in-process (no OAuth client). */
510
+ export interface WhoAmI {
511
+ xid: string | null
512
+ name: string | null
513
+ active: boolean
514
+ superuser: boolean
515
+ roles: string[]
516
+ groups: string[]
517
+ /** `trusted`: this client may act as users (confidential, active, flagged trusted). */
518
+ client: { id: string; trusted: boolean } | null
519
+ }
520
+
521
+ export type SynthigyErrorCategory =
522
+ | 'auth' | 'iam' | 'validation' | 'not_found'
523
+ | 'conflict' | 'rate_limit' | 'network' | 'internal'
524
+
525
+ /** 1-based source position (XSQL_PARSE_ERROR, TEMPLATE_*). */
526
+ export interface SourcePosition { line: number; col: number }
527
+
327
528
  export class SynthigyError extends Error {
328
529
  name: 'SynthigyError'
329
530
  code: SynthigyErrorCode
531
+ category: SynthigyErrorCategory
532
+ /** True for known network/rate_limit/internal codes, or when the server marked the error retryable. */
533
+ retryable: boolean
330
534
  details?: unknown
331
- constructor(message: string, code: string, details?: unknown)
535
+ hint?: string
536
+ available?: string[]
537
+ path?: unknown
538
+ entity?: string
539
+ relation?: string
540
+ operator?: string
541
+ requestId?: string
542
+ status?: number
543
+ line?: number
544
+ col?: number
545
+ start?: SourcePosition
546
+ end?: SourcePosition
547
+ diagnostics?: unknown[]
548
+ constructor(message: string, code: string, details?: unknown, extras?: {
549
+ hint?: string; available?: string[]; path?: unknown; entity?: string; relation?: string
550
+ operator?: string; requestId?: string; status?: number; retryable?: boolean
551
+ line?: number; col?: number; start?: SourcePosition; end?: SourcePosition; diagnostics?: unknown[]
552
+ })
332
553
  }
333
554
 
334
555
  // ============================================================================
@@ -685,6 +906,10 @@ export class SynthigyClient {
685
906
  /** Fetch current subscription state from the server. */
686
907
  subscriptions(): Promise<{ subscriptions: SubscriptionInfo[] }>
687
908
 
909
+ // ── Files — upload, download, versions ───────────────────────────────────
910
+
911
+ files: FilesApi
912
+
688
913
  // ── History — temporal query surface ─────────────────────────────────────
689
914
 
690
915
  history: {
@@ -789,6 +1014,9 @@ export class SynthigyClient {
789
1014
  */
790
1015
  deployedModel(opts?: ExecOpts): Promise<Record<string, unknown>>
791
1016
 
1017
+ /** Who a call runs as, after `actingAs` — roles, superuser, and whether this client may act as users. */
1018
+ whoami(opts?: ExecOpts): Promise<WhoAmI>
1019
+
792
1020
  /**
793
1021
  * Deploy a dataset version from a modeler export — pass the export file's
794
1022
  * contents verbatim, the server decodes it. Requires `dataset:deploy`.
@@ -1210,6 +1438,7 @@ export const op: {
1210
1438
  query(xsql: string, params?: Record<string, unknown>, verb?: string): Operation
1211
1439
  deployedModel(): Operation
1212
1440
  runtimeModel(): Operation
1441
+ whoami(): Operation
1213
1442
  deploy(exportContents: string): Operation
1214
1443
  destroy(datasetXid: string): Operation
1215
1444
  }
@@ -1291,9 +1520,11 @@ export const onboardComplete: SynthigyClient['onboardComplete']
1291
1520
  export const deploy: SynthigyClient['deploy']
1292
1521
  export const destroy: SynthigyClient['destroy']
1293
1522
  export const deployedModel: SynthigyClient['deployedModel']
1523
+ export const whoami: SynthigyClient['whoami']
1294
1524
  export const runtimeModel: SynthigyClient['runtimeModel']
1295
1525
  export const token: SynthigyClient['token']
1296
1526
  export function history(): SynthigyClient['history']
1527
+ export function files(): SynthigyClient['files']
1297
1528
  export function login(): SynthigyClient['login']
1298
1529
 
1299
1530
  export const listen: SynthigyClient['listen']
package/src/index.js CHANGED
@@ -125,7 +125,8 @@ export function rel(selections, args, alias) {
125
125
  * Join semantics are the SERVER's: absent `_join` is LEFT (a selection is
126
126
  * a projection and never drops parents; relation args filter the related
127
127
  * rows). The client injects nothing — pass `args: { _join: 'inner' }`
128
- * explicitly when the relation's existence should scope its parent.
128
+ * explicitly when the relation's existence should scope its parent, or
129
+ * `'none'` to keep only parents with no matching related row.
129
130
  */
130
131
  /**
131
132
  * Ensure an XSQL operation DOCUMENT (STRICT wire: XSQL travels only as
@@ -680,6 +681,12 @@ const ERROR_CATEGORIES = {
680
681
  CLAIM_INVALID: 'auth',
681
682
  NO_TOKEN: 'auth', // client-side: no token source configured — retrying can't fix it
682
683
  NO_ENDPOINT: 'validation', // client-side: no endpoint configured — retrying can't fix it
684
+ IMMUTABLE_VERSION_ALTERED: 'validation',
685
+ ENTITY_NAME_CONFLICT: 'validation',
686
+ FIELD_KEY_CONFLICT: 'validation',
687
+ SYSTEM_ENTITY_RLS: 'validation',
688
+ RLS_GUARD_BROKEN: 'validation',
689
+ VERSION_NAME_TAKEN: 'validation',
683
690
  NO_LOGIN_STORE: 'auth', // client-side: no login store registered — retrying can't fix it
684
691
  LOGIN_NONCE_MISMATCH: 'auth',
685
692
  LOGIN_EXCHANGE_FAILED: 'auth',
@@ -705,13 +712,18 @@ const ERROR_CATEGORIES = {
705
712
  CREATE_FORBIDDEN: 'iam',
706
713
  DELETE_FORBIDDEN: 'iam',
707
714
  SLOT_OCCUPIED: 'iam',
715
+ SYSTEM_DATASET_ENGINE_OWNED: 'iam',
716
+ SYSTEM_ENTITY_ALTERED: 'iam',
717
+ ENGINE_OWNED: 'iam',
708
718
  // validation
709
719
  INVALID_BODY: 'validation',
710
720
  NO_OPERATIONS: 'validation',
711
721
  UNKNOWN_OP: 'validation',
712
722
  UNKNOWN_OPERATOR: 'validation',
723
+ INVALID_JOIN: 'validation',
713
724
  MISSING_ON: 'validation',
714
725
  MISSING_ROOT: 'validation',
726
+ UNKNOWN_TREE_RELATION: 'validation',
715
727
  MISSING_RECORDS: 'validation',
716
728
  EMPTY_RECORDS: 'validation',
717
729
  MISSING_ENTITIES: 'validation',
@@ -743,14 +755,38 @@ const ERROR_CATEGORIES = {
743
755
  CLAIM_METHOD_NOT_ALLOWED: 'validation',
744
756
  PASSWORD_TOO_WEAK: 'validation',
745
757
  RETURN_URL_NOT_REGISTERED: 'validation',
758
+ NAME_REQUIRED: 'validation',
759
+ FILE_TOO_LARGE: 'validation',
760
+ UNSUPPORTED_MEDIA_TYPE: 'validation',
761
+ RANGE_NOT_SATISFIABLE: 'validation',
746
762
  // not_found
747
763
  UNKNOWN_ENTITY: 'not_found',
748
764
  UNKNOWN_RELATION: 'not_found',
749
765
  UNKNOWN_TEMPLATE_RELATION: 'not_found',
750
766
  HISTORY_UNAVAILABLE: 'not_found', // no audit provider — retrying can't fix it
767
+ NOT_FOUND: 'not_found',
768
+ FILE_NOT_FOUND: 'not_found',
769
+ FOLDER_NOT_FOUND: 'not_found',
770
+ FOLDER_NOT_EMPTY: 'conflict',
771
+ FOLDER_NAME_TAKEN: 'conflict',
772
+ FOLDER_CYCLE: 'conflict',
773
+ INVALID_NAME: 'validation',
774
+ INVALID_ACCESS: 'validation',
775
+ INVALID_PATH: 'validation',
776
+ INVALID_SETTINGS: 'validation',
777
+ INVALID_QUERY: 'validation',
778
+ METHOD_NOT_ALLOWED: 'validation',
779
+ INVALID_SLICE: 'validation',
780
+ FILES_UNAVAILABLE: 'not_found', // the files module is not running — retrying can't fix it
781
+ AUDIT_UNAVAILABLE: 'not_found', // file writes need audit history and none is running
751
782
  // conflict (e.g., DB constraint violations the server translates)
752
783
  FK_VIOLATION: 'conflict',
784
+ MODEL_BUSY: 'conflict',
753
785
  UNIQUE_VIOLATION: 'conflict',
786
+ FILE_NAME_TAKEN: 'conflict',
787
+ FILE_CHANGED: 'conflict',
788
+ FILE_NOT_READY: 'conflict',
789
+ VERSION_EXPIRED: 'not_found',
754
790
  CHECK_VIOLATION: 'conflict',
755
791
  NOT_NULL_VIOLATION: 'conflict',
756
792
  // rate / capacity
@@ -2003,6 +2039,19 @@ class SynthigyClient {
2003
2039
  return result.data
2004
2040
  }
2005
2041
 
2042
+ /**
2043
+ * Who a call runs as, after `actingAs`: xid, name, active, superuser, roles,
2044
+ * groups, and `client` ({ id, trusted }) — trusted means it may act as users.
2045
+ *
2046
+ * @param {Object} [opts] - Options ({ actingAs })
2047
+ * @returns {Promise<Object>}
2048
+ */
2049
+ async whoami(opts) {
2050
+ const [result] = await this.exec([{ op: 'whoami' }], opts)
2051
+ if (!result.ok) throw _errorFromServer(result.error, undefined, result._requestId)
2052
+ return result.data
2053
+ }
2054
+
2006
2055
  /**
2007
2056
  * Deploy a dataset version from a modeler export. Pass the export file's
2008
2057
  * contents verbatim — the server decodes it. Requires `dataset:deploy`.
@@ -2088,6 +2137,275 @@ class SynthigyClient {
2088
2137
  return body.result
2089
2138
  }
2090
2139
 
2140
+ /** @private */
2141
+ async _filesFetch(path, fetchOpts = {}) {
2142
+ const resp = await this._fetchAuth(`${this._endpoint}/files${path}`, fetchOpts)
2143
+ if (resp.ok || resp.status === 304) return resp
2144
+ const requestId = resp.headers?.get?.('x-request-id') ?? undefined
2145
+ if (resp.status === 416) {
2146
+ throw new SynthigyError('Range not satisfiable', 'RANGE_NOT_SATISFIABLE', undefined,
2147
+ { requestId, status: 416 })
2148
+ }
2149
+ const text = await resp.text().catch(() => '')
2150
+ let data
2151
+ try { data = JSON.parse(text) } catch { /* not JSON */ }
2152
+ if (data?.error?.code === 'NOT_FOUND' || (resp.status === 404 && !data?.error)) {
2153
+ throw new SynthigyError('Files are not available on this server — is the files module running?',
2154
+ 'FILES_UNAVAILABLE', undefined, { requestId, status: resp.status })
2155
+ }
2156
+ if (data?.error) throw _errorFromServer(data.error, resp.status, requestId)
2157
+ throw new SynthigyError(`Files request failed (${resp.status})`, 'HTTP_ERROR', text,
2158
+ { requestId, status: resp.status })
2159
+ }
2160
+
2161
+ // Bytes live behind /files, their rows behind /data: `files` speaks both.
2162
+ get files() {
2163
+ if (!this._files) {
2164
+ const self = this
2165
+ const body = (data, contentType) => {
2166
+ const type = contentType
2167
+ ?? (typeof Blob !== 'undefined' && data instanceof Blob && data.type ? data.type : undefined)
2168
+ ?? (typeof data === 'string' ? 'text/plain; charset=utf-8' : 'application/octet-stream')
2169
+ if (/^application\/x-www-form-urlencoded/i.test(type)) {
2170
+ throw new SynthigyError('a form-encoded body is parsed as a form, not stored — send the bytes with their own type',
2171
+ 'UNSUPPORTED_MEDIA_TYPE')
2172
+ }
2173
+ const streaming = (typeof ReadableStream !== 'undefined' && data instanceof ReadableStream)
2174
+ || (data && typeof data[Symbol.asyncIterator] === 'function' && !(typeof Blob !== 'undefined' && data instanceof Blob))
2175
+ return {
2176
+ headers: { 'Content-Type': type },
2177
+ body: data,
2178
+ ...(streaming && { duplex: 'half' }),
2179
+ }
2180
+ }
2181
+ const isPath = (x) => typeof x === 'string' && x.startsWith('/')
2182
+ const xidOf = (x) => (typeof x === 'string' ? x : x?.xid)
2183
+ const isFolder = (x) => (isPath(x) && x.endsWith('/'))
2184
+ || (x !== null && typeof x === 'object' && typeof x.path === 'string' && x.path.endsWith('/'))
2185
+ const folderRef = (x) => (typeof x === 'string' ? x : (x?.path ?? x?.xid))
2186
+ const query = (params) => {
2187
+ const q = new URLSearchParams()
2188
+ for (const [k, v] of Object.entries(params)) {
2189
+ if (v !== undefined && v !== null && v !== '') q.set(k, String(v))
2190
+ }
2191
+ const s = q.toString()
2192
+ return s ? `?${s}` : ''
2193
+ }
2194
+ const encodePath = (p) => p.split('/').map((part) => encodeURIComponent(part)
2195
+ .replace(/[!'()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`)).join('/')
2196
+ const fileAt = (ref, tail = '') => (isPath(ref) ? `${tail}${query({ path: ref })}` : `/${encodeURIComponent(xidOf(ref))}${tail}`)
2197
+ const folderAt = (ref, tail = '') => {
2198
+ const r = folderRef(ref)
2199
+ return isPath(r) ? `/folders${tail}${query({ path: r })}` : `/folders/${encodeURIComponent(r)}${tail}`
2200
+ }
2201
+ const json = async (path, method, payload) => {
2202
+ const resp = await self._filesFetch(path, {
2203
+ method,
2204
+ ...(payload !== undefined && { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }),
2205
+ })
2206
+ return resp.status === 204 ? undefined : resp.json()
2207
+ }
2208
+ const read = async (resp, as) => {
2209
+ switch (as) {
2210
+ case 'response': return resp
2211
+ case 'stream': return resp.body
2212
+ case 'arrayBuffer': return resp.arrayBuffer()
2213
+ case 'text': return resp.text()
2214
+ case 'json': return resp.json()
2215
+ default: return resp.blob()
2216
+ }
2217
+ }
2218
+ const xidAt = async (ref) => {
2219
+ if (!isPath(ref)) return xidOf(ref)
2220
+ const file = await self.files.info(ref)
2221
+ if (!file) throw new SynthigyError(`No such file: ${ref}`, 'FILE_NOT_FOUND', undefined, { status: 404 })
2222
+ return file.xid
2223
+ }
2224
+ const grantees = (who = {}) => ({ users: (who.users ?? []).map(xidOf), groups: (who.groups ?? []).map(xidOf) })
2225
+ this._files = {
2226
+ /**
2227
+ * Store bytes. A path ('/a/b/name', the folder must exist) creates the
2228
+ * file or replaces it; an xid or the file replaces that file;
2229
+ * `{ name }` makes a new file in no folder.
2230
+ */
2231
+ async put(target, data, opts = {}) {
2232
+ const { headers, ...rest } = body(data, opts.contentType)
2233
+ const expected = typeof opts.ifMatch === 'string' ? opts.ifMatch : opts.ifMatch?.hash
2234
+ let method = 'PUT'
2235
+ let path
2236
+ if (isPath(target)) path = query({ path: target })
2237
+ else if (xidOf(target)) path = `/${encodeURIComponent(xidOf(target))}`
2238
+ else if (target?.name) { method = 'POST'; path = query({ name: target.name }) }
2239
+ else throw new SynthigyError('put takes a path, an xid or file, or { name } for a file in no folder', 'INVALID_PATH')
2240
+ const resp = await self._filesFetch(path, {
2241
+ method, signal: opts.signal, ...rest,
2242
+ headers: { ...headers, ...(expected && { 'If-Match': `"${expected}"` }) },
2243
+ })
2244
+ return resp.json()
2245
+ },
2246
+
2247
+ /**
2248
+ * The bytes of a file; `as` picks the shape (default blob), `range:
2249
+ * [start, end]` (inclusive) reads part of it, `version` a kept one.
2250
+ */
2251
+ async download(ref, opts = {}) {
2252
+ const headers = {}
2253
+ if (opts.range) {
2254
+ const [start, end] = Array.isArray(opts.range) ? opts.range : [opts.range.start, opts.range.end]
2255
+ headers.Range = `bytes=${start ?? ''}-${end ?? ''}`
2256
+ }
2257
+ if (opts.ifNoneMatch) headers['If-None-Match'] = `"${opts.ifNoneMatch}"`
2258
+ const path = opts.version === undefined
2259
+ ? fileAt(ref)
2260
+ : `/${encodeURIComponent(await xidAt(ref))}/versions/${encodeURIComponent(opts.version?.id ?? opts.version)}`
2261
+ const resp = await self._filesFetch(path, { method: 'GET', headers, signal: opts.signal })
2262
+ if (resp.status === 304) return null
2263
+ return read(resp, opts.as)
2264
+ },
2265
+
2266
+ /** The file, or null when it is not there or you cannot see it. */
2267
+ async info(ref) {
2268
+ try {
2269
+ return await (await self._filesFetch(fileAt(ref, '/info'), { method: 'GET' })).json()
2270
+ } catch (err) {
2271
+ if (err instanceof SynthigyError && (err.code === 'FILE_NOT_FOUND' || err.code === 'FOLDER_NOT_FOUND')) return null
2272
+ throw err
2273
+ }
2274
+ },
2275
+
2276
+ /**
2277
+ * A folder's files and subfolders. `null`: the files in no folder;
2278
+ * nothing: every file you can see, newest first (with `q`, a search).
2279
+ */
2280
+ async list(folder, opts = {}) {
2281
+ const page = { q: opts.q, limit: opts.limit, offset: opts.offset }
2282
+ const files = async (params) => (await (await self._filesFetch(query(params), { method: 'GET' })).json()).files
2283
+ if (folder === undefined) return { files: await files(page), folders: [] }
2284
+ if (folder === null) return { files: await files({ ...page, folderless: 'true' }), folders: [] }
2285
+ const ref = folderRef(folder)
2286
+ const [found, folders] = await Promise.all([
2287
+ files({ ...page, folder: ref }),
2288
+ self._filesFetch(`/folders${query({ parent: ref })}`, { method: 'GET' }).then((r) => r.json()).then((b) => b.folders),
2289
+ ])
2290
+ const words = (opts.q ?? '').trim().split(/\s+/).filter(Boolean)
2291
+ const holds = new RegExp(words.map((w) => w.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('.*'), 'i')
2292
+ return { files: found, folders: words.length ? folders.filter((f) => holds.test(f.name)) : folders }
2293
+ },
2294
+
2295
+ /**
2296
+ * Folders and files whose paths hold the words of `q` in order,
2297
+ * shortest path first; `folder` keeps it below that folder.
2298
+ */
2299
+ async search(q, opts = {}) {
2300
+ const params = { q, folder: opts.folder === undefined ? undefined : folderRef(opts.folder), limit: opts.limit, offset: opts.offset }
2301
+ return (await self._filesFetch(`/search${query(params)}`, { method: 'GET', signal: opts.signal })).json()
2302
+ },
2303
+
2304
+ /** Delete a file, or an empty folder (a path ending in / or the folder itself). */
2305
+ async delete(ref) {
2306
+ await self._filesFetch(isFolder(ref) ? folderAt(ref) : fileAt(ref), { method: 'DELETE' })
2307
+ },
2308
+
2309
+ /**
2310
+ * Where a file loads from, without a request: /files/<xid>, or
2311
+ * /files/path/... for a path. `inline` opens it in the browser
2312
+ * instead of downloading it.
2313
+ */
2314
+ url(ref, opts = {}) {
2315
+ let u
2316
+ if (isPath(ref)) {
2317
+ if (ref.endsWith('/')) throw new SynthigyError('url takes a file, not a folder', 'INVALID_PATH')
2318
+ u = `${self._endpoint}/files/path${encodePath(ref)}`
2319
+ } else {
2320
+ u = `${self._endpoint}/files/${encodeURIComponent(xidOf(ref))}`
2321
+ }
2322
+ return opts.inline ? `${u}?disposition=inline` : u
2323
+ },
2324
+
2325
+ /**
2326
+ * Rename or move. A file goes to `to` ('/a/new.png'), or into a folder
2327
+ * when `to` ends in / (the name stays); a folder's `to` is its new path.
2328
+ */
2329
+ async move(from, to) {
2330
+ if (!isPath(to)) throw new SynthigyError('move takes the new path, for example /site/brand/logo.png', 'INVALID_PATH')
2331
+ if (isFolder(from)) {
2332
+ const parts = to.split('/').filter(Boolean)
2333
+ const name = parts.pop()
2334
+ if (!name) throw new SynthigyError('a folder moves to a path like /archive/2026/', 'INVALID_PATH')
2335
+ return json(folderAt(from), 'PATCH', { name, parent: parts.length ? `/${parts.join('/')}/` : '/' })
2336
+ }
2337
+ const cut = to.lastIndexOf('/')
2338
+ return json(fileAt(from), 'PATCH',
2339
+ to.endsWith('/') ? { folder: to } : { folder: to.slice(0, cut + 1), name: to.slice(cut + 1) })
2340
+ },
2341
+
2342
+ /** The folder at `path`, made with every missing folder above it; `settings` sets its Public and Cache. */
2343
+ async folder(path, settings) {
2344
+ return json(`/folders${query({ path })}`, 'PUT', settings ?? {})
2345
+ },
2346
+
2347
+ /** Give users and groups (xids or records) an access on a folder; writers of the folder may. */
2348
+ async share(folder, access, to) {
2349
+ await json(folderAt(folder, '/share'), 'POST', { access, ...grantees(to) })
2350
+ },
2351
+
2352
+ /** Take an access on a folder away from users and groups. */
2353
+ async unshare(folder, access, from) {
2354
+ await json(folderAt(folder, '/unshare'), 'POST', { access, ...grantees(from) })
2355
+ },
2356
+
2357
+ /** Who holds read, write and delete on a folder. */
2358
+ async access(folder) {
2359
+ return (await self._filesFetch(folderAt(folder, '/access'), { method: 'GET' })).json()
2360
+ },
2361
+
2362
+ /** The kept versions (the last ten), newest first. */
2363
+ async versions(ref) {
2364
+ return (await (await self._filesFetch(`/${encodeURIComponent(await xidAt(ref))}/versions`, { method: 'GET' })).json()).versions
2365
+ },
2366
+
2367
+ /** Make a kept version current again; the bytes are not copied. */
2368
+ async restore(ref, version) {
2369
+ const id = version?.id ?? version
2370
+ return (await self._filesFetch(`/${encodeURIComponent(await xidAt(ref))}/versions/${encodeURIComponent(id)}/restore`,
2371
+ { method: 'POST' })).json()
2372
+ },
2373
+
2374
+ /** Browser: download with your token and get a blob: URL for <img> or <iframe>; call revoke when done. */
2375
+ async objectUrl(ref, opts = {}) {
2376
+ const blob = await self.files.download(ref, { as: 'blob', signal: opts.signal })
2377
+ const url = URL.createObjectURL(blob)
2378
+ return { url, revoke: () => URL.revokeObjectURL(url) }
2379
+ },
2380
+
2381
+ /**
2382
+ * Server: answer a browser's request with the file. Range, If-None-Match
2383
+ * and If-Range go through, and the answer keeps status, type, length,
2384
+ * ETag and name; a refusal comes back as the engine's own answer.
2385
+ */
2386
+ async serve(ref, request, opts = {}) {
2387
+ const headers = {}
2388
+ for (const k of ['range', 'if-none-match', 'if-range']) {
2389
+ const v = typeof request?.headers?.get === 'function' ? request.headers.get(k) : request?.headers?.[k]
2390
+ if (v) headers[k] = v
2391
+ }
2392
+ const path = fileAt(ref)
2393
+ const url = `${self._endpoint}/files${path}${opts.inline ? `${path.includes('?') ? '&' : '?'}disposition=inline` : ''}`
2394
+ const resp = await self._fetchAuth(url, { method: request?.method === 'HEAD' ? 'HEAD' : 'GET', headers },
2395
+ { applyTimeout: false })
2396
+ const out = new Headers()
2397
+ for (const k of ['content-type', 'content-length', 'content-range', 'accept-ranges', 'etag',
2398
+ 'content-disposition', 'cache-control', 'x-content-type-options', 'content-security-policy']) {
2399
+ const v = resp.headers.get(k)
2400
+ if (v) out.set(k, v)
2401
+ }
2402
+ return new Response(resp.body, { status: resp.status, headers: out })
2403
+ },
2404
+ }
2405
+ }
2406
+ return this._files
2407
+ }
2408
+
2091
2409
  get login() {
2092
2410
  if (!this._login) this._login = createLoginApi(this, { SynthigyError })
2093
2411
  return this._login
@@ -2561,6 +2879,7 @@ export const op = {
2561
2879
  query: (xsql, params, verb = 'search') => ({ op: 'xsql', xsql: xsqlDocument(xsql, verb), ...(params !== undefined && { params }) }),
2562
2880
  deployedModel: () => ({ op: 'deployed-model' }),
2563
2881
  runtimeModel: () => ({ op: 'runtime-model' }),
2882
+ whoami: () => ({ op: 'whoami' }),
2564
2883
  deploy: (exportContents) => ({ op: 'deploy', data: exportContents }),
2565
2884
  destroy: (datasetXid) => ({ op: 'delete', entity: 'dataset', data: { xid: datasetXid } }),
2566
2885
  }
@@ -2692,9 +3011,11 @@ export const onboardComplete = (...a) => _dflt().onboardComplete(...a)
2692
3011
  export const deploy = (...a) => _dflt().deploy(...a)
2693
3012
  export const destroy = (...a) => _dflt().destroy(...a)
2694
3013
  export const deployedModel = (...a) => _dflt().deployedModel(...a)
3014
+ export const whoami = (...a) => _dflt().whoami(...a)
2695
3015
  export const runtimeModel = (...a) => _dflt().runtimeModel(...a)
2696
3016
  export const token = (...a) => _dflt().token(...a)
2697
3017
  export const history = () => _dflt().history
3018
+ export const files = () => _dflt().files
2698
3019
  export const login = () => _dflt().login
2699
3020
 
2700
3021
  // Streaming + live watches