@capacms/sdk 1.0.0-next.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.
@@ -0,0 +1,57 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * capa-codegen — write a tenant's generated types to disk.
4
+ *
5
+ * CAPA_BASE_URL=... CAPA_API_KEY=... CAPA_TENANT_ID=... \
6
+ * capa-codegen --out src/capa-types.ts
7
+ *
8
+ * Exit codes: 0 wrote or already current, 1 failed, 2 --check found a diff.
9
+ * `--check` is the CI mode: it fails when the committed file is out of date
10
+ * with the tenant's schema, which is the whole point of committing it.
11
+ */
12
+ const fs = require("node:fs");
13
+ const path = require("node:path");
14
+ const { generate } = require("../dist/codegen.js");
15
+
16
+ function arg(name, fallback) {
17
+ const i = process.argv.indexOf(name);
18
+ return i === -1 ? fallback : process.argv[i + 1];
19
+ }
20
+
21
+ async function main() {
22
+ const out = arg("--out", "capa-types.ts");
23
+ const check = process.argv.includes("--check");
24
+ const config = {
25
+ baseUrl: process.env.CAPA_BASE_URL || "",
26
+ apiKey: process.env.CAPA_API_KEY || "",
27
+ tenantId: process.env.CAPA_TENANT_ID || "",
28
+ };
29
+ const target = path.resolve(process.cwd(), out);
30
+ const existing = fs.existsSync(target) ? fs.readFileSync(target, "utf8") : null;
31
+
32
+ const result = await generate(config, existing);
33
+
34
+ if (!result.changed) {
35
+ console.log(`capa-codegen: ${out} is up to date (schema ${result.checksum ?? "unknown"}).`);
36
+ return 0;
37
+ }
38
+ if (check) {
39
+ console.error(
40
+ `capa-codegen: ${out} is OUT OF DATE with the tenant schema (${result.checksum}).\n` +
41
+ ` Run: capa-codegen --out ${out}\n` +
42
+ ` Types now present: ${result.types.join(", ")}`
43
+ );
44
+ return 2;
45
+ }
46
+ fs.writeFileSync(target, result.source, "utf8");
47
+ console.log(`capa-codegen: wrote ${out} — ${result.types.length} types (schema ${result.checksum}).`);
48
+ return 0;
49
+ }
50
+
51
+ main().then(
52
+ (code) => process.exit(code),
53
+ (err) => {
54
+ console.error(`capa-codegen: ${err.message}`);
55
+ process.exit(1);
56
+ }
57
+ );
@@ -0,0 +1,413 @@
1
+ /**
2
+ * client.ts — the read client, plus the one thing it can write.
3
+ *
4
+ * This file used to say the SDK was read-only "because the surface it talks to
5
+ * is". That was true when it was written and is not any more: the agent surface
6
+ * (#383) put `/v2/agent/*` behind `verifyApiKey`, ADMIN_UI_OVERHAUL 0h.4b added
7
+ * `/v2/agent/workspaces`, and `tenant_api_keys.permission` became a real
8
+ * control (#4646).
9
+ *
10
+ * So `workspaces` writes, and nothing else does. A workspace is NAVIGATION —
11
+ * which models, entries and media folders the admin's left rail keeps in reach,
12
+ * in which folders — so applying one touches no record and deletes nothing. The
13
+ * content writes an SDK could now technically reach need a surface designed for
14
+ * them, not a fourth method quietly added to this object.
15
+ */
16
+ import { type CapaConfig } from "./config";
17
+ import { type WebhooksResource } from "./webhooks";
18
+ export interface ListOptions {
19
+ limit?: number;
20
+ page?: number;
21
+ /** Relation hops to follow. Higher is much larger. */
22
+ depth?: number;
23
+ sort?: string;
24
+ /**
25
+ * Field filters, applied server-side: `{ handle: "about-us" }` becomes
26
+ * `?handle=about-us`. Verified against a live tenant — a matching value
27
+ * returns the row, a non-matching one returns an empty set.
28
+ */
29
+ where?: Record<string, string | number | boolean>;
30
+ }
31
+ export interface Page<T> {
32
+ data: T[];
33
+ /**
34
+ * Instance ids positionally matching `data`, `null` only when the API sent
35
+ * no usable id at all. See `instanceIdOf`. Read these rather than `row.id`,
36
+ * which is the model's own field whenever one is named `id`.
37
+ */
38
+ ids: (string | null)[];
39
+ meta: {
40
+ total?: number;
41
+ totalPages?: number;
42
+ currentPage?: number;
43
+ limit?: number;
44
+ hasNextPage?: boolean | null;
45
+ hasPrevPage?: boolean | null;
46
+ };
47
+ /** Fastly surrogate keys for this response, for the host's CDN purging. */
48
+ cacheTags: string[];
49
+ }
50
+ export interface CapaClient {
51
+ listContent<T = Record<string, unknown>>(namespace: string, options?: ListOptions): Promise<Page<T>>;
52
+ getContentById<T = Record<string, unknown>>(namespace: string, id: string, options?: Pick<ListOptions, "depth">): Promise<T | null>;
53
+ findOne<T = Record<string, unknown>>(namespace: string, where: Record<string, string | number | boolean>, options?: Pick<ListOptions, "depth">): Promise<T | null>;
54
+ search(q: string, options?: {
55
+ namespace?: string;
56
+ limit?: number;
57
+ page?: number;
58
+ }): Promise<SearchHit[]>;
59
+ /** Saved arrangements of the Capa admin's left rail. ADMIN_UI_OVERHAUL 0h. */
60
+ workspaces: WorkspacesResource;
61
+ /** Model schema reads, and the entry-editor layout. ADMIN_UI_OVERHAUL 0m. */
62
+ models: ModelsResource;
63
+ /** Scheduled publishes and unpublishes. docs/PUBLISHING.md. */
64
+ scheduledActions: ScheduledActionsResource;
65
+ /**
66
+ * Webhook endpoints and their deliveries. docs/WEBHOOKS.md.
67
+ *
68
+ * The one resource that needs `accessToken` rather than `apiKey`: the routes
69
+ * behind it have no API key mount at all (publishing spec D8), and every
70
+ * method throws with that message when the token is missing.
71
+ */
72
+ webhooks: WebhooksResource;
73
+ }
74
+ export type LayoutWidth = "full" | "two_thirds" | "half" | "third";
75
+ /**
76
+ * How a relation field renders. `picker` is a link to the related entry,
77
+ * `inline` a list of expandable rows, and `embedded` (0m.8) the related
78
+ * entry's own fields drawn directly in the parent card.
79
+ */
80
+ export type LayoutDisplay = "picker" | "inline" | "embedded";
81
+ export interface LayoutInline {
82
+ allowCreate: boolean;
83
+ allowRemove: boolean;
84
+ allowReorder: boolean;
85
+ /** Up to 3 field ids of the RELATED model, shown on a collapsed row. */
86
+ summary: string[];
87
+ }
88
+ export interface LayoutField {
89
+ t: "field";
90
+ /** Layout node id — stable across edits, unique in the document. */
91
+ id: string;
92
+ /** A field id of THIS model. Every field appears at most once. */
93
+ fieldId: string;
94
+ width: LayoutWidth;
95
+ /** Relation fields only. Left out, the related model's `embedByDefault` decides. */
96
+ display?: LayoutDisplay;
97
+ /** Relation fields only. */
98
+ inline?: LayoutInline;
99
+ }
100
+ export interface LayoutCard {
101
+ t: "card";
102
+ id: string;
103
+ title: string;
104
+ description?: string;
105
+ collapsible: boolean;
106
+ collapsed: boolean;
107
+ items: LayoutField[];
108
+ }
109
+ /**
110
+ * How one model's entry editor is arranged: cards in a main column and a side
111
+ * column. `null` on a model means the linear editor, which is what every model
112
+ * had before 0m and what a model without a layout still gets.
113
+ */
114
+ export interface ModelLayout {
115
+ v: 1;
116
+ main: LayoutCard[];
117
+ aside: LayoutCard[];
118
+ }
119
+ /**
120
+ * The two model-level facts 0m adds, as `GET /v2/models/:id` carries them.
121
+ *
122
+ * They are read together because a form renderer needs both: the layout says
123
+ * where the fields go, and `embedByDefault` decides what a relation field with
124
+ * no `display` does — including in linear mode, where there is no layout to
125
+ * carry a node at all.
126
+ */
127
+ export interface ModelLayoutInfo {
128
+ /** The layout document, or null when the model is in linear mode. */
129
+ layout: ModelLayout | null;
130
+ /**
131
+ * Whether a relation field pointing AT this model renders this model's
132
+ * fields inside the parent form by default (0m.8). A layout node's own
133
+ * `display` always wins over it.
134
+ */
135
+ embedByDefault: boolean;
136
+ }
137
+ export interface ModelsResource {
138
+ /** The layout document, or null when the model is in linear mode. */
139
+ getLayout(id: string): Promise<ModelLayout | null>;
140
+ /** `getLayout` plus the model's pass-through default, in one read. */
141
+ getLayoutInfo(id: string): Promise<ModelLayoutInfo>;
142
+ /**
143
+ * Save a layout, or pass `null` to reset the model to the linear editor.
144
+ *
145
+ * The API validates the document against the model (§0m.1) and answers 400
146
+ * with `{ error, path }` naming the offending node — `CapaError.message`
147
+ * carries both. It stores the NORMALISED document, which is what comes back,
148
+ * so the return value is what the next `getLayout` will return.
149
+ */
150
+ setLayout(id: string, layout: ModelLayout | null): Promise<ModelLayout | null>;
151
+ }
152
+ /**
153
+ * One node of a workspace document. Models take a namespace OR an id.
154
+ *
155
+ * §0t made a folder hold ANY of these. 0h had three separate trees keyed by
156
+ * section, so a folder had a kind; the customer names folders now, and a folder
157
+ * called "Config" cannot also mean a kind.
158
+ */
159
+ export type WorkspaceDocNode = {
160
+ folder: string;
161
+ children?: WorkspaceDocNode[];
162
+ } | {
163
+ model: string;
164
+ } | {
165
+ instance: string;
166
+ } | {
167
+ media_folder: string;
168
+ };
169
+ /**
170
+ * 0h's three-key tree, accepted for ONE more release.
171
+ *
172
+ * Sending it lands the three lists in folders called Models, Content and Media,
173
+ * and a key you omit is left alone even in replace mode — that was 0h's rule
174
+ * for a section it had no key for, and breaking it would turn a grace period
175
+ * into a data-loss bug. Send `WorkspaceDocNode[]` instead.
176
+ *
177
+ * @deprecated Send a `WorkspaceDocNode[]` as `tree`.
178
+ */
179
+ export interface WorkspaceTreeDocument {
180
+ model?: WorkspaceDocNode[];
181
+ content?: WorkspaceDocNode[];
182
+ /** Media folder ids. Media stays folder-backed; a workspace pins a subset. */
183
+ media?: string[];
184
+ }
185
+ /** What a workspace starts with (§0t). A template is only a folder list. */
186
+ export type WorkspaceTemplate = "blank" | "website" | "catalog";
187
+ /**
188
+ * What `get` returns and `apply` accepts — the SAME shape, so a caller can read
189
+ * one, edit it and write it back without a translation step.
190
+ */
191
+ export interface WorkspaceDocument {
192
+ id: string;
193
+ name: string;
194
+ icon: string | null;
195
+ visibility: "team" | "private";
196
+ isDefault: boolean;
197
+ /** The top level. There is no root node, so this IS the workspace. */
198
+ tree: WorkspaceDocNode[];
199
+ }
200
+ export interface WorkspaceSummary {
201
+ id: string;
202
+ name: string;
203
+ icon: string | null;
204
+ visibility: "team" | "private";
205
+ isDefault: boolean;
206
+ sortOrder: number;
207
+ assignedRoles: string[];
208
+ createdById: string | null;
209
+ isMine: boolean;
210
+ nodeCount: number;
211
+ createdAt: string;
212
+ updatedAt: string;
213
+ }
214
+ /**
215
+ * What changed. Read it rather than the final state to find out whether a call
216
+ * did anything: applying the same document twice reports all zeros.
217
+ */
218
+ export interface WorkspaceChanges {
219
+ created: number;
220
+ moved: number;
221
+ renamed: number;
222
+ removed: number;
223
+ unchanged: number;
224
+ }
225
+ export interface WorkspaceApplyResult {
226
+ workspace: WorkspaceSummary;
227
+ changes: WorkspaceChanges;
228
+ /** Ids and namespaces the document named that this tenant does not have. */
229
+ unresolved?: string[];
230
+ }
231
+ export interface WorkspacesResource {
232
+ list(options?: {
233
+ includePrivate?: boolean;
234
+ }): Promise<{
235
+ workspaces: WorkspaceSummary[];
236
+ currentId: string | null;
237
+ defaultId: string | null;
238
+ }>;
239
+ /** One workspace's summary row, or null. */
240
+ get(id: string): Promise<WorkspaceSummary | null>;
241
+ /** The declarative document — the shape `apply` takes back. */
242
+ getDocument(id: string): Promise<WorkspaceDocument>;
243
+ apply(id: string, document: {
244
+ tree?: WorkspaceDocNode[] | WorkspaceTreeDocument;
245
+ }, options?: {
246
+ mode?: "replace" | "merge";
247
+ }): Promise<WorkspaceApplyResult>;
248
+ create(input: {
249
+ name: string;
250
+ icon?: string | null;
251
+ /** Folders to start with. Ignored when `tree` is given. */
252
+ template?: WorkspaceTemplate;
253
+ tree?: WorkspaceDocNode[] | WorkspaceTreeDocument;
254
+ }): Promise<WorkspaceApplyResult>;
255
+ }
256
+ export type ScheduledActionStatus = "pending" | "running" | "done" | "failed" | "dead" | "cancelled";
257
+ /**
258
+ * One row of the publishing ledger: one thing to do, to one target, at one
259
+ * instant. `versionId: null` means the latest draft AT FIRE TIME, which is the
260
+ * default and the point of the feature (decision D1) — a typo fixed on Tuesday
261
+ * is live when Wednesday's schedule runs.
262
+ *
263
+ * `runAt` is the only column the worker reads. `wallTime` and `timezone` are
264
+ * what the person chose, kept so a screen can show it back and a reschedule can
265
+ * re-resolve it; they are never used to decide when to fire.
266
+ */
267
+ export interface ScheduledAction {
268
+ id: string;
269
+ tenantId: string;
270
+ batchId: string;
271
+ action: "publish" | "unpublish";
272
+ targetType: "instance" | "model";
273
+ targetId: string;
274
+ versionId: string | null;
275
+ runAt: string;
276
+ /** `runAt` rendered in this row's own zone, ISO with offset. */
277
+ runAtInTimezone: string;
278
+ timezone: string;
279
+ wallTime: string;
280
+ status: ScheduledActionStatus;
281
+ attempts: number;
282
+ lastError: string | null;
283
+ errorCode: string | null;
284
+ /** The version row the publish created. "Why did this go live at 3am". */
285
+ resultVersionId: string | null;
286
+ requestedBy: string | null;
287
+ cancelledBy: string | null;
288
+ cancelledAt: string | null;
289
+ completedAt: string | null;
290
+ retryOfId: string | null;
291
+ createdAt: string;
292
+ updatedAt: string;
293
+ /** `null` when the target row is gone. */
294
+ target: {
295
+ title: string | null;
296
+ modelId: string;
297
+ modelNamespace: string;
298
+ modelName: string;
299
+ } | null;
300
+ [column: string]: unknown;
301
+ }
302
+ /** What the server made of a `wallTime` + `timezone` pair. */
303
+ export interface ResolvedScheduleTime {
304
+ runAt: string;
305
+ wallTime: string;
306
+ timezone: string;
307
+ runAtInTimezone: string;
308
+ /**
309
+ * Non-null when daylight saving moved the time: a wall time that does not
310
+ * exist resolves forward, one that happens twice takes the earlier offset.
311
+ * Written as a sentence to show a person, so show it.
312
+ */
313
+ note: string | null;
314
+ }
315
+ export interface ScheduledActionTarget {
316
+ /**
317
+ * `"model"` is a session-only target in phase 1. It needs `model:publish`,
318
+ * and no API key bundle grants that, so a model target sent with a key is a
319
+ * 403 every time. Schedule models from the admin until a bundle carries it.
320
+ */
321
+ type: "instance" | "model";
322
+ id: string;
323
+ /** Omit for the latest draft at fire time, which is what you usually want. */
324
+ versionId?: string;
325
+ }
326
+ export interface CreateScheduledActionsInput {
327
+ action: "publish" | "unpublish";
328
+ targets: ScheduledActionTarget[];
329
+ /** Local wall clock, NO offset: "2026-10-01T09:00". */
330
+ wallTime: string;
331
+ /** IANA name, e.g. "America/New_York". */
332
+ timezone: string;
333
+ }
334
+ export interface CreateScheduledActionsResult {
335
+ batchId: string;
336
+ runAt: string;
337
+ resolved: ResolvedScheduleTime;
338
+ actions: ScheduledAction[];
339
+ /** Ids of actions cancelled to make room. One active action per target per verb. */
340
+ replaced: string[];
341
+ }
342
+ export interface ScheduledActionsPage {
343
+ actions: ScheduledAction[];
344
+ pagination: {
345
+ total: number;
346
+ page: number;
347
+ limit: number;
348
+ totalPages: number;
349
+ hasMore: boolean;
350
+ };
351
+ }
352
+ export interface ListScheduledActionsOptions {
353
+ status?: ScheduledActionStatus[];
354
+ targetId?: string;
355
+ batchId?: string;
356
+ from?: string;
357
+ to?: string;
358
+ page?: number;
359
+ limit?: number;
360
+ }
361
+ export interface ScheduledActionsResource {
362
+ create(input: CreateScheduledActionsInput): Promise<CreateScheduledActionsResult>;
363
+ list(options?: ListScheduledActionsOptions): Promise<ScheduledActionsPage>;
364
+ get(id: string): Promise<ScheduledAction | null>;
365
+ /**
366
+ * Returns the moved action with the server's `resolved` block attached, the
367
+ * same one `create` returns. Read `resolved.note` before showing a time.
368
+ */
369
+ reschedule(id: string, when: {
370
+ wallTime: string;
371
+ timezone: string;
372
+ }): Promise<ScheduledAction & {
373
+ resolved: ResolvedScheduleTime;
374
+ }>;
375
+ cancel(id: string): Promise<ScheduledAction>;
376
+ /** Creates a NEW pending row; the failed one keeps its history. */
377
+ retry(id: string): Promise<ScheduledAction>;
378
+ }
379
+ export interface SearchHit {
380
+ /**
381
+ * The instance UUID, via `instanceIdOf`. `null` means the server sent no
382
+ * usable id: an extended search row hoists the model's own fields the same
383
+ * way a content row does, so a model with a field named `id` shadows it, and
384
+ * a server older than #4628 sends no `instanceId` beside it.
385
+ */
386
+ id: string | null;
387
+ namespace: string | null;
388
+ model?: string;
389
+ title: unknown;
390
+ }
391
+ /**
392
+ * The Capa instance id for a row.
393
+ *
394
+ * `instanceId` FIRST, and it is the answer on any current server: #4628 added
395
+ * it to every public read row as the last key, precisely so that one key always
396
+ * means the instance id.
397
+ *
398
+ * `id` is the fallback, and it is a fallback because it is not trustworthy.
399
+ * `GET /v2/api/:ns` builds each row as `{...instance, ...formatInstanceData(instance)}`
400
+ * — the model's own fields are hoisted to the top level and spread SECOND, so a
401
+ * field named `id` overwrites the instance's id and the real UUID is then absent
402
+ * from the row, not merely moved. The same happens to `title` and `tags`. In one
403
+ * ordinary tenant 20 of 20 models have a field named `title` and 7 of 20 have
404
+ * one named `id` (every shopify_* model, plus judge_me_review), so this is the
405
+ * common case. The UUID test is therefore kept for servers older than the fix,
406
+ * where it is the only thing separating an instance id from a Shopify numeric
407
+ * id that happens to live in a field called `id`.
408
+ *
409
+ * `null` means the server sent no usable id at all: an old server AND a
410
+ * shadowing model. `getContentById` cannot be reached for such a row.
411
+ */
412
+ export declare function instanceIdOf(row: unknown): string | null;
413
+ export declare function createClient(config: CapaConfig): CapaClient;