@synthigy/sdk 0.2.2 → 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 +10 -3
- package/package.json +2 -1
- package/src/browser-login.js +2 -2
- package/src/index.d.ts +189 -2
- package/src/index.js +307 -1
package/README.md
CHANGED
|
@@ -87,7 +87,7 @@ synthigy up
|
|
|
87
87
|
```
|
|
88
88
|
|
|
89
89
|
The first `up` prints a `/setup` link; open it and pick a database. (No
|
|
90
|
-
browser? `synthigy up --
|
|
90
|
+
browser? `synthigy up --bundle sqlite` skips the wizard.) Already have a server?
|
|
91
91
|
Skip this step.
|
|
92
92
|
|
|
93
93
|
**2. Deploy your data model** in the modeler (or from code with `client.deploy()`, as a
|
|
@@ -168,6 +168,7 @@ repo.
|
|
|
168
168
|
| Doc | What's in it |
|
|
169
169
|
|---|---|
|
|
170
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 |
|
|
171
172
|
| [CHANGELOG.md](./CHANGELOG.md) | Version history |
|
|
172
173
|
| [synthigy/examples: movies/js](https://github.com/synthigy/examples/tree/main/movies/js) | End-to-end Datastar BFF against the published package |
|
|
173
174
|
| [src/index.d.ts](./src/index.d.ts) | Full type definitions |
|
|
@@ -179,13 +180,19 @@ node --test 'test/*.test.js' # unit
|
|
|
179
180
|
SYNTHIGY_ENDPOINT=http://localhost:7887 \
|
|
180
181
|
SYNTHIGY_CLIENT_ID=… SYNTHIGY_CLIENT_SECRET=… \
|
|
181
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)
|
|
182
186
|
```
|
|
183
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
|
+
|
|
184
191
|
## License
|
|
185
192
|
|
|
186
193
|
MIT — see [LICENSE](LICENSE).
|
|
187
194
|
|
|
188
195
|
The SDKs are deliberately permissive: they are client libraries you embed in
|
|
189
|
-
your own application. The Synthigy **engine** is separate, and is
|
|
190
|
-
under the
|
|
196
|
+
your own application. The Synthigy **engine** is separate, and is source-available
|
|
197
|
+
under the Business Source License 1.1.
|
|
191
198
|
|
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,11 +493,17 @@ 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
|
|
|
327
509
|
/** `whoami()` — the identity a call runs as. `client` is null in-process (no OAuth client). */
|
|
@@ -724,6 +906,10 @@ export class SynthigyClient {
|
|
|
724
906
|
/** Fetch current subscription state from the server. */
|
|
725
907
|
subscriptions(): Promise<{ subscriptions: SubscriptionInfo[] }>
|
|
726
908
|
|
|
909
|
+
// ── Files — upload, download, versions ───────────────────────────────────
|
|
910
|
+
|
|
911
|
+
files: FilesApi
|
|
912
|
+
|
|
727
913
|
// ── History — temporal query surface ─────────────────────────────────────
|
|
728
914
|
|
|
729
915
|
history: {
|
|
@@ -1338,6 +1524,7 @@ export const whoami: SynthigyClient['whoami']
|
|
|
1338
1524
|
export const runtimeModel: SynthigyClient['runtimeModel']
|
|
1339
1525
|
export const token: SynthigyClient['token']
|
|
1340
1526
|
export function history(): SynthigyClient['history']
|
|
1527
|
+
export function files(): SynthigyClient['files']
|
|
1341
1528
|
export function login(): SynthigyClient['login']
|
|
1342
1529
|
|
|
1343
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
|
|
@@ -2101,6 +2137,275 @@ class SynthigyClient {
|
|
|
2101
2137
|
return body.result
|
|
2102
2138
|
}
|
|
2103
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
|
+
|
|
2104
2409
|
get login() {
|
|
2105
2410
|
if (!this._login) this._login = createLoginApi(this, { SynthigyError })
|
|
2106
2411
|
return this._login
|
|
@@ -2710,6 +3015,7 @@ export const whoami = (...a) => _dflt().whoami(...a)
|
|
|
2710
3015
|
export const runtimeModel = (...a) => _dflt().runtimeModel(...a)
|
|
2711
3016
|
export const token = (...a) => _dflt().token(...a)
|
|
2712
3017
|
export const history = () => _dflt().history
|
|
3018
|
+
export const files = () => _dflt().files
|
|
2713
3019
|
export const login = () => _dflt().login
|
|
2714
3020
|
|
|
2715
3021
|
// Streaming + live watches
|