@howells/stow-server 0.1.1 → 0.2.0

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/dist/index.mjs CHANGED
@@ -1,4 +1,5 @@
1
1
  // src/index.ts
2
+ import { createHash } from "crypto";
2
3
  import { z } from "zod";
3
4
  var StowError = class extends Error {
4
5
  status;
@@ -10,12 +11,74 @@ var StowError = class extends Error {
10
11
  this.code = code;
11
12
  }
12
13
  };
14
+ var fileColorSchema = z.object({
15
+ position: z.number().int(),
16
+ proportion: z.number(),
17
+ hex: z.string(),
18
+ name: z.string().nullable(),
19
+ hsl: z.object({ h: z.number(), s: z.number(), l: z.number() }),
20
+ oklab: z.object({ L: z.number(), a: z.number(), b: z.number() }).nullable(),
21
+ oklch: z.object({ l: z.number(), c: z.number(), h: z.number() }).nullable()
22
+ });
23
+ var fileColorProfileSchema = z.object({
24
+ palette: z.object({
25
+ mood: z.string(),
26
+ brightness: z.number(),
27
+ temperature: z.number(),
28
+ vibrancy: z.number(),
29
+ complexity: z.number(),
30
+ dominantFamily: z.string().nullable()
31
+ }),
32
+ backgroundHex: z.string().nullable(),
33
+ accent: z.object({
34
+ hex: z.string(),
35
+ name: z.string().nullable(),
36
+ oklab: z.object({ L: z.number(), a: z.number(), b: z.number() }).nullable(),
37
+ oklch: z.object({ l: z.number(), c: z.number(), h: z.number() }).nullable()
38
+ }).nullable(),
39
+ extractedAt: z.string(),
40
+ colorCount: z.number().int()
41
+ });
13
42
  var uploadResultSchema = z.object({
14
43
  key: z.string(),
15
44
  url: z.string().nullable(),
16
45
  size: z.number(),
17
46
  contentType: z.string().optional(),
18
- metadata: z.record(z.string(), z.string()).optional()
47
+ metadata: z.record(z.string(), z.string()).optional(),
48
+ deduped: z.boolean().optional()
49
+ });
50
+ var bucketSchema = z.object({
51
+ id: z.string(),
52
+ name: z.string(),
53
+ description: z.string().nullable().optional(),
54
+ isPublic: z.boolean().optional(),
55
+ searchable: z.boolean().optional(),
56
+ allowedTypes: z.array(z.string()).nullable().optional(),
57
+ maxFileSize: z.coerce.number().nullable().optional(),
58
+ storageQuota: z.coerce.number().nullable().optional(),
59
+ fileCountLimit: z.coerce.number().nullable().optional(),
60
+ fileCount: z.coerce.number().optional(),
61
+ usageBytes: z.coerce.number().optional(),
62
+ createdAt: z.string().optional()
63
+ });
64
+ var listBucketsSchema = z.object({
65
+ buckets: z.array(bucketSchema)
66
+ });
67
+ var bucketResponseSchema = z.object({
68
+ bucket: bucketSchema
69
+ });
70
+ var whoamiSchema = z.object({
71
+ user: z.object({ email: z.string() }),
72
+ stats: z.object({
73
+ totalBytes: z.coerce.number(),
74
+ totalFiles: z.coerce.number(),
75
+ bucketCount: z.coerce.number()
76
+ }),
77
+ key: z.object({
78
+ name: z.string(),
79
+ scope: z.string(),
80
+ permissions: z.record(z.string(), z.boolean())
81
+ }).optional()
19
82
  });
20
83
  var listFilesSchema = z.object({
21
84
  files: z.array(
@@ -24,11 +87,26 @@ var listFilesSchema = z.object({
24
87
  size: z.number(),
25
88
  lastModified: z.string(),
26
89
  url: z.string().nullable(),
27
- metadata: z.record(z.string(), z.string()).optional()
90
+ width: z.number().nullable().optional(),
91
+ height: z.number().nullable().optional(),
92
+ duration: z.number().nullable().optional(),
93
+ metadata: z.record(z.string(), z.string()).optional(),
94
+ colorProfile: fileColorProfileSchema.nullable().optional(),
95
+ colors: z.array(fileColorSchema).optional()
28
96
  })
29
97
  ),
30
98
  nextCursor: z.string().nullable()
31
99
  });
