@withmestra/sdk 0.0.0-stage → 0.1.1

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
@@ -1,3 +1,127 @@
1
- # Temporary Holding Version
1
+ # @withmestra/sdk
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ A TypeScript client for Mestra's public API, with a helper for editing designs: open a size or a master, change it by role (the headline, the button), look at the result, and save. Drawing is always done by Mestra's servers: this package never draws anything, it sends your edits and brings back the picture.
4
+
5
+ ## Install
6
+
7
+ ```sh
8
+ npm install @withmestra/sdk
9
+ ```
10
+
11
+ It needs an API key (they start `ptk_`) with the Designs permission, made in Settings, then API. It runs in Node 20 or later and in browsers.
12
+
13
+ ```ts
14
+ import { createMestra } from "@withmestra/sdk";
15
+
16
+ const mestra = createMestra({ token: process.env.MESTRA_KEY! });
17
+ ```
18
+
19
+ `createMestra` also takes `baseUrl` (default `https://app.withmestra.com`) and `fetch`. `mestra.api` is a typed client for every other call in the API (`mestra.api.GET("/api/v1/campaigns", ...)`), made from the same OpenAPI file as the reference.
20
+
21
+ ## Edit a size
22
+
23
+ ```ts
24
+ // The run's id and the size's key (from the run, or from mestra.editable(runId)).
25
+ const size = await mestra.openSize("run-4f2a", "300x250");
26
+
27
+ // Name the element by what it is. If two elements have the role you get a refusal, not a guess.
28
+ const answer = await size.edit("set_text", { role: "headline", text: "Summer sale, this week" });
29
+ if (answer.refused) {
30
+ console.log(answer.refused, answer.detail, answer.candidates); // nothing was changed
31
+ }
32
+
33
+ const picture = await size.preview(); // { png, width, height, check, current }
34
+ await fs.writeFile("preview.png", picture.png);
35
+
36
+ await size.undo("loaded"); // back to how it opened
37
+ await size.edit("set_text", { role: "headline", text: "Summer sale" });
38
+ const saved = await size.save(); // free; joins the run's review
39
+ console.log(saved.sceneHash, saved.round);
40
+ ```
41
+
42
+ - `size.elements` lists what is in the design (`ref`, `role`, `name`, `kind`, `kept`, `frame`, `text`). A role is lower case with words joined by `_`.
43
+ - `size.editMany([{ name, input }, ...])` makes several edits in one request.
44
+ - `size.uploadPicture(bytes)` keeps a picture for the session; then `size.edit("swap_picture", { role: "photo", sha256: picture.sha256 })`.
45
+ - `size.check({ render: true })` judges the drawing against the rules.
46
+ - `save()` is safe to retry: pass your own `idempotencyKey` and send the same one again if you lost the answer. Nothing is saved twice.
47
+
48
+ ### A lost session
49
+
50
+ A session ends after an hour unused, or if Mestra restarts. The helper handles that for you: it opens the same size again, checks it is still the design you were editing, replays your edits, and retries the call. If the size was changed by someone else meanwhile it throws `SizeChangedError` and replays nothing; if a replayed edit comes out differently it throws `ReplayDivergedError`. A save that finds its session gone is tried again with the same key first, because the first save may already have been recorded.
51
+
52
+ ## Edit a master
53
+
54
+ ```ts
55
+ import { VersionChangedError } from "@withmestra/sdk";
56
+
57
+ const master = await mestra.openMaster("5625b1a8-2f39-4f06-9ede-73ccdcbc04c3"); // the latest, or { version: 3 }
58
+
59
+ await master.edit("set_role", { ref: "tagline", role: "subhead" });
60
+ await master.edit("set_opacity", { role: "headline", opacity: 0.9 });
61
+
62
+ try {
63
+ const saved = await master.save();
64
+ console.log(saved.version, saved.status, saved.unchanged);
65
+ } catch (e) {
66
+ if (e instanceof VersionChangedError) {
67
+ // Someone (or an app) saved version e.latest since you opened. Open it again and redo the edits.
68
+ console.log(`Version ${e.latest} was saved by ${e.byName || e.by}`);
69
+ } else throw e;
70
+ }
71
+ ```
72
+
73
+ A master's save sends the version number as `If-Match`; the session keeps it for you, and moves it on after each save. Reopening a lost session never moves it forward, so a newer version still gets its 412. `set_role`, `set_shape`, `set_opacity`, and `verticalAlign`, `caps` and `paras` on text are for masters only; on a size the first three throw a `MestraError` with code `master_only`, and the others come back as a refusal.
74
+
75
+ ## Edit many sizes at once
76
+
77
+ ```ts
78
+ const edited = await mestra.editSizes(
79
+ {
80
+ campaign: "winter-sale",
81
+ sizes: "all", // or ["300x250", "728x90"]
82
+ edit: { name: "set_text", input: { role: "headline", text: "Book now" } },
83
+ },
84
+ { onProgress: (p) => console.log(`${p.done} of ${p.total}`) },
85
+ );
86
+
87
+ for (const r of edited.results) {
88
+ if (r.outcome === "refused") console.log(r.size, r.refused, r.candidates);
89
+ }
90
+
91
+ // Look at the sizes you want (r.editSession is a normal size session), then save them.
92
+ const keep = edited.results.flatMap((r) => (r.editSession ? [r.editSession] : []));
93
+ const saved = await mestra.saveSizes(keep);
94
+ ```
95
+
96
+ The server answers at once when everything is done within ten seconds; otherwise it gives you an operation and the work goes on. `editSizes` and `saveSizes` poll for you and give the same answer either way. Options: `pollIntervalMs` (default 1000), `timeoutMs` (default 600000, ten minutes; the operation carries on if you stop waiting, and `WaitTimeoutError` names it), and `onProgress`. To follow it yourself, use `startEditSizes` / `startSaveSizes` for the first answer and `for await (const p of mestra.watchSizes(first)) ...` for each step. Only size sessions can be saved this way; save a master with `master.save()`.
97
+
98
+ An elements list is not returned for a session made by `editSizes`: it fills in with your next edit.
99
+
100
+ ## Errors
101
+
102
+ A refusal of an edit is not an error. The API answers it with `200`, and you read it on the answer: `refused` is a code (empty when the edit was made), with `detail`, `limit` and, for a role, `candidates`. Everything else is thrown as a `MestraError` with `status`, `code`, `field` and `details` (the rest of the body, such as `limit` or `max`).
103
+
104
+ | Answer | Where you see it |
105
+ |---|---|
106
+ | `200` with `refused` set: `unknown_role`, `ambiguous_role` (with `candidates`), `bad-number`, `style-refused`, `unknown-ref`, `bad-input` (`master_only:` in `detail` for master fields on a size), others | Data on the answer of `edit`, `editMany`, `undo` |
107
+ | `outcome: "refused"`, `"not_editable"`, `"busy"`, `"failed"` on one size of an edit across sizes; `outcome: "failed"` with `status` and `code` on one size of a save | Data on that size's result |
108
+ | `404` `session_gone` | Recovered by the helper; `SessionGoneError` only if recovery itself finds it gone |
109
+ | `412` `scene_changed` (size changed since opened), or the reopened size differs | `SizeChangedError` |
110
+ | `412` `version_changed` (master has a newer version) | `VersionChangedError` with `latest`, `by`, `byName` |
111
+ | Replayed edit answered a different checkpoint | `ReplayDivergedError` |
112
+ | `409` `sizes_busy` | `SizesBusyError` |
113
+ | `404` `operation_gone` | `OperationGoneError` |
114
+ | Waited longer than `timeoutMs` | `WaitTimeoutError` (`operation`) |
115
+ | `409` `idempotency_conflict` (key used for a different save or edit), `review_moved` (save again) | `MestraError` |
116
+ | `409` `not_editable`, `library_off`, `api_off` | `MestraError` |
117
+ | `400` `master_only` (a master edit on a size, or across sizes), `if_match`; `400` with `field` `name`, `edits`, `X-Mestra-Edit-Session`, `idempotencyKey`, `campaign`, `sizes`, `edit.name` | `MestraError` |
118
+ | `400` picture codes `picture_sum`, `picture_empty`, `picture_short`, `picture_size`, `image_too_large` (`limit`); `411` `picture_length`; `413` `picture_too_big` (`max`); `415` `picture_type`; `422` `run_images_full` (`max`); `429` | `MestraError` |
119
+ | `422` `stand_in`, `lossy`, `cannot_draw` on save (nothing saved) | `MestraError` |
120
+ | `428` `if_match_required` | `MestraError` |
121
+ | `401` `invalid_token`; `403` `wrong_key` (key lacks Designs · Read or Write) | `MestraError` |
122
+ | `404` `campaign_not_found`, `not_editable`, `headless_off`; `404` for a run or size that does not exist | `MestraError` |
123
+ | `502` `render_failed`; `503` `editor_unavailable`, `editor_busy` (retry shortly) | `MestraError` |
124
+
125
+ ## Types
126
+
127
+ `src/v1.d.ts` is generated from the OpenAPI file; `paths`, `components` and `operations` are exported. The package follows version 1 of the API: new fields can appear, so read defensively.
package/dist/edit.d.ts ADDED
@@ -0,0 +1,146 @@
1
+ import { Transport } from "./http.js";
2
+ import type { CheckAnswer, Edit, EditAnswer, EditCheck, EditElement, FormatInfo, MasterInfo, MasterSaveAnswer, Picture, Preview, SizeSaveAnswer } from "./types.js";
3
+ type LogEntry = {
4
+ kind: "apply";
5
+ body: Record<string, unknown>;
6
+ label: string;
7
+ } | {
8
+ kind: "restore";
9
+ asked: string;
10
+ label: string;
11
+ } | {
12
+ kind: "upload";
13
+ bytes: Uint8Array;
14
+ mime: string;
15
+ sha256: string;
16
+ };
17
+ /**
18
+ * What a size session and a master session share: the op log, recovery from a lost session, and the calls.
19
+ *
20
+ * The log keeps what changed the design since the session opened (edits that made a checkpoint, restores and
21
+ * uploads). When the server forgets the session (404 `session_gone`) the helper opens it again, checks it is the
22
+ * same design, replays the log, and retries the call. It never replays onto a design that has changed.
23
+ */
24
+ export declare abstract class EditSession<Saved> {
25
+ protected readonly transport: Transport;
26
+ /** The session id. It changes when a lost session is reopened. */
27
+ id: string;
28
+ elements: EditElement[];
29
+ /** The latest check of the design rules the session has seen. Call `check()` for a fresh one. */
30
+ lastCheck: EditCheck;
31
+ /** The checkpoint the session stands on. */
32
+ label: string;
33
+ abstract readonly kind: "size" | "master";
34
+ protected log: LogEntry[];
35
+ protected constructor(transport: Transport, open: {
36
+ id: string;
37
+ elements: EditElement[];
38
+ check: EditCheck;
39
+ label: string;
40
+ });
41
+ /** What `If-Match` carries on the next save. */
42
+ protected abstract ifMatchHeader(): string;
43
+ /** Opens the same design again on a new session and answers its id. Throws SizeChangedError if it differs. */
44
+ protected abstract reopen(): Promise<string>;
45
+ /** Takes what a save answered. */
46
+ protected abstract saved(answer: Record<string, unknown>): Saved;
47
+ private sendEdit;
48
+ private absorb;
49
+ protected replay(id: string): Promise<void>;
50
+ private diverged;
51
+ private recover;
52
+ /** Runs a call; if the session is gone, recovers it and runs the call once more. */
53
+ protected call<T>(fn: () => Promise<T>): Promise<T>;
54
+ /** Makes one edit. A refusal comes back as `answer.refused`, with nothing applied. */
55
+ edit(name: string, input?: Record<string, unknown>): Promise<EditAnswer>;
56
+ /** Makes several edits in one request. They share one checkpoint and stop at the first refusal. */
57
+ editMany(edits: Edit[]): Promise<EditAnswer>;
58
+ private apply;
59
+ /** Puts the session back on a checkpoint: `loaded`, an `e<n>` label an edit answered, `current` or `best`. */
60
+ undo(label: string): Promise<EditAnswer>;
61
+ /** Checks the design against the rules. With `render` it is also drawn and judged. */
62
+ check(o?: {
63
+ render?: boolean;
64
+ }): Promise<CheckAnswer>;
65
+ /** Draws the design as it stands. Mestra's servers draw it; nothing is drawn here. */
66
+ preview(): Promise<Preview>;
67
+ /** Keeps a picture for the session; then `edit("swap_picture", {ref, sha256})`. Works on a size and on a master. */
68
+ uploadPicture(bytes: Uint8Array, mime?: "image/png" | "image/jpeg"): Promise<Picture>;
69
+ /**
70
+ * Saves the design. The key makes the save safe to retry: pass your own and send the same one again if an answer
71
+ * is lost, and nothing is saved twice. If the session has ended the save is first tried again with the same key
72
+ * (the server may have recorded it); only then is the session reopened, checked and replayed, and the save made.
73
+ */
74
+ save(o?: {
75
+ idempotencyKey?: string;
76
+ }): Promise<Saved>;
77
+ }
78
+ /** One size of a run, open for editing. */
79
+ export declare class SizeSession extends EditSession<SizeSaveAnswer> {
80
+ readonly kind = "size";
81
+ /** The run's id (the `{id}` of the run). */
82
+ readonly runId: string;
83
+ /** The size's key. */
84
+ readonly size: string;
85
+ /** The hash the log is built on, and what the next save sends as `If-Match`. */
86
+ sceneHash: string;
87
+ format: FormatInfo | undefined;
88
+ constructor(transport: Transport, runId: string, size: string, open: {
89
+ id: string;
90
+ elements: EditElement[];
91
+ check: EditCheck;
92
+ label: string;
93
+ sceneHash: string;
94
+ format?: FormatInfo;
95
+ });
96
+ /** A session `editSizes` opened and edited for you: the edit it made is in the log so recovery keeps it. */
97
+ static fromBatch(transport: Transport, r: {
98
+ run: string;
99
+ size: string;
100
+ session: string;
101
+ sceneHash: string;
102
+ label?: string;
103
+ check?: EditCheck;
104
+ elements?: EditElement[];
105
+ }, edit: {
106
+ name: string;
107
+ input?: Record<string, unknown>;
108
+ }): SizeSession;
109
+ protected ifMatchHeader(): string;
110
+ protected reopen(): Promise<string>;
111
+ protected saved(a: Record<string, unknown>): SizeSaveAnswer;
112
+ /** After a save made across sizes. */
113
+ markSaved(sceneHash: string): void;
114
+ }
115
+ /** One version of a master, open for editing. */
116
+ export declare class MasterSession extends EditSession<MasterSaveAnswer> {
117
+ readonly kind = "master";
118
+ readonly masterId: string;
119
+ /** The version the session stands on: the one opened, then the one the last save made. Recovery reopens it. */
120
+ version: number;
121
+ /** What the next save sends as `If-Match`. Only a save moves it, never a reopen. */
122
+ ifMatch: number;
123
+ master: MasterInfo;
124
+ ruleSets: unknown[];
125
+ copyOverrides: Record<string, number>;
126
+ constructor(transport: Transport, open: {
127
+ id: string;
128
+ elements: EditElement[];
129
+ check: EditCheck;
130
+ label: string;
131
+ master: MasterInfo;
132
+ ifMatch: number;
133
+ ruleSets: unknown[];
134
+ copyOverrides: Record<string, number>;
135
+ });
136
+ protected ifMatchHeader(): string;
137
+ protected reopen(): Promise<string>;
138
+ protected saved(a: Record<string, unknown>): MasterSaveAnswer;
139
+ }
140
+ /** Opens one size of a run for editing. */
141
+ export declare function openSize(t: Transport, runId: string, size: string): Promise<SizeSession>;
142
+ /** Opens a master for editing: `version` is a number, or the latest by default. */
143
+ export declare function openMaster(t: Transport, masterId: string, o?: {
144
+ version?: number | "latest";
145
+ }): Promise<MasterSession>;
146
+ export {};
package/dist/edit.js ADDED
@@ -0,0 +1,344 @@
1
+ import { sessionHeader } from "./http.js";
2
+ import { ReplayDivergedError, SessionGoneError, SizeChangedError } from "./errors.js";
3
+ /** A check with nothing in it, for a session built before its first check. */
4
+ const emptyCheck = () => ({ failures: [], warnings: [], objective: 0 });
5
+ function answerOf(body) {
6
+ return {
7
+ elements: [],
8
+ fixes: [],
9
+ carried: {},
10
+ check: emptyCheck(),
11
+ refused: "",
12
+ detail: "",
13
+ limit: null,
14
+ noChange: false,
15
+ label: "",
16
+ ...body,
17
+ };
18
+ }
19
+ function randomKey() {
20
+ return globalThis.crypto.randomUUID();
21
+ }
22
+ async function sha256Hex(bytes) {
23
+ const digest = await globalThis.crypto.subtle.digest("SHA-256", bytes);
24
+ return Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, "0")).join("");
25
+ }
26
+ function sniffMime(bytes) {
27
+ if (bytes[0] === 0xff && bytes[1] === 0xd8)
28
+ return "image/jpeg";
29
+ return "image/png";
30
+ }
31
+ function fromBase64(data) {
32
+ const raw = atob(data);
33
+ const out = new Uint8Array(raw.length);
34
+ for (let i = 0; i < raw.length; i++)
35
+ out[i] = raw.charCodeAt(i);
36
+ return out;
37
+ }
38
+ /**
39
+ * What a size session and a master session share: the op log, recovery from a lost session, and the calls.
40
+ *
41
+ * The log keeps what changed the design since the session opened (edits that made a checkpoint, restores and
42
+ * uploads). When the server forgets the session (404 `session_gone`) the helper opens it again, checks it is the
43
+ * same design, replays the log, and retries the call. It never replays onto a design that has changed.
44
+ */
45
+ export class EditSession {
46
+ transport;
47
+ /** The session id. It changes when a lost session is reopened. */
48
+ id;
49
+ elements;
50
+ /** The latest check of the design rules the session has seen. Call `check()` for a fresh one. */
51
+ lastCheck;
52
+ /** The checkpoint the session stands on. */
53
+ label;
54
+ log = [];
55
+ constructor(transport, open) {
56
+ this.transport = transport;
57
+ this.id = open.id;
58
+ this.elements = open.elements;
59
+ this.lastCheck = open.check;
60
+ this.label = open.label;
61
+ }
62
+ async sendEdit(path, json, id = this.id) {
63
+ return this.transport.send("POST", path, { headers: { [sessionHeader]: id }, json });
64
+ }
65
+ absorb(a) {
66
+ if (a.check && Object.keys(a.check).length > 0)
67
+ this.lastCheck = a.check;
68
+ if (a.label)
69
+ this.label = a.label;
70
+ // A refusal carries no copy, so its elements would blank the text we hold.
71
+ if (a.refused === "" && a.elements.length > 0)
72
+ this.elements = a.elements;
73
+ }
74
+ async replay(id) {
75
+ for (const e of this.log) {
76
+ if (e.kind === "apply") {
77
+ const a = answerOf((await this.sendEdit("/api/v1/edit/apply", e.body, id)).body);
78
+ if (a.label !== e.label)
79
+ this.diverged(e.label, a.label);
80
+ }
81
+ else if (e.kind === "restore") {
82
+ const a = answerOf((await this.sendEdit("/api/v1/edit/restore", { label: e.asked }, id)).body);
83
+ if (a.label !== e.label)
84
+ this.diverged(e.label, a.label);
85
+ }
86
+ else {
87
+ await this.transport.send("PUT", `/api/v1/edit/assets/${e.sha256}`, {
88
+ headers: { [sessionHeader]: id },
89
+ bytes: e.bytes,
90
+ contentType: e.mime,
91
+ });
92
+ }
93
+ }
94
+ }
95
+ diverged(was, now) {
96
+ throw new ReplayDivergedError(`Replaying the edits after the session ended gave checkpoint "${now}" where it first gave "${was}". Nothing was saved.`, { status: 409, code: "replay_diverged" });
97
+ }
98
+ async recover() {
99
+ const id = await this.reopen();
100
+ await this.replay(id);
101
+ this.id = id;
102
+ }
103
+ /** Runs a call; if the session is gone, recovers it and runs the call once more. */
104
+ async call(fn) {
105
+ try {
106
+ return await fn();
107
+ }
108
+ catch (e) {
109
+ if (!(e instanceof SessionGoneError))
110
+ throw e;
111
+ }
112
+ await this.recover();
113
+ return fn();
114
+ }
115
+ /** Makes one edit. A refusal comes back as `answer.refused`, with nothing applied. */
116
+ edit(name, input = {}) {
117
+ return this.apply({ name, input });
118
+ }
119
+ /** Makes several edits in one request. They share one checkpoint and stop at the first refusal. */
120
+ editMany(edits) {
121
+ return this.apply({ edits: edits.map((e) => ({ name: e.name, input: e.input ?? {} })) });
122
+ }
123
+ async apply(body) {
124
+ const a = answerOf((await this.call(() => this.sendEdit("/api/v1/edit/apply", body))).body);
125
+ if (a.label)
126
+ this.log.push({ kind: "apply", body, label: a.label });
127
+ this.absorb(a);
128
+ return a;
129
+ }
130
+ /** Puts the session back on a checkpoint: `loaded`, an `e<n>` label an edit answered, `current` or `best`. */
131
+ async undo(label) {
132
+ const a = answerOf((await this.call(() => this.sendEdit("/api/v1/edit/restore", { label }))).body);
133
+ if (a.label && a.refused === "")
134
+ this.log.push({ kind: "restore", asked: label, label: a.label });
135
+ this.absorb(a);
136
+ return a;
137
+ }
138
+ /** Checks the design against the rules. With `render` it is also drawn and judged. */
139
+ async check(o = {}) {
140
+ const json = o.render ? { render: true } : undefined;
141
+ const res = await this.call(() => this.transport.send("POST", "/api/v1/edit/check", {
142
+ headers: { [sessionHeader]: this.id },
143
+ ...(json ? { json } : {}),
144
+ }));
145
+ const a = res.body;
146
+ if (a.check)
147
+ this.lastCheck = a.check;
148
+ return a;
149
+ }
150
+ /** Draws the design as it stands. Mestra's servers draw it; nothing is drawn here. */
151
+ async preview() {
152
+ const res = await this.call(() => this.transport.send("POST", "/api/v1/edit/preview", { headers: { [sessionHeader]: this.id } }));
153
+ const b = res.body;
154
+ if (b.check)
155
+ this.lastCheck = b.check;
156
+ return {
157
+ png: fromBase64(b.image.data),
158
+ width: b.image.width,
159
+ height: b.image.height,
160
+ check: b.check,
161
+ current: b.current !== false,
162
+ standIns: b.standIns ?? [],
163
+ lossy: b.lossy === true,
164
+ hash: b.hash ?? "",
165
+ };
166
+ }
167
+ /** Keeps a picture for the session; then `edit("swap_picture", {ref, sha256})`. Works on a size and on a master. */
168
+ async uploadPicture(bytes, mime) {
169
+ const type = mime ?? sniffMime(bytes);
170
+ const sha256 = await sha256Hex(bytes);
171
+ const res = await this.call(() => this.transport.send("PUT", `/api/v1/edit/assets/${sha256}`, {
172
+ headers: { [sessionHeader]: this.id },
173
+ bytes,
174
+ contentType: type,
175
+ }));
176
+ this.log.push({ kind: "upload", bytes, mime: type, sha256 });
177
+ return { ...res.body.asset, created: res.body.created === true };
178
+ }
179
+ /**
180
+ * Saves the design. The key makes the save safe to retry: pass your own and send the same one again if an answer
181
+ * is lost, and nothing is saved twice. If the session has ended the save is first tried again with the same key
182
+ * (the server may have recorded it); only then is the session reopened, checked and replayed, and the save made.
183
+ */
184
+ async save(o = {}) {
185
+ const idempotencyKey = o.idempotencyKey ?? randomKey();
186
+ const attempt = () => this.transport.send("PUT", "/api/v1/edit/save", {
187
+ headers: { [sessionHeader]: this.id, "If-Match": this.ifMatchHeader() },
188
+ json: { idempotencyKey },
189
+ });
190
+ let res;
191
+ try {
192
+ res = await attempt();
193
+ }
194
+ catch (e) {
195
+ if (!(e instanceof SessionGoneError))
196
+ throw e;
197
+ try {
198
+ res = await attempt();
199
+ }
200
+ catch (e2) {
201
+ if (!(e2 instanceof SessionGoneError))
202
+ throw e2;
203
+ await this.recover();
204
+ res = await attempt();
205
+ }
206
+ }
207
+ const out = this.saved(res.body);
208
+ this.log = [];
209
+ return out;
210
+ }
211
+ }
212
+ /** One size of a run, open for editing. */
213
+ export class SizeSession extends EditSession {
214
+ kind = "size";
215
+ /** The run's id (the `{id}` of the run). */
216
+ runId;
217
+ /** The size's key. */
218
+ size;
219
+ /** The hash the log is built on, and what the next save sends as `If-Match`. */
220
+ sceneHash;
221
+ format;
222
+ constructor(transport, runId, size, open) {
223
+ super(transport, open);
224
+ this.runId = runId;
225
+ this.size = size;
226
+ this.sceneHash = open.sceneHash;
227
+ this.format = open.format;
228
+ }
229
+ /** A session `editSizes` opened and edited for you: the edit it made is in the log so recovery keeps it. */
230
+ static fromBatch(transport, r, edit) {
231
+ const s = new SizeSession(transport, r.run, r.size, {
232
+ id: r.session,
233
+ elements: r.elements ?? [],
234
+ check: r.check ?? emptyCheck(),
235
+ label: r.label || "loaded",
236
+ sceneHash: r.sceneHash,
237
+ });
238
+ if (r.label)
239
+ s.log.push({ kind: "apply", body: { name: edit.name, input: edit.input ?? {} }, label: r.label });
240
+ return s;
241
+ }
242
+ ifMatchHeader() {
243
+ return this.sceneHash;
244
+ }
245
+ async reopen() {
246
+ const o = await openSizeRaw(this.transport, this.runId, this.size);
247
+ if (o.sceneHash !== this.sceneHash) {
248
+ throw new SizeChangedError("This size changed since you opened it, so your edits were not replayed. Open it again and redo them.", { status: 412, code: "scene_changed" });
249
+ }
250
+ return o.id;
251
+ }
252
+ saved(a) {
253
+ const out = a;
254
+ this.sceneHash = out.sceneHash;
255
+ if (out.check)
256
+ this.lastCheck = out.check;
257
+ if (out.label)
258
+ this.label = out.label;
259
+ return out;
260
+ }
261
+ /** After a save made across sizes. */
262
+ markSaved(sceneHash) {
263
+ this.sceneHash = sceneHash;
264
+ this.log = [];
265
+ }
266
+ }
267
+ /** One version of a master, open for editing. */
268
+ export class MasterSession extends EditSession {
269
+ kind = "master";
270
+ masterId;
271
+ /** The version the session stands on: the one opened, then the one the last save made. Recovery reopens it. */
272
+ version;
273
+ /** What the next save sends as `If-Match`. Only a save moves it, never a reopen. */
274
+ ifMatch;
275
+ master;
276
+ ruleSets;
277
+ copyOverrides;
278
+ constructor(transport, open) {
279
+ super(transport, open);
280
+ this.masterId = open.master.id;
281
+ this.version = open.master.version;
282
+ this.ifMatch = open.ifMatch;
283
+ this.master = open.master;
284
+ this.ruleSets = open.ruleSets;
285
+ this.copyOverrides = open.copyOverrides;
286
+ }
287
+ ifMatchHeader() {
288
+ return String(this.ifMatch);
289
+ }
290
+ async reopen() {
291
+ // The same version, by number: `latest` could land on a newer one. The answer's ifMatch is ignored on purpose,
292
+ // so a newer version still gets its 412 at save.
293
+ const o = await openMasterRaw(this.transport, this.masterId, this.version);
294
+ return o.session;
295
+ }
296
+ saved(a) {
297
+ const out = a;
298
+ this.ifMatch = out.ifMatch;
299
+ this.version = out.version;
300
+ if (out.check)
301
+ this.lastCheck = out.check;
302
+ if (out.label)
303
+ this.label = out.label;
304
+ return out;
305
+ }
306
+ }
307
+ /* eslint-disable @typescript-eslint/no-explicit-any */
308
+ async function openSizeRaw(t, runId, size) {
309
+ const res = await t.send("POST", `/api/v1/jobs/${encodeURIComponent(runId)}/formats/${encodeURIComponent(size)}/edit`);
310
+ const b = res.body;
311
+ return {
312
+ run: (b.run ?? runId),
313
+ size: (b.size ?? size),
314
+ id: b.session,
315
+ elements: (b.elements ?? []),
316
+ check: (b.check ?? emptyCheck()),
317
+ label: (b.label ?? "loaded"),
318
+ sceneHash: b.sceneHash,
319
+ format: b.format,
320
+ };
321
+ }
322
+ async function openMasterRaw(t, masterId, version) {
323
+ const res = await t.send("POST", `/api/v1/masters/${encodeURIComponent(masterId)}/versions/${version}/edit`);
324
+ return res.body;
325
+ }
326
+ /** Opens one size of a run for editing. */
327
+ export async function openSize(t, runId, size) {
328
+ const o = await openSizeRaw(t, runId, size);
329
+ return new SizeSession(t, o.run, o.size, o);
330
+ }
331
+ /** Opens a master for editing: `version` is a number, or the latest by default. */
332
+ export async function openMaster(t, masterId, o = {}) {
333
+ const b = await openMasterRaw(t, masterId, o.version ?? "latest");
334
+ return new MasterSession(t, {
335
+ id: b.session,
336
+ elements: b.elements ?? [],
337
+ check: b.check ?? emptyCheck(),
338
+ label: b.label ?? "loaded",
339
+ master: b.master,
340
+ ifMatch: b.ifMatch,
341
+ ruleSets: b.ruleSets ?? [],
342
+ copyOverrides: b.copyOverrides ?? {},
343
+ });
344
+ }
@@ -0,0 +1,51 @@
1
+ /** Anything the API answers with an error status. `code` and `field` are the API's own. */
2
+ export declare class MestraError extends Error {
3
+ readonly status: number;
4
+ readonly code?: string;
5
+ readonly field?: string;
6
+ /** The rest of the error body (`limit`, `max`, `latest`, ...), as the API sent it. */
7
+ readonly details: Record<string, unknown>;
8
+ constructor(message: string, init: {
9
+ status: number;
10
+ code?: string;
11
+ field?: string;
12
+ details?: Record<string, unknown>;
13
+ });
14
+ }
15
+ /** 404 `session_gone`. The session helpers recover from it by themselves; you only see it if recovery fails. */
16
+ export declare class SessionGoneError extends MestraError {
17
+ }
18
+ /** The size changed while you were editing (412 `scene_changed`), or changed before a lost session could be replayed. */
19
+ export declare class SizeChangedError extends MestraError {
20
+ }
21
+ /** 412 `version_changed`: the master has a newer version than the one you opened. Open it again and redo the edits. */
22
+ export declare class VersionChangedError extends MestraError {
23
+ /** The master's newest version number. */
24
+ readonly latest: number;
25
+ /** `mestra` for a save made here, else the app: `indesign`, `figma`, `canva` or `upload`. */
26
+ readonly by: string;
27
+ /** Who saved it, for a save made in Mestra; empty for an app's. */
28
+ readonly byName: string;
29
+ constructor(message: string, init: {
30
+ status: number;
31
+ code?: string;
32
+ field?: string;
33
+ details?: Record<string, unknown>;
34
+ });
35
+ }
36
+ /** A lost session was reopened and the log replayed, but an edit answered differently from the first time. */
37
+ export declare class ReplayDivergedError extends MestraError {
38
+ }
39
+ /** 409 `sizes_busy`: another across-sizes call by the same user is still running. */
40
+ export declare class SizesBusyError extends MestraError {
41
+ }
42
+ /** 404 `operation_gone`: the operation is unknown, has ended, or is another user's. */
43
+ export declare class OperationGoneError extends MestraError {
44
+ }
45
+ /** Waiting for an across-sizes operation took longer than `timeoutMs`. The operation itself carries on. */
46
+ export declare class WaitTimeoutError extends MestraError {
47
+ /** Pass it to `getSizesOperation` or `watchSizes` to keep waiting. */
48
+ readonly operation: string;
49
+ constructor(message: string, operation: string);
50
+ }
51
+ export declare function errorFor(status: number, body: unknown): MestraError;