@howells/stow-server
Advanced tools
+21
| MIT License | ||
| Copyright (c) 2025 Stow | ||
| Permission is hereby granted, free of charge, to any person obtaining a copy | ||
| of this software and associated documentation files (the "Software"), to deal | ||
| in the Software without restriction, including without limitation the rights | ||
| to use, copy, modify, merge, publish, distribute, sublicense, and/or sell | ||
| copies of the Software, and to permit persons to whom the Software is | ||
| furnished to do so, subject to the following conditions: | ||
| The above copyright notice and this permission notice shall be included in all | ||
| copies or substantial portions of the Software. | ||
| THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR | ||
| IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, | ||
| FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE | ||
| AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER | ||
| LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, | ||
| OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | ||
| SOFTWARE. |
+373
-56
@@ -0,1 +1,8 @@ | ||
| /** Error thrown when the Stow API returns a non-success response. */ | ||
| declare class StowError extends Error { | ||
| readonly status: number; | ||
| readonly code?: string; | ||
| constructor(message: string, status: number, code?: string); | ||
| } | ||
| /** | ||
@@ -24,8 +31,2 @@ * Stow Server SDK | ||
| */ | ||
| /** Error thrown when the Stow API returns a non-success response. */ | ||
| declare class StowError extends Error { | ||
| readonly status: number; | ||
| readonly code?: string; | ||
| constructor(message: string, status: number, code?: string); | ||
| } | ||
| /** Configuration for creating a {@link StowServer} instance. */ | ||
@@ -261,6 +262,17 @@ interface StowServerConfig { | ||
| } | ||
| /** Input for creating a taste profile. */ | ||
| /** | ||
| * Input for creating a taste profile. | ||
| * | ||
| * Profiles are the right abstraction when you want a reusable preference vector | ||
| * that can evolve over time through file membership and weighted interaction | ||
| * signals such as `like`, `save`, or `purchase`. | ||
| * | ||
| * Use {@link ClusterCreateRequest} instead when you want to group a fixed, | ||
| * curated subset of files without behavioral weighting. | ||
| */ | ||
| interface ProfileCreateRequest { | ||
| bucket?: string; | ||
| /** Initial files to seed into the profile before any interaction signals exist. */ | ||
| fileKeys?: string[]; | ||
| /** Optional display name for dashboards, logs, or search integrations. */ | ||
| name?: string; | ||
@@ -280,3 +292,3 @@ } | ||
| */ | ||
| tags?: Array<{ | ||
| tags?: { | ||
| group: string; | ||
@@ -287,3 +299,3 @@ groupSlug: string; | ||
| tagSlug: string; | ||
| }>; | ||
| }[]; | ||
| totalWeight: number; | ||
@@ -302,4 +314,114 @@ } | ||
| } | ||
| /** Input for recomputing profile clusters. */ | ||
| /** | ||
| * Input for creating a curated clustering resource from an explicit file set. | ||
| * | ||
| * This resource is designed for cases like "cluster these 200 featured images | ||
| * into 12 visual groups" where every listed file should be treated equally. | ||
| * No behavioral signals or master preference vector are involved. | ||
| * | ||
| * The API returns immediately with the persisted resource. Clustering and | ||
| * cluster naming complete asynchronously, so call `stow.clusters.get(id)` to | ||
| * observe progress. | ||
| * | ||
| * @example | ||
| * ```typescript | ||
| * const resource = await stow.clusters.create({ | ||
| * bucket: "inspiration", | ||
| * name: "Spring navigator", | ||
| * fileKeys: featuredKeys, | ||
| * clusterCount: 12, | ||
| * }); | ||
| * ``` | ||
| */ | ||
| interface ClusterCreateRequest { | ||
| /** Bucket name or ID. Optional when the {@link StowServer} instance already has a default bucket. */ | ||
| bucket?: string; | ||
| /** | ||
| * Number of K-means clusters to produce. | ||
| * | ||
| * When omitted, the API auto-selects a value using | ||
| * `max(3, min(15, round(sqrt(fileCount / 10))))`. | ||
| */ | ||
| clusterCount?: number; | ||
| /** Exact file keys to cluster. Files without embeddings cannot participate. */ | ||
| fileKeys: string[]; | ||
| /** Optional human-friendly resource name. */ | ||
| name?: string; | ||
| } | ||
| /** | ||
| * One group within a curated clustering resource. | ||
| * | ||
| * Group names and descriptions are generated asynchronously. Immediately after | ||
| * creation or reclustering, `name` and `description` may be `null` until the | ||
| * naming worker completes or a human renames the cluster manually. | ||
| */ | ||
| interface ClusterGroupResult { | ||
| description: string | null; | ||
| /** Number of files currently assigned to this group. */ | ||
| fileCount: number; | ||
| id: string; | ||
| /** Zero-based index produced by the clustering run. */ | ||
| index: number; | ||
| name: string | null; | ||
| /** Timestamp for the most recent generated or manual naming pass. */ | ||
| nameGeneratedAt: string | null; | ||
| } | ||
| /** | ||
| * Persisted curated clustering resource. | ||
| * | ||
| * `clusterCount` is the requested or auto-selected target. `clusters.length` | ||
| * represents the current persisted groups for the latest clustering run. | ||
| * `clusteredAt` stays `null` until the worker has written assignments. | ||
| */ | ||
| interface ClusterResourceResult { | ||
| clusterCount: number; | ||
| /** Timestamp of the last completed clustering pass. Null while work is still pending. */ | ||
| clusteredAt: string | null; | ||
| clusters: ClusterGroupResult[]; | ||
| createdAt: string; | ||
| /** Number of source files included in the resource. */ | ||
| fileCount: number; | ||
| id: string; | ||
| name: string | null; | ||
| updatedAt: string; | ||
| } | ||
| /** | ||
| * One file assigned to a curated cluster group. | ||
| * | ||
| * When returned from `stow.clusters.files(...)`, items are ordered by ascending | ||
| * distance to the group centroid so the first files are usually the best | ||
| * representatives for labeling, previews, or inspiration navigators. | ||
| */ | ||
| interface ClusterFileResult { | ||
| bucketId: string; | ||
| contentType: string; | ||
| createdAt: string; | ||
| /** Euclidean distance from the cluster centroid. Lower means more representative. */ | ||
| distance: number | null; | ||
| id: string; | ||
| key: string; | ||
| metadata: Record<string, string> | null; | ||
| originalFilename: string | null; | ||
| size: number; | ||
| } | ||
| /** | ||
| * Paginated files assigned to one curated cluster group. | ||
| * | ||
| * Results are stable within a clustering run and ordered by distance to the | ||
| * centroid, making page 1 suitable for "representative examples". | ||
| */ | ||
| interface ClusterFilesResult { | ||
| clusterId: string; | ||
| files: ClusterFileResult[]; | ||
| limit: number; | ||
| offset: number; | ||
| total: number; | ||
| } | ||
| /** | ||
| * Input for recomputing clusters. | ||
| * | ||
| * Used by both `profiles.recluster(...)` and `clusters.recluster(...)`. | ||
| */ | ||
| interface ReclusterRequest { | ||
| /** Override the existing cluster count for the next clustering pass. */ | ||
| clusterCount?: number; | ||
@@ -314,3 +436,8 @@ } | ||
| } | ||
| /** Input for updating cluster display metadata. */ | ||
| /** | ||
| * Input for manually naming a cluster. | ||
| * | ||
| * This is intended for the final editorial pass after generated names are | ||
| * available. You can send just `name`, just `description`, or both. | ||
| */ | ||
| interface RenameClusterRequest { | ||
@@ -460,3 +587,17 @@ description?: string; | ||
| } | ||
| /** Input payload for similarity search. */ | ||
| /** | ||
| * Input payload for similarity search. | ||
| * | ||
| * Provide exactly one semantic seed: | ||
| * - `fileKey` | ||
| * - `anchorId` | ||
| * - `profileId` | ||
| * - `clusterId` | ||
| * - `clusterIds` | ||
| * - `vector` | ||
| * | ||
| * Cluster seeds follow the same shape as profile clustering: | ||
| * - with `profileId`, `clusterId` or `clusterIds` reference profile clusters | ||
| * - without `profileId`, they reference curated clusters created via `/clusters` | ||
| */ | ||
| interface SimilarSearchRequest { | ||
@@ -467,5 +608,15 @@ /** Use an anchor's embedding as the query vector */ | ||
| bucket?: string; | ||
| /** Use a specific cluster centroid instead of the profile master vector. Requires profileId. */ | ||
| /** | ||
| * Use a specific cluster centroid as the query vector. | ||
| * | ||
| * With `profileId`, this resolves a profile cluster. Without `profileId`, it | ||
| * resolves a curated cluster group from `stow.clusters`. | ||
| */ | ||
| clusterId?: string; | ||
| /** Blend multiple cluster centroids as query vector. Requires profileId. */ | ||
| /** | ||
| * Blend multiple cluster centroids into one query vector. | ||
| * | ||
| * With `profileId`, these resolve profile clusters. Without `profileId`, they | ||
| * resolve curated cluster groups from `stow.clusters`. | ||
| */ | ||
| clusterIds?: string[]; | ||
@@ -482,3 +633,3 @@ /** File keys to exclude from results (e.g. already-seen items). Max 500. */ | ||
| limit?: number; | ||
| /** Search using a taste profile's vector */ | ||
| /** Search using a taste profile's master vector. */ | ||
| profileId?: string; | ||
@@ -488,3 +639,8 @@ /** Search directly with a vector (1024 dimensions) */ | ||
| } | ||
| /** Input payload for diversity-aware search. */ | ||
| /** | ||
| * Input payload for diversity-aware search. | ||
| * | ||
| * This uses the same seed rules as {@link SimilarSearchRequest} but balances | ||
| * relevance against diversity using `lambda`. | ||
| */ | ||
| interface DiverseSearchRequest { | ||
@@ -495,5 +651,15 @@ /** Use an anchor's embedding as the query vector */ | ||
| bucket?: string; | ||
| /** Use a specific cluster centroid instead of the profile master vector. Requires profileId. */ | ||
| /** | ||
| * Use a specific cluster centroid as the seed vector. | ||
| * | ||
| * With `profileId`, this resolves a profile cluster. Without `profileId`, it | ||
| * resolves a curated cluster group from `stow.clusters`. | ||
| */ | ||
| clusterId?: string; | ||
| /** Blend multiple cluster centroids as query vector. Requires profileId. */ | ||
| /** | ||
| * Blend multiple cluster centroids into one seed vector. | ||
| * | ||
| * With `profileId`, these resolve profile clusters. Without `profileId`, they | ||
| * resolve curated cluster groups from `stow.clusters`. | ||
| */ | ||
| clusterIds?: string[]; | ||
@@ -533,7 +699,7 @@ /** File keys to exclude from results (e.g. already-seen items). Max 500. */ | ||
| size: number; | ||
| tags?: Array<{ | ||
| tags?: { | ||
| slug: string; | ||
| name: string; | ||
| }>; | ||
| taxonomies?: Array<{ | ||
| }[]; | ||
| taxonomies?: { | ||
| externalUri: string | null; | ||
@@ -545,3 +711,3 @@ group: string; | ||
| source: string; | ||
| }>; | ||
| }[]; | ||
| width?: number | null; | ||
@@ -699,3 +865,31 @@ } | ||
| } | ||
| /** Server-side SDK client for Stow's API. */ | ||
| /** | ||
| * Server-side SDK client for Stow's HTTP API. | ||
| * | ||
| * Use this package from trusted environments only: Next.js route handlers, | ||
| * server actions, workers, backend services, CLIs, or scripts. It owns API-key | ||
| * auth and exposes the full bucket/file/search/profile/cluster surface. | ||
| * | ||
| * Bucket scoping rules: | ||
| * - pass `bucket` to the constructor to set a default for the instance | ||
| * - pass `bucket` to a method to override that default for one call | ||
| * - omit both only when the API key itself is bucket-scoped | ||
| * | ||
| * @example | ||
| * ```typescript | ||
| * const stow = new StowServer({ | ||
| * apiKey: process.env.STOW_API_KEY!, | ||
| * bucket: "brand-assets", | ||
| * }); | ||
| * | ||
| * const upload = await stow.uploadFile(buffer, { | ||
| * filename: "hero.jpg", | ||
| * contentType: "image/jpeg", | ||
| * title: true, | ||
| * altText: true, | ||
| * }); | ||
| * | ||
| * const { results } = await stow.search.similar({ fileKey: upload.key, limit: 8 }); | ||
| * ``` | ||
| */ | ||
| declare class StowServer { | ||
@@ -707,6 +901,18 @@ private readonly apiKey; | ||
| private readonly retries; | ||
| constructor(config: StowServerConfig | string); | ||
| /** | ||
| * Get the base URL for this instance (used by client SDK) | ||
| * Pure helper for building signed transform URLs from an existing public file URL. | ||
| * | ||
| * This does not perform any network I/O and is safe to pass around as a plain | ||
| * function, for example to view-layer code that needs responsive image URLs. | ||
| */ | ||
| readonly getTransformUrl: (url: string, options?: TransformOptions) => string; | ||
| /** | ||
| * Create a server SDK instance. | ||
| * | ||
| * Pass a bare string when you only need the API key and want default values | ||
| * for `baseUrl`, `timeout`, and retries. Pass an object when you want a | ||
| * default bucket, a non-production API origin, or custom transport settings. | ||
| */ | ||
| constructor(config: StowServerConfig | string); | ||
| /** Return the configured API origin, mainly for adapter packages such as `stow-next`. */ | ||
| getBaseUrl(): string; | ||
@@ -762,5 +968,26 @@ /** | ||
| private request; | ||
| private sleep; | ||
| /** | ||
| * Upload a file directly from the server | ||
| * Upload bytes from a trusted server environment. | ||
| * | ||
| * This is the highest-level server upload helper: | ||
| * 1. compute a SHA-256 hash for dedupe | ||
| * 2. request a presigned upload URL | ||
| * 3. PUT bytes to storage | ||
| * 4. confirm the upload with optional AI metadata generation | ||
| * | ||
| * Prefer this method when your code already has the file bytes in memory. | ||
| * Use `getPresignedUrl()` + `confirmUpload()` for direct browser uploads | ||
| * instead, and `uploadFromUrl()` when the source is an external URL. | ||
| * | ||
| * @example | ||
| * ```typescript | ||
| * await stow.uploadFile(buffer, { | ||
| * filename: "product.jpg", | ||
| * contentType: "image/jpeg", | ||
| * bucket: "catalog", | ||
| * metadata: { sku: "SKU-123" }, | ||
| * title: true, | ||
| * altText: true, | ||
| * }); | ||
| * ``` | ||
| */ | ||
@@ -783,3 +1010,8 @@ uploadFile(file: Buffer | Blob, options?: { | ||
| /** | ||
| * Upload a file from a URL (server-side fetch + upload) | ||
| * Import a remote asset by URL. | ||
| * | ||
| * Stow fetches the remote URL server-side, stores the resulting bytes, and | ||
| * persists the file as if it had been uploaded normally. This is useful for | ||
| * migrations, ingestion pipelines, or bringing third-party assets into Stow | ||
| * without downloading them into your own process first. | ||
| */ | ||
@@ -818,20 +1050,32 @@ uploadFromUrl(url: string, filename: string, options?: { | ||
| /** | ||
| * Get a presigned URL for direct client-side upload. | ||
| * Get a presigned URL for a direct client upload. | ||
| * | ||
| * This enables uploads that bypass your server entirely: | ||
| * 1. Client calls your endpoint | ||
| * 2. Your endpoint calls this method | ||
| * 3. Client PUTs directly to the returned uploadUrl | ||
| * 4. Client calls confirmUpload to finalize | ||
| * This is the server-side half of the browser upload flow used by | ||
| * `@howells/stow-client` and `@howells/stow-next`: | ||
| * 1. browser calls your app | ||
| * 2. your app calls `getPresignedUrl()` | ||
| * 3. browser PUTs bytes to `uploadUrl` | ||
| * 4. browser or your app calls `confirmUpload()` | ||
| * | ||
| * If `contentHash` matches an existing file in the target bucket, the API | ||
| * short-circuits with `{ dedupe: true, ... }` and no upload is required. | ||
| */ | ||
| getPresignedUrl(request: PresignRequest): Promise<PresignResult>; | ||
| /** | ||
| * Confirm a presigned upload after the client has uploaded to R2. | ||
| * This creates the file record in the database. | ||
| * Confirm a direct upload after the client has finished the storage PUT. | ||
| * | ||
| * This finalizes the file record in the database and optionally triggers | ||
| * post-processing such as AI-generated title/description/alt text. | ||
| * | ||
| * Call this exactly once per successful presigned upload. | ||
| */ | ||
| confirmUpload(request: ConfirmUploadRequest): Promise<UploadResult>; | ||
| /** | ||
| * List files in the bucket | ||
| * List files in a bucket with optional prefix filtering and enrichment blocks. | ||
| * | ||
| * @param options.include - Optional enrichment fields: `"tags"`, `"taxonomies"` | ||
| * Use `cursor` to continue pagination from a previous page. When requesting | ||
| * `include`, Stow expands those relationships inline so you can avoid follow-up | ||
| * per-file lookups. | ||
| * | ||
| * @param options.include Optional enrichment fields: `"tags"`, `"taxonomies"` | ||
| */ | ||
@@ -864,5 +1108,8 @@ listFiles(options?: { | ||
| /** | ||
| * Get a single file by key | ||
| * Get one file by key. | ||
| * | ||
| * @param options.include - Optional enrichment fields: `"tags"`, `"taxonomies"` | ||
| * This is the detailed file view and includes dimensions, embeddings status, | ||
| * extracted colors, and AI metadata fields when available. | ||
| * | ||
| * @param options.include Optional enrichment fields: `"tags"`, `"taxonomies"` | ||
| */ | ||
@@ -927,13 +1174,2 @@ getFile(key: string, options?: { | ||
| /** | ||
| * Get a transform URL for an image. | ||
| * | ||
| * Appends transform query params (?w=, ?h=, ?q=, ?f=) to a file URL. | ||
| * Transforms are applied at the edge by the Cloudflare Worker — no | ||
| * server round-trip needed. | ||
| * | ||
| * @param url - Full file URL (e.g. from upload result's fileUrl) | ||
| * @param options - Transform options (width, height, quality, format) | ||
| */ | ||
| getTransformUrl(url: string, options?: TransformOptions): string; | ||
| /** | ||
| * Tags namespace for creating, listing, and deleting tags | ||
@@ -943,3 +1179,3 @@ */ | ||
| list: () => Promise<{ | ||
| tags: Array<{ | ||
| tags: { | ||
| id: string; | ||
@@ -950,3 +1186,3 @@ name: string; | ||
| createdAt: string; | ||
| }>; | ||
| }[]; | ||
| }>; | ||
@@ -996,3 +1232,30 @@ create: (params: { | ||
| /** | ||
| * Search namespace for vector similarity search | ||
| * Semantic search namespace. | ||
| * | ||
| * Methods: | ||
| * - `text(...)` embeds text and finds matching files | ||
| * - `similar(...)` finds nearest neighbors for a file, anchor, profile, or cluster | ||
| * - `diverse(...)` balances similarity against result spread | ||
| * - `color(...)` performs palette similarity search | ||
| * - `image(...)` searches using an existing file or an external image URL | ||
| * | ||
| * Cluster-aware search examples: | ||
| * | ||
| * @example | ||
| * ```typescript | ||
| * const cluster = await stow.clusters.create({ | ||
| * bucket: "inspiration", | ||
| * fileKeys, | ||
| * clusterCount: 12, | ||
| * }); | ||
| * | ||
| * const firstGroup = cluster.clusters[0]; | ||
| * if (firstGroup) { | ||
| * const related = await stow.search.similar({ | ||
| * bucket: "inspiration", | ||
| * clusterId: firstGroup.id, | ||
| * limit: 20, | ||
| * }); | ||
| * } | ||
| * ``` | ||
| */ | ||
@@ -1036,3 +1299,7 @@ get search(): { | ||
| /** | ||
| * Profiles namespace for managing taste profiles | ||
| * Taste-profile namespace. | ||
| * | ||
| * Profiles are long-lived preference objects. They can be seeded from files, | ||
| * updated through weighted signals, clustered into interpretable segments, and | ||
| * then reused as semantic search seeds. | ||
| */ | ||
@@ -1055,2 +1322,46 @@ get profiles(): { | ||
| }; | ||
| /** | ||
| * Curated cluster namespace. | ||
| * | ||
| * Use this when you have an explicit file set that should be grouped by visual | ||
| * similarity, but should not be modeled as a behavioral profile. | ||
| * | ||
| * Typical workflow: | ||
| * 1. `create({ fileKeys, clusterCount })` | ||
| * 2. poll `get(id)` until `clusteredAt` is non-null | ||
| * 3. inspect `clusters` | ||
| * 4. fetch representative files with `files(id, clusterId)` | ||
| * 5. optionally `renameCluster(...)` | ||
| * 6. use `clusterId` with `search.similar(...)` or `search.diverse(...)` | ||
| * | ||
| * @example | ||
| * ```typescript | ||
| * const resource = await stow.clusters.create({ | ||
| * bucket: "featured-products", | ||
| * fileKeys: featuredKeys, | ||
| * clusterCount: 12, | ||
| * name: "Navigator groups", | ||
| * }); | ||
| * | ||
| * const latest = await stow.clusters.get(resource.id, "featured-products"); | ||
| * const group = latest.clusters[0]; | ||
| * if (group) { | ||
| * const representatives = await stow.clusters.files(resource.id, group.id, { | ||
| * limit: 12, | ||
| * offset: 0, | ||
| * }); | ||
| * } | ||
| * ``` | ||
| */ | ||
| get clusters(): { | ||
| create: (params: ClusterCreateRequest) => Promise<ClusterResourceResult>; | ||
| get: (id: string, bucket?: string) => Promise<ClusterResourceResult>; | ||
| recluster: (id: string, params?: ReclusterRequest, bucket?: string) => Promise<ClusterResourceResult>; | ||
| files: (id: string, clusterId: string, params?: { | ||
| limit?: number; | ||
| offset?: number; | ||
| }, bucket?: string) => Promise<ClusterFilesResult>; | ||
| renameCluster: (id: string, clusterId: string, params: RenameClusterRequest, bucket?: string) => Promise<ClusterGroupResult>; | ||
| delete: (id: string, bucket?: string) => Promise<void>; | ||
| }; | ||
| private createProfile; | ||
@@ -1066,2 +1377,8 @@ private getProfile; | ||
| private renameProfileCluster; | ||
| private createClustersResource; | ||
| private getClustersResource; | ||
| private reclusterClustersResource; | ||
| private getClusterFiles; | ||
| private renameClusterGroup; | ||
| private deleteClustersResource; | ||
| /** | ||
@@ -1098,2 +1415,2 @@ * Anchors namespace for creating, listing, updating, and deleting text anchors. | ||
| export { type Anchor, type AnchorSearchResult, type AppliedFilters, type BucketResult, type ColorSearchRequest, type ColorSearchResult, type ColorSearchResultItem, type ConfirmUploadRequest, type CreateAnchorRequest, type CreateBucketRequest, type DeleteProfileSignalsResult, type DiverseSearchRequest, type Drop, type DropResult, type FileColor, type FileColorProfile, type FileIncludeField, type FileResult, type FileTag, type FileTaxonomy, type FilteredMetadata, type ListBucketsResult, type ListDropsResult, type ListFilesItem, type ListFilesResult, type PresignDedupeResult, type PresignNewResult, type PresignRequest, type PresignResult, type ProfileClusterResult, type ProfileCreateRequest, type ProfileFilesResult, type ProfileResult, type ProfileSignalInput, type ProfileSignalResult, type ProfileSignalType, type ProfileSignalsResponse, type QueuedResult, type ReclusterRequest, type ReclusterResult, type RenameClusterRequest, type ReplaceResult, type SearchByImageInput, type SearchByImageOptions, type SearchByImageResult, type SearchByImageSource, type SearchFilters, type SearchIncludeField, type SearchResultItem, type SimilarSearchRequest, type SimilarSearchResult, StowError, StowServer, type StowServerConfig, type TaskTriggerResult, type TaxonomyGroup, type TaxonomyListResult, type TaxonomyTerm, type TextSearchRequest, type TransformOptions, type UpdateAnchorRequest, type UpdateBucketRequest, type UploadResult, type WhoamiResult }; | ||
| export { type Anchor, type AnchorSearchResult, type AppliedFilters, type BucketResult, type ClusterCreateRequest, type ClusterFileResult, type ClusterFilesResult, type ClusterGroupResult, type ClusterResourceResult, type ColorSearchRequest, type ColorSearchResult, type ColorSearchResultItem, type ConfirmUploadRequest, type CreateAnchorRequest, type CreateBucketRequest, type DeleteProfileSignalsResult, type DiverseSearchRequest, type Drop, type DropResult, type FileColor, type FileColorProfile, type FileIncludeField, type FileResult, type FileTag, type FileTaxonomy, type FilteredMetadata, type ListBucketsResult, type ListDropsResult, type ListFilesItem, type ListFilesResult, type PresignDedupeResult, type PresignNewResult, type PresignRequest, type PresignResult, type ProfileClusterResult, type ProfileCreateRequest, type ProfileFilesResult, type ProfileResult, type ProfileSignalInput, type ProfileSignalResult, type ProfileSignalType, type ProfileSignalsResponse, type QueuedResult, type ReclusterRequest, type ReclusterResult, type RenameClusterRequest, type ReplaceResult, type SearchByImageInput, type SearchByImageOptions, type SearchByImageResult, type SearchByImageSource, type SearchFilters, type SearchIncludeField, type SearchResultItem, type SimilarSearchRequest, type SimilarSearchResult, StowError, StowServer, type StowServerConfig, type TaskTriggerResult, type TaxonomyGroup, type TaxonomyListResult, type TaxonomyTerm, type TextSearchRequest, type TransformOptions, type UpdateAnchorRequest, type UpdateBucketRequest, type UploadResult, type WhoamiResult }; |
+373
-56
@@ -0,1 +1,8 @@ | ||
| /** Error thrown when the Stow API returns a non-success response. */ | ||
| declare class StowError extends Error { | ||
| readonly status: number; | ||
| readonly code?: string; | ||
| constructor(message: string, status: number, code?: string); | ||
| } | ||
| /** | ||
@@ -24,8 +31,2 @@ * Stow Server SDK | ||
| */ | ||
| /** Error thrown when the Stow API returns a non-success response. */ | ||
| declare class StowError extends Error { | ||
| readonly status: number; | ||
| readonly code?: string; | ||
| constructor(message: string, status: number, code?: string); | ||
| } | ||
| /** Configuration for creating a {@link StowServer} instance. */ | ||
@@ -261,6 +262,17 @@ interface StowServerConfig { | ||
| } | ||
| /** Input for creating a taste profile. */ | ||
| /** | ||
| * Input for creating a taste profile. | ||
| * | ||
| * Profiles are the right abstraction when you want a reusable preference vector | ||
| * that can evolve over time through file membership and weighted interaction | ||
| * signals such as `like`, `save`, or `purchase`. | ||
| * | ||
| * Use {@link ClusterCreateRequest} instead when you want to group a fixed, | ||
| * curated subset of files without behavioral weighting. | ||
| */ | ||
| interface ProfileCreateRequest { | ||
| bucket?: string; | ||
| /** Initial files to seed into the profile before any interaction signals exist. */ | ||
| fileKeys?: string[]; | ||
| /** Optional display name for dashboards, logs, or search integrations. */ | ||
| name?: string; | ||
@@ -280,3 +292,3 @@ } | ||
| */ | ||
| tags?: Array<{ | ||
| tags?: { | ||
| group: string; | ||
@@ -287,3 +299,3 @@ groupSlug: string; | ||
| tagSlug: string; | ||
| }>; | ||
| }[]; | ||
| totalWeight: number; | ||
@@ -302,4 +314,114 @@ } | ||
| } | ||
| /** Input for recomputing profile clusters. */ | ||
| /** | ||
| * Input for creating a curated clustering resource from an explicit file set. | ||
| * | ||
| * This resource is designed for cases like "cluster these 200 featured images | ||
| * into 12 visual groups" where every listed file should be treated equally. | ||
| * No behavioral signals or master preference vector are involved. | ||
| * | ||
| * The API returns immediately with the persisted resource. Clustering and | ||
| * cluster naming complete asynchronously, so call `stow.clusters.get(id)` to | ||
| * observe progress. | ||
| * | ||
| * @example | ||
| * ```typescript | ||
| * const resource = await stow.clusters.create({ | ||
| * bucket: "inspiration", | ||
| * name: "Spring navigator", | ||
| * fileKeys: featuredKeys, | ||
| * clusterCount: 12, | ||
| * }); | ||
| * ``` | ||
| */ | ||
| interface ClusterCreateRequest { | ||
| /** Bucket name or ID. Optional when the {@link StowServer} instance already has a default bucket. */ | ||
| bucket?: string; | ||
| /** | ||
| * Number of K-means clusters to produce. | ||
| * | ||
| * When omitted, the API auto-selects a value using | ||
| * `max(3, min(15, round(sqrt(fileCount / 10))))`. | ||
| */ | ||
| clusterCount?: number; | ||
| /** Exact file keys to cluster. Files without embeddings cannot participate. */ | ||
| fileKeys: string[]; | ||
| /** Optional human-friendly resource name. */ | ||
| name?: string; | ||
| } | ||
| /** | ||
| * One group within a curated clustering resource. | ||
| * | ||
| * Group names and descriptions are generated asynchronously. Immediately after | ||
| * creation or reclustering, `name` and `description` may be `null` until the | ||
| * naming worker completes or a human renames the cluster manually. | ||
| */ | ||
| interface ClusterGroupResult { | ||
| description: string | null; | ||
| /** Number of files currently assigned to this group. */ | ||
| fileCount: number; | ||
| id: string; | ||
| /** Zero-based index produced by the clustering run. */ | ||
| index: number; | ||
| name: string | null; | ||
| /** Timestamp for the most recent generated or manual naming pass. */ | ||
| nameGeneratedAt: string | null; | ||
| } | ||
| /** | ||
| * Persisted curated clustering resource. | ||
| * | ||
| * `clusterCount` is the requested or auto-selected target. `clusters.length` | ||
| * represents the current persisted groups for the latest clustering run. | ||
| * `clusteredAt` stays `null` until the worker has written assignments. | ||
| */ | ||
| interface ClusterResourceResult { | ||
| clusterCount: number; | ||
| /** Timestamp of the last completed clustering pass. Null while work is still pending. */ | ||
| clusteredAt: string | null; | ||
| clusters: ClusterGroupResult[]; | ||
| createdAt: string; | ||
| /** Number of source files included in the resource. */ | ||
| fileCount: number; | ||
| id: string; | ||
| name: string | null; | ||
| updatedAt: string; | ||
| } | ||
| /** | ||
| * One file assigned to a curated cluster group. | ||
| * | ||
| * When returned from `stow.clusters.files(...)`, items are ordered by ascending | ||
| * distance to the group centroid so the first files are usually the best | ||
| * representatives for labeling, previews, or inspiration navigators. | ||
| */ | ||
| interface ClusterFileResult { | ||
| bucketId: string; | ||
| contentType: string; | ||
| createdAt: string; | ||
| /** Euclidean distance from the cluster centroid. Lower means more representative. */ | ||
| distance: number | null; | ||
| id: string; | ||
| key: string; | ||
| metadata: Record<string, string> | null; | ||
| originalFilename: string | null; | ||
| size: number; | ||
| } | ||
| /** | ||
| * Paginated files assigned to one curated cluster group. | ||
| * | ||
| * Results are stable within a clustering run and ordered by distance to the | ||
| * centroid, making page 1 suitable for "representative examples". | ||
| */ | ||
| interface ClusterFilesResult { | ||
| clusterId: string; | ||
| files: ClusterFileResult[]; | ||
| limit: number; | ||
| offset: number; | ||
| total: number; | ||
| } | ||
| /** | ||
| * Input for recomputing clusters. | ||
| * | ||
| * Used by both `profiles.recluster(...)` and `clusters.recluster(...)`. | ||
| */ | ||
| interface ReclusterRequest { | ||
| /** Override the existing cluster count for the next clustering pass. */ | ||
| clusterCount?: number; | ||
@@ -314,3 +436,8 @@ } | ||
| } | ||
| /** Input for updating cluster display metadata. */ | ||
| /** | ||
| * Input for manually naming a cluster. | ||
| * | ||
| * This is intended for the final editorial pass after generated names are | ||
| * available. You can send just `name`, just `description`, or both. | ||
| */ | ||
| interface RenameClusterRequest { | ||
@@ -460,3 +587,17 @@ description?: string; | ||
| } | ||
| /** Input payload for similarity search. */ | ||
| /** | ||
| * Input payload for similarity search. | ||
| * | ||
| * Provide exactly one semantic seed: | ||
| * - `fileKey` | ||
| * - `anchorId` | ||
| * - `profileId` | ||
| * - `clusterId` | ||
| * - `clusterIds` | ||
| * - `vector` | ||
| * | ||
| * Cluster seeds follow the same shape as profile clustering: | ||
| * - with `profileId`, `clusterId` or `clusterIds` reference profile clusters | ||
| * - without `profileId`, they reference curated clusters created via `/clusters` | ||
| */ | ||
| interface SimilarSearchRequest { | ||
@@ -467,5 +608,15 @@ /** Use an anchor's embedding as the query vector */ | ||
| bucket?: string; | ||
| /** Use a specific cluster centroid instead of the profile master vector. Requires profileId. */ | ||
| /** | ||
| * Use a specific cluster centroid as the query vector. | ||
| * | ||
| * With `profileId`, this resolves a profile cluster. Without `profileId`, it | ||
| * resolves a curated cluster group from `stow.clusters`. | ||
| */ | ||
| clusterId?: string; | ||
| /** Blend multiple cluster centroids as query vector. Requires profileId. */ | ||
| /** | ||
| * Blend multiple cluster centroids into one query vector. | ||
| * | ||
| * With `profileId`, these resolve profile clusters. Without `profileId`, they | ||
| * resolve curated cluster groups from `stow.clusters`. | ||
| */ | ||
| clusterIds?: string[]; | ||
@@ -482,3 +633,3 @@ /** File keys to exclude from results (e.g. already-seen items). Max 500. */ | ||
| limit?: number; | ||
| /** Search using a taste profile's vector */ | ||
| /** Search using a taste profile's master vector. */ | ||
| profileId?: string; | ||
@@ -488,3 +639,8 @@ /** Search directly with a vector (1024 dimensions) */ | ||
| } | ||
| /** Input payload for diversity-aware search. */ | ||
| /** | ||
| * Input payload for diversity-aware search. | ||
| * | ||
| * This uses the same seed rules as {@link SimilarSearchRequest} but balances | ||
| * relevance against diversity using `lambda`. | ||
| */ | ||
| interface DiverseSearchRequest { | ||
@@ -495,5 +651,15 @@ /** Use an anchor's embedding as the query vector */ | ||
| bucket?: string; | ||
| /** Use a specific cluster centroid instead of the profile master vector. Requires profileId. */ | ||
| /** | ||
| * Use a specific cluster centroid as the seed vector. | ||
| * | ||
| * With `profileId`, this resolves a profile cluster. Without `profileId`, it | ||
| * resolves a curated cluster group from `stow.clusters`. | ||
| */ | ||
| clusterId?: string; | ||
| /** Blend multiple cluster centroids as query vector. Requires profileId. */ | ||
| /** | ||
| * Blend multiple cluster centroids into one seed vector. | ||
| * | ||
| * With `profileId`, these resolve profile clusters. Without `profileId`, they | ||
| * resolve curated cluster groups from `stow.clusters`. | ||
| */ | ||
| clusterIds?: string[]; | ||
@@ -533,7 +699,7 @@ /** File keys to exclude from results (e.g. already-seen items). Max 500. */ | ||
| size: number; | ||
| tags?: Array<{ | ||
| tags?: { | ||
| slug: string; | ||
| name: string; | ||
| }>; | ||
| taxonomies?: Array<{ | ||
| }[]; | ||
| taxonomies?: { | ||
| externalUri: string | null; | ||
@@ -545,3 +711,3 @@ group: string; | ||
| source: string; | ||
| }>; | ||
| }[]; | ||
| width?: number | null; | ||
@@ -699,3 +865,31 @@ } | ||
| } | ||
| /** Server-side SDK client for Stow's API. */ | ||
| /** | ||
| * Server-side SDK client for Stow's HTTP API. | ||
| * | ||
| * Use this package from trusted environments only: Next.js route handlers, | ||
| * server actions, workers, backend services, CLIs, or scripts. It owns API-key | ||
| * auth and exposes the full bucket/file/search/profile/cluster surface. | ||
| * | ||
| * Bucket scoping rules: | ||
| * - pass `bucket` to the constructor to set a default for the instance | ||
| * - pass `bucket` to a method to override that default for one call | ||
| * - omit both only when the API key itself is bucket-scoped | ||
| * | ||
| * @example | ||
| * ```typescript | ||
| * const stow = new StowServer({ | ||
| * apiKey: process.env.STOW_API_KEY!, | ||
| * bucket: "brand-assets", | ||
| * }); | ||
| * | ||
| * const upload = await stow.uploadFile(buffer, { | ||
| * filename: "hero.jpg", | ||
| * contentType: "image/jpeg", | ||
| * title: true, | ||
| * altText: true, | ||
| * }); | ||
| * | ||
| * const { results } = await stow.search.similar({ fileKey: upload.key, limit: 8 }); | ||
| * ``` | ||
| */ | ||
| declare class StowServer { | ||
@@ -707,6 +901,18 @@ private readonly apiKey; | ||
| private readonly retries; | ||
| constructor(config: StowServerConfig | string); | ||
| /** | ||
| * Get the base URL for this instance (used by client SDK) | ||
| * Pure helper for building signed transform URLs from an existing public file URL. | ||
| * | ||
| * This does not perform any network I/O and is safe to pass around as a plain | ||
| * function, for example to view-layer code that needs responsive image URLs. | ||
| */ | ||
| readonly getTransformUrl: (url: string, options?: TransformOptions) => string; | ||
| /** | ||
| * Create a server SDK instance. | ||
| * | ||
| * Pass a bare string when you only need the API key and want default values | ||
| * for `baseUrl`, `timeout`, and retries. Pass an object when you want a | ||
| * default bucket, a non-production API origin, or custom transport settings. | ||
| */ | ||
| constructor(config: StowServerConfig | string); | ||
| /** Return the configured API origin, mainly for adapter packages such as `stow-next`. */ | ||
| getBaseUrl(): string; | ||
@@ -762,5 +968,26 @@ /** | ||
| private request; | ||
| private sleep; | ||
| /** | ||
| * Upload a file directly from the server | ||
| * Upload bytes from a trusted server environment. | ||
| * | ||
| * This is the highest-level server upload helper: | ||
| * 1. compute a SHA-256 hash for dedupe | ||
| * 2. request a presigned upload URL | ||
| * 3. PUT bytes to storage | ||
| * 4. confirm the upload with optional AI metadata generation | ||
| * | ||
| * Prefer this method when your code already has the file bytes in memory. | ||
| * Use `getPresignedUrl()` + `confirmUpload()` for direct browser uploads | ||
| * instead, and `uploadFromUrl()` when the source is an external URL. | ||
| * | ||
| * @example | ||
| * ```typescript | ||
| * await stow.uploadFile(buffer, { | ||
| * filename: "product.jpg", | ||
| * contentType: "image/jpeg", | ||
| * bucket: "catalog", | ||
| * metadata: { sku: "SKU-123" }, | ||
| * title: true, | ||
| * altText: true, | ||
| * }); | ||
| * ``` | ||
| */ | ||
@@ -783,3 +1010,8 @@ uploadFile(file: Buffer | Blob, options?: { | ||
| /** | ||
| * Upload a file from a URL (server-side fetch + upload) | ||
| * Import a remote asset by URL. | ||
| * | ||
| * Stow fetches the remote URL server-side, stores the resulting bytes, and | ||
| * persists the file as if it had been uploaded normally. This is useful for | ||
| * migrations, ingestion pipelines, or bringing third-party assets into Stow | ||
| * without downloading them into your own process first. | ||
| */ | ||
@@ -818,20 +1050,32 @@ uploadFromUrl(url: string, filename: string, options?: { | ||
| /** | ||
| * Get a presigned URL for direct client-side upload. | ||
| * Get a presigned URL for a direct client upload. | ||
| * | ||
| * This enables uploads that bypass your server entirely: | ||
| * 1. Client calls your endpoint | ||
| * 2. Your endpoint calls this method | ||
| * 3. Client PUTs directly to the returned uploadUrl | ||
| * 4. Client calls confirmUpload to finalize | ||
| * This is the server-side half of the browser upload flow used by | ||
| * `@howells/stow-client` and `@howells/stow-next`: | ||
| * 1. browser calls your app | ||
| * 2. your app calls `getPresignedUrl()` | ||
| * 3. browser PUTs bytes to `uploadUrl` | ||
| * 4. browser or your app calls `confirmUpload()` | ||
| * | ||
| * If `contentHash` matches an existing file in the target bucket, the API | ||
| * short-circuits with `{ dedupe: true, ... }` and no upload is required. | ||
| */ | ||
| getPresignedUrl(request: PresignRequest): Promise<PresignResult>; | ||
| /** | ||
| * Confirm a presigned upload after the client has uploaded to R2. | ||
| * This creates the file record in the database. | ||
| * Confirm a direct upload after the client has finished the storage PUT. | ||
| * | ||
| * This finalizes the file record in the database and optionally triggers | ||
| * post-processing such as AI-generated title/description/alt text. | ||
| * | ||
| * Call this exactly once per successful presigned upload. | ||
| */ | ||
| confirmUpload(request: ConfirmUploadRequest): Promise<UploadResult>; | ||
| /** | ||
| * List files in the bucket | ||
| * List files in a bucket with optional prefix filtering and enrichment blocks. | ||
| * | ||
| * @param options.include - Optional enrichment fields: `"tags"`, `"taxonomies"` | ||
| * Use `cursor` to continue pagination from a previous page. When requesting | ||
| * `include`, Stow expands those relationships inline so you can avoid follow-up | ||
| * per-file lookups. | ||
| * | ||
| * @param options.include Optional enrichment fields: `"tags"`, `"taxonomies"` | ||
| */ | ||
@@ -864,5 +1108,8 @@ listFiles(options?: { | ||
| /** | ||
| * Get a single file by key | ||
| * Get one file by key. | ||
| * | ||
| * @param options.include - Optional enrichment fields: `"tags"`, `"taxonomies"` | ||
| * This is the detailed file view and includes dimensions, embeddings status, | ||
| * extracted colors, and AI metadata fields when available. | ||
| * | ||
| * @param options.include Optional enrichment fields: `"tags"`, `"taxonomies"` | ||
| */ | ||
@@ -927,13 +1174,2 @@ getFile(key: string, options?: { | ||
| /** | ||
| * Get a transform URL for an image. | ||
| * | ||
| * Appends transform query params (?w=, ?h=, ?q=, ?f=) to a file URL. | ||
| * Transforms are applied at the edge by the Cloudflare Worker — no | ||
| * server round-trip needed. | ||
| * | ||
| * @param url - Full file URL (e.g. from upload result's fileUrl) | ||
| * @param options - Transform options (width, height, quality, format) | ||
| */ | ||
| getTransformUrl(url: string, options?: TransformOptions): string; | ||
| /** | ||
| * Tags namespace for creating, listing, and deleting tags | ||
@@ -943,3 +1179,3 @@ */ | ||
| list: () => Promise<{ | ||
| tags: Array<{ | ||
| tags: { | ||
| id: string; | ||
@@ -950,3 +1186,3 @@ name: string; | ||
| createdAt: string; | ||
| }>; | ||
| }[]; | ||
| }>; | ||
@@ -996,3 +1232,30 @@ create: (params: { | ||
| /** | ||
| * Search namespace for vector similarity search | ||
| * Semantic search namespace. | ||
| * | ||
| * Methods: | ||
| * - `text(...)` embeds text and finds matching files | ||
| * - `similar(...)` finds nearest neighbors for a file, anchor, profile, or cluster | ||
| * - `diverse(...)` balances similarity against result spread | ||
| * - `color(...)` performs palette similarity search | ||
| * - `image(...)` searches using an existing file or an external image URL | ||
| * | ||
| * Cluster-aware search examples: | ||
| * | ||
| * @example | ||
| * ```typescript | ||
| * const cluster = await stow.clusters.create({ | ||
| * bucket: "inspiration", | ||
| * fileKeys, | ||
| * clusterCount: 12, | ||
| * }); | ||
| * | ||
| * const firstGroup = cluster.clusters[0]; | ||
| * if (firstGroup) { | ||
| * const related = await stow.search.similar({ | ||
| * bucket: "inspiration", | ||
| * clusterId: firstGroup.id, | ||
| * limit: 20, | ||
| * }); | ||
| * } | ||
| * ``` | ||
| */ | ||
@@ -1036,3 +1299,7 @@ get search(): { | ||
| /** | ||
| * Profiles namespace for managing taste profiles | ||
| * Taste-profile namespace. | ||
| * | ||
| * Profiles are long-lived preference objects. They can be seeded from files, | ||
| * updated through weighted signals, clustered into interpretable segments, and | ||
| * then reused as semantic search seeds. | ||
| */ | ||
@@ -1055,2 +1322,46 @@ get profiles(): { | ||
| }; | ||
| /** | ||
| * Curated cluster namespace. | ||
| * | ||
| * Use this when you have an explicit file set that should be grouped by visual | ||
| * similarity, but should not be modeled as a behavioral profile. | ||
| * | ||
| * Typical workflow: | ||
| * 1. `create({ fileKeys, clusterCount })` | ||
| * 2. poll `get(id)` until `clusteredAt` is non-null | ||
| * 3. inspect `clusters` | ||
| * 4. fetch representative files with `files(id, clusterId)` | ||
| * 5. optionally `renameCluster(...)` | ||
| * 6. use `clusterId` with `search.similar(...)` or `search.diverse(...)` | ||
| * | ||
| * @example | ||
| * ```typescript | ||
| * const resource = await stow.clusters.create({ | ||
| * bucket: "featured-products", | ||
| * fileKeys: featuredKeys, | ||
| * clusterCount: 12, | ||
| * name: "Navigator groups", | ||
| * }); | ||
| * | ||
| * const latest = await stow.clusters.get(resource.id, "featured-products"); | ||
| * const group = latest.clusters[0]; | ||
| * if (group) { | ||
| * const representatives = await stow.clusters.files(resource.id, group.id, { | ||
| * limit: 12, | ||
| * offset: 0, | ||
| * }); | ||
| * } | ||
| * ``` | ||
| */ | ||
| get clusters(): { | ||
| create: (params: ClusterCreateRequest) => Promise<ClusterResourceResult>; | ||
| get: (id: string, bucket?: string) => Promise<ClusterResourceResult>; | ||
| recluster: (id: string, params?: ReclusterRequest, bucket?: string) => Promise<ClusterResourceResult>; | ||
| files: (id: string, clusterId: string, params?: { | ||
| limit?: number; | ||
| offset?: number; | ||
| }, bucket?: string) => Promise<ClusterFilesResult>; | ||
| renameCluster: (id: string, clusterId: string, params: RenameClusterRequest, bucket?: string) => Promise<ClusterGroupResult>; | ||
| delete: (id: string, bucket?: string) => Promise<void>; | ||
| }; | ||
| private createProfile; | ||
@@ -1066,2 +1377,8 @@ private getProfile; | ||
| private renameProfileCluster; | ||
| private createClustersResource; | ||
| private getClustersResource; | ||
| private reclusterClustersResource; | ||
| private getClusterFiles; | ||
| private renameClusterGroup; | ||
| private deleteClustersResource; | ||
| /** | ||
@@ -1098,2 +1415,2 @@ * Anchors namespace for creating, listing, updating, and deleting text anchors. | ||
| export { type Anchor, type AnchorSearchResult, type AppliedFilters, type BucketResult, type ColorSearchRequest, type ColorSearchResult, type ColorSearchResultItem, type ConfirmUploadRequest, type CreateAnchorRequest, type CreateBucketRequest, type DeleteProfileSignalsResult, type DiverseSearchRequest, type Drop, type DropResult, type FileColor, type FileColorProfile, type FileIncludeField, type FileResult, type FileTag, type FileTaxonomy, type FilteredMetadata, type ListBucketsResult, type ListDropsResult, type ListFilesItem, type ListFilesResult, type PresignDedupeResult, type PresignNewResult, type PresignRequest, type PresignResult, type ProfileClusterResult, type ProfileCreateRequest, type ProfileFilesResult, type ProfileResult, type ProfileSignalInput, type ProfileSignalResult, type ProfileSignalType, type ProfileSignalsResponse, type QueuedResult, type ReclusterRequest, type ReclusterResult, type RenameClusterRequest, type ReplaceResult, type SearchByImageInput, type SearchByImageOptions, type SearchByImageResult, type SearchByImageSource, type SearchFilters, type SearchIncludeField, type SearchResultItem, type SimilarSearchRequest, type SimilarSearchResult, StowError, StowServer, type StowServerConfig, type TaskTriggerResult, type TaxonomyGroup, type TaxonomyListResult, type TaxonomyTerm, type TextSearchRequest, type TransformOptions, type UpdateAnchorRequest, type UpdateBucketRequest, type UploadResult, type WhoamiResult }; | ||
| export { type Anchor, type AnchorSearchResult, type AppliedFilters, type BucketResult, type ClusterCreateRequest, type ClusterFileResult, type ClusterFilesResult, type ClusterGroupResult, type ClusterResourceResult, type ColorSearchRequest, type ColorSearchResult, type ColorSearchResultItem, type ConfirmUploadRequest, type CreateAnchorRequest, type CreateBucketRequest, type DeleteProfileSignalsResult, type DiverseSearchRequest, type Drop, type DropResult, type FileColor, type FileColorProfile, type FileIncludeField, type FileResult, type FileTag, type FileTaxonomy, type FilteredMetadata, type ListBucketsResult, type ListDropsResult, type ListFilesItem, type ListFilesResult, type PresignDedupeResult, type PresignNewResult, type PresignRequest, type PresignResult, type ProfileClusterResult, type ProfileCreateRequest, type ProfileFilesResult, type ProfileResult, type ProfileSignalInput, type ProfileSignalResult, type ProfileSignalType, type ProfileSignalsResponse, type QueuedResult, type ReclusterRequest, type ReclusterResult, type RenameClusterRequest, type ReplaceResult, type SearchByImageInput, type SearchByImageOptions, type SearchByImageResult, type SearchByImageSource, type SearchFilters, type SearchIncludeField, type SearchResultItem, type SimilarSearchRequest, type SimilarSearchResult, StowError, StowServer, type StowServerConfig, type TaskTriggerResult, type TaxonomyGroup, type TaxonomyListResult, type TaxonomyTerm, type TextSearchRequest, type TransformOptions, type UpdateAnchorRequest, type UpdateBucketRequest, type UploadResult, type WhoamiResult }; |
+304
-105
@@ -28,3 +28,6 @@ "use strict"; | ||
| var import_node_crypto = require("crypto"); | ||
| var import_promises = require("timers/promises"); | ||
| var import_zod = require("zod"); | ||
| // src/stow-error.ts | ||
| var StowError = class extends Error { | ||
@@ -40,2 +43,4 @@ status; | ||
| }; | ||
| // src/index.ts | ||
| var fileColorSchema = import_zod.z.object({ | ||
@@ -199,6 +204,3 @@ position: import_zod.z.number().int(), | ||
| }); | ||
| var presignResultSchema = import_zod.z.union([ | ||
| presignDedupeResultSchema, | ||
| presignNewResultSchema | ||
| ]); | ||
| var presignResultSchema = import_zod.z.union([presignDedupeResultSchema, presignNewResultSchema]); | ||
| var confirmResultSchema = import_zod.z.object({ | ||
@@ -255,2 +257,38 @@ key: import_zod.z.string(), | ||
| }); | ||
| var clusterGroupResultSchema = import_zod.z.object({ | ||
| id: import_zod.z.string(), | ||
| index: import_zod.z.number().int(), | ||
| name: import_zod.z.string().nullable(), | ||
| description: import_zod.z.string().nullable(), | ||
| fileCount: import_zod.z.number().int(), | ||
| nameGeneratedAt: import_zod.z.string().nullable() | ||
| }); | ||
| var clusterResourceResultSchema = import_zod.z.object({ | ||
| id: import_zod.z.string(), | ||
| name: import_zod.z.string().nullable(), | ||
| clusterCount: import_zod.z.number().int(), | ||
| fileCount: import_zod.z.number().int(), | ||
| clusteredAt: import_zod.z.string().nullable(), | ||
| createdAt: import_zod.z.string(), | ||
| updatedAt: import_zod.z.string(), | ||
| clusters: import_zod.z.array(clusterGroupResultSchema) | ||
| }); | ||
| var clusterFileResultSchema = import_zod.z.object({ | ||
| id: import_zod.z.string(), | ||
| key: import_zod.z.string(), | ||
| bucketId: import_zod.z.string(), | ||
| originalFilename: import_zod.z.string().nullable(), | ||
| size: import_zod.z.number(), | ||
| contentType: import_zod.z.string(), | ||
| metadata: import_zod.z.record(import_zod.z.string(), import_zod.z.string()).nullable(), | ||
| createdAt: import_zod.z.string(), | ||
| distance: import_zod.z.number().nullable() | ||
| }); | ||
| var clusterFilesResultSchema = import_zod.z.object({ | ||
| clusterId: import_zod.z.string(), | ||
| files: import_zod.z.array(clusterFileResultSchema), | ||
| limit: import_zod.z.number().int(), | ||
| offset: import_zod.z.number().int(), | ||
| total: import_zod.z.number().int() | ||
| }); | ||
| var profileFilesResultSchema = import_zod.z.object({ | ||
@@ -303,2 +341,24 @@ id: import_zod.z.string(), | ||
| }); | ||
| function sleep(ms) { | ||
| return (0, import_promises.setTimeout)(ms); | ||
| } | ||
| function buildTransformUrl(url, options) { | ||
| if (!(options && (options.width || options.height || options.quality || options.format))) { | ||
| return url; | ||
| } | ||
| const parsed = new URL(url); | ||
| if (options.width) { | ||
| parsed.searchParams.set("w", String(options.width)); | ||
| } | ||
| if (options.height) { | ||
| parsed.searchParams.set("h", String(options.height)); | ||
| } | ||
| if (options.quality) { | ||
| parsed.searchParams.set("q", String(options.quality)); | ||
| } | ||
| if (options.format) { | ||
| parsed.searchParams.set("f", options.format); | ||
| } | ||
| return parsed.toString(); | ||
| } | ||
| var StowServer = class { | ||
@@ -310,2 +370,16 @@ apiKey; | ||
| retries; | ||
| /** | ||
| * Pure helper for building signed transform URLs from an existing public file URL. | ||
| * | ||
| * This does not perform any network I/O and is safe to pass around as a plain | ||
| * function, for example to view-layer code that needs responsive image URLs. | ||
| */ | ||
| getTransformUrl; | ||
| /** | ||
| * Create a server SDK instance. | ||
| * | ||
| * Pass a bare string when you only need the API key and want default values | ||
| * for `baseUrl`, `timeout`, and retries. Pass an object when you want a | ||
| * default bucket, a non-production API origin, or custom transport settings. | ||
| */ | ||
| constructor(config) { | ||
@@ -324,6 +398,5 @@ if (typeof config === "string") { | ||
| } | ||
| this.getTransformUrl = buildTransformUrl; | ||
| } | ||
| /** | ||
| * Get the base URL for this instance (used by client SDK) | ||
| */ | ||
| /** Return the configured API origin, mainly for adapter packages such as `stow-next`. */ | ||
| getBaseUrl() { | ||
@@ -442,3 +515,3 @@ return this.baseUrl; | ||
| const maxAttempts = this.retries + 1; | ||
| for (let attempt = 0; attempt < maxAttempts; attempt++) { | ||
| for (let attempt = 0; attempt < maxAttempts; attempt += 1) { | ||
| const controller = new AbortController(); | ||
@@ -465,3 +538,3 @@ const timeoutId = setTimeout(() => controller.abort(), this.timeout); | ||
| if (isRetryable && attempt < maxAttempts - 1) { | ||
| await this.sleep(1e3 * 2 ** attempt); | ||
| await sleep(1e3 * 2 ** attempt); | ||
| continue; | ||
@@ -472,22 +545,18 @@ } | ||
| return schema ? schema.parse(data) : data; | ||
| } catch (err) { | ||
| if (err instanceof StowError) { | ||
| throw err; | ||
| } catch (error) { | ||
| if (error instanceof StowError) { | ||
| throw error; | ||
| } | ||
| if (err instanceof import_zod.z.ZodError) { | ||
| throw new StowError( | ||
| "Invalid response format", | ||
| 500, | ||
| "INVALID_RESPONSE" | ||
| ); | ||
| if (error instanceof import_zod.z.ZodError) { | ||
| throw new StowError("Invalid response format", 500, "INVALID_RESPONSE"); | ||
| } | ||
| if (err instanceof DOMException || err instanceof Error && err.name === "AbortError") { | ||
| if (error instanceof DOMException || error instanceof Error && error.name === "AbortError") { | ||
| throw new StowError("Request timed out", 408, "TIMEOUT"); | ||
| } | ||
| if (attempt < maxAttempts - 1) { | ||
| await this.sleep(1e3 * 2 ** attempt); | ||
| await sleep(1e3 * 2 ** attempt); | ||
| continue; | ||
| } | ||
| throw new StowError( | ||
| err instanceof Error ? err.message : "Network error", | ||
| error instanceof Error ? error.message : "Network error", | ||
| 0, | ||
@@ -502,7 +571,26 @@ "NETWORK_ERROR" | ||
| } | ||
| sleep(ms) { | ||
| return new Promise((resolve) => setTimeout(resolve, ms)); | ||
| } | ||
| /** | ||
| * Upload a file directly from the server | ||
| * Upload bytes from a trusted server environment. | ||
| * | ||
| * This is the highest-level server upload helper: | ||
| * 1. compute a SHA-256 hash for dedupe | ||
| * 2. request a presigned upload URL | ||
| * 3. PUT bytes to storage | ||
| * 4. confirm the upload with optional AI metadata generation | ||
| * | ||
| * Prefer this method when your code already has the file bytes in memory. | ||
| * Use `getPresignedUrl()` + `confirmUpload()` for direct browser uploads | ||
| * instead, and `uploadFromUrl()` when the source is an external URL. | ||
| * | ||
| * @example | ||
| * ```typescript | ||
| * await stow.uploadFile(buffer, { | ||
| * filename: "product.jpg", | ||
| * contentType: "image/jpeg", | ||
| * bucket: "catalog", | ||
| * metadata: { sku: "SKU-123" }, | ||
| * title: true, | ||
| * altText: true, | ||
| * }); | ||
| * ``` | ||
| */ | ||
@@ -541,6 +629,3 @@ async uploadFile(file, options) { | ||
| return this.request( | ||
| this.withBucket( | ||
| presign.confirmUrl || "/presign/confirm", | ||
| options?.bucket | ||
| ), | ||
| this.withBucket(presign.confirmUrl || "/presign/confirm", options?.bucket), | ||
| { | ||
@@ -565,3 +650,8 @@ method: "POST", | ||
| /** | ||
| * Upload a file from a URL (server-side fetch + upload) | ||
| * Import a remote asset by URL. | ||
| * | ||
| * Stow fetches the remote URL server-side, stores the resulting bytes, and | ||
| * persists the file as if it had been uploaded normally. This is useful for | ||
| * migrations, ingestion pipelines, or bringing third-party assets into Stow | ||
| * without downloading them into your own process first. | ||
| */ | ||
@@ -601,3 +691,3 @@ async uploadFromUrl(url, filename, options) { | ||
| */ | ||
| async queueUploadFromUrl(url, filename, options) { | ||
| queueUploadFromUrl(url, filename, options) { | ||
| return this.request( | ||
@@ -623,20 +713,16 @@ this.withBucket("/upload", options?.bucket), | ||
| /** | ||
| * Get a presigned URL for direct client-side upload. | ||
| * Get a presigned URL for a direct client upload. | ||
| * | ||
| * This enables uploads that bypass your server entirely: | ||
| * 1. Client calls your endpoint | ||
| * 2. Your endpoint calls this method | ||
| * 3. Client PUTs directly to the returned uploadUrl | ||
| * 4. Client calls confirmUpload to finalize | ||
| * This is the server-side half of the browser upload flow used by | ||
| * `@howells/stow-client` and `@howells/stow-next`: | ||
| * 1. browser calls your app | ||
| * 2. your app calls `getPresignedUrl()` | ||
| * 3. browser PUTs bytes to `uploadUrl` | ||
| * 4. browser or your app calls `confirmUpload()` | ||
| * | ||
| * If `contentHash` matches an existing file in the target bucket, the API | ||
| * short-circuits with `{ dedupe: true, ... }` and no upload is required. | ||
| */ | ||
| getPresignedUrl(request) { | ||
| const { | ||
| filename, | ||
| contentType, | ||
| size, | ||
| route, | ||
| bucket, | ||
| metadata, | ||
| contentHash | ||
| } = request; | ||
| const { filename, contentType, size, route, bucket, metadata, contentHash } = request; | ||
| return this.request( | ||
@@ -660,4 +746,8 @@ this.withBucket("/presign", bucket), | ||
| /** | ||
| * Confirm a presigned upload after the client has uploaded to R2. | ||
| * This creates the file record in the database. | ||
| * Confirm a direct upload after the client has finished the storage PUT. | ||
| * | ||
| * This finalizes the file record in the database and optionally triggers | ||
| * post-processing such as AI-generated title/description/alt text. | ||
| * | ||
| * Call this exactly once per successful presigned upload. | ||
| */ | ||
@@ -698,5 +788,9 @@ confirmUpload(request) { | ||
| /** | ||
| * List files in the bucket | ||
| * List files in a bucket with optional prefix filtering and enrichment blocks. | ||
| * | ||
| * @param options.include - Optional enrichment fields: `"tags"`, `"taxonomies"` | ||
| * Use `cursor` to continue pagination from a previous page. When requesting | ||
| * `include`, Stow expands those relationships inline so you can avoid follow-up | ||
| * per-file lookups. | ||
| * | ||
| * @param options.include Optional enrichment fields: `"tags"`, `"taxonomies"` | ||
| */ | ||
@@ -721,7 +815,3 @@ listFiles(options) { | ||
| const path = `/files?${params}`; | ||
| return this.request( | ||
| this.withBucket(path, options?.bucket), | ||
| { method: "GET" }, | ||
| listFilesSchema | ||
| ); | ||
| return this.request(this.withBucket(path, options?.bucket), { method: "GET" }, listFilesSchema); | ||
| } | ||
@@ -749,5 +839,8 @@ /** | ||
| /** | ||
| * Get a single file by key | ||
| * Get one file by key. | ||
| * | ||
| * @param options.include - Optional enrichment fields: `"tags"`, `"taxonomies"` | ||
| * This is the detailed file view and includes dimensions, embeddings status, | ||
| * extracted colors, and AI metadata fields when available. | ||
| * | ||
| * @param options.include Optional enrichment fields: `"tags"`, `"taxonomies"` | ||
| */ | ||
@@ -860,31 +953,2 @@ getFile(key, options) { | ||
| } | ||
| /** | ||
| * Get a transform URL for an image. | ||
| * | ||
| * Appends transform query params (?w=, ?h=, ?q=, ?f=) to a file URL. | ||
| * Transforms are applied at the edge by the Cloudflare Worker — no | ||
| * server round-trip needed. | ||
| * | ||
| * @param url - Full file URL (e.g. from upload result's fileUrl) | ||
| * @param options - Transform options (width, height, quality, format) | ||
| */ | ||
| getTransformUrl(url, options) { | ||
| if (!(options && (options.width || options.height || options.quality || options.format))) { | ||
| return url; | ||
| } | ||
| const parsed = new URL(url); | ||
| if (options.width) { | ||
| parsed.searchParams.set("w", String(options.width)); | ||
| } | ||
| if (options.height) { | ||
| parsed.searchParams.set("h", String(options.height)); | ||
| } | ||
| if (options.quality) { | ||
| parsed.searchParams.set("q", String(options.quality)); | ||
| } | ||
| if (options.format) { | ||
| parsed.searchParams.set("f", options.format); | ||
| } | ||
| return parsed.toString(); | ||
| } | ||
| // ============================================================ | ||
@@ -967,3 +1031,30 @@ // TAGS - Org-scoped labels for file organization | ||
| /** | ||
| * Search namespace for vector similarity search | ||
| * Semantic search namespace. | ||
| * | ||
| * Methods: | ||
| * - `text(...)` embeds text and finds matching files | ||
| * - `similar(...)` finds nearest neighbors for a file, anchor, profile, or cluster | ||
| * - `diverse(...)` balances similarity against result spread | ||
| * - `color(...)` performs palette similarity search | ||
| * - `image(...)` searches using an existing file or an external image URL | ||
| * | ||
| * Cluster-aware search examples: | ||
| * | ||
| * @example | ||
| * ```typescript | ||
| * const cluster = await stow.clusters.create({ | ||
| * bucket: "inspiration", | ||
| * fileKeys, | ||
| * clusterCount: 12, | ||
| * }); | ||
| * | ||
| * const firstGroup = cluster.clusters[0]; | ||
| * if (firstGroup) { | ||
| * const related = await stow.search.similar({ | ||
| * bucket: "inspiration", | ||
| * clusterId: firstGroup.id, | ||
| * limit: 20, | ||
| * }); | ||
| * } | ||
| * ``` | ||
| */ | ||
@@ -1140,3 +1231,7 @@ get search() { | ||
| /** | ||
| * Profiles namespace for managing taste profiles | ||
| * Taste-profile namespace. | ||
| * | ||
| * Profiles are long-lived preference objects. They can be seeded from files, | ||
| * updated through weighted signals, clustered into interpretable segments, and | ||
| * then reused as semantic search seeds. | ||
| */ | ||
@@ -1157,2 +1252,45 @@ get profiles() { | ||
| } | ||
| /** | ||
| * Curated cluster namespace. | ||
| * | ||
| * Use this when you have an explicit file set that should be grouped by visual | ||
| * similarity, but should not be modeled as a behavioral profile. | ||
| * | ||
| * Typical workflow: | ||
| * 1. `create({ fileKeys, clusterCount })` | ||
| * 2. poll `get(id)` until `clusteredAt` is non-null | ||
| * 3. inspect `clusters` | ||
| * 4. fetch representative files with `files(id, clusterId)` | ||
| * 5. optionally `renameCluster(...)` | ||
| * 6. use `clusterId` with `search.similar(...)` or `search.diverse(...)` | ||
| * | ||
| * @example | ||
| * ```typescript | ||
| * const resource = await stow.clusters.create({ | ||
| * bucket: "featured-products", | ||
| * fileKeys: featuredKeys, | ||
| * clusterCount: 12, | ||
| * name: "Navigator groups", | ||
| * }); | ||
| * | ||
| * const latest = await stow.clusters.get(resource.id, "featured-products"); | ||
| * const group = latest.clusters[0]; | ||
| * if (group) { | ||
| * const representatives = await stow.clusters.files(resource.id, group.id, { | ||
| * limit: 12, | ||
| * offset: 0, | ||
| * }); | ||
| * } | ||
| * ``` | ||
| */ | ||
| get clusters() { | ||
| return { | ||
| create: (params) => this.createClustersResource(params), | ||
| get: (id, bucket) => this.getClustersResource(id, bucket), | ||
| recluster: (id, params, bucket) => this.reclusterClustersResource(id, params, bucket), | ||
| files: (id, clusterId, params, bucket) => this.getClusterFiles(id, clusterId, params, bucket), | ||
| renameCluster: (id, clusterId, params, bucket) => this.renameClusterGroup(id, clusterId, params, bucket), | ||
| delete: (id, bucket) => this.deleteClustersResource(id, bucket) | ||
| }; | ||
| } | ||
| createProfile(params) { | ||
@@ -1180,6 +1318,5 @@ return this.request( | ||
| async deleteProfile(id, bucket) { | ||
| await this.request( | ||
| this.withBucket(`/profiles/${encodeURIComponent(id)}`, bucket), | ||
| { method: "DELETE" } | ||
| ); | ||
| await this.request(this.withBucket(`/profiles/${encodeURIComponent(id)}`, bucket), { | ||
| method: "DELETE" | ||
| }); | ||
| } | ||
@@ -1231,10 +1368,31 @@ addProfileFiles(id, fileKeys, bucket) { | ||
| getProfileClusters(id, bucket) { | ||
| return this.request(this.withBucket(`/profiles/${encodeURIComponent(id)}/clusters`, bucket), { | ||
| method: "GET" | ||
| }); | ||
| } | ||
| reclusterProfile(id, params, bucket) { | ||
| return this.request(this.withBucket(`/profiles/${encodeURIComponent(id)}/clusters`, bucket), { | ||
| method: "POST", | ||
| headers: { "Content-Type": "application/json" }, | ||
| body: JSON.stringify( | ||
| params?.clusterCount === void 0 ? {} : { clusterCount: params.clusterCount } | ||
| ) | ||
| }); | ||
| } | ||
| renameProfileCluster(profileId, clusterId, params, bucket) { | ||
| return this.request( | ||
| this.withBucket(`/profiles/${encodeURIComponent(id)}/clusters`, bucket), | ||
| { method: "GET" } | ||
| this.withBucket( | ||
| `/profiles/${encodeURIComponent(profileId)}/clusters/${encodeURIComponent(clusterId)}`, | ||
| bucket | ||
| ), | ||
| { | ||
| method: "PUT", | ||
| headers: { "Content-Type": "application/json" }, | ||
| body: JSON.stringify(params) | ||
| } | ||
| ); | ||
| } | ||
| reclusterProfile(id, params, bucket) { | ||
| createClustersResource(params) { | ||
| return this.request( | ||
| this.withBucket(`/profiles/${encodeURIComponent(id)}/clusters`, bucket), | ||
| "/clusters", | ||
| { | ||
@@ -1244,11 +1402,47 @@ method: "POST", | ||
| body: JSON.stringify({ | ||
| ...params?.clusterCount === void 0 ? {} : { clusterCount: params.clusterCount } | ||
| fileKeys: params.fileKeys, | ||
| ...params.clusterCount === void 0 ? {} : { clusterCount: params.clusterCount }, | ||
| ...params.name ? { name: params.name } : {}, | ||
| ...params.bucket ? { bucket: params.bucket } : {} | ||
| }) | ||
| } | ||
| }, | ||
| clusterResourceResultSchema | ||
| ); | ||
| } | ||
| renameProfileCluster(profileId, clusterId, params, bucket) { | ||
| getClustersResource(id, bucket) { | ||
| return this.request( | ||
| this.withBucket(`/clusters/${encodeURIComponent(id)}`, bucket), | ||
| { method: "GET" }, | ||
| clusterResourceResultSchema | ||
| ); | ||
| } | ||
| reclusterClustersResource(id, params, bucket) { | ||
| return this.request( | ||
| this.withBucket(`/clusters/${encodeURIComponent(id)}/recluster`, bucket), | ||
| { | ||
| method: "POST", | ||
| headers: { "Content-Type": "application/json" }, | ||
| body: JSON.stringify( | ||
| params?.clusterCount === void 0 ? {} : { clusterCount: params.clusterCount } | ||
| ) | ||
| }, | ||
| clusterResourceResultSchema | ||
| ); | ||
| } | ||
| getClusterFiles(id, clusterId, params, bucket) { | ||
| const searchParams = new URLSearchParams(); | ||
| if (params?.limit !== void 0) { | ||
| searchParams.set("limit", String(params.limit)); | ||
| } | ||
| if (params?.offset !== void 0) { | ||
| searchParams.set("offset", String(params.offset)); | ||
| } | ||
| const qs = searchParams.toString(); | ||
| const path = `/clusters/${encodeURIComponent(id)}/clusters/${encodeURIComponent(clusterId)}/files${qs ? `?${qs}` : ""}`; | ||
| return this.request(this.withBucket(path, bucket), { method: "GET" }, clusterFilesResultSchema); | ||
| } | ||
| renameClusterGroup(id, clusterId, params, bucket) { | ||
| return this.request( | ||
| this.withBucket( | ||
| `/profiles/${encodeURIComponent(profileId)}/clusters/${encodeURIComponent(clusterId)}`, | ||
| `/clusters/${encodeURIComponent(id)}/clusters/${encodeURIComponent(clusterId)}`, | ||
| bucket | ||
@@ -1260,5 +1454,11 @@ ), | ||
| body: JSON.stringify(params) | ||
| } | ||
| }, | ||
| clusterGroupResultSchema | ||
| ); | ||
| } | ||
| async deleteClustersResource(id, bucket) { | ||
| await this.request(this.withBucket(`/clusters/${encodeURIComponent(id)}`, bucket), { | ||
| method: "DELETE" | ||
| }); | ||
| } | ||
| // ============================================================ | ||
@@ -1325,6 +1525,5 @@ // ANCHORS - Named semantic reference points in vector space | ||
| async deleteAnchor(id, options) { | ||
| await this.request( | ||
| this.withBucket(`/anchors/${encodeURIComponent(id)}`, options?.bucket), | ||
| { method: "DELETE" } | ||
| ); | ||
| await this.request(this.withBucket(`/anchors/${encodeURIComponent(id)}`, options?.bucket), { | ||
| method: "DELETE" | ||
| }); | ||
| } | ||
@@ -1331,0 +1530,0 @@ }; |
+304
-105
| // src/index.ts | ||
| import { createHash } from "crypto"; | ||
| import { setTimeout as delay } from "timers/promises"; | ||
| import { z } from "zod"; | ||
| // src/stow-error.ts | ||
| var StowError = class extends Error { | ||
@@ -14,2 +17,4 @@ status; | ||
| }; | ||
| // src/index.ts | ||
| var fileColorSchema = z.object({ | ||
@@ -173,6 +178,3 @@ position: z.number().int(), | ||
| }); | ||
| var presignResultSchema = z.union([ | ||
| presignDedupeResultSchema, | ||
| presignNewResultSchema | ||
| ]); | ||
| var presignResultSchema = z.union([presignDedupeResultSchema, presignNewResultSchema]); | ||
| var confirmResultSchema = z.object({ | ||
@@ -229,2 +231,38 @@ key: z.string(), | ||
| }); | ||
| var clusterGroupResultSchema = z.object({ | ||
| id: z.string(), | ||
| index: z.number().int(), | ||
| name: z.string().nullable(), | ||
| description: z.string().nullable(), | ||
| fileCount: z.number().int(), | ||
| nameGeneratedAt: z.string().nullable() | ||
| }); | ||
| var clusterResourceResultSchema = z.object({ | ||
| id: z.string(), | ||
| name: z.string().nullable(), | ||
| clusterCount: z.number().int(), | ||
| fileCount: z.number().int(), | ||
| clusteredAt: z.string().nullable(), | ||
| createdAt: z.string(), | ||
| updatedAt: z.string(), | ||
| clusters: z.array(clusterGroupResultSchema) | ||
| }); | ||
| var clusterFileResultSchema = z.object({ | ||
| id: z.string(), | ||
| key: z.string(), | ||
| bucketId: z.string(), | ||
| originalFilename: z.string().nullable(), | ||
| size: z.number(), | ||
| contentType: z.string(), | ||
| metadata: z.record(z.string(), z.string()).nullable(), | ||
| createdAt: z.string(), | ||
| distance: z.number().nullable() | ||
| }); | ||
| var clusterFilesResultSchema = z.object({ | ||
| clusterId: z.string(), | ||
| files: z.array(clusterFileResultSchema), | ||
| limit: z.number().int(), | ||
| offset: z.number().int(), | ||
| total: z.number().int() | ||
| }); | ||
| var profileFilesResultSchema = z.object({ | ||
@@ -277,2 +315,24 @@ id: z.string(), | ||
| }); | ||
| function sleep(ms) { | ||
| return delay(ms); | ||
| } | ||
| function buildTransformUrl(url, options) { | ||
| if (!(options && (options.width || options.height || options.quality || options.format))) { | ||
| return url; | ||
| } | ||
| const parsed = new URL(url); | ||
| if (options.width) { | ||
| parsed.searchParams.set("w", String(options.width)); | ||
| } | ||
| if (options.height) { | ||
| parsed.searchParams.set("h", String(options.height)); | ||
| } | ||
| if (options.quality) { | ||
| parsed.searchParams.set("q", String(options.quality)); | ||
| } | ||
| if (options.format) { | ||
| parsed.searchParams.set("f", options.format); | ||
| } | ||
| return parsed.toString(); | ||
| } | ||
| var StowServer = class { | ||
@@ -284,2 +344,16 @@ apiKey; | ||
| retries; | ||
| /** | ||
| * Pure helper for building signed transform URLs from an existing public file URL. | ||
| * | ||
| * This does not perform any network I/O and is safe to pass around as a plain | ||
| * function, for example to view-layer code that needs responsive image URLs. | ||
| */ | ||
| getTransformUrl; | ||
| /** | ||
| * Create a server SDK instance. | ||
| * | ||
| * Pass a bare string when you only need the API key and want default values | ||
| * for `baseUrl`, `timeout`, and retries. Pass an object when you want a | ||
| * default bucket, a non-production API origin, or custom transport settings. | ||
| */ | ||
| constructor(config) { | ||
@@ -298,6 +372,5 @@ if (typeof config === "string") { | ||
| } | ||
| this.getTransformUrl = buildTransformUrl; | ||
| } | ||
| /** | ||
| * Get the base URL for this instance (used by client SDK) | ||
| */ | ||
| /** Return the configured API origin, mainly for adapter packages such as `stow-next`. */ | ||
| getBaseUrl() { | ||
@@ -416,3 +489,3 @@ return this.baseUrl; | ||
| const maxAttempts = this.retries + 1; | ||
| for (let attempt = 0; attempt < maxAttempts; attempt++) { | ||
| for (let attempt = 0; attempt < maxAttempts; attempt += 1) { | ||
| const controller = new AbortController(); | ||
@@ -439,3 +512,3 @@ const timeoutId = setTimeout(() => controller.abort(), this.timeout); | ||
| if (isRetryable && attempt < maxAttempts - 1) { | ||
| await this.sleep(1e3 * 2 ** attempt); | ||
| await sleep(1e3 * 2 ** attempt); | ||
| continue; | ||
@@ -446,22 +519,18 @@ } | ||
| return schema ? schema.parse(data) : data; | ||
| } catch (err) { | ||
| if (err instanceof StowError) { | ||
| throw err; | ||
| } catch (error) { | ||
| if (error instanceof StowError) { | ||
| throw error; | ||
| } | ||
| if (err instanceof z.ZodError) { | ||
| throw new StowError( | ||
| "Invalid response format", | ||
| 500, | ||
| "INVALID_RESPONSE" | ||
| ); | ||
| if (error instanceof z.ZodError) { | ||
| throw new StowError("Invalid response format", 500, "INVALID_RESPONSE"); | ||
| } | ||
| if (err instanceof DOMException || err instanceof Error && err.name === "AbortError") { | ||
| if (error instanceof DOMException || error instanceof Error && error.name === "AbortError") { | ||
| throw new StowError("Request timed out", 408, "TIMEOUT"); | ||
| } | ||
| if (attempt < maxAttempts - 1) { | ||
| await this.sleep(1e3 * 2 ** attempt); | ||
| await sleep(1e3 * 2 ** attempt); | ||
| continue; | ||
| } | ||
| throw new StowError( | ||
| err instanceof Error ? err.message : "Network error", | ||
| error instanceof Error ? error.message : "Network error", | ||
| 0, | ||
@@ -476,7 +545,26 @@ "NETWORK_ERROR" | ||
| } | ||
| sleep(ms) { | ||
| return new Promise((resolve) => setTimeout(resolve, ms)); | ||
| } | ||
| /** | ||
| * Upload a file directly from the server | ||
| * Upload bytes from a trusted server environment. | ||
| * | ||
| * This is the highest-level server upload helper: | ||
| * 1. compute a SHA-256 hash for dedupe | ||
| * 2. request a presigned upload URL | ||
| * 3. PUT bytes to storage | ||
| * 4. confirm the upload with optional AI metadata generation | ||
| * | ||
| * Prefer this method when your code already has the file bytes in memory. | ||
| * Use `getPresignedUrl()` + `confirmUpload()` for direct browser uploads | ||
| * instead, and `uploadFromUrl()` when the source is an external URL. | ||
| * | ||
| * @example | ||
| * ```typescript | ||
| * await stow.uploadFile(buffer, { | ||
| * filename: "product.jpg", | ||
| * contentType: "image/jpeg", | ||
| * bucket: "catalog", | ||
| * metadata: { sku: "SKU-123" }, | ||
| * title: true, | ||
| * altText: true, | ||
| * }); | ||
| * ``` | ||
| */ | ||
@@ -515,6 +603,3 @@ async uploadFile(file, options) { | ||
| return this.request( | ||
| this.withBucket( | ||
| presign.confirmUrl || "/presign/confirm", | ||
| options?.bucket | ||
| ), | ||
| this.withBucket(presign.confirmUrl || "/presign/confirm", options?.bucket), | ||
| { | ||
@@ -539,3 +624,8 @@ method: "POST", | ||
| /** | ||
| * Upload a file from a URL (server-side fetch + upload) | ||
| * Import a remote asset by URL. | ||
| * | ||
| * Stow fetches the remote URL server-side, stores the resulting bytes, and | ||
| * persists the file as if it had been uploaded normally. This is useful for | ||
| * migrations, ingestion pipelines, or bringing third-party assets into Stow | ||
| * without downloading them into your own process first. | ||
| */ | ||
@@ -575,3 +665,3 @@ async uploadFromUrl(url, filename, options) { | ||
| */ | ||
| async queueUploadFromUrl(url, filename, options) { | ||
| queueUploadFromUrl(url, filename, options) { | ||
| return this.request( | ||
@@ -597,20 +687,16 @@ this.withBucket("/upload", options?.bucket), | ||
| /** | ||
| * Get a presigned URL for direct client-side upload. | ||
| * Get a presigned URL for a direct client upload. | ||
| * | ||
| * This enables uploads that bypass your server entirely: | ||
| * 1. Client calls your endpoint | ||
| * 2. Your endpoint calls this method | ||
| * 3. Client PUTs directly to the returned uploadUrl | ||
| * 4. Client calls confirmUpload to finalize | ||
| * This is the server-side half of the browser upload flow used by | ||
| * `@howells/stow-client` and `@howells/stow-next`: | ||
| * 1. browser calls your app | ||
| * 2. your app calls `getPresignedUrl()` | ||
| * 3. browser PUTs bytes to `uploadUrl` | ||
| * 4. browser or your app calls `confirmUpload()` | ||
| * | ||
| * If `contentHash` matches an existing file in the target bucket, the API | ||
| * short-circuits with `{ dedupe: true, ... }` and no upload is required. | ||
| */ | ||
| getPresignedUrl(request) { | ||
| const { | ||
| filename, | ||
| contentType, | ||
| size, | ||
| route, | ||
| bucket, | ||
| metadata, | ||
| contentHash | ||
| } = request; | ||
| const { filename, contentType, size, route, bucket, metadata, contentHash } = request; | ||
| return this.request( | ||
@@ -634,4 +720,8 @@ this.withBucket("/presign", bucket), | ||
| /** | ||
| * Confirm a presigned upload after the client has uploaded to R2. | ||
| * This creates the file record in the database. | ||
| * Confirm a direct upload after the client has finished the storage PUT. | ||
| * | ||
| * This finalizes the file record in the database and optionally triggers | ||
| * post-processing such as AI-generated title/description/alt text. | ||
| * | ||
| * Call this exactly once per successful presigned upload. | ||
| */ | ||
@@ -672,5 +762,9 @@ confirmUpload(request) { | ||
| /** | ||
| * List files in the bucket | ||
| * List files in a bucket with optional prefix filtering and enrichment blocks. | ||
| * | ||
| * @param options.include - Optional enrichment fields: `"tags"`, `"taxonomies"` | ||
| * Use `cursor` to continue pagination from a previous page. When requesting | ||
| * `include`, Stow expands those relationships inline so you can avoid follow-up | ||
| * per-file lookups. | ||
| * | ||
| * @param options.include Optional enrichment fields: `"tags"`, `"taxonomies"` | ||
| */ | ||
@@ -695,7 +789,3 @@ listFiles(options) { | ||
| const path = `/files?${params}`; | ||
| return this.request( | ||
| this.withBucket(path, options?.bucket), | ||
| { method: "GET" }, | ||
| listFilesSchema | ||
| ); | ||
| return this.request(this.withBucket(path, options?.bucket), { method: "GET" }, listFilesSchema); | ||
| } | ||
@@ -723,5 +813,8 @@ /** | ||
| /** | ||
| * Get a single file by key | ||
| * Get one file by key. | ||
| * | ||
| * @param options.include - Optional enrichment fields: `"tags"`, `"taxonomies"` | ||
| * This is the detailed file view and includes dimensions, embeddings status, | ||
| * extracted colors, and AI metadata fields when available. | ||
| * | ||
| * @param options.include Optional enrichment fields: `"tags"`, `"taxonomies"` | ||
| */ | ||
@@ -834,31 +927,2 @@ getFile(key, options) { | ||
| } | ||
| /** | ||
| * Get a transform URL for an image. | ||
| * | ||
| * Appends transform query params (?w=, ?h=, ?q=, ?f=) to a file URL. | ||
| * Transforms are applied at the edge by the Cloudflare Worker — no | ||
| * server round-trip needed. | ||
| * | ||
| * @param url - Full file URL (e.g. from upload result's fileUrl) | ||
| * @param options - Transform options (width, height, quality, format) | ||
| */ | ||
| getTransformUrl(url, options) { | ||
| if (!(options && (options.width || options.height || options.quality || options.format))) { | ||
| return url; | ||
| } | ||
| const parsed = new URL(url); | ||
| if (options.width) { | ||
| parsed.searchParams.set("w", String(options.width)); | ||
| } | ||
| if (options.height) { | ||
| parsed.searchParams.set("h", String(options.height)); | ||
| } | ||
| if (options.quality) { | ||
| parsed.searchParams.set("q", String(options.quality)); | ||
| } | ||
| if (options.format) { | ||
| parsed.searchParams.set("f", options.format); | ||
| } | ||
| return parsed.toString(); | ||
| } | ||
| // ============================================================ | ||
@@ -941,3 +1005,30 @@ // TAGS - Org-scoped labels for file organization | ||
| /** | ||
| * Search namespace for vector similarity search | ||
| * Semantic search namespace. | ||
| * | ||
| * Methods: | ||
| * - `text(...)` embeds text and finds matching files | ||
| * - `similar(...)` finds nearest neighbors for a file, anchor, profile, or cluster | ||
| * - `diverse(...)` balances similarity against result spread | ||
| * - `color(...)` performs palette similarity search | ||
| * - `image(...)` searches using an existing file or an external image URL | ||
| * | ||
| * Cluster-aware search examples: | ||
| * | ||
| * @example | ||
| * ```typescript | ||
| * const cluster = await stow.clusters.create({ | ||
| * bucket: "inspiration", | ||
| * fileKeys, | ||
| * clusterCount: 12, | ||
| * }); | ||
| * | ||
| * const firstGroup = cluster.clusters[0]; | ||
| * if (firstGroup) { | ||
| * const related = await stow.search.similar({ | ||
| * bucket: "inspiration", | ||
| * clusterId: firstGroup.id, | ||
| * limit: 20, | ||
| * }); | ||
| * } | ||
| * ``` | ||
| */ | ||
@@ -1114,3 +1205,7 @@ get search() { | ||
| /** | ||
| * Profiles namespace for managing taste profiles | ||
| * Taste-profile namespace. | ||
| * | ||
| * Profiles are long-lived preference objects. They can be seeded from files, | ||
| * updated through weighted signals, clustered into interpretable segments, and | ||
| * then reused as semantic search seeds. | ||
| */ | ||
@@ -1131,2 +1226,45 @@ get profiles() { | ||
| } | ||
| /** | ||
| * Curated cluster namespace. | ||
| * | ||
| * Use this when you have an explicit file set that should be grouped by visual | ||
| * similarity, but should not be modeled as a behavioral profile. | ||
| * | ||
| * Typical workflow: | ||
| * 1. `create({ fileKeys, clusterCount })` | ||
| * 2. poll `get(id)` until `clusteredAt` is non-null | ||
| * 3. inspect `clusters` | ||
| * 4. fetch representative files with `files(id, clusterId)` | ||
| * 5. optionally `renameCluster(...)` | ||
| * 6. use `clusterId` with `search.similar(...)` or `search.diverse(...)` | ||
| * | ||
| * @example | ||
| * ```typescript | ||
| * const resource = await stow.clusters.create({ | ||
| * bucket: "featured-products", | ||
| * fileKeys: featuredKeys, | ||
| * clusterCount: 12, | ||
| * name: "Navigator groups", | ||
| * }); | ||
| * | ||
| * const latest = await stow.clusters.get(resource.id, "featured-products"); | ||
| * const group = latest.clusters[0]; | ||
| * if (group) { | ||
| * const representatives = await stow.clusters.files(resource.id, group.id, { | ||
| * limit: 12, | ||
| * offset: 0, | ||
| * }); | ||
| * } | ||
| * ``` | ||
| */ | ||
| get clusters() { | ||
| return { | ||
| create: (params) => this.createClustersResource(params), | ||
| get: (id, bucket) => this.getClustersResource(id, bucket), | ||
| recluster: (id, params, bucket) => this.reclusterClustersResource(id, params, bucket), | ||
| files: (id, clusterId, params, bucket) => this.getClusterFiles(id, clusterId, params, bucket), | ||
| renameCluster: (id, clusterId, params, bucket) => this.renameClusterGroup(id, clusterId, params, bucket), | ||
| delete: (id, bucket) => this.deleteClustersResource(id, bucket) | ||
| }; | ||
| } | ||
| createProfile(params) { | ||
@@ -1154,6 +1292,5 @@ return this.request( | ||
| async deleteProfile(id, bucket) { | ||
| await this.request( | ||
| this.withBucket(`/profiles/${encodeURIComponent(id)}`, bucket), | ||
| { method: "DELETE" } | ||
| ); | ||
| await this.request(this.withBucket(`/profiles/${encodeURIComponent(id)}`, bucket), { | ||
| method: "DELETE" | ||
| }); | ||
| } | ||
@@ -1205,10 +1342,31 @@ addProfileFiles(id, fileKeys, bucket) { | ||
| getProfileClusters(id, bucket) { | ||
| return this.request(this.withBucket(`/profiles/${encodeURIComponent(id)}/clusters`, bucket), { | ||
| method: "GET" | ||
| }); | ||
| } | ||
| reclusterProfile(id, params, bucket) { | ||
| return this.request(this.withBucket(`/profiles/${encodeURIComponent(id)}/clusters`, bucket), { | ||
| method: "POST", | ||
| headers: { "Content-Type": "application/json" }, | ||
| body: JSON.stringify( | ||
| params?.clusterCount === void 0 ? {} : { clusterCount: params.clusterCount } | ||
| ) | ||
| }); | ||
| } | ||
| renameProfileCluster(profileId, clusterId, params, bucket) { | ||
| return this.request( | ||
| this.withBucket(`/profiles/${encodeURIComponent(id)}/clusters`, bucket), | ||
| { method: "GET" } | ||
| this.withBucket( | ||
| `/profiles/${encodeURIComponent(profileId)}/clusters/${encodeURIComponent(clusterId)}`, | ||
| bucket | ||
| ), | ||
| { | ||
| method: "PUT", | ||
| headers: { "Content-Type": "application/json" }, | ||
| body: JSON.stringify(params) | ||
| } | ||
| ); | ||
| } | ||
| reclusterProfile(id, params, bucket) { | ||
| createClustersResource(params) { | ||
| return this.request( | ||
| this.withBucket(`/profiles/${encodeURIComponent(id)}/clusters`, bucket), | ||
| "/clusters", | ||
| { | ||
@@ -1218,11 +1376,47 @@ method: "POST", | ||
| body: JSON.stringify({ | ||
| ...params?.clusterCount === void 0 ? {} : { clusterCount: params.clusterCount } | ||
| fileKeys: params.fileKeys, | ||
| ...params.clusterCount === void 0 ? {} : { clusterCount: params.clusterCount }, | ||
| ...params.name ? { name: params.name } : {}, | ||
| ...params.bucket ? { bucket: params.bucket } : {} | ||
| }) | ||
| } | ||
| }, | ||
| clusterResourceResultSchema | ||
| ); | ||
| } | ||
| renameProfileCluster(profileId, clusterId, params, bucket) { | ||
| getClustersResource(id, bucket) { | ||
| return this.request( | ||
| this.withBucket(`/clusters/${encodeURIComponent(id)}`, bucket), | ||
| { method: "GET" }, | ||
| clusterResourceResultSchema | ||
| ); | ||
| } | ||
| reclusterClustersResource(id, params, bucket) { | ||
| return this.request( | ||
| this.withBucket(`/clusters/${encodeURIComponent(id)}/recluster`, bucket), | ||
| { | ||
| method: "POST", | ||
| headers: { "Content-Type": "application/json" }, | ||
| body: JSON.stringify( | ||
| params?.clusterCount === void 0 ? {} : { clusterCount: params.clusterCount } | ||
| ) | ||
| }, | ||
| clusterResourceResultSchema | ||
| ); | ||
| } | ||
| getClusterFiles(id, clusterId, params, bucket) { | ||
| const searchParams = new URLSearchParams(); | ||
| if (params?.limit !== void 0) { | ||
| searchParams.set("limit", String(params.limit)); | ||
| } | ||
| if (params?.offset !== void 0) { | ||
| searchParams.set("offset", String(params.offset)); | ||
| } | ||
| const qs = searchParams.toString(); | ||
| const path = `/clusters/${encodeURIComponent(id)}/clusters/${encodeURIComponent(clusterId)}/files${qs ? `?${qs}` : ""}`; | ||
| return this.request(this.withBucket(path, bucket), { method: "GET" }, clusterFilesResultSchema); | ||
| } | ||
| renameClusterGroup(id, clusterId, params, bucket) { | ||
| return this.request( | ||
| this.withBucket( | ||
| `/profiles/${encodeURIComponent(profileId)}/clusters/${encodeURIComponent(clusterId)}`, | ||
| `/clusters/${encodeURIComponent(id)}/clusters/${encodeURIComponent(clusterId)}`, | ||
| bucket | ||
@@ -1234,5 +1428,11 @@ ), | ||
| body: JSON.stringify(params) | ||
| } | ||
| }, | ||
| clusterGroupResultSchema | ||
| ); | ||
| } | ||
| async deleteClustersResource(id, bucket) { | ||
| await this.request(this.withBucket(`/clusters/${encodeURIComponent(id)}`, bucket), { | ||
| method: "DELETE" | ||
| }); | ||
| } | ||
| // ============================================================ | ||
@@ -1299,6 +1499,5 @@ // ANCHORS - Named semantic reference points in vector space | ||
| async deleteAnchor(id, options) { | ||
| await this.request( | ||
| this.withBucket(`/anchors/${encodeURIComponent(id)}`, options?.bucket), | ||
| { method: "DELETE" } | ||
| ); | ||
| await this.request(this.withBucket(`/anchors/${encodeURIComponent(id)}`, options?.bucket), { | ||
| method: "DELETE" | ||
| }); | ||
| } | ||
@@ -1305,0 +1504,0 @@ }; |
+24
-24
| { | ||
| "name": "@howells/stow-server", | ||
| "version": "2.2.1", | ||
| "version": "2.3.0", | ||
| "description": "Server-side SDK for Stow file storage", | ||
| "keywords": [ | ||
| "file-storage", | ||
| "s3", | ||
| "sdk", | ||
| "stow", | ||
| "upload" | ||
| ], | ||
| "homepage": "https://stow.sh", | ||
| "license": "MIT", | ||
@@ -11,9 +19,4 @@ "repository": { | ||
| }, | ||
| "homepage": "https://stow.sh", | ||
| "keywords": [ | ||
| "stow", | ||
| "file-storage", | ||
| "s3", | ||
| "upload", | ||
| "sdk" | ||
| "files": [ | ||
| "dist" | ||
| ], | ||
@@ -30,10 +33,9 @@ "main": "./dist/index.js", | ||
| }, | ||
| "files": [ | ||
| "dist" | ||
| ], | ||
| "scripts": { | ||
| "build": "tsup src/index.ts --format cjs,esm --dts", | ||
| "dev": "tsup src/index.ts --format cjs,esm --dts --watch", | ||
| "test": "vitest run", | ||
| "test:watch": "vitest" | ||
| "devDependencies": { | ||
| "@types/node": "^25.5.0", | ||
| "tsup": "^8.5.1", | ||
| "typescript": "^5.9.3", | ||
| "vite-plus": "latest", | ||
| "zod": "^4.3.6", | ||
| "@stow/typescript-config": "0.0.0" | ||
| }, | ||
@@ -43,10 +45,8 @@ "peerDependencies": { | ||
| }, | ||
| "devDependencies": { | ||
| "@stow/typescript-config": "workspace:*", | ||
| "@types/node": "^25.5.0", | ||
| "tsup": "^8.5.1", | ||
| "typescript": "^5.9.3", | ||
| "vitest": "^4.1.0", | ||
| "zod": "^4.3.6" | ||
| "scripts": { | ||
| "build": "tsup src/index.ts --format cjs,esm --dts", | ||
| "dev": "tsup src/index.ts --format cjs,esm --dts --watch", | ||
| "test": "vp test run", | ||
| "test:watch": "vp test" | ||
| } | ||
| } | ||
| } |
+3
-5
@@ -70,6 +70,3 @@ # @howells/stow-server | ||
| ```typescript | ||
| const result = await stow.uploadFromUrl( | ||
| "https://example.com/image.jpg", | ||
| "downloaded-image.jpg" | ||
| ); | ||
| const result = await stow.uploadFromUrl("https://example.com/image.jpg", "downloaded-image.jpg"); | ||
| ``` | ||
@@ -85,3 +82,3 @@ | ||
| "image/jpeg", | ||
| "avatars" | ||
| "avatars", | ||
| ); | ||
@@ -127,2 +124,3 @@ | ||
| Transform options: | ||
| - `width` — Max width in pixels (clamped to 4096) | ||
@@ -129,0 +127,0 @@ - `height` — Max height in pixels (clamped to 4096) |
197047
25.36%7
16.67%4375
19.37%160
-1.23%