100
+ var reprocessResultSchema = z.object({
101
+ key: z.string(),
102
+ triggered: z.array(z.string())
103
+ });
104
+ var replaceResultSchema = z.object({
105
+ key: z.string(),
106
+ size: z.number(),
107
+ contentType: z.string(),
108
+ triggered: z.array(z.string())
109
+ });
32
110
  var errorSchema = z.object({
33
111
  error: z.string(),
34
112
  code: z.string().optional()
@@ -53,12 +131,23 @@ var listDropsSchema = z.object({
53
131
  drops: z.array(dropSchema),
54
132
  usage: z.object({ bytes: z.number(), limit: z.number() })
55
133
  });
56
- var presignResultSchema = z.object({
134
+ var presignNewResultSchema = z.object({
57
135
  fileKey: z.string(),
58
136
  uploadUrl: z.string(),
59
- r2Key: z.string(),
60
- confirmUrl: z.string()
137
+ confirmUrl: z.string(),
138
+ dedupe: z.literal(false).optional()
61
139
  });
140
+ var presignDedupeResultSchema = z.object({
141
+ dedupe: z.literal(true),
142
+ key: z.string(),
143
+ url: z.string().nullable(),
144
+ size: z.number(),
145
+ contentType: z.string()
146
+ });
147
+ var presignResultSchema = z.union([
148
+ presignDedupeResultSchema,
149
+ presignNewResultSchema
150
+ ]);
62
151
  var confirmResultSchema = z.object({
63
152
  key: z.string(),
64
153
  url: z.string().nullable(),
@@ -66,18 +155,93 @@ var confirmResultSchema = z.object({
66
155
  contentType: z.string(),
67
156
  metadata: z.record(z.string(), z.string()).optional()
68
157
  });
158
+ var fileResultSchema = z.object({
159
+ key: z.string(),
160
+ size: z.number(),
161
+ contentType: z.string(),
162
+ url: z.string().nullable(),
163
+ width: z.number().nullable(),
164
+ height: z.number().nullable(),
165
+ duration: z.number().nullable(),
166
+ metadata: z.record(z.string(), z.string()).nullable(),
167
+ colorProfile: fileColorProfileSchema.nullable(),
168
+ colors: z.array(fileColorSchema),
169
+ embeddingStatus: z.string().nullable(),
170
+ createdAt: z.string()
171
+ });
172
+ var profileClusterResultSchema = z.object({
173
+ id: z.string(),
174
+ index: z.number().int(),
175
+ name: z.string().nullable(),
176
+ description: z.string().nullable(),
177
+ signalCount: z.number().int(),
178
+ totalWeight: z.number(),
179
+ nameGeneratedAt: z.string().nullable()
180
+ });
181
+ var profileResultSchema = z.object({
182
+ id: z.string(),
183
+ name: z.string().nullable(),
184
+ fileCount: z.number(),
185
+ signalCount: z.number(),
186
+ vector: z.array(z.number()).nullable(),
187
+ clusters: z.array(profileClusterResultSchema).optional(),
188
+ createdAt: z.string(),
189
+ updatedAt: z.string()
190
+ });
191
+ var profileFilesResultSchema = z.object({
192
+ id: z.string(),
193
+ fileCount: z.number()
194
+ });
195
+ var profileSignalResultSchema = z.object({
196
+ id: z.string(),
197
+ fileKey: z.string(),
198
+ type: z.enum([
199
+ "view",
200
+ "view_long",
201
+ "click",
202
+ "like",
203
+ "save",
204
+ "choose",
205
+ "purchase",
206
+ "share",
207
+ "dismiss",
208
+ "skip",
209
+ "reject",
210
+ "report",
211
+ "custom"
212
+ ]),
213
+ weight: z.number()
214
+ });
215
+ var profileSignalsResponseSchema = z.object({
216
+ profileId: z.string(),
217
+ signals: z.array(profileSignalResultSchema),
218
+ totalSignals: z.number(),
219
+ vectorUpdated: z.boolean()
220
+ });
221
+ var deleteProfileSignalsResponseSchema = z.object({
222
+ profileId: z.string(),
223
+ removed: z.number(),
224
+ totalSignals: z.number(),
225
+ vectorUpdated: z.boolean()
226
+ });
69
227
  var StowServer = class {
70
228
  apiKey;
71
229
  baseUrl;
72
230
  bucket;
231
+ timeout;
232
+ retries;
73
233
  constructor(config) {
74
234
  if (typeof config === "string") {
75
235
  this.apiKey = config;
76
236
  this.baseUrl = "https://app.stow.sh";
237
+ this.timeout = 3e4;
238
+ this.retries = 3;
77
239
  } else {
78
240
  this.apiKey = config.apiKey;
79
241
  this.baseUrl = config.baseUrl || "https://app.stow.sh";
80
242
  this.bucket = config.bucket;
243
+ this.timeout = config.timeout ?? 3e4;
244
+ this.retries = config.retries ?? 3;
81
245
  }
82
246
  }
83
247
  /**
@@ -86,6 +250,88 @@ var StowServer = class {
86
250
  getBaseUrl() {
87
251
  return this.baseUrl;
88
252
  }
253
+ /**
254
+ * Return account usage and API key info for the current credential.
255
+ */
256
+ whoami() {
257
+ return this.request("/api/whoami", { method: "GET" }, whoamiSchema);
258
+ }
259
+ /**
260
+ * List all buckets available to the current organization.
261
+ */
262
+ listBuckets() {
263
+ return this.request("/api/buckets", { method: "GET" }, listBucketsSchema);
264
+ }
265
+ /**
266
+ * Create a new bucket.
267
+ */
268
+ async createBucket(request) {
269
+ const result = await this.request(
270
+ "/api/buckets",
271
+ {
272
+ method: "POST",
273
+ headers: { "Content-Type": "application/json" },
274
+ body: JSON.stringify(request)
275
+ },
276
+ bucketResponseSchema
277
+ );
278
+ return result.bucket;
279
+ }
280
+ /**
281
+ * Get bucket details by id.
282
+ */
283
+ async getBucket(id) {
284
+ const result = await this.request(
285
+ `/api/buckets/${encodeURIComponent(id)}`,
286
+ { method: "GET" },
287
+ bucketResponseSchema
288
+ );
289
+ return result.bucket;
290
+ }
291
+ /**
292
+ * Update bucket settings by id.
293
+ */
294
+ async updateBucket(id, updates) {
295
+ const result = await this.request(
296
+ `/api/buckets/${encodeURIComponent(id)}`,
297
+ {
298
+ method: "PATCH",
299
+ headers: { "Content-Type": "application/json" },
300
+ body: JSON.stringify(updates)
301
+ },
302
+ bucketResponseSchema
303
+ );
304
+ return result.bucket;
305
+ }
306
+ /**
307
+ * Rename/update a bucket by current bucket name.
308
+ */
309
+ async updateBucketByName(name, updates) {
310
+ const result = await this.request(
311
+ `/api/buckets/_?bucket=${encodeURIComponent(name)}`,
312
+ {
313
+ method: "PATCH",
314
+ headers: { "Content-Type": "application/json" },
315
+ body: JSON.stringify(updates)
316
+ },
317
+ bucketResponseSchema
318
+ );
319
+ return result.bucket;
320
+ }
321
+ /**
322
+ * Rename a bucket by current bucket name.
323
+ */
324
+ renameBucket(name, newName) {
325
+ return this.updateBucketByName(name, { name: newName });
326
+ }
327
+ /**
328
+ * Delete a bucket by id.
329
+ */
330
+ async deleteBucket(id) {
331
+ await this.request(`/api/buckets/${encodeURIComponent(id)}`, {
332
+ method: "DELETE"
333
+ });
334
+ }
89
335
  /**
90
336
  * Resolve the effective bucket for this request.
91
337
  * Per-call override > constructor default.
@@ -105,55 +351,131 @@ var StowServer = class {
105
351
  return `${path}${sep}bucket=${encodeURIComponent(b)}`;
106
352
  }
107
353
  /**
108
- * Make an API request with proper error handling
354
+ * Make an API request with retry, timeout, and error handling.
355
+ *
356
+ * - Retries on 429 (rate limit) and 5xx with exponential backoff (1s, 2s, 4s).
357
+ * - AbortController timeout (default 30s).
358
+ * - Consumer can pass `signal` in options to cancel.
109
359
  */
360
+ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: retry + timeout + error normalization intentionally handled in one request pipeline
110
361
  async request(path, options, schema) {
111
- const response = await fetch(`${this.baseUrl}${path}`, {
112
- ...options,
113
- headers: {
114
- ...options.headers,
115
- "x-api-key": this.apiKey
362
+ const maxAttempts = this.retries + 1;
363
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
364
+ const controller = new AbortController();
365
+ const timeoutId = setTimeout(() => controller.abort(), this.timeout);
366
+ if (options.signal) {
367
+ options.signal.addEventListener("abort", () => controller.abort());
368
+ }
369
+ try {
370
+ const response = await fetch(`${this.baseUrl}${path}`, {
371
+ ...options,
372
+ signal: controller.signal,
373
+ headers: {
374
+ ...options.headers,
375
+ "x-api-key": this.apiKey
376
+ }
377
+ });
378
+ const data = await response.json();
379
+ if (!response.ok) {
380
+ const error = errorSchema.safeParse(data);
381
+ const message = error.success ? error.data.error : "Request failed";
382
+ const code = error.success ? error.data.code : void 0;
383
+ const isRetryable = response.status === 429 || response.status >= 500;
384
+ if (isRetryable && attempt < maxAttempts - 1) {
385
+ await this.sleep(1e3 * 2 ** attempt);
386
+ continue;
387
+ }
388
+ throw new StowError(message, response.status, code);
389
+ }
390
+ return schema ? schema.parse(data) : data;
391
+ } catch (err) {
392
+ if (err instanceof StowError) {
393
+ throw err;
394
+ }
395
+ if (err instanceof z.ZodError) {
396
+ throw new StowError(
397
+ "Invalid response format",
398
+ 500,
399
+ "INVALID_RESPONSE"
400
+ );
401
+ }
402
+ if (err instanceof DOMException || err instanceof Error && err.name === "AbortError") {
403
+ throw new StowError("Request timed out", 408, "TIMEOUT");
404
+ }
405
+ if (attempt < maxAttempts - 1) {
406
+ await this.sleep(1e3 * 2 ** attempt);
407
+ continue;
408
+ }
409
+ throw new StowError(
410
+ err instanceof Error ? err.message : "Network error",
411
+ 0,
412
+ "NETWORK_ERROR"
413
+ );
414
+ } finally {
415
+ clearTimeout(timeoutId);
116
416
  }
117
- });
118
- const data = await response.json();
119
- if (!response.ok) {
120
- const error = errorSchema.safeParse(data);
121
- const message = error.success ? error.data.error : "Request failed";
122
- const code = error.success ? error.data.code : void 0;
123
- throw new StowError(message, response.status, code);
124
417
  }
125
- return schema ? schema.parse(data) : data;
418
+ throw new StowError("Max retries exceeded", 0, "MAX_RETRIES");
419
+ }
420
+ sleep(ms) {
421
+ return new Promise((resolve) => setTimeout(resolve, ms));
126
422
  }
127
423
  /**
128
424
  * Upload a file directly from the server
129
425
  */
130
426
  async uploadFile(file, options) {
131
- const formData = new FormData();
132
- const blob = Buffer.isBuffer(file) ? new Blob([new Uint8Array(file)], {
133
- type: options?.contentType || "application/octet-stream"
134
- }) : file;
135
- formData.append("file", blob, options?.filename || "file");
136
- if (options?.route) {
137
- formData.append("route", options.route);
427
+ const filename = options?.filename || "file";
428
+ const buffer = Buffer.isBuffer(file) ? file : Buffer.from(await file.arrayBuffer());
429
+ const contentType = options?.contentType || (!Buffer.isBuffer(file) && file.type ? file.type : "application/octet-stream");
430
+ const contentHash = createHash("sha256").update(buffer).digest("hex");
431
+ const presign = await this.getPresignedUrl({
432
+ filename,
433
+ contentType,
434
+ size: buffer.length,
435
+ ...options?.route ? { route: options.route } : {},
436
+ ...options?.bucket ? { bucket: options.bucket } : {},
437
+ ...options?.metadata ? { metadata: options.metadata } : {},
438
+ contentHash
439
+ });
440
+ if (presign.dedupe) {
441
+ return {
442
+ key: presign.key,
443
+ url: presign.url,
444
+ size: presign.size,
445
+ contentType: presign.contentType,
446
+ deduped: true
447
+ };
138
448
  }
139
- if (options?.metadata) {
140
- formData.append("metadata", JSON.stringify(options.metadata));
449
+ const uploadRes = await fetch(presign.uploadUrl, {
450
+ method: "PUT",
451
+ headers: { "Content-Type": contentType },
452
+ body: new Uint8Array(buffer)
453
+ });
454
+ if (!uploadRes.ok) {
455
+ throw new StowError("Failed to upload to storage", uploadRes.status);
141
456
  }
142
- const result = await this.request(
143
- this.withBucket("/api/upload", options?.bucket),
457
+ return this.request(
458
+ this.withBucket(
459
+ presign.confirmUrl || "/api/presign/confirm",
460
+ options?.bucket
461
+ ),
144
462
  {
145
463
  method: "POST",
146
- body: formData
464
+ headers: { "Content-Type": "application/json" },
465
+ body: JSON.stringify({
466
+ fileKey: presign.fileKey,
467
+ size: buffer.length,
468
+ contentType,
469
+ ...options?.metadata ? { metadata: options.metadata } : {},
470
+ contentHash,
471
+ skipVerify: true,
472
+ ...options?.title ? { title: true } : {},
473
+ ...options?.describe ? { describe: true } : {},
474
+ ...options?.altText ? { altText: true } : {}
475
+ })
147
476
  },
148
- uploadResultSchema
477
+ confirmResultSchema
149
478
  );
150
- return {
151
- key: result.key,
152
- url: result.url,
153
- size: result.size,
154
- contentType: result.contentType || options?.contentType || "application/octet-stream",
155
- ...result.metadata ? { metadata: result.metadata } : {}
156
- };
157
479
  }
158
480
  /**
159
481
  * Upload a file from a URL (server-side fetch + upload)
@@ -167,7 +489,11 @@ var StowServer = class {
167
489
  body: JSON.stringify({
168
490
  url,
169
491
  filename,
170
- ...options?.metadata ? { metadata: options.metadata } : {}
492
+ ...options?.metadata ? { metadata: options.metadata } : {},
493
+ ...options?.headers ? { headers: options.headers } : {},
494
+ ...options?.title ? { title: true } : {},
495
+ ...options?.describe ? { describe: true } : {},
496
+ ...options?.altText ? { altText: true } : {}
171
497
  })
172
498
  },
173
499
  uploadResultSchema
@@ -190,7 +516,15 @@ var StowServer = class {
190
516
  * 4. Client calls confirmUpload to finalize
191
517
  */
192
518
  getPresignedUrl(request) {
193
- const { filename, contentType, size, route, bucket, metadata } = request;
519
+ const {
520
+ filename,
521
+ contentType,
522
+ size,
523
+ route,
524
+ bucket,
525
+ metadata,
526
+ contentHash
527
+ } = request;
194
528
  return this.request(
195
529
  this.withBucket("/api/presign", bucket),
196
530
  {
@@ -201,7 +535,8 @@ var StowServer = class {
201
535
  contentType,
202
536
  size,
203
537
  route,
204
- ...metadata ? { metadata } : {}
538
+ ...metadata ? { metadata } : {},
539
+ ...contentHash ? { contentHash } : {}
205
540
  })
206
541
  },
207
542
  presignResultSchema
@@ -212,7 +547,19 @@ var StowServer = class {
212
547
  * This creates the file record in the database.
213
548
  */
214
549
  confirmUpload(request) {
215
- const { fileKey, size, contentType, bucket, metadata } = request;
550
+ const {
551
+ fileKey,
552
+ size,
553
+ contentType,
554
+ bucket,
555
+ metadata,
556
+ skipVerify,
557
+ deferKvSync,
558
+ contentHash,
559
+ title,
560
+ describe,
561
+ altText
562
+ } = request;
216
563
  return this.request(
217
564
  this.withBucket("/api/presign/confirm", bucket),
218
565
  {
@@ -222,7 +569,13 @@ var StowServer = class {
222
569
  fileKey,
223
570
  size,
224
571
  contentType,
225
- ...metadata ? { metadata } : {}
572
+ ...metadata ? { metadata } : {},
573
+ ...skipVerify ? { skipVerify } : {},
574
+ ...deferKvSync ? { deferKvSync } : {},
575
+ ...contentHash ? { contentHash } : {},
576
+ ...title ? { title } : {},
577
+ ...describe ? { describe } : {},
578
+ ...altText ? { altText } : {}
226
579
  })
227
580
  },
228
581
  confirmResultSchema
@@ -264,7 +617,7 @@ var StowServer = class {
264
617
  /**
265
618
  * Update metadata on an existing file
266
619
  */
267
- async updateFileMetadata(key, metadata, options) {
620
+ updateFileMetadata(key, metadata, options) {
268
621
  const path = `/api/files/${encodeURIComponent(key)}`;
269
622
  return this.request(this.withBucket(path, options?.bucket), {
270
623
  method: "PATCH",
@@ -272,6 +625,51 @@ var StowServer = class {
272
625
  body: JSON.stringify({ metadata })
273
626
  });
274
627
  }
628
+ /**
629
+ * Get a single file by key
630
+ */
631
+ getFile(key, options) {
632
+ const path = `/api/files/${encodeURIComponent(key)}`;
633
+ return this.request(
634
+ this.withBucket(path, options?.bucket),
635
+ { method: "GET" },
636
+ fileResultSchema
637
+ );
638
+ }
639
+ /**
640
+ * Reprocess a file: reset all derived data (embeddings, colors, dimensions,
641
+ * AI metadata, taxonomies) and re-trigger processing tasks.
642
+ */
643
+ reprocessFile(key, options) {
644
+ const path = `/api/files/${encodeURIComponent(key)}/reprocess`;
645
+ return this.request(
646
+ this.withBucket(path, options?.bucket),
647
+ { method: "POST" },
648
+ reprocessResultSchema
649
+ );
650
+ }
651
+ /**
652
+ * Replace a file's content by fetching from a new URL.
653
+ *
654
+ * Keeps the same file key but replaces the stored object and resets all
655
+ * derived data (dimensions, embeddings, colors, AI metadata). Processing
656
+ * tasks are re-dispatched as if the file were newly uploaded.
657
+ */
658
+ replaceFile(key, url, options) {
659
+ const path = `/api/files/${encodeURIComponent(key)}/replace`;
660
+ return this.request(
661
+ this.withBucket(path, options?.bucket),
662
+ {
663
+ method: "PUT",
664
+ headers: { "Content-Type": "application/json" },
665
+ body: JSON.stringify({
666
+ url,
667
+ ...options?.headers ? { headers: options.headers } : {}
668
+ })
669
+ },
670
+ replaceResultSchema
671
+ );
672
+ }
275
673
  /**
276
674
  * Get a transform URL for an image.
277
675
  *
@@ -283,25 +681,23 @@ var StowServer = class {
283
681
  * @param options - Transform options (width, height, quality, format)
284
682
  */
285
683
  getTransformUrl(url, options) {
286
- const params = new URLSearchParams();
287
- if (options?.width) {
288
- params.set("w", options.width.toString());
684
+ if (!(options && (options.width || options.height || options.quality || options.format))) {
685
+ return url;
289
686
  }
290
- if (options?.height) {
291
- params.set("h", options.height.toString());
687
+ const parsed = new URL(url);
688
+ if (options.width) {
689
+ parsed.searchParams.set("w", String(options.width));
292
690
  }
293
- if (options?.quality) {
294
- params.set("q", options.quality.toString());
691
+ if (options.height) {
692
+ parsed.searchParams.set("h", String(options.height));
295
693
  }
296
- if (options?.format) {
297
- params.set("f", options.format);
694
+ if (options.quality) {
695
+ parsed.searchParams.set("q", String(options.quality));
298
696
  }
299
- const query = params.toString();
300
- if (!query) {
301
- return url;
697
+ if (options.format) {
698
+ parsed.searchParams.set("f", options.format);
302
699
  }
303
- const separator = url.includes("?") ? "&" : "?";
304
- return `${url}${separator}${query}`;
700
+ return parsed.toString();
305
701
  }
306
702
  // ============================================================
307
703
  // TAGS - Org-scoped labels for file organization
@@ -362,18 +758,77 @@ var StowServer = class {
362
758
  */
363
759
  get search() {
364
760
  return {
365
- similar: (params) => this.searchSimilar(params)
761
+ similar: (params) => this.searchSimilar(params),
762
+ diverse: (params) => this.searchDiverse(params ?? {}),
763
+ text: (params) => this.searchText(params),
764
+ color: (params) => this.searchColor(params)
366
765
  };
367
766
  }
368
767
  searchSimilar(params) {
768
+ const bucket = this.resolveBucket(params.bucket);
369
769
  return this.request("/api/search/similar", {
370
770
  method: "POST",
371
771
  headers: { "Content-Type": "application/json" },
372
772
  body: JSON.stringify({
373
773
  ...params.fileKey ? { fileKey: params.fileKey } : {},
374
774
  ...params.vector ? { vector: params.vector } : {},
375
- ...params.bucket ? { bucket: params.bucket } : {},
376
- ...params.limit ? { limit: params.limit } : {}
775
+ ...params.profileId ? { profileId: params.profileId } : {},
776
+ ...params.clusterId ? { clusterId: params.clusterId } : {},
777
+ ...params.clusterIds?.length ? { clusterIds: params.clusterIds } : {},
778
+ ...bucket ? { bucket } : {},
779
+ ...params.limit ? { limit: params.limit } : {},
780
+ ...params.excludeKeys?.length ? { excludeKeys: params.excludeKeys } : {},
781
+ ...params.filters ? { filters: params.filters } : {},
782
+ ...params.include?.length ? { include: params.include } : {}
783
+ })
784
+ });
785
+ }
786
+ searchDiverse(params) {
787
+ const bucket = this.resolveBucket(params.bucket);
788
+ return this.request("/api/search/diverse", {
789
+ method: "POST",
790
+ headers: { "Content-Type": "application/json" },
791
+ body: JSON.stringify({
792
+ ...params.fileKey ? { fileKey: params.fileKey } : {},
793
+ ...params.vector ? { vector: params.vector } : {},
794
+ ...params.profileId ? { profileId: params.profileId } : {},
795
+ ...params.clusterId ? { clusterId: params.clusterId } : {},
796
+ ...params.clusterIds?.length ? { clusterIds: params.clusterIds } : {},
797
+ ...bucket ? { bucket } : {},
798
+ ...params.limit ? { limit: params.limit } : {},
799
+ ...params.lambda !== void 0 ? { lambda: params.lambda } : {},
800
+ ...params.excludeKeys?.length ? { excludeKeys: params.excludeKeys } : {},
801
+ ...params.filters ? { filters: params.filters } : {},
802
+ ...params.include?.length ? { include: params.include } : {}
803
+ })
804
+ });
805
+ }
806
+ searchText(params) {
807
+ const bucket = this.resolveBucket(params.bucket);
808
+ return this.request("/api/search/text", {
809
+ method: "POST",
810
+ headers: { "Content-Type": "application/json" },
811
+ body: JSON.stringify({
812
+ query: params.query,
813
+ ...bucket ? { bucket } : {},
814
+ ...params.limit ? { limit: params.limit } : {},
815
+ ...params.filters ? { filters: params.filters } : {},
816
+ ...params.include?.length ? { include: params.include } : {}
817
+ })
818
+ });
819
+ }
820
+ searchColor(params) {
821
+ const bucket = this.resolveBucket(params.bucket);
822
+ return this.request("/api/search/color", {
823
+ method: "POST",
824
+ headers: { "Content-Type": "application/json" },
825
+ body: JSON.stringify({
826
+ ...params.hex ? { hex: params.hex } : {},
827
+ ...params.oklab ? { oklab: params.oklab } : {},
828
+ ...bucket ? { bucket } : {},
829
+ ...params.limit ? { limit: params.limit } : {},
830
+ ...params.minProportion !== void 0 ? { minProportion: params.minProportion } : {},
831
+ ...params.dominantOnly ? { dominantOnly: params.dominantOnly } : {}
377
832
  })
378
833
  });
379
834
  }
@@ -414,7 +869,7 @@ var StowServer = class {
414
869
  throw new StowError("Failed to upload to storage", putRes.status);
415
870
  }
416
871
  return this.request(
417
- "/api/drops/presign/confirm",
872
+ presign.confirmUrl || "/api/drops/presign/confirm",
418
873
  {
419
874
  method: "POST",
420
875
  headers: { "Content-Type": "application/json" },
@@ -444,6 +899,130 @@ var StowServer = class {
444
899
  method: "DELETE"
445
900
  });
446
901
  }
902
+ // ============================================================
903
+ // PROFILES - Taste/preference profiles from file collections
904
+ // ============================================================
905
+ /**
906
+ * Profiles namespace for managing taste profiles
907
+ */
908
+ get profiles() {
909
+ return {
910
+ create: (params) => this.createProfile(params),
911
+ get: (id) => this.getProfile(id),
912
+ delete: (id) => this.deleteProfile(id),
913
+ addFiles: (id, fileKeys, bucket) => this.addProfileFiles(id, fileKeys, bucket),
914
+ removeFiles: (id, fileKeys, bucket) => this.removeProfileFiles(id, fileKeys, bucket),
915
+ signal: (id, signals, bucket) => this.signalProfile(id, signals, bucket),
916
+ deleteSignals: (id, signalIds) => this.deleteProfileSignals(id, signalIds),
917
+ clusters: (id) => this.getProfileClusters(id),
918
+ recluster: (id, params) => this.reclusterProfile(id, params),
919
+ renameCluster: (profileId, clusterId, params) => this.renameProfileCluster(profileId, clusterId, params)
920
+ };
921
+ }
922
+ createProfile(params) {
923
+ return this.request(
924
+ "/api/profiles",
925
+ {
926
+ method: "POST",
927
+ headers: { "Content-Type": "application/json" },
928
+ body: JSON.stringify({
929
+ ...params?.name ? { name: params.name } : {},
930
+ ...params?.fileKeys ? { fileKeys: params.fileKeys } : {},
931
+ ...params?.bucket ? { bucket: params.bucket } : {}
932
+ })
933
+ },
934
+ profileResultSchema
935
+ );
936
+ }
937
+ getProfile(id) {
938
+ return this.request(
939
+ `/api/profiles/${encodeURIComponent(id)}`,
940
+ { method: "GET" },
941
+ profileResultSchema
942
+ );
943
+ }
944
+ async deleteProfile(id) {
945
+ await this.request(`/api/profiles/${encodeURIComponent(id)}`, {
946
+ method: "DELETE"
947
+ });
948
+ }
949
+ addProfileFiles(id, fileKeys, bucket) {
950
+ return this.request(
951
+ `/api/profiles/${encodeURIComponent(id)}/files`,
952
+ {
953
+ method: "POST",
954
+ headers: { "Content-Type": "application/json" },
955
+ body: JSON.stringify({
956
+ fileKeys,
957
+ ...bucket ? { bucket } : {}
958
+ })
959
+ },
960
+ profileFilesResultSchema
961
+ );
962
+ }
963
+ removeProfileFiles(id, fileKeys, bucket) {
964
+ return this.request(
965
+ `/api/profiles/${encodeURIComponent(id)}/files`,
966
+ {
967
+ method: "DELETE",
968
+ headers: { "Content-Type": "application/json" },
969
+ body: JSON.stringify({
970
+ fileKeys,
971
+ ...bucket ? { bucket } : {}
972
+ })
973
+ },
974
+ profileFilesResultSchema
975
+ );
976
+ }
977
+ signalProfile(id, signals, bucket) {
978
+ return this.request(
979
+ `/api/profiles/${encodeURIComponent(id)}/signals`,
980
+ {
981
+ method: "POST",
982
+ headers: { "Content-Type": "application/json" },
983
+ body: JSON.stringify({
984
+ signals,
985
+ ...bucket ? { bucket } : {}
986
+ })
987
+ },
988
+ profileSignalsResponseSchema
989
+ );
990
+ }
991
+ deleteProfileSignals(id, signalIds) {
992
+ return this.request(
993
+ `/api/profiles/${encodeURIComponent(id)}/signals`,
994
+ {
995
+ method: "DELETE",
996
+ headers: { "Content-Type": "application/json" },
997
+ body: JSON.stringify({ signalIds })
998
+ },
999
+ deleteProfileSignalsResponseSchema
1000
+ );
1001
+ }
1002
+ getProfileClusters(id) {
1003
+ return this.request(`/api/profiles/${encodeURIComponent(id)}/clusters`, {
1004
+ method: "GET"
1005
+ });
1006
+ }
1007
+ reclusterProfile(id, params) {
1008
+ return this.request(`/api/profiles/${encodeURIComponent(id)}/clusters`, {
1009
+ method: "POST",
1010
+ headers: { "Content-Type": "application/json" },
1011
+ body: JSON.stringify({
1012
+ ...params?.clusterCount !== void 0 ? { clusterCount: params.clusterCount } : {}
1013
+ })
1014
+ });
1015
+ }
1016
+ renameProfileCluster(profileId, clusterId, params) {
1017
+ return this.request(
1018
+ `/api/profiles/${encodeURIComponent(profileId)}/clusters/${encodeURIComponent(clusterId)}`,
1019
+ {
1020
+ method: "PUT",
1021
+ headers: { "Content-Type": "application/json" },
1022
+ body: JSON.stringify(params)
1023
+ }
1024
+ );
1025
+ }
447
1026
  };
448
1027
  export {
449
1028
  StowError,