@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 +13 -4
- package/codegen/codegen.mjs +3 -12
- package/codegen/pull.mjs +4 -4
- package/package.json +2 -1
- package/src/browser-login.js +2 -2
- package/src/index.d.ts +234 -3
- package/src/index.js +322 -1
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
|
|
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 --
|
|
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
|
|
188
|
-
under the
|
|
196
|
+
your own application. The Synthigy **engine** is separate, and is source-available
|
|
197
|
+
under the Business Source License 1.1.
|
|
189
198
|
|
package/codegen/codegen.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
25
|
-
|
|
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
|
|
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.
|
|
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": [
|
package/src/browser-login.js
CHANGED
|
@@ -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.
|
|
4
|
-
* tools
|
|
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
|
-
|
|
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
|