@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 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 --db sqlite` skips the wizard.) Already have a server?
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 fair-code
190
- 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.
191
198
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@synthigy/sdk",
3
- "version": "0.2.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": [
@@ -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,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