@nitida/sdk 0.20.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/AGENTS.md +48 -0
- package/README.md +1158 -0
- package/dist/expo.d.ts +48 -0
- package/dist/expo.js +25 -0
- package/dist/expo.js.map +1 -0
- package/dist/index.d.ts +790 -0
- package/dist/index.js +802 -0
- package/dist/index.js.map +1 -0
- package/dist/native.d.ts +1 -0
- package/dist/native.js +16 -0
- package/dist/native.js.map +1 -0
- package/dist/react.d.ts +47 -0
- package/dist/react.js +96 -0
- package/dist/react.js.map +1 -0
- package/dist/server.d.ts +72 -0
- package/dist/server.js +808 -0
- package/dist/server.js.map +1 -0
- package/dist/web.d.ts +147 -0
- package/dist/web.js +864 -0
- package/dist/web.js.map +1 -0
- package/package.json +122 -0
- package/skills/nitida-sdk/SKILL.md +306 -0
- package/src/expo/index.ts +76 -0
- package/src/index.ts +1562 -0
- package/src/native/index.ts +48 -0
- package/src/react/index.ts +169 -0
- package/src/server/index.ts +124 -0
- package/src/web/index.ts +334 -0
package/README.md
ADDED
|
@@ -0,0 +1,1158 @@
|
|
|
1
|
+
# `@nitida/sdk`
|
|
2
|
+
|
|
3
|
+
Universal SDK for the [aquienpz](https://aquienpz.com) multi-tenant asset platform.
|
|
4
|
+
A Cloudinary-style asset manager with deterministic CDN URLs, content-addressed
|
|
5
|
+
dedup, server-side variants (thumb / sm / md / lg / poster / video), and **slot
|
|
6
|
+
bindings** — admin-managed names that resolve to assets at runtime.
|
|
7
|
+
|
|
8
|
+
## One client, every environment
|
|
9
|
+
|
|
10
|
+
The whole SDK ships as **one `AquienpzClient` class** behind environment-
|
|
11
|
+
specific subpath entries. Each subpath bundles the same client PLUS the
|
|
12
|
+
helpers safe for that runtime — same pattern as **Vercel Blob** (`@vercel/blob`
|
|
13
|
+
vs `@vercel/blob/client`), **Better Auth**, **Uploadthing**, and the **Vercel AI
|
|
14
|
+
SDK**. Stripe/Cloudinary's split into two separate npm packages is the legacy
|
|
15
|
+
shape — modern bundlers tree-shake subpaths perfectly and a single version
|
|
16
|
+
eliminates type drift between server and client surfaces.
|
|
17
|
+
|
|
18
|
+
| Where you run | Import | What it bundles |
|
|
19
|
+
|---|---|---|
|
|
20
|
+
| Node 20+, Bun, Cloud Run, Lambda, Edge, agents, cron | **`@nitida/sdk/server`** | `AquienpzClient` + URL builders + `signTransformUrl`. **No browser-only code.** Hard guarantee against shipping `Blob`-workflow helpers to the wrong runtime. |
|
|
21
|
+
| Browser (Next.js client, Vite, CRA, web workers) | **`@nitida/sdk/web`** | `AquienpzClient` + everything in `/server` + `createWebUploader` (multipart, parallel parts, IndexedDB resume) + `compressImage` (HEIC→WebP, compressorjs). |
|
|
22
|
+
| React Native / Expo apps | `@nitida/sdk/native` | `compressImage` for RN (libjpeg-turbo via `react-native-compressor`). Pair with `/expo`. |
|
|
23
|
+
| Expo background uploads | `@nitida/sdk/expo` | `createExpoUploader` — native `URLSession` (iOS) / `WorkManager` (Android) sessions that survive app backgrounding. |
|
|
24
|
+
| Next.js / React (hooks) | `@nitida/sdk/react` | `AquienpzProvider` + `useSlot` / `useSlots` / `useAsset`. |
|
|
25
|
+
| Backwards-compat (root) | `@nitida/sdk` | Same shape as today; equivalent to `/web` for the public symbols. **Prefer the explicit subpath in new code.** |
|
|
26
|
+
|
|
27
|
+
Uploaders + compressors are **optional** peer deps (`@aquienpz/asset-uploader-{web,expo}`,
|
|
28
|
+
`@aquienpz/asset-compressor-{web,native}`). Skip them if your app only resolves
|
|
29
|
+
slots and reads assets — your bundle stays a few KB.
|
|
30
|
+
|
|
31
|
+
### Quick decision tree
|
|
32
|
+
|
|
33
|
+
- Server-side rendering, API routes, BFF, cron, agents → `@nitida/sdk/server`.
|
|
34
|
+
- Browser components, build-time pre-resolution → `@nitida/sdk/web`.
|
|
35
|
+
- Expo / React Native app → `@nitida/sdk/native` + `@nitida/sdk/expo`.
|
|
36
|
+
- React hooks (any env) → `@nitida/sdk/react`.
|
|
37
|
+
|
|
38
|
+
**Never ship the API key to the browser.** Whichever subpath you import,
|
|
39
|
+
the long-lived `amk_rt_*` key lives on the server. Browser flows hit a BFF
|
|
40
|
+
route that proxies to aquienpz with the real key.
|
|
41
|
+
|
|
42
|
+
## Which subpath do I import from?
|
|
43
|
+
|
|
44
|
+
| Context | Import | Notes |
|
|
45
|
+
|---|---|---|
|
|
46
|
+
| Browser (Next.js client component, SPA, Worker, web extension) | `@nitida/sdk/web` | **No `apiKey`** — your BFF injects it. Type omits the field; build error if you try. |
|
|
47
|
+
| Node/Bun server (API route, Server Action, Cloud Run, Fly, BFF) | `@nitida/sdk/server` | Requires `apiKey` at construction. |
|
|
48
|
+
| React Native / Expo | `@nitida/sdk/native` or `@nitida/sdk/expo` | Same BFF model as `/web` — keep keys server-side. |
|
|
49
|
+
| Universal React hook | `@nitida/sdk/react` | Wraps the right subpath for the runtime. |
|
|
50
|
+
|
|
51
|
+
### Why subpaths, not a runtime flag
|
|
52
|
+
|
|
53
|
+
If you import `/server` in browser code, the bundler throws a build error.
|
|
54
|
+
If we used a runtime `{ mode: "browser" }` flag and you forgot it, your API
|
|
55
|
+
key would silently bundle into the client. Subpaths make security mistakes
|
|
56
|
+
loud — same pattern as `@vercel/blob/client`, `better-auth/client`, AI SDK's
|
|
57
|
+
`/edge` subpath.
|
|
58
|
+
|
|
59
|
+
`@nitida/sdk/web` v0.17+ enforces this physically: the constructor type
|
|
60
|
+
is `Omit<AquienpzClientOptions, "apiKey" | "signingKey">`. Passing `apiKey`
|
|
61
|
+
won't compile, period.
|
|
62
|
+
|
|
63
|
+
## BFF-proxy mode (browser → your route → aquienpz)
|
|
64
|
+
|
|
65
|
+
Browser code constructs the client against a **relative endpoint** that
|
|
66
|
+
points at your own route handler. The handler attaches the real bearer
|
|
67
|
+
token and forwards to aquienpz. Same pattern Vercel Blob uses for
|
|
68
|
+
`@vercel/blob/client.upload`.
|
|
69
|
+
|
|
70
|
+
### Next.js — client component
|
|
71
|
+
|
|
72
|
+
```tsx
|
|
73
|
+
"use client";
|
|
74
|
+
import { AquienpzClient } from "@nitida/sdk/web";
|
|
75
|
+
|
|
76
|
+
const aq = new AquienpzClient({
|
|
77
|
+
endpoint: "/api/am", // OK: relative → same-origin BFF
|
|
78
|
+
tenantCode: "realtyone-cr",
|
|
79
|
+
tenantId: 1,
|
|
80
|
+
// apiKey: ... ERROR: TS error: not assignable to WebClientOptions
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
export function HeroPicker() {
|
|
84
|
+
return <input type="file" onChange={async (e) => {
|
|
85
|
+
const file = e.target.files?.[0];
|
|
86
|
+
if (file) await aq.upload(file, { compress: true });
|
|
87
|
+
}} />;
|
|
88
|
+
}
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
### Next.js — BFF route handler
|
|
92
|
+
|
|
93
|
+
```ts
|
|
94
|
+
// app/api/am/[...path]/route.ts
|
|
95
|
+
import { NextRequest } from "next/server";
|
|
96
|
+
|
|
97
|
+
const UPSTREAM = process.env.AQUIENPZ_URL!; // server-only
|
|
98
|
+
const API_KEY = process.env.AQUIENPZ_API_KEY!; // server-only
|
|
99
|
+
const TENANT = process.env.AQUIENPZ_TENANT_CODE!; // e.g. "realtyone-cr"
|
|
100
|
+
|
|
101
|
+
async function proxy(req: NextRequest, { params }: { params: { path: string[] } }) {
|
|
102
|
+
const search = new URL(req.url).search;
|
|
103
|
+
const url = `${UPSTREAM}/${params.path.join("/")}${search}`;
|
|
104
|
+
const body = ["GET", "HEAD"].includes(req.method) ? undefined : await req.arrayBuffer();
|
|
105
|
+
return fetch(url, {
|
|
106
|
+
method: req.method,
|
|
107
|
+
body,
|
|
108
|
+
headers: {
|
|
109
|
+
Authorization: `Bearer ${API_KEY}`,
|
|
110
|
+
"X-Tenant-Code": TENANT,
|
|
111
|
+
"Content-Type": req.headers.get("content-type") ?? "application/json",
|
|
112
|
+
},
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export { proxy as GET, proxy as POST, proxy as PUT, proxy as PATCH, proxy as DELETE };
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
### Bun / Elysia backend
|
|
120
|
+
|
|
121
|
+
```ts
|
|
122
|
+
import { AquienpzClient } from "@nitida/sdk/server";
|
|
123
|
+
|
|
124
|
+
const aq = new AquienpzClient({
|
|
125
|
+
endpoint: process.env.AQUIENPZ_URL!,
|
|
126
|
+
apiKey: process.env.AQUIENPZ_API_KEY!, // OK: required by ServerClientOptions
|
|
127
|
+
tenantCode: "realtyone-cr",
|
|
128
|
+
tenantId: 1,
|
|
129
|
+
});
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
### Expo
|
|
133
|
+
|
|
134
|
+
```ts
|
|
135
|
+
import { AquienpzClient } from "@nitida/sdk/web"; // same browser-safe type
|
|
136
|
+
// Construct against your /api/am proxy; no apiKey in the app bundle.
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
### SSG storefront (build-time)
|
|
140
|
+
|
|
141
|
+
Use `/server` at build time (Node/Bun) with the absolute aquienpz URL —
|
|
142
|
+
no proxy needed because keys never reach the browser bundle. Static
|
|
143
|
+
HTML output references the CDN directly.
|
|
144
|
+
|
|
145
|
+
## Install
|
|
146
|
+
|
|
147
|
+
```bash
|
|
148
|
+
bun add @nitida/sdk @nitida/asset-client
|
|
149
|
+
# or
|
|
150
|
+
npm install @nitida/sdk @nitida/asset-client
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
Optional, only if you need large uploads:
|
|
154
|
+
|
|
155
|
+
```bash
|
|
156
|
+
# Web / Vite / Next.js client uploads
|
|
157
|
+
bun add @aquienpz/asset-uploader-web
|
|
158
|
+
|
|
159
|
+
# Expo / React Native background uploads
|
|
160
|
+
bun add @aquienpz/asset-uploader-expo
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
Peer deps: `@nitida/asset-client` (URL builders + types) and `react` (only if
|
|
164
|
+
you use the `/react` subpath).
|
|
165
|
+
|
|
166
|
+
## Quick start
|
|
167
|
+
|
|
168
|
+
```ts
|
|
169
|
+
import { AquienpzClient } from "@nitida/sdk";
|
|
170
|
+
|
|
171
|
+
const aq = new AquienpzClient({
|
|
172
|
+
endpoint: "https://aquienpz-asset-manager-xxxx.run.app",
|
|
173
|
+
apiKey: process.env.ASSET_MANAGER_RUNTIME_KEY!, // amk_rt_* Better Auth API key
|
|
174
|
+
tenantCode: "your-tenant",
|
|
175
|
+
tenantId: 42, // numeric tenant id (used in CDN URL prefix)
|
|
176
|
+
cdnBase: "https://8ok.uk", // optional, defaults to https://8ok.uk
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
// Slots — the recommended way to reference brand assets in code.
|
|
180
|
+
// Source never hardcodes a CDN URL; admin rebinds from asset-lab-web.
|
|
181
|
+
const hero = await aq.slots.resolve("storefront.home.hero");
|
|
182
|
+
// → { slot: { asset, preset, … }, preset: "lg", url: "https://8ok.uk/2a/v/<sha>-l.webp" }
|
|
183
|
+
|
|
184
|
+
// Bulk resolution in one round-trip.
|
|
185
|
+
const heroes = await aq.slots.resolveMany([
|
|
186
|
+
"storefront.home.hero",
|
|
187
|
+
"storefront.home.tile-1",
|
|
188
|
+
"storefront.home.tile-2",
|
|
189
|
+
]);
|
|
190
|
+
|
|
191
|
+
// Lower-level operations.
|
|
192
|
+
const asset = await aq.assets.byHash("3c…<64 hex>…");
|
|
193
|
+
const { assets, nextCursor } = await aq.assets.list({ limit: 50 });
|
|
194
|
+
|
|
195
|
+
// Uploads — hash-deduped; returns the canonical v2 URL immediately.
|
|
196
|
+
const result = await aq.upload(file, { fileName: "cover.jpg" });
|
|
197
|
+
// → { assetId, sha256, cdnUrl }
|
|
198
|
+
|
|
199
|
+
// Uploading raw bytes (Node/Bun, e.g. re-hosting a remote image)? A Uint8Array has no
|
|
200
|
+
// inherent MIME, so give it one — otherwise it stores as kind:"other" (NO image variants):
|
|
201
|
+
await aq.upload(bytes, { fileName: "cover.webp" }); // MIME inferred from .webp ✓
|
|
202
|
+
await aq.upload(bytes, { contentType: "image/webp" }); // or be explicit ✓
|
|
203
|
+
// And request the variants you'll render — `presets` DEFAULTS TO ["original"] (just the raw
|
|
204
|
+
// bytes), so getAssetUrl(sha, "md") style preset URLs 404 unless you ask for the ladder:
|
|
205
|
+
await aq.upload(bytes, { contentType: "image/webp", presets: ["thumb", "sm", "md", "lg", "xl"] });
|
|
206
|
+
|
|
207
|
+
// Bind a slot (admin operation).
|
|
208
|
+
await aq.slots.bind("storefront.home.hero", {
|
|
209
|
+
assetId: result.assetId,
|
|
210
|
+
preset: "lg",
|
|
211
|
+
description: "Homepage hero — uploaded by admin on 2026-05-16",
|
|
212
|
+
});
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
## Client-side compression (browsers)
|
|
216
|
+
|
|
217
|
+
`aq.upload(file, { compress: true })` runs the file through
|
|
218
|
+
compressorjs + heic2any before the PUT, saving the user's bandwidth.
|
|
219
|
+
Typical result for an 8 MB iPhone HEIC photo: ~800 KB uploaded after
|
|
220
|
+
HEIC → JPEG → WebP @ q=0.80, max-edge 3840 px.
|
|
221
|
+
|
|
222
|
+
```ts
|
|
223
|
+
// Default — uses DEFAULT_COMPRESSION_OPTIONS (webapp-tuned values)
|
|
224
|
+
const result = await aq.upload(file, {
|
|
225
|
+
compress: true,
|
|
226
|
+
presets: ["thumb", "sm", "md", "lg"],
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
// Custom tuning per call
|
|
230
|
+
await aq.upload(file, {
|
|
231
|
+
compress: { quality: 0.7, maxWidth: 2048 },
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
// Track progress for UI
|
|
235
|
+
await aq.upload(file, {
|
|
236
|
+
compress: {
|
|
237
|
+
onProgress: (stage) => console.log(stage),
|
|
238
|
+
// stage ∈ "convertingHeic" | "compressing" | "compressingKeepingDimensions"
|
|
239
|
+
},
|
|
240
|
+
});
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
**Defaults** (matched to the realtyone-cr production webapp:
|
|
244
|
+
`LISTING_STANDARD_*` constants):
|
|
245
|
+
|
|
246
|
+
| Option | Default |
|
|
247
|
+
|---|---|
|
|
248
|
+
| `quality` | `0.80` |
|
|
249
|
+
| `mimeType` | `"image/webp"` |
|
|
250
|
+
| `maxWidth` / `maxHeight` | `3840` |
|
|
251
|
+
| `convertSize` (PNG → JPEG threshold) | `5 MB` |
|
|
252
|
+
| `strict` | `true` |
|
|
253
|
+
|
|
254
|
+
**Caveats**:
|
|
255
|
+
|
|
256
|
+
- Browser-only. In Node / Bun the call is a silent no-op (warns to
|
|
257
|
+
console) and the raw bytes upload as-is.
|
|
258
|
+
- Non-image MIMEs (video, PDF) are passed through regardless of
|
|
259
|
+
`compress: true`. Safe to set blanket-fashion on mixed media batches.
|
|
260
|
+
- Adds ~50 KB to the runtime bundle **only when used** — lazy-imported
|
|
261
|
+
from `@nitida/sdk/web`.
|
|
262
|
+
- HEIC inputs go through heic2any first (additional ~200 KB lazy bundle).
|
|
263
|
+
- Expo / React Native: the `/expo` subpath uses native
|
|
264
|
+
`expo-image-manipulator` instead (already wired in the uploader). The
|
|
265
|
+
`compress` option on `aq.upload` is web-only.
|
|
266
|
+
- The server-side `assets.client_original_bytes` column is populated
|
|
267
|
+
from the original pre-compression size, surfacing the savings story
|
|
268
|
+
in admin dashboards.
|
|
269
|
+
|
|
270
|
+
Direct access to the compressor (without going through `aq.upload`):
|
|
271
|
+
|
|
272
|
+
```ts
|
|
273
|
+
import { compressImage, compressImages, DEFAULT_COMPRESSION_OPTIONS }
|
|
274
|
+
from "@nitida/sdk/web";
|
|
275
|
+
|
|
276
|
+
const { blob, originalBytes } = await compressImage(file, { quality: 0.85 });
|
|
277
|
+
const results = await compressImages([fileA, fileB, fileC]);
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
## Large uploads (web)
|
|
281
|
+
|
|
282
|
+
The core `aq.upload()` handles files under 50 MB in a single PUT. For
|
|
283
|
+
bigger files use the `/web` subpath — it delegates to
|
|
284
|
+
`@aquienpz/asset-uploader-web`'s multipart `UploadTask` (chunks, parallel
|
|
285
|
+
parts, retries with backoff, IndexedDB-persisted progress, Web Worker
|
|
286
|
+
SHA-256).
|
|
287
|
+
|
|
288
|
+
```ts
|
|
289
|
+
import { createWebUploader } from "@nitida/sdk/web";
|
|
290
|
+
|
|
291
|
+
const task = createWebUploader(aq, { file });
|
|
292
|
+
task.on("progress", ({ ratio }) => setProgress(ratio));
|
|
293
|
+
task.on("ready", ({ assetId }) => bindSlot(assetId));
|
|
294
|
+
const { assetId, deduped } = await task.start();
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
Peer dep: `@aquienpz/asset-uploader-web` (lazy — apps that only need
|
|
298
|
+
small uploads skip it).
|
|
299
|
+
|
|
300
|
+
## Large uploads (Expo / React Native)
|
|
301
|
+
|
|
302
|
+
Native background uploads survive app suspend, low-memory kills, and
|
|
303
|
+
network blips. iOS uses URLSession's background config; Android uses
|
|
304
|
+
WorkManager. Same JS API as the web flavor.
|
|
305
|
+
|
|
306
|
+
```tsx
|
|
307
|
+
import { useEffect, useState } from "react";
|
|
308
|
+
import { Image } from "react-native";
|
|
309
|
+
import * as ImagePicker from "expo-image-picker";
|
|
310
|
+
import { AquienpzClient } from "@nitida/sdk";
|
|
311
|
+
import {
|
|
312
|
+
createExpoUploader,
|
|
313
|
+
listResumableSessions,
|
|
314
|
+
} from "@nitida/sdk/expo";
|
|
315
|
+
|
|
316
|
+
const aq = new AquienpzClient({
|
|
317
|
+
endpoint: process.env.EXPO_PUBLIC_AQUIENPZ_URL!,
|
|
318
|
+
apiKey: process.env.EXPO_PUBLIC_AQUIENPZ_API_KEY!, // amk_rt_*
|
|
319
|
+
tenantCode: "your-tenant",
|
|
320
|
+
tenantId: 42,
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
export function UploadHeroScreen() {
|
|
324
|
+
const [progress, setProgress] = useState(0);
|
|
325
|
+
const [url, setUrl] = useState<string | null>(null);
|
|
326
|
+
|
|
327
|
+
// Offer to resume anything from a prior app launch on boot.
|
|
328
|
+
useEffect(() => {
|
|
329
|
+
listResumableSessions().then((sessions) => {
|
|
330
|
+
// …show a banner if sessions.length > 0
|
|
331
|
+
});
|
|
332
|
+
}, []);
|
|
333
|
+
|
|
334
|
+
async function pickAndUpload() {
|
|
335
|
+
const picked = await ImagePicker.launchImageLibraryAsync({
|
|
336
|
+
mediaTypes: ImagePicker.MediaTypeOptions.Videos,
|
|
337
|
+
allowsMultipleSelection: false,
|
|
338
|
+
});
|
|
339
|
+
if (picked.canceled || !picked.assets[0]) return;
|
|
340
|
+
const { uri, mimeType, fileName } = picked.assets[0];
|
|
341
|
+
|
|
342
|
+
const task = createExpoUploader(aq, {
|
|
343
|
+
file: {
|
|
344
|
+
uri,
|
|
345
|
+
mime: mimeType ?? "video/mp4",
|
|
346
|
+
name: fileName ?? "tour.mp4",
|
|
347
|
+
},
|
|
348
|
+
});
|
|
349
|
+
task.on("progress", ({ ratio }) => setProgress(ratio));
|
|
350
|
+
|
|
351
|
+
const { assetId } = await task.start();
|
|
352
|
+
// Bind to a slot so the storefront picks it up without a redeploy.
|
|
353
|
+
await aq.slots.bind("storefront.tour.video", { assetId, preset: "video" });
|
|
354
|
+
const resolved = await aq.slots.resolve("storefront.tour.video");
|
|
355
|
+
setUrl(resolved.url);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
return /* …UI with pickAndUpload + progress bar + <Image source={{ uri: url }}/> */;
|
|
359
|
+
}
|
|
360
|
+
```
|
|
361
|
+
|
|
362
|
+
Peer dep: `@aquienpz/asset-uploader-expo`. Ships a config plugin for the Expo
|
|
363
|
+
prebuild step (background mode + native module registration); add it to
|
|
364
|
+
`app.json` under `plugins` before running `expo prebuild`.
|
|
365
|
+
|
|
366
|
+
## Next.js App Router (Server Components)
|
|
367
|
+
|
|
368
|
+
The cheapest path: resolve slots at render time on the server. No client JS,
|
|
369
|
+
no provider, no hook. The slot URLs ship as plain `<img>` markup.
|
|
370
|
+
|
|
371
|
+
```tsx
|
|
372
|
+
// app/page.tsx — Server Component
|
|
373
|
+
import { AquienpzClient } from "@nitida/sdk";
|
|
374
|
+
|
|
375
|
+
const aq = new AquienpzClient({
|
|
376
|
+
endpoint: process.env.AQUIENPZ_URL!,
|
|
377
|
+
apiKey: process.env.AQUIENPZ_API_KEY!, // amk_rt_* — server-only
|
|
378
|
+
tenantCode: "your-tenant",
|
|
379
|
+
tenantId: 42,
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
export default async function Page() {
|
|
383
|
+
const heroes = await aq.slots.resolveMany([
|
|
384
|
+
"storefront.home.hero",
|
|
385
|
+
"storefront.home.tile-1",
|
|
386
|
+
"storefront.home.tile-2",
|
|
387
|
+
]);
|
|
388
|
+
|
|
389
|
+
return (
|
|
390
|
+
<>
|
|
391
|
+
{heroes["storefront.home.hero"].url && (
|
|
392
|
+
<img src={heroes["storefront.home.hero"].url} alt="" />
|
|
393
|
+
)}
|
|
394
|
+
{/* … */}
|
|
395
|
+
</>
|
|
396
|
+
);
|
|
397
|
+
}
|
|
398
|
+
```
|
|
399
|
+
|
|
400
|
+
Keep the API key on the server — never expose `amk_rt_*` to `NEXT_PUBLIC_*`.
|
|
401
|
+
Uploads from Client Components should go through a thin BFF route handler that
|
|
402
|
+
proxies `aq.upload()` server-side.
|
|
403
|
+
|
|
404
|
+
For images that benefit from `next/image`, use `aq.urlFor()` + `aq.srcSetFor()`
|
|
405
|
+
to emit a static URL set; Next then handles its own optimization pipeline.
|
|
406
|
+
|
|
407
|
+
## React hooks (Client Components / SPA)
|
|
408
|
+
|
|
409
|
+
Reactive resolution on the client. The provider holds the configured client;
|
|
410
|
+
each hook subscribes to the in-process cache.
|
|
411
|
+
|
|
412
|
+
```tsx
|
|
413
|
+
// app/providers.tsx — Client Component
|
|
414
|
+
"use client";
|
|
415
|
+
import { AquienpzClient } from "@nitida/sdk";
|
|
416
|
+
import { AquienpzProvider } from "@nitida/sdk/react";
|
|
417
|
+
|
|
418
|
+
// In production, get apiKey from a /session route instead of bundling it.
|
|
419
|
+
const client = new AquienpzClient({
|
|
420
|
+
endpoint: process.env.NEXT_PUBLIC_AQUIENPZ_URL!,
|
|
421
|
+
apiKey: process.env.NEXT_PUBLIC_AQUIENPZ_API_KEY!,
|
|
422
|
+
tenantCode: "your-tenant",
|
|
423
|
+
tenantId: 42,
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
export function Providers({ children }: { children: React.ReactNode }) {
|
|
427
|
+
return <AquienpzProvider client={client}>{children}</AquienpzProvider>;
|
|
428
|
+
}
|
|
429
|
+
```
|
|
430
|
+
|
|
431
|
+
```tsx
|
|
432
|
+
// app/hero.tsx
|
|
433
|
+
"use client";
|
|
434
|
+
import { useSlot } from "@nitida/sdk/react";
|
|
435
|
+
|
|
436
|
+
export function Hero() {
|
|
437
|
+
const { url, isLoading } = useSlot("storefront.home.hero");
|
|
438
|
+
if (isLoading) return <Skeleton />;
|
|
439
|
+
if (!url) return <PlaceholderHero />;
|
|
440
|
+
return <img src={url} alt="" />;
|
|
441
|
+
}
|
|
442
|
+
```
|
|
443
|
+
|
|
444
|
+
Works in any React 18+ host: Vite, CRA, Remix, Astro islands, Expo Router,
|
|
445
|
+
React Native — wherever `react-dom` (or `react-native`) runs.
|
|
446
|
+
|
|
447
|
+
## Why slots?
|
|
448
|
+
|
|
449
|
+
Hardcoding `https://cdn.your-tenant.com/abc123.webp` in source code couples
|
|
450
|
+
deploys to brand decisions. With slots:
|
|
451
|
+
|
|
452
|
+
| Without slots | With slots |
|
|
453
|
+
|---|---|
|
|
454
|
+
| Edit URL in code | Drag-drop a new asset in asset-lab-web |
|
|
455
|
+
| Commit + PR + deploy | Cache refreshes (60s default) |
|
|
456
|
+
| 5-30 minute roundtrip | Instant |
|
|
457
|
+
|
|
458
|
+
The slot key (`storefront.home.hero`, `webapp.wizard.pool-type.icon-1`) is the
|
|
459
|
+
**stable contract** between code and brand operations. Tenants own the bindings;
|
|
460
|
+
code is a passive consumer.
|
|
461
|
+
|
|
462
|
+
## URL conventions — variants are tenant-scoped, transforms are not
|
|
463
|
+
|
|
464
|
+
Two delivery paths, **by design**:
|
|
465
|
+
|
|
466
|
+
| Kind | Builder | URL shape | Tenant in path? |
|
|
467
|
+
|---|---|---|---|
|
|
468
|
+
| Variant / preset | `urlFor`, `srcSetFor`, upload `cdnUrl` | `<cdn>/<tenantId b36>/v/<sha>-<preset>.<ext>` | **Yes** — `/4/v/<sha>-lg.webp` |
|
|
469
|
+
| On-the-fly transform | `transform`, `transformSrcSet` | `<cdn>/t/<dsl>/<sha>.<ext>` | **No** — `/t/width=1280,.../<sha>.webp` |
|
|
470
|
+
|
|
471
|
+
The transform service is content-addressed by `sha` and resizes from the source on demand,
|
|
472
|
+
so it carries **no tenant segment**. Hand-building a transform URL as `/<tenant>/t/...` **404s**.
|
|
473
|
+
(Variants are stored per-tenant, so those DO carry the tenant prefix.) Always use the SDK
|
|
474
|
+
builders rather than concatenating paths — and never prepend the tenant to a `/t/` URL.
|
|
475
|
+
|
|
476
|
+
## Presets
|
|
477
|
+
|
|
478
|
+
Presets are **platform-wide and fixed** — every tenant gets the same set,
|
|
479
|
+
generated server-side by the asset-manager's variant pipeline. A tenant
|
|
480
|
+
cannot define custom dimensions through the SDK; they pick which preset a
|
|
481
|
+
slot defaults to and emit responsive `srcSet` for browser-side resizing.
|
|
482
|
+
|
|
483
|
+
### Image presets
|
|
484
|
+
|
|
485
|
+
| Preset | Code | Max-side | Typical use |
|
|
486
|
+
|-----------|------|--------------------|---|
|
|
487
|
+
| `thumb` | `q` | 256×256 smart-crop | avatars, micro-tiles |
|
|
488
|
+
| `sm` | `s` | 640 | mobile thumbs, list cards |
|
|
489
|
+
| `md` | `m` | 1280 | desktop cards, modal previews |
|
|
490
|
+
| `lg` | `l` | 1920 (≈2K) | hero, full-bleed |
|
|
491
|
+
| `xl` | `x` | 3840 (4K) | print, 4K screens |
|
|
492
|
+
|
|
493
|
+
**Defaults — what actually runs:**
|
|
494
|
+
|
|
495
|
+
| Caller path | Variants generated on upload |
|
|
496
|
+
|-------------------------------------------------------|------------------------------|
|
|
497
|
+
| `aq.upload(file)` (SDK, no `presets`) | `original` only |
|
|
498
|
+
| `POST /assets/upload-url` (HTTP direct, no `presets`) | `original` only (same as SDK) |
|
|
499
|
+
| `presets: ["original"]` (either path) | `original` only |
|
|
500
|
+
| `presets: ["thumb","sm","md","lg"]` | exactly the four listed |
|
|
501
|
+
|
|
502
|
+
Omitting `presets` is always equivalent to `["original"]` — the platform
|
|
503
|
+
never auto-generates the responsive ladder. This keeps logo / SVG /
|
|
504
|
+
one-shot uploads cheap and avoids surprise R2 writes (the ladder is 4×
|
|
505
|
+
the source bytes). Apps that want responsive sizes pass them explicitly:
|
|
506
|
+
|
|
507
|
+
```ts
|
|
508
|
+
await aq.upload(file, {
|
|
509
|
+
presets: ["thumb", "sm", "md", "lg"], // four WebP variants
|
|
510
|
+
});
|
|
511
|
+
```
|
|
512
|
+
|
|
513
|
+
Or add missing variants later without re-uploading:
|
|
514
|
+
|
|
515
|
+
```ts
|
|
516
|
+
await aq.assets.regenerate(assetId, { presets: ["thumb", "sm", "md", "lg"] });
|
|
517
|
+
```
|
|
518
|
+
|
|
519
|
+
Variants are WebP `quality: 75–80`. Total R2 cost when generating the
|
|
520
|
+
full responsive ladder ≈ 4× source bytes.
|
|
521
|
+
|
|
522
|
+
**Variants never upscale.** The pipeline clamps each preset's target to
|
|
523
|
+
`min(presetMaxSide, sourceMaxSide)`. A 1080×720 photo asked for `xl`
|
|
524
|
+
(3840) yields a 1080×720 `xl` variant, not a blurry 3840-wide stretch.
|
|
525
|
+
The preset is a **ceiling**, not a target.
|
|
526
|
+
|
|
527
|
+
### Non-image / passthrough
|
|
528
|
+
|
|
529
|
+
| Preset | Code | Purpose |
|
|
530
|
+
|-----------|------|---|
|
|
531
|
+
| `original`| `o` | Raw uploaded bytes, no transformation. Used today for non-image MIMEs (PDFs, audio, etc.). |
|
|
532
|
+
|
|
533
|
+
### Video presets
|
|
534
|
+
|
|
535
|
+
| Preset | Code | Purpose |
|
|
536
|
+
|-----------|------|------------------------|
|
|
537
|
+
| `poster` | `p` | extracted poster WebP |
|
|
538
|
+
| `video` | `v` | original MP4 |
|
|
539
|
+
| `aiproxy` | `a` | low-res proxy for AI captioning / search (opt-in) |
|
|
540
|
+
|
|
541
|
+
### Per-upload preset selection
|
|
542
|
+
|
|
543
|
+
**The SDK's default is `["original"]`** — calling `aq.upload(file)`
|
|
544
|
+
with no `presets` option stores only the raw bytes. Add the
|
|
545
|
+
responsive ladder when you actually need it, or generate it later
|
|
546
|
+
with `aq.assets.regenerate()` (no re-upload required).
|
|
547
|
+
|
|
548
|
+
```ts
|
|
549
|
+
// Default: only the original variant lands on the CDN.
|
|
550
|
+
const { assetId, cdnUrl } = await aq.upload(logoFile);
|
|
551
|
+
// asset.presets === "o"
|
|
552
|
+
// cdnUrl = https://8ok.uk/<sha>-o.svg
|
|
553
|
+
```
|
|
554
|
+
|
|
555
|
+
**Responsive ladder (the old default — now explicit):**
|
|
556
|
+
|
|
557
|
+
```ts
|
|
558
|
+
await aq.upload(heroFile, {
|
|
559
|
+
fileName: "homepage-hero.jpg",
|
|
560
|
+
presets: ["thumb", "sm", "md", "lg"], // classic 4-step
|
|
561
|
+
});
|
|
562
|
+
```
|
|
563
|
+
|
|
564
|
+
**4K hero with the full size ladder:**
|
|
565
|
+
|
|
566
|
+
```ts
|
|
567
|
+
await aq.upload(heroFile, {
|
|
568
|
+
presets: ["thumb", "sm", "md", "lg", "xl"],
|
|
569
|
+
});
|
|
570
|
+
// asset.presets === "qsmlx"; aq.srcSetFor(asset) now emits an xl entry.
|
|
571
|
+
```
|
|
572
|
+
|
|
573
|
+
**Video without the AI proxy transcode:**
|
|
574
|
+
|
|
575
|
+
```ts
|
|
576
|
+
await aq.upload(videoFile, {
|
|
577
|
+
presets: ["poster", "video"], // skip aiproxy
|
|
578
|
+
});
|
|
579
|
+
```
|
|
580
|
+
|
|
581
|
+
**Large video — bump the readiness timeout:**
|
|
582
|
+
|
|
583
|
+
`aq.upload()` waits up to **5 minutes** by default for the asset to
|
|
584
|
+
transition to `ready`. Transcode time scales with input size and CPU,
|
|
585
|
+
so videos ≥30 MB (especially HLS ladders) can blow past that. Opt in
|
|
586
|
+
to a longer deadline via `timeoutMs`:
|
|
587
|
+
|
|
588
|
+
```ts
|
|
589
|
+
// Large video (>30MB): bump timeout to 15min
|
|
590
|
+
await aq.upload(file, { timeoutMs: 15 * 60_000 });
|
|
591
|
+
```
|
|
592
|
+
|
|
593
|
+
The default is unchanged — existing call sites need no migration.
|
|
594
|
+
|
|
595
|
+
Variants never upscale — each size preset is a **ceiling**, not a
|
|
596
|
+
target. A 1080×720 source asked for `xl` (3840) yields a 1080×720 xl
|
|
597
|
+
variant.
|
|
598
|
+
|
|
599
|
+
**Caveat (content-addressed dedup):** if someone already uploaded the
|
|
600
|
+
same bytes with different presets, `aq.upload()` returns the existing
|
|
601
|
+
asset without re-processing. The result's `cdnUrl` will use whichever
|
|
602
|
+
preset is actually available on that asset (the SDK picks `lg → md → sm
|
|
603
|
+
→ thumb → xl → original` for images, `video → poster` for videos). To
|
|
604
|
+
add variants to an existing asset, use `aq.assets.regenerate()`.
|
|
605
|
+
|
|
606
|
+
## Adding variants later (no re-upload)
|
|
607
|
+
|
|
608
|
+
The most common flow with the new default:
|
|
609
|
+
|
|
610
|
+
```ts
|
|
611
|
+
// Day 0 — upload original only.
|
|
612
|
+
const { assetId } = await aq.upload(logoFile);
|
|
613
|
+
|
|
614
|
+
// Day 7 — peek at what's there.
|
|
615
|
+
const variants = await aq.assets.variants(assetId);
|
|
616
|
+
console.log(variants.map((v) => v.preset)); // → ["original"]
|
|
617
|
+
|
|
618
|
+
// Day 7 — need a thumb for an avatar slot. Generate it server-side,
|
|
619
|
+
// merged with the existing variant set.
|
|
620
|
+
await aq.assets.regenerate(assetId, { presets: ["thumb"] });
|
|
621
|
+
|
|
622
|
+
const after = await aq.assets.variants(assetId);
|
|
623
|
+
console.log(after.map((v) => v.preset)); // → ["original", "thumb"]
|
|
624
|
+
```
|
|
625
|
+
|
|
626
|
+
`regenerate()` merges — existing variants you didn't ask for stay put.
|
|
627
|
+
You can call it many times; it's idempotent per preset.
|
|
628
|
+
|
|
629
|
+
### Source-byte lifecycle (why regenerate always works)
|
|
630
|
+
|
|
631
|
+
Two distinct R2 prefixes; most people only care about one:
|
|
632
|
+
|
|
633
|
+
| R2 path | Role | Lifetime |
|
|
634
|
+
|---|---|---|
|
|
635
|
+
| `raw/<sha>.<ext>` | Landing zone for the presigned PUT. `/process` reads it once to verify the SHA. Never CDN-served. | Deleted ~24h after upload by the cleanup job |
|
|
636
|
+
| `variants/<sha>-<preset>.<ext>` | The CDN-served files. `<sha>-o.<ext>` is the permanent home of `original`. | **Permanent** — only deleted by explicit DELETE on the asset or variant |
|
|
637
|
+
|
|
638
|
+
`regenerate()` walks a fidelity-ordered fallback chain until it finds
|
|
639
|
+
usable bytes — it **never fails on a still-present asset**:
|
|
640
|
+
|
|
641
|
+
```
|
|
642
|
+
1. variants/<sha>-o.<ext> ← permanent original (best)
|
|
643
|
+
2. raw/<sha>.<ext> ← exact upload bytes, only during the 24h grace
|
|
644
|
+
3. variants/<sha>-x.webp ← xl
|
|
645
|
+
4. variants/<sha>-l.webp ← lg
|
|
646
|
+
5. variants/<sha>-m.webp ← md
|
|
647
|
+
6. variants/<sha>-s.webp ← sm
|
|
648
|
+
7. variants/<sha>-q.webp ← thumb (last resort — smart-cropped square,
|
|
649
|
+
so aspect ratio of derived variants
|
|
650
|
+
will inherit the thumb's crop)
|
|
651
|
+
```
|
|
652
|
+
|
|
653
|
+
The no-upscale clamp guarantees we never invent pixels: asking for
|
|
654
|
+
`lg` (1920) from a 640 `sm` source yields a 640-side `lg` variant.
|
|
655
|
+
The result type's `sourceUsed` field tells you which fallback was
|
|
656
|
+
picked so you can decide whether the quality is good enough:
|
|
657
|
+
|
|
658
|
+
```ts
|
|
659
|
+
const result = await aq.assets.regenerate(assetId, { presets: ["xl"] });
|
|
660
|
+
if (result.kind === "image" && result.sourceUsed !== "original" && result.sourceUsed !== "raw") {
|
|
661
|
+
console.warn(`xl was derived from ${result.sourceUsed} — quality degraded`);
|
|
662
|
+
}
|
|
663
|
+
```
|
|
664
|
+
|
|
665
|
+
**Recommendation:** include `"original"` in the upload preset list
|
|
666
|
+
when you want guaranteed lossless future re-derivation. The SDK's
|
|
667
|
+
default (`["original"]`) already does this for you.
|
|
668
|
+
|
|
669
|
+
`aq.assets.variants(id)` and `aq.assets.regenerate(id, opts)` are
|
|
670
|
+
fully typed — your editor autocompletes the preset names and the
|
|
671
|
+
returned shape gives you `{ preset, url, width?, height?, bytes }[]`.
|
|
672
|
+
|
|
673
|
+
## On-the-fly transformations
|
|
674
|
+
|
|
675
|
+
Pre-generated presets (`thumb`/`sm`/`md`/`lg`/`xl`) cover the common cases.
|
|
676
|
+
For everything else — exact CSS pixel widths, art-directed crops, devicePixelRatio
|
|
677
|
+
ladders, square thumbs from rectangular sources — call `aq.transform()`:
|
|
678
|
+
|
|
679
|
+
```tsx
|
|
680
|
+
import { AquienpzClient } from "@nitida/sdk";
|
|
681
|
+
|
|
682
|
+
const aq = new AquienpzClient({ /* ... */ });
|
|
683
|
+
const asset = await aq.assets.byHash(sha256);
|
|
684
|
+
|
|
685
|
+
// Single URL
|
|
686
|
+
<Image
|
|
687
|
+
src={aq.transform(asset!, { width: 1280, format: "auto" })}
|
|
688
|
+
alt="..."
|
|
689
|
+
/>
|
|
690
|
+
|
|
691
|
+
// Responsive — one transform URL per width, all other params shared
|
|
692
|
+
<Image
|
|
693
|
+
src={aq.transform(asset!, { width: 1280 })}
|
|
694
|
+
srcSet={aq.transformSrcSet(asset!, [640, 960, 1280, 1920])}
|
|
695
|
+
sizes="(max-width: 768px) 100vw, 50vw"
|
|
696
|
+
alt="..."
|
|
697
|
+
/>
|
|
698
|
+
```
|
|
699
|
+
|
|
700
|
+
### DSL params
|
|
701
|
+
|
|
702
|
+
| Param | Values | Default |
|
|
703
|
+
|---|---|---|
|
|
704
|
+
| `width` | a **`TransformWidth`** — predefined ladder; off-ladder → compile error (see note) | — |
|
|
705
|
+
| `height` | `1`–`7680` (`number`; aspect ratio derived from `width`) | — |
|
|
706
|
+
| `fit` | `cover` / `contain` / `fill` / `inside` / `outside` | `cover` |
|
|
707
|
+
| `gravity` | `auto` / `face` / `center` / `north` / `south` / `east` / `west` | `center` |
|
|
708
|
+
| `format` | `auto` / `avif` / `webp` / `jpeg` / `png` | `auto` (→ `webp`) |
|
|
709
|
+
| `quality` | `auto` / `1`–`100` | `auto` (source-complexity-adaptive) |
|
|
710
|
+
| `dpr` | `1` / `2` / `3` | `1` |
|
|
711
|
+
|
|
712
|
+
> **`width` is strongly typed.** `TransformOptions.width` is a **`TransformWidth`** — the
|
|
713
|
+
> predefined CDN ladder (`160, 240, 256, 320, 400, 480, 600, 640, 800, 960, 1080, 1200,
|
|
714
|
+
> 1280, 1440, 1600, 1920, 2560, 3840`, exported as `TRANSFORM_WIDTHS`). An off-ladder
|
|
715
|
+
> width is a **compile error**: the edge whitelists exactly these as a DoS guard and
|
|
716
|
+
> HTTP 400s anything else on unsigned URLs. Need a custom off-ladder width? **Sign it** —
|
|
717
|
+
> `aq.transform(asset, { width: 1490 }, { sign: true })` and `getSignedTransformUrl` take
|
|
718
|
+
> `SignedTransformOptions` (where `width` widens to `number`); a valid `?sig=` earns the
|
|
719
|
+
> edge-whitelist bypass. `transformSrcSet` / `getTransformSrcSet` deliberately keep
|
|
720
|
+
> `number[]` because a responsive ladder may legitimately include DPR-row widths (e.g.
|
|
721
|
+
> `2400`). `height` stays `number` — for the responsive path it's derived from `width`
|
|
722
|
+
> by aspect ratio; only fixed-canvas crops / `genfill` set it explicitly.
|
|
723
|
+
|
|
724
|
+
`format=auto` resolves to **WebP** by default. The bench in `apps/asset-manager/bench/avif-vs-webp-summary.md`
|
|
725
|
+
(100 random realtyone-cr `lg.webp` samples, sharp 0.34.5) showed AVIF was 4.5–9.8% **larger**
|
|
726
|
+
than WebP in every source-size bracket with virtually identical SSIM. The policy lives
|
|
727
|
+
in a single function (`pickAutoFormat` in `transform.format-policy.ts`) so re-evaluation
|
|
728
|
+
when libavif improves is a one-file swap.
|
|
729
|
+
|
|
730
|
+
`gravity=face` runs the source image through a ~1 MB Ultra-Light face
|
|
731
|
+
detector (ultraface-RFB-320 via ONNX Runtime) and crops to the
|
|
732
|
+
highest-confidence face with sensible padding (50 % of face dimensions
|
|
733
|
+
on each side, clamped to source bounds, keeping the requested aspect
|
|
734
|
+
ratio). When no face is detected, it falls back to sharp's `attention`
|
|
735
|
+
strategy and returns the response with `X-Transform-Face: fallback` so
|
|
736
|
+
you can detect the cache state. Cost: ~10 ms inference per image
|
|
737
|
+
(warm); ~150 ms cold-start on the first request after a new Cloud Run
|
|
738
|
+
instance boots.
|
|
739
|
+
|
|
740
|
+
`quality=auto` adapts the per-format quality to source complexity
|
|
741
|
+
(luminance stddev via sharp `.stats()`):
|
|
742
|
+
|
|
743
|
+
| Bucket (stddev) | WebP | AVIF | JPEG |
|
|
744
|
+
|---|---:|---:|---:|
|
|
745
|
+
| simple (< 25) — logos, solids | 55 | 45 | 70 |
|
|
746
|
+
| normal (25–55) — most photos | 70 | 60 | 80 |
|
|
747
|
+
| complex (≥ 55) — busy textures | 72 | 65 | 82 |
|
|
748
|
+
|
|
749
|
+
Validated on 100 random realtyone-cr `lg.webp` samples: **+15.4 %
|
|
750
|
+
bytes saved vs fixed `quality=80` baseline, |ΔSSIM| 0.0011** (budget
|
|
751
|
+
0.005). See `bench/auto-quality-summary.md`. Both the bucket
|
|
752
|
+
boundaries and per-format table live in
|
|
753
|
+
`transform.quality-policy.ts` — re-tune by editing the constant and
|
|
754
|
+
re-running `bun run apps/asset-manager/scripts/bench-auto-quality.ts`.
|
|
755
|
+
|
|
756
|
+
### Canonicalization & caching
|
|
757
|
+
|
|
758
|
+
URLs with the same params in different order share the same R2 cache entry:
|
|
759
|
+
|
|
760
|
+
```ts
|
|
761
|
+
aq.transform(asset, { width: 480, fit: "contain" })
|
|
762
|
+
// → https://8ok.uk/t/fit=contain,width=480/<sha>.webp
|
|
763
|
+
|
|
764
|
+
aq.transform(asset, { fit: "contain", width: 480 })
|
|
765
|
+
// → https://8ok.uk/t/fit=contain,width=480/<sha>.webp (same URL)
|
|
766
|
+
```
|
|
767
|
+
|
|
768
|
+
The server canonicalizes incoming DSL the same way the SDK does (sort keys
|
|
769
|
+
alphabetically, lowercase string values, drop `undefined`) and hashes the
|
|
770
|
+
canonical string into the R2 cache key — so even non-SDK URLs (e.g. typed
|
|
771
|
+
by a developer in a browser bar) collapse onto the same cache entry as long
|
|
772
|
+
as they specify the same params.
|
|
773
|
+
|
|
774
|
+
### Signed URLs + strict mode (Phase 3)
|
|
775
|
+
|
|
776
|
+
Every tenant has an HMAC-SHA256 signing key (`signing_key` column in
|
|
777
|
+
`public.tenants`, 32 random bytes generated on tenant creation).
|
|
778
|
+
Optionally enable `strict_transforms = true` to reject unsigned URLs
|
|
779
|
+
with a 401 — useful when transform URLs leak from a private surface
|
|
780
|
+
(internal admin, b2b portal) and you don't want third parties
|
|
781
|
+
generating arbitrary crops.
|
|
782
|
+
|
|
783
|
+
```ts
|
|
784
|
+
const aq = new AquienpzClient({
|
|
785
|
+
endpoint: process.env.AQUIENPZ_URL!,
|
|
786
|
+
apiKey: process.env.AQUIENPZ_API_KEY!,
|
|
787
|
+
tenantCode: "your-tenant",
|
|
788
|
+
tenantId: 42,
|
|
789
|
+
// Fetch via GET /admin/projects/your-tenant; do NOT ship to the browser.
|
|
790
|
+
signingKey: process.env.AQUIENPZ_SIGNING_KEY!,
|
|
791
|
+
});
|
|
792
|
+
|
|
793
|
+
// Async when { sign: true } is set — overload returns Promise<string>.
|
|
794
|
+
const signed = await aq.transform(asset, { width: 1280 }, { sign: true });
|
|
795
|
+
// → https://8ok.uk/t/width=1280/<sha>.webp?sig=<64-hex>
|
|
796
|
+
|
|
797
|
+
// Responsive
|
|
798
|
+
const srcset = await aq.transformSrcSet(
|
|
799
|
+
asset,
|
|
800
|
+
[640, 960, 1280, 1920],
|
|
801
|
+
{},
|
|
802
|
+
{ sign: true },
|
|
803
|
+
);
|
|
804
|
+
```
|
|
805
|
+
|
|
806
|
+
Signature shape: `HMAC-SHA256(signingKey, "<canonical-DSL>/<filename>")`,
|
|
807
|
+
hex-encoded. The server canonicalizes the URL the same way the SDK does
|
|
808
|
+
(sort keys, lowercase strings), so two URLs with the same params in
|
|
809
|
+
different order accept the same signature.
|
|
810
|
+
|
|
811
|
+
**Admin operations** (system-scope admin key):
|
|
812
|
+
|
|
813
|
+
```bash
|
|
814
|
+
# Rotate the signing key — invalidates every URL signed with the old one.
|
|
815
|
+
curl -X POST -H "Authorization: Bearer amk_ad_..." \
|
|
816
|
+
https://aquienpz-asset-manager.../admin/projects/your-tenant/rotate-signing-key
|
|
817
|
+
# → { ok: true, tenantId, code, signingKey: "<64-hex>" }
|
|
818
|
+
|
|
819
|
+
# Flip strict mode on/off.
|
|
820
|
+
curl -X PATCH -H "Authorization: Bearer amk_ad_..." -H "Content-Type: application/json" \
|
|
821
|
+
-d '{"enabled":true}' \
|
|
822
|
+
https://aquienpz-asset-manager.../admin/projects/your-tenant/strict-transforms
|
|
823
|
+
```
|
|
824
|
+
|
|
825
|
+
**Rotation cost**: cached transform variants on R2 are NOT re-keyed by
|
|
826
|
+
the signature, so they keep serving the same bytes. Only the URLs your
|
|
827
|
+
consumers hold need re-signing. Coordinate the rotation with anyone who
|
|
828
|
+
pre-signs at build time (e.g. SSG / next-build).
|
|
829
|
+
|
|
830
|
+
### Background removal (`effect=removebg`)
|
|
831
|
+
|
|
832
|
+
```ts
|
|
833
|
+
// Full-resolution transparent PNG cutout
|
|
834
|
+
const url = aq.transform(asset, { effect: "removebg" });
|
|
835
|
+
// → https://8ok.uk/t/effect=removebg/<sha>.png
|
|
836
|
+
|
|
837
|
+
// Cut out + resize in one call
|
|
838
|
+
const thumbUrl = aq.transform(asset, { effect: "removebg", width: 400 });
|
|
839
|
+
// → https://8ok.uk/t/effect=removebg,width=400/<sha>.png
|
|
840
|
+
|
|
841
|
+
// Or convert format alone (no resize / no effect) — useful e.g. to
|
|
842
|
+
// force a JPEG copy of a WebP source for legacy email clients
|
|
843
|
+
const jpegUrl = aq.transform(asset, { format: "jpeg" });
|
|
844
|
+
// → https://8ok.uk/t/format=jpeg/<sha>.jpg
|
|
845
|
+
```
|
|
846
|
+
|
|
847
|
+
`effect=removebg` is provider-pluggable via the
|
|
848
|
+
`BG_REMOVAL_BACKEND` env on the asset-manager:
|
|
849
|
+
|
|
850
|
+
- **`local`** (default) — U²-Net ONNX inference, Apache 2.0 weights
|
|
851
|
+
shipped under `/app/models/u2net.onnx` (~176 MB, fetched at
|
|
852
|
+
Docker build time, sha256-verified). ~1-2 s warm CPU inference,
|
|
853
|
+
~5-7 s cold-start. Free runtime.
|
|
854
|
+
- **`replicate`** — proxies to Replicate's `851-labs/background-remover`
|
|
855
|
+
(BRIA-quality, commercially licensed via Replicate). ~3-8 s GPU,
|
|
856
|
+
~$0.001-0.005 per image. Requires `REPLICATE_API_TOKEN` in Secret
|
|
857
|
+
Manager.
|
|
858
|
+
|
|
859
|
+
In both cases the route caches the PNG in R2 under the standard
|
|
860
|
+
`<sha>-t<dslHash>.png` key, so subsequent identical requests are 302
|
|
861
|
+
redirects to the CDN — no inference, no per-image cost. **Always
|
|
862
|
+
forces `format=png`** because the entire point is preserving alpha.
|
|
863
|
+
|
|
864
|
+
The output is the same dimensions as the source. Chain with `width`
|
|
865
|
+
to resize the cutout in a single request (cached as one R2 entry per
|
|
866
|
+
canonical DSL).
|
|
867
|
+
|
|
868
|
+
To re-tune or swap the model:
|
|
869
|
+
1. Drop a new `.onnx` into `apps/asset-manager/src/features/assets/bg/`
|
|
870
|
+
2. Update `MODEL_PATH` + preprocessing constants in `bg/remove.ts`
|
|
871
|
+
3. Update the Dockerfile's `COPY src/features/assets/bg/*.onnx /app/models/`
|
|
872
|
+
4. Re-bench against a sample set (a CSV in `apps/asset-manager/bench/`
|
|
873
|
+
makes sense once the comparison is non-trivial).
|
|
874
|
+
|
|
875
|
+
### Generative fill / aspect outpaint (`effect=genfill`)
|
|
876
|
+
|
|
877
|
+
Extend a source image into a different aspect ratio without the
|
|
878
|
+
awkward edge mirroring that classic content-aware fill produces.
|
|
879
|
+
Primary use case: building OG cards (1200×630) from portrait listing
|
|
880
|
+
photos, or 1:1 social tiles from 16:9 originals.
|
|
881
|
+
|
|
882
|
+
```ts
|
|
883
|
+
// 1200×630 OG card from a portrait listing cover — gutters generated
|
|
884
|
+
// by Flux-Fill Pro, source pasted centered.
|
|
885
|
+
const ogUrl = aq.transform(asset, {
|
|
886
|
+
effect: "genfill",
|
|
887
|
+
width: 1200,
|
|
888
|
+
height: 630,
|
|
889
|
+
});
|
|
890
|
+
// → https://8ok.uk/t/effect=genfill,height=630,width=1200/<sha>.png
|
|
891
|
+
|
|
892
|
+
// 1:1 social tile from a landscape original
|
|
893
|
+
const tileUrl = aq.transform(asset, {
|
|
894
|
+
effect: "genfill",
|
|
895
|
+
width: 1080,
|
|
896
|
+
height: 1080,
|
|
897
|
+
});
|
|
898
|
+
```
|
|
899
|
+
|
|
900
|
+
**Requires both `width` and `height`.** Without them the route returns
|
|
901
|
+
422 — the effect needs an explicit target canvas to know what to
|
|
902
|
+
outpaint.
|
|
903
|
+
|
|
904
|
+
**Output defaults to WebP** at q=85 (~150KB for a 1200×630 OG card —
|
|
905
|
+
12× lighter than raw Flux-Fill PNG output). Honors `format=` for
|
|
906
|
+
explicit overrides:
|
|
907
|
+
|
|
908
|
+
| Format | Bytes (typical 1200×630) | Use case |
|
|
909
|
+
|---|---|---|
|
|
910
|
+
| `format=webp` (default) | ~150KB | OG cards, social tiles, storefront cards |
|
|
911
|
+
| `format=png` | ~1.7MB | Lossless — print, marketing fold-outs |
|
|
912
|
+
| `format=avif` | ~120KB | Modern browsers, even better compression |
|
|
913
|
+
| `format=jpeg` | ~180KB | Legacy email clients |
|
|
914
|
+
|
|
915
|
+
**Real-estate caveat**: outpainting is mediocre when the target aspect
|
|
916
|
+
differs heavily from the source (1:1 from horizontal photo → tiled
|
|
917
|
+
artifacts because the model has to invent rooftops and floors).
|
|
918
|
+
Reserve `genfill` for SMALL aspect deltas (OG card 1200×630 from
|
|
919
|
+
landscape source ✓). For bigger crops, prefer `gravity=auto` smart-crop
|
|
920
|
+
which is deterministic and free (no Replicate cost, no AI invention).
|
|
921
|
+
|
|
922
|
+
Powered by Replicate's `black-forest-labs/flux-fill-pro`. ~$0.05 per
|
|
923
|
+
first request per (sha, dsl, format) tuple; subsequent identical
|
|
924
|
+
requests are 302 redirects to the R2 cache — zero Replicate cost.
|
|
925
|
+
|
|
926
|
+
Requires `REPLICATE_API_TOKEN` in the asset-manager's Secret Manager
|
|
927
|
+
secrets. Same token as `BG_REMOVAL_BACKEND=replicate`; no separate
|
|
928
|
+
provisioning needed.
|
|
929
|
+
|
|
930
|
+
**Short-circuit**: when the source already matches the target aspect
|
|
931
|
+
exactly (resized to fill the canvas with zero padding), the server
|
|
932
|
+
returns the resized PNG without calling Replicate — you don't pay
|
|
933
|
+
$0.05 for an effective no-op.
|
|
934
|
+
|
|
935
|
+
### Video transforms (`aq.transformVideo`)
|
|
936
|
+
|
|
937
|
+
The same DSL works for videos too — the server branches on the asset's
|
|
938
|
+
`kind` column. Image params (`width`, `height`, `fit`) carry over;
|
|
939
|
+
video adds `start` (seconds, decimal OK) + `duration` (seconds, 1..300).
|
|
940
|
+
|
|
941
|
+
```ts
|
|
942
|
+
// 16:9 source → 9:16 mobile clip, first 15 s, h.264 mp4
|
|
943
|
+
const portraitUrl = aq.transformVideo(asset, {
|
|
944
|
+
width: 1080,
|
|
945
|
+
height: 1920,
|
|
946
|
+
fit: "cover",
|
|
947
|
+
start: 0,
|
|
948
|
+
duration: 15,
|
|
949
|
+
});
|
|
950
|
+
|
|
951
|
+
// WebM output for bandwidth-conscious storefronts
|
|
952
|
+
const webmUrl = aq.transformVideo(asset, { format: "webm", width: 1280 });
|
|
953
|
+
```
|
|
954
|
+
|
|
955
|
+
**First request is async**. Cache miss → Cloud Run Job runs ffmpeg
|
|
956
|
+
(typically 5-30 s for short clips) → output written to R2. The route
|
|
957
|
+
returns **202 Accepted** with `Retry-After: 10` and a `Location`
|
|
958
|
+
header pointing at the eventual CDN URL. The response body has
|
|
959
|
+
`{ status, message, retryAfterSec, outputUrl }`. Subsequent requests
|
|
960
|
+
hit the cache → **302** to the CDN.
|
|
961
|
+
|
|
962
|
+
Consumer pattern with Video.js v10 / `<video>`:
|
|
963
|
+
|
|
964
|
+
```tsx
|
|
965
|
+
const src = aq.transformVideo(asset, { width: 1080, height: 1920 });
|
|
966
|
+
// Pass directly to <video src={src} />. While the Job runs the
|
|
967
|
+
// browser sees 202 → retry; once cached, the 302 → CDN. Most players
|
|
968
|
+
// retry transparently; if yours doesn't, poll `src` every 5 s until
|
|
969
|
+
// `Response.redirected` is true or the body content-type starts with
|
|
970
|
+
// "video/".
|
|
971
|
+
```
|
|
972
|
+
|
|
973
|
+
Implementation: extends the EXISTING `asset-processing` Cloud Run Job
|
|
974
|
+
with a `transform-video` op. Reuses the same Cloud Tasks dispatch
|
|
975
|
+
pattern as `process-video` and `compose-slideshow`. Idempotent —
|
|
976
|
+
re-dispatching the same DSL noops if R2 already has the output.
|
|
977
|
+
|
|
978
|
+
### Adaptive HLS streaming (`aq.streamingUrl`)
|
|
979
|
+
|
|
980
|
+
For long-form video — property tours, walkthroughs — point an
|
|
981
|
+
HLS-aware player at the master playlist:
|
|
982
|
+
|
|
983
|
+
```tsx
|
|
984
|
+
<video
|
|
985
|
+
src={aq.streamingUrl(asset)}
|
|
986
|
+
controls playsInline
|
|
987
|
+
// Video.js v10 ships native HLS via @videojs/http-streaming —
|
|
988
|
+
// no plugin needed. Same with hls.js or iOS Safari.
|
|
989
|
+
/>
|
|
990
|
+
|
|
991
|
+
// Sub-clip (HLS ladder built only for the clipped range)
|
|
992
|
+
const teaser = aq.streamingUrl(asset, { start: 0, duration: 30 });
|
|
993
|
+
```
|
|
994
|
+
|
|
995
|
+
First request to a new HLS URL returns **202 Accepted** with
|
|
996
|
+
`Retry-After: 20` while a Cloud Run Job builds the multi-rung ladder
|
|
997
|
+
(typically 1-3 min for a 90 s source). Subsequent requests get
|
|
998
|
+
**302** to the cached `master.m3u8`. The R2 layout:
|
|
999
|
+
|
|
1000
|
+
```
|
|
1001
|
+
<tid>/v/<sha>-hls<dslHash>/master.m3u8 ← entry point
|
|
1002
|
+
<tid>/v/<sha>-hls<dslHash>/240p/playlist.m3u8
|
|
1003
|
+
<tid>/v/<sha>-hls<dslHash>/240p/seg-000.ts
|
|
1004
|
+
<tid>/v/<sha>-hls<dslHash>/360p/...
|
|
1005
|
+
…
|
|
1006
|
+
<tid>/v/<sha>-hls<dslHash>/1080p/...
|
|
1007
|
+
```
|
|
1008
|
+
|
|
1009
|
+
The ladder shrinks to fit the source: a 480p source produces three
|
|
1010
|
+
rungs (240p / 360p / 480p), a 1080p source produces five, and a 4K
|
|
1011
|
+
source goes up to 2160p. The player picks the right rung on the fly
|
|
1012
|
+
based on the current connection — a user on 3G starts at 240p and
|
|
1013
|
+
climbs to 1080p as bandwidth improves, vs the monolithic MP4 that
|
|
1014
|
+
either loaded or timed out.
|
|
1015
|
+
|
|
1016
|
+
### Billing model
|
|
1017
|
+
|
|
1018
|
+
Transforms are billed as **storage**, not as "transformations" the way
|
|
1019
|
+
Cloudinary does — one R2 PUT per unique canonical DSL, then served from
|
|
1020
|
+
the CDN cache forever (until manually invalidated). The cache key is
|
|
1021
|
+
deterministic, so identical DSLs across deploys/tenants don't re-encode;
|
|
1022
|
+
mounting an existing CDN URL costs zero compute.
|
|
1023
|
+
|
|
1024
|
+
## Palette + LQIP (compact placeholder UX)
|
|
1025
|
+
|
|
1026
|
+
Every successfully decoded image gets a palette extracted alongside
|
|
1027
|
+
the upload — a tiny 7-color set you can use for ambient gradients,
|
|
1028
|
+
fallback backgrounds, or themed UI accents. It survives across all
|
|
1029
|
+
preset selections (yes, even `presets: ["original"]`), because
|
|
1030
|
+
palette is metadata derived from the source bytes, not from a
|
|
1031
|
+
specific resized variant.
|
|
1032
|
+
|
|
1033
|
+
```ts
|
|
1034
|
+
import {
|
|
1035
|
+
getPaletteBlurBackground,
|
|
1036
|
+
pickAmbientBackground,
|
|
1037
|
+
getAmbientGradient,
|
|
1038
|
+
getTextColorForBackground,
|
|
1039
|
+
} from "@nitida/sdk";
|
|
1040
|
+
|
|
1041
|
+
const asset = await aq.assets.get(assetId);
|
|
1042
|
+
// asset.palette = { d: "#1a1a1a", v: "#c5a95e", m: "#8b7d4f", ... }
|
|
1043
|
+
// asset.blur = "data:image/webp;base64,UklGRhAA..." // tiny LQIP
|
|
1044
|
+
|
|
1045
|
+
// Compact 4-stop gradient for hero / card backgrounds:
|
|
1046
|
+
const bg = getAmbientGradient(asset.palette);
|
|
1047
|
+
// bg = "linear-gradient(135deg, oklch(...), oklch(...))"
|
|
1048
|
+
|
|
1049
|
+
// Auto-pick text color that contrasts with the chosen ambient:
|
|
1050
|
+
const fg = getTextColorForBackground(asset.palette);
|
|
1051
|
+
// fg = "#fff" | "#000" | similar
|
|
1052
|
+
|
|
1053
|
+
// Or just the blurry LQIP for a CSS background placeholder:
|
|
1054
|
+
const placeholder = getPaletteBlurBackground(asset.palette);
|
|
1055
|
+
```
|
|
1056
|
+
|
|
1057
|
+
The wire format is intentionally tight: `{d, v, m, dv, lv, dm, lm}`
|
|
1058
|
+
(dominant, vibrant, muted, dark-vibrant, light-vibrant, dark-muted,
|
|
1059
|
+
light-muted). 7 hex strings per asset — much smaller than a full
|
|
1060
|
+
base64 LQIP, but composes into nicer ambient UX.
|
|
1061
|
+
|
|
1062
|
+
When sharp can't decode the image (SVG sources, exotic formats,
|
|
1063
|
+
deliberately corrupted bytes), palette + blur silently come back
|
|
1064
|
+
`null`. The rest of the pipeline still succeeds.
|
|
1065
|
+
|
|
1066
|
+
Per-slot default:
|
|
1067
|
+
|
|
1068
|
+
```ts
|
|
1069
|
+
await aq.slots.bind("storefront.hero", { assetId, preset: "lg" });
|
|
1070
|
+
```
|
|
1071
|
+
|
|
1072
|
+
Per-call override (the resolver respects this over the slot's default):
|
|
1073
|
+
|
|
1074
|
+
```ts
|
|
1075
|
+
const { url } = await aq.slots.resolve("storefront.hero", { preset: "md" });
|
|
1076
|
+
```
|
|
1077
|
+
|
|
1078
|
+
Responsive `<img srcSet>` across all available presets:
|
|
1079
|
+
|
|
1080
|
+
```tsx
|
|
1081
|
+
<img
|
|
1082
|
+
src={aq.urlFor(asset, "lg")}
|
|
1083
|
+
srcSet={aq.srcSetFor(asset)}
|
|
1084
|
+
sizes="(max-width: 768px) 100vw, 1280px"
|
|
1085
|
+
alt=""
|
|
1086
|
+
/>
|
|
1087
|
+
```
|
|
1088
|
+
|
|
1089
|
+
If you need a dimension that doesn't exist, two options: use the closest
|
|
1090
|
+
preset and let the browser scale, or file an issue to add it to the
|
|
1091
|
+
server-side pipeline (a platform-wide addition, not a per-tenant one).
|
|
1092
|
+
|
|
1093
|
+
### CDN URL format
|
|
1094
|
+
|
|
1095
|
+
`<cdnBase>/<sha16>-<presetCode>.<ext>`
|
|
1096
|
+
|
|
1097
|
+
Example: `https://8ok.uk/c482458e824c730e-q.webp` (the `thumb` preset of
|
|
1098
|
+
sha `c482458e…` rendered as WebP). The path is content-addressed, so the
|
|
1099
|
+
same source bytes produce the same URL regardless of which tenant uploaded
|
|
1100
|
+
them — and the same URL never invalidates.
|
|
1101
|
+
|
|
1102
|
+
## Auth
|
|
1103
|
+
|
|
1104
|
+
Auth is a Better Auth API key (`amk_rt_*`) emitted by
|
|
1105
|
+
`aquienpz/bootstrap-project.ts` per tenant. The key's metadata holds the tenant
|
|
1106
|
+
id, so `X-Tenant-Code` is log-only.
|
|
1107
|
+
|
|
1108
|
+
API key tiers:
|
|
1109
|
+
|
|
1110
|
+
- `amk_rt_*` — runtime / read-and-write
|
|
1111
|
+
- `amk_ad_*` — admin (slot bind, asset delete)
|
|
1112
|
+
- `amk_ci_*` — CI / batch jobs
|
|
1113
|
+
|
|
1114
|
+
## API surface
|
|
1115
|
+
|
|
1116
|
+
| Namespace | Method | Description |
|
|
1117
|
+
|---|---|---|
|
|
1118
|
+
| `aq.slots` | `resolve(key)` / `resolveMany(keys)` | Read-side, cached 60s |
|
|
1119
|
+
| `aq.slots` | `list({prefix})` | Admin tree view |
|
|
1120
|
+
| `aq.slots` | `bind(key, {assetId, preset})` | Admin rebind |
|
|
1121
|
+
| `aq.slots` | `unbind(key)` | Remove binding |
|
|
1122
|
+
| `aq.slots` | `invalidateCache(key?)` | After admin rebind |
|
|
1123
|
+
| `aq.assets` | `byHash(sha)` / `byHashes([])` | Lookup |
|
|
1124
|
+
| `aq.assets` | `list({limit, cursor})` | Paginated |
|
|
1125
|
+
| `aq.assets` | `get(id)` | Full DTO |
|
|
1126
|
+
| `aq.assets` | `patchMetadata(id, {…})` | Merge JSON |
|
|
1127
|
+
| `aq.upload(file, {fileName})` | | Hash-dedup, returns canonical URL |
|
|
1128
|
+
| `aq.urlFor(asset, preset)` | | Build URL from DTO |
|
|
1129
|
+
| `aq.srcSetFor(asset)` | | Responsive `<img srcSet>` |
|
|
1130
|
+
| `aq.usage` | `snapshot()` | Storage + today + last 30 days totals (current tenant) |
|
|
1131
|
+
| `aq.usage` | `timeseries(days)` | Daily rollup for charts (1..365 days) |
|
|
1132
|
+
| `aq.usage` | `keys()` | Per-API-key breakdown month-to-date |
|
|
1133
|
+
|
|
1134
|
+
## Usage / consumption
|
|
1135
|
+
|
|
1136
|
+
Cloudinary-style "where am I in my plan" view, scoped to the API key's
|
|
1137
|
+
own tenant (no cross-tenant data ever leaks):
|
|
1138
|
+
|
|
1139
|
+
```ts
|
|
1140
|
+
const usage = await aq.usage.snapshot();
|
|
1141
|
+
// {
|
|
1142
|
+
// tenant: { id: 4, code: "realtyone-cr" },
|
|
1143
|
+
// storage: { totalBytes: 4_810_000_000, assetCount: 15_760 },
|
|
1144
|
+
// today: { reads: 3201, writes: 18, processes: 6, bytesIn: 12_400_000, ... },
|
|
1145
|
+
// last30Days: { reads: 86_400, writes: 412, ... }
|
|
1146
|
+
// }
|
|
1147
|
+
|
|
1148
|
+
const chart = await aq.usage.timeseries(30); // for a 30-day line chart
|
|
1149
|
+
const byKey = await aq.usage.keys(); // who's using the most quota
|
|
1150
|
+
```
|
|
1151
|
+
|
|
1152
|
+
Backed by the daily rollup of `assets.api_key_usage` →
|
|
1153
|
+
`assets.tenant_usage_daily`. The "today" window queries the raw
|
|
1154
|
+
`api_key_usage` table since the rollup runs once per day at ~00:30 UTC.
|
|
1155
|
+
|
|
1156
|
+
## License
|
|
1157
|
+
|
|
1158
|
+
UNLICENSED — internal use only.
|