@civitai/sdk 0.13.1 → 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 +139 -2
- package/dist/app/index.d.ts +18 -6
- package/dist/app/index.js +11 -6
- package/dist/doc-store/fake.d.ts +94 -0
- package/dist/doc-store/fake.js +1141 -0
- package/dist/doc-store/index.d.ts +187 -0
- package/dist/doc-store/index.js +260 -0
- package/dist/http/index.js +18 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.js +1 -0
- package/dist/shared-storage/index.d.ts +5 -0
- package/dist/storage/index.d.ts +29 -3
- package/dist/testing.d.ts +2 -0
- package/dist/testing.js +1 -0
- package/dist/version.generated.d.ts +1 -1
- package/dist/version.generated.js +1 -1
- package/package.json +1 -1
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
|
|
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
|
-
|
|
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
|
package/dist/app/index.d.ts
CHANGED
|
@@ -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
|
-
*
|
|
24
|
+
* App storage. Two stores share this property while apps migrate:
|
|
24
25
|
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
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
|
|
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
|
|
92
|
-
// `test/workflows/seam.test.ts` pin the
|
|
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
|
-
|
|
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 {};
|