@civitai/sdk 0.13.0 → 0.14.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/README.md CHANGED
@@ -185,15 +185,106 @@ Routes are addressed by path, so a route `/api/v1` gains needs no release here:
185
185
  const images = await app.site.get('images', { query: { limit: 20, username: 'civitai' } });
186
186
  ```
187
187
 
188
- A `401` is retried once with a fresh token. Any failure the server answered is an
188
+ A `401` is retried once with a fresh token — except one whose body's `code` is
189
+ `sign_in_required`, which is about the viewer, not the token. Any failure the server answered is an
189
190
  `ApiError` carrying `status`, the parsed `body`, and the server's own message —
190
191
  while a request the browser never delivered (CORS, DNS, offline) surfaces as a
191
192
  fetch `TypeError` with no `status`, so test `error instanceof ApiError` before
192
193
  reading one.
193
194
 
195
+ ## Document storage
196
+
197
+ An app declares its **collections** in its manifest, and `app.storage.collection(name)`
198
+ reads and writes their JSON documents. `private` collections hold one key space per
199
+ viewer; `shared` ones one key space every viewer reads, written under the policy the
200
+ collection declares (`own`: viewers create, only the author edits or deletes;
201
+ `none`: nobody writes documents, viewers only increment counters and set marks).
202
+
203
+ ```json
204
+ "scopes": ["apps:storage", "apps:storage:publish"],
205
+ "storage": { "collections": {
206
+ "prefs": { "visibility": "private" },
207
+ "requests": { "visibility": "shared", "write": "own", "marks": ["vote"],
208
+ "indexes": { "status": "string" }, "showAuthor": true },
209
+ "plays": { "visibility": "shared", "write": "none", "counters": ["plays"] }
210
+ }}
211
+ ```
212
+
213
+ ```ts
214
+ interface Request {
215
+ title: string;
216
+ status: 'open' | 'done';
217
+ }
218
+
219
+ const requests = app.storage.collection<Request>('requests');
220
+
221
+ const created = await requests.create({ title: 'More lighting presets', status: 'open' });
222
+ const doc = await requests.get(created.key); // null when there is none
223
+ await requests.put(created.key, { title: 'More lighting presets', status: 'done' }, { ifVersion: doc?.version });
224
+ await requests.mark(created.key, 'vote'); // { marked: true, count }
225
+
226
+ const page = await requests.list({ where: { status: 'open' }, sort: '-mark:vote', limit: 20 });
227
+ for (const item of page.items) {
228
+ console.log(item.data.title, item.marks.vote, item.author, item.viewer?.marks);
229
+ }
230
+ const mine = await requests.list({ author: 'me' });
231
+ const voted = await requests.list({ markedBy: 'me', mark: 'vote' });
232
+
233
+ await app.storage.collection('plays').increment('model-123', 'plays'); // creates the doc in a `none` collection
234
+ const { limits, usage } = await app.storage.info();
235
+ ```
236
+
237
+ - **Documents** are `{ key, data, version, createdAt, updatedAt, author, counters,
238
+ marks, viewer }`. `createdAt`/`updatedAt` are `Date`s. `author` is
239
+ `{ kind: 'user', isViewer, username? }` (the username only in a `showAuthor`
240
+ collection), `{ kind: 'counter' }` for a document a counter created, or
241
+ `{ kind: 'app' }` — never a numeric user id. `viewer` is `null` for an anonymous
242
+ viewer.
243
+ - **Conditional writes:** `put(key, data, { ifVersion })` and
244
+ `delete(key, { ifVersion })` write only at that version (`0` = only if absent);
245
+ a mismatch rejects `version_conflict` with `details.currentVersion`.
246
+ - **Lists** take ONE equality on a declared index (`where`), one `sort`
247
+ (`[-]key|createdAt|updatedAt|<index>|counter:<n>|mark:<n>`), `author: 'me'`,
248
+ `markedBy: 'me'` with `mark`, `keyPrefix`, `limit` and `cursor`. `nextCursor` is
249
+ `null` exactly on the last page; pass it back with the same query.
250
+ - **A refusal in the storage envelope is a `StorageError`** (an `ApiError`) with
251
+ the server's `code`, `status` and `details`; `retryAfter` is set when the server
252
+ named a wait. So is a name or key the client refuses before sending (an invalid
253
+ collection name is `collection_not_declared`; a key of `''`, `.` or `..` is
254
+ `invalid_request`). Anything else arrives as it would from any route: a response
255
+ without the envelope (a proxy error page, a site without these routes) is a plain
256
+ `ApiError` with its `status`, a request that never got an answer (offline, CORS,
257
+ DNS) is the `fetch` `TypeError`, and a 2xx the client cannot read is a
258
+ `CivitaiError`. `get` reads only `not_found` as "no document". Branch on `code`:
259
+
260
+ ```ts
261
+ import { StorageError } from '@civitai/sdk';
262
+
263
+ try {
264
+ await requests.put('r1', { title: 'Renamed', status: 'open' }, { ifVersion: 3 });
265
+ } catch (error) {
266
+ if (error instanceof StorageError && error.code === 'version_conflict') reloadAndRetry();
267
+ else if (error instanceof StorageError && error.code === 'consent_required') askForPublishConsent();
268
+ else throw error;
269
+ }
270
+ ```
271
+
272
+ Anonymous viewers may read `shared` collections and nothing else
273
+ (`sign_in_required`). Creating or editing a document in a viewer-written shared
274
+ collection needs the consent-gated `apps:storage:publish`; deleting your own, marks
275
+ and counters need only `apps:storage`. Shared writes pass a minimum-trust gate and
276
+ moderation. Only phase-one operations exist: no batch writes, change feed, TTL or
277
+ owner-written documents yet.
278
+
279
+ The legacy stores below keep working, unchanged, while apps move; their methods are
280
+ `@deprecated`. Nothing is copied from them into collections.
281
+
194
282
  ## App storage
195
283
 
196
- `app.storage` is the viewer's own key/value store, scoped to this app and this
284
+ > **Deprecated** in favour of [document storage](#document-storage): declare a
285
+ > `private` collection and use `app.storage.collection(name)`.
286
+
287
+ `app.storage`'s `get`/`set`/`delete`/`list`/`getQuota` are the viewer's own key/value store, scoped to this app and this
197
288
  block instance. It needs the block token the host mints — an app holding an
198
289
  OAuth access token has no per-viewer app storage — and an anonymous viewer is
199
290
  refused, so gate on `app.viewer` rather than reading an empty page as "nothing
@@ -237,6 +328,9 @@ try {
237
328
 
238
329
  ## Shared storage
239
330
 
331
+ > **Deprecated** in favour of [document storage](#document-storage): declare a
332
+ > `shared` collection, which has authorship, counters and marks built in.
333
+
240
334
  `app.sharedStorage` is this app's **cross-user** key/value store — one namespace
241
335
  every viewer reads and writes, unlike `app.storage`, which is private per viewer.
242
336
  Five methods: `list`, `get`, `append`, `update`, `withdraw`.
@@ -788,6 +882,49 @@ entry.
788
882
  `@civitai/sdk/testing` has `createFakeTransport()`: pass it as
789
883
  `initialize({ transport })` and script the host's answers.
790
884
 
885
+ ### A fake document store
886
+
887
+ `createFakeStorage({ manifest })` is an in-memory document store behind the same
888
+ `Http` seam the client uses, enforcing the server's rules: scopes and consent, write
889
+ policies, the minimum-trust gate, anonymous readers, keys, limits and quotas,
890
+ moderation, error codes and statuses. This package runs the platform's own storage
891
+ contract against it, so where the contract speaks the fake and the server agree.
892
+
893
+ ```ts
894
+ import { createFakeStorage } from '@civitai/sdk/testing';
895
+
896
+ const fake = createFakeStorage({ manifest, viewer: { username: 'alice' } });
897
+ const prefs = fake.storage.collection('prefs'); // the real client, against the fake
898
+ await prefs.put('theme', { mode: 'dark' });
899
+
900
+ const bob = fake.as({ viewer: { username: 'bob', trusted: false } }).storage;
901
+ const anon = fake.as({ viewer: null }).storage;
902
+ ```
903
+
904
+ **Rate limits are OFF by default**, so your own tests are never throttled. Pass
905
+ `enforceRateLimits: true` to apply the server's — the per-minute and per-day windows
906
+ for reads, private writes and deletes, marks and counters, and reports, plus the
907
+ shared write budget — when you want to test how the app handles `rate_limited`
908
+ (drive the clock with `now`).
909
+
910
+ Where it differs from the server on purpose:
911
+
912
+ - A declaration using a feature the platform has not shipped — `write: "owner"` or
913
+ `"anyone"`, `kind: "log"`/`maxEntries`, `ttlSeconds`, `dailyWritesPerAuthor` —
914
+ makes `createFakeStorage` throw, naming it, instead of failing per request. It is
915
+ not a manifest validator otherwise; `civitai app validate` is.
916
+ - A list `nextCursor` is an opaque token, refused for any other collection, sort or
917
+ filter than the query that issued it. Treat it as opaque against the server too.
918
+
919
+ `fake.calls` records every request and answer. Options also cover smaller `limits`,
920
+ `consent` (revoked scopes, or `'unavailable'`), `appStatus`, `disabled` and a custom
921
+ `moderate`; by default the scanner refuses the words in `FAKE_STORAGE_MODERATION`. It
922
+ does not model moderator hides or the review-preview namespace, and at the `Http`
923
+ seam there is no URL resolution — the client refuses the segments a URL would
924
+ rewrite (`''`, `.`, `..`) before sending.
925
+
926
+ ### The legacy key/value stores
927
+
791
928
  There is no published fake for app storage, on purpose. The seam is `fetch`, so
792
929
  pass your own `fetch` to `initialize({ token, fetch })` and the client's URLs,
793
930
  bodies, statuses and date revival are all real — which a fake replacing the
@@ -4,6 +4,7 @@ import { type Host } from '../host/index.js';
4
4
  import { type OrchestrationClient } from '../orchestration/index.js';
5
5
  import { type GrantOptions, type Scope, type TokenOptions, type TokenSessionOptions } from '../session/index.js';
6
6
  import { type SharedStorageClient } from '../shared-storage/index.js';
7
+ import { type DocumentStorage } from '../doc-store/index.js';
7
8
  import { type SiteClient } from '../site/index.js';
8
9
  import { type StorageClient } from '../storage/index.js';
9
10
  import { type WorkflowsClient } from '../workflows/index.js';
@@ -20,15 +21,26 @@ export interface AppClient {
20
21
  */
21
22
  readonly site: SiteClient;
22
23
  /**
23
- * The viewer's own per-app key/value store.
24
+ * App storage. Two stores share this property while apps migrate:
24
25
  *
25
- * 🔴 Requires the block token the host mints: an app that authenticated with
26
- * an OAuth access token has no per-viewer app storage, and every call here is
27
- * refused. An anonymous viewer is refused too — gate on `viewer` rather than
28
- * reading an empty result as "nothing stored".
26
+ * - **The document store** — `collection(name)` and `info()`: the collections
27
+ * the app declares under `storage.collections` in its manifest, private or
28
+ * shared, with counters, marks, declared indexes and conditional writes.
29
+ * Use this for anything new.
30
+ * - **The legacy per-viewer key/value store** — `get`, `set`, `delete`, `list`,
31
+ * `getQuota`, each `@deprecated`. It keeps working, unchanged, until the apps
32
+ * that use it have moved; nothing is copied between the two.
33
+ *
34
+ * 🔴 Both require the block token the host mints: an app that authenticated
35
+ * with an OAuth access token is refused. The legacy store refuses an anonymous
36
+ * viewer outright; the document store lets one read SHARED collections only.
29
37
  */
30
- readonly storage: StorageClient;
38
+ readonly storage: StorageClient & DocumentStorage;
31
39
  /**
40
+ * @deprecated Declare a `shared` collection under `storage.collections` and use
41
+ * `app.storage.collection(name)`. This store keeps working, unchanged, until the
42
+ * apps that use it have moved.
43
+ *
32
44
  * This app's CROSS-USER store — one namespace every viewer of this app reads
33
45
  * and writes, which is what makes a shared feed or a "popular" rail possible.
34
46
  *
package/dist/app/index.js CHANGED
@@ -5,6 +5,7 @@ import { ApiError, createHttp } from '../http/index.js';
5
5
  import { createOrchestrationClient, DEFAULT_ORCHESTRATION_URL, } from '../orchestration/index.js';
6
6
  import { createTokenSession, } from '../session/index.js';
7
7
  import { createSharedStorageClient, } from '../shared-storage/index.js';
8
+ import { createDocumentStorage } from '../doc-store/index.js';
8
9
  import { createSiteClient, DEFAULT_SITE_URL } from '../site/index.js';
9
10
  import { createStorageClient } from '../storage/index.js';
10
11
  import { createWorkflowsClient } from '../workflows/index.js';
@@ -85,15 +86,19 @@ function createAppClient(session, options, holdsBlockTokenNow = () => false, hol
85
86
  // clients' routes live under the same base URL, so a `siteUrl` override
86
87
  // redirects them together.
87
88
  // Wrapped once, so `BLOCK_NAMESPACE` is the single place that decides where the
88
- // diagnosis applies. None of the three `blocks/*` clients needs an exemption
89
- // of its own: their routes are `blocks/app-storage/*`, `blocks/shared-storage/*`
90
- // and `blocks/workflows/*`, which that namespace already excludes, and
91
- // `test/storage/seam.test.ts`, `test/shared-storage/seam.test.ts` and
92
- // `test/workflows/seam.test.ts` pin the three `BASE` values there textually.
89
+ // diagnosis applies. None of the four `blocks/*` clients needs an exemption
90
+ // of its own: their routes are `blocks/app-storage/*`, `blocks/shared-storage/*`,
91
+ // `blocks/storage/*` and `blocks/workflows/*`, which that namespace already excludes, and
92
+ // `test/storage/seam.test.ts`, `test/shared-storage/seam.test.ts`,
93
+ // `test/doc-store/client.test.ts` and `test/workflows/seam.test.ts` pin the four
94
+ // `BASE` values there textually.
93
95
  const siteHttp = explainApiRefusal(http(options.siteUrl ?? DEFAULT_SITE_URL), holdsBlockTokenNow);
94
96
  return {
95
97
  site: createSiteClient(siteHttp),
96
- storage: createStorageClient(siteHttp),
98
+ // One object, two stores, no shared names: the legacy five and the document
99
+ // store's `collection`/`info`. Spread rather than chained so neither keeps a
100
+ // reference to the other.
101
+ storage: { ...createStorageClient(siteHttp), ...createDocumentStorage(siteHttp) },
97
102
  sharedStorage: createSharedStorageClient(siteHttp),
98
103
  workflows: createWorkflowsClient(explainWorkflowRefusal(siteHttp, holdsOAuthTokenNow)),
99
104
  orchestration: createOrchestrationClient(refuseBlockToken(http(options.orchestrationUrl ?? DEFAULT_ORCHESTRATION_URL), holdsBlockTokenNow)),
@@ -0,0 +1,94 @@
1
+ import { type Http } from '../http/index.js';
2
+ import { type DocumentStorage } from './index.js';
3
+ declare const DEFAULT_LIMITS: {
4
+ appBytes: number;
5
+ appDocs: number;
6
+ viewerBytes: number;
7
+ viewerDocs: number;
8
+ };
9
+ /** The default scanner's words: the contract's `conventions`, so a test can reach each refusal. */
10
+ export declare const FAKE_STORAGE_MODERATION: Readonly<{
11
+ blockedText: "contract-blocked-term";
12
+ blockedLink: "https://contract-blocked.example/x";
13
+ scannerDown: "contract-scanner-down";
14
+ }>;
15
+ export interface FakeStorageViewer {
16
+ /** The viewer's identity in this fake, and what `showAuthor` collections display. */
17
+ username: string;
18
+ /**
19
+ * The viewer's numeric user id, as the platform keys it. Internal: no response
20
+ * carries it, which a test can check by choosing a distinctive one. Default: a
21
+ * small number assigned on first use.
22
+ */
23
+ id?: number;
24
+ /** Passes the minimum-trust gate on shared writes. Default `true`. */
25
+ trusted?: boolean;
26
+ /** `details.reason` when untrusted. Default `account_too_new`. */
27
+ untrustedReason?: string;
28
+ }
29
+ /** Who is calling: a viewer (or `null`, anonymous) holding a token of some kind and scopes. */
30
+ export interface FakeStorageCaller {
31
+ /** Default: the fake's `viewer`. `null` is an anonymous viewer with a token. */
32
+ viewer?: FakeStorageViewer | null;
33
+ /** `block` (default), `dev` (the developer's own namespace) or `none` (no token at all). */
34
+ token?: 'block' | 'dev' | 'none';
35
+ /** The token's scopes. Default: the manifest's `scopes`. */
36
+ scopes?: readonly string[];
37
+ }
38
+ export type FakeModerationVerdict = 'blocked' | 'link' | 'unavailable' | null;
39
+ export interface FakeStorageOptions {
40
+ /** The app's manifest; `storage.collections` is what the fake enforces. */
41
+ manifest: {
42
+ scopes?: readonly string[];
43
+ storage?: unknown;
44
+ };
45
+ /** The default caller's viewer. Default `{ username: 'viewer' }`; `null` for anonymous. */
46
+ viewer?: FakeStorageViewer | null;
47
+ /** Default `approved`; anything else refuses every call `not_eligible`. */
48
+ appStatus?: string;
49
+ /** Consent state: scopes the viewer revoked, or `unavailable` (the marker could not be read). */
50
+ consent?: {
51
+ revoked: readonly string[];
52
+ } | 'unavailable';
53
+ /** `true` answers every data operation `storage_disabled`, as a server with no database does. */
54
+ disabled?: boolean;
55
+ /** Override the per-app and per-viewer ceilings, e.g. a small `viewerDocs` to reach the quota. */
56
+ limits?: Partial<typeof DEFAULT_LIMITS>;
57
+ /**
58
+ * Apply the server's rate limits: the per-minute and per-day windows (reads, private
59
+ * writes and deletes, marks and counters, reports) and the shared write budget.
60
+ * Default `false`, so an app's own tests are never throttled; turn it on to test how
61
+ * the app handles `rate_limited`.
62
+ */
63
+ enforceRateLimits?: boolean;
64
+ /** The clock rate limits and timestamps read. Default `Date.now`. */
65
+ now?: () => number;
66
+ /**
67
+ * Classify one string (a value, an object key or a document key) of a shared write.
68
+ * Default: {@link FAKE_STORAGE_MODERATION}'s words.
69
+ */
70
+ moderate?: (text: string) => FakeModerationVerdict;
71
+ }
72
+ /** One request as the fake received it, and what it answered. */
73
+ export interface FakeStorageCall {
74
+ method: string;
75
+ path: string;
76
+ query: Record<string, string | string[]>;
77
+ body: unknown;
78
+ status: number;
79
+ response: unknown;
80
+ }
81
+ export interface FakeStorageSession {
82
+ /** The `Http` seam, as this caller. */
83
+ readonly http: Http;
84
+ /** The real client over that seam. */
85
+ readonly storage: DocumentStorage;
86
+ }
87
+ export interface FakeStorage extends FakeStorageSession {
88
+ /** The same store, as a different caller. */
89
+ as(caller: FakeStorageCaller): FakeStorageSession;
90
+ /** Every request received, in order, by any caller. */
91
+ readonly calls: FakeStorageCall[];
92
+ }
93
+ export declare function createFakeStorage(options: FakeStorageOptions): FakeStorage;
94
+ export {};