@ankinize/sdk 0.0.0-stage → 0.1.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 +78 -2
- package/dist/batch.d.ts +157 -0
- package/dist/batch.js +263 -0
- package/dist/gen/client/client.gen.d.ts +2 -0
- package/dist/gen/client/client.gen.js +216 -0
- package/dist/gen/client/index.d.ts +10 -0
- package/dist/gen/client/index.js +6 -0
- package/dist/gen/client/types.gen.d.ts +120 -0
- package/dist/gen/client/types.gen.js +2 -0
- package/dist/gen/client/utils.gen.d.ts +37 -0
- package/dist/gen/client/utils.gen.js +228 -0
- package/dist/gen/client.gen.d.ts +12 -0
- package/dist/gen/client.gen.js +4 -0
- package/dist/gen/core/auth.gen.d.ts +25 -0
- package/dist/gen/core/auth.gen.js +14 -0
- package/dist/gen/core/bodySerializer.gen.d.ts +25 -0
- package/dist/gen/core/bodySerializer.gen.js +57 -0
- package/dist/gen/core/params.gen.d.ts +43 -0
- package/dist/gen/core/params.gen.js +109 -0
- package/dist/gen/core/pathSerializer.gen.d.ts +33 -0
- package/dist/gen/core/pathSerializer.gen.js +106 -0
- package/dist/gen/core/queryKeySerializer.gen.d.ts +18 -0
- package/dist/gen/core/queryKeySerializer.gen.js +92 -0
- package/dist/gen/core/serverSentEvents.gen.d.ts +71 -0
- package/dist/gen/core/serverSentEvents.gen.js +132 -0
- package/dist/gen/core/types.gen.d.ts +83 -0
- package/dist/gen/core/types.gen.js +2 -0
- package/dist/gen/core/utils.gen.d.ts +19 -0
- package/dist/gen/core/utils.gen.js +87 -0
- package/dist/gen/public.gen.d.ts +1 -0
- package/dist/gen/public.gen.js +2 -0
- package/dist/gen/sdk.gen.d.ts +504 -0
- package/dist/gen/sdk.gen.js +880 -0
- package/dist/gen/types.gen.d.ts +3452 -0
- package/dist/gen/types.gen.js +2 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +5 -0
- package/dist/runtime.d.ts +19 -0
- package/dist/runtime.js +57 -0
- package/dist/typed.d.ts +90 -0
- package/dist/typed.js +51 -0
- package/package.json +38 -5
package/README.md
CHANGED
|
@@ -1,3 +1,79 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @ankinize/sdk
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The official TypeScript SDK for the Ankinize REST API: one typed function per endpoint, standard `fetch` underneath, no dependencies, and a bundle that holds only the functions you import.
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
import { client, createNote, listDecks } from "@ankinize/sdk";
|
|
7
|
+
|
|
8
|
+
client.setConfig({
|
|
9
|
+
baseUrl: "https://your-ankinize.example",
|
|
10
|
+
auth: process.env.ANKINIZE_API_KEY,
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
const { decks } = await listDecks();
|
|
14
|
+
await createNote({
|
|
15
|
+
notetypeName: "Basic",
|
|
16
|
+
deckName: "Languages::German",
|
|
17
|
+
fields: { front: "der Hund", back: "the dog" },
|
|
18
|
+
});
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
A note names its note type and deck (`notetypeName`, `deckName`, compared ignoring case) and gives `fields` as an object keyed by each field's name in camelCase (`Back Extra` is `backExtra`). Send `notetypeId` or `deckId` instead when you have the id: exactly one of `notetypeName` and `notetypeId`, and at most one of `deckName` and `deckId`. A key is a field's name cut into words at spaces, punctuation and lower-to-upper changes, the first word in lower case: `The field` is `theField`, `ab_x` is `abX`, `URLText` is `urlText`. A field's exact name works as a key too, and each note type lists its keys in `getNoteType`. Reading a note (`getNote`, `listNotes`) gives `fields` in the same shape, and `updateNote` takes the same object with only the fields that change.
|
|
22
|
+
|
|
23
|
+
Parameters are camelCase everywhere. To make a write safe to retry, pass `idempotencyKey`; the SDK sends it as the `Idempotency-Key` header the API reads.
|
|
24
|
+
|
|
25
|
+
## Type-safe names
|
|
26
|
+
|
|
27
|
+
Out of the box the names are plain strings. To have the compiler check them, generate a declaration of your own collection and keep it in your project:
|
|
28
|
+
|
|
29
|
+
```ts
|
|
30
|
+
import { client, generateTypes } from "@ankinize/sdk";
|
|
31
|
+
|
|
32
|
+
client.setConfig({ baseUrl, auth });
|
|
33
|
+
await Bun.write("ankinize.d.ts", await generateTypes());
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
It declares your note types with their field keys, and your decks, by augmenting `Register`:
|
|
37
|
+
|
|
38
|
+
```ts
|
|
39
|
+
declare module "@ankinize/sdk" {
|
|
40
|
+
interface Register {
|
|
41
|
+
noteTypes: { Basic: "front" | "back"; Cloze: "text" | "backExtra" };
|
|
42
|
+
decks: "Default" | "Languages::German";
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
After that `createNote` and `createNotes` reject a misspelt `notetypeName` or `deckName`, and a `fields` key that the chosen note type does not have. Run it again after you add a note type, a field or a deck. Notes made by `notetypeId` are not checked.
|
|
48
|
+
|
|
49
|
+
## Batches
|
|
50
|
+
|
|
51
|
+
`createNotes`, `updateNotes`, `deleteNotes`, `updateCards`, `deleteCards`, `createTags`, `updateTags` (a rename) and `deleteTags` take one item or an array, so you need not know the batch limits. They are the only way the SDK reaches the batch endpoints; the one-call functions behind them (`createNotesBatch`, ...) are not exported. They cut the array into calls the API accepts (200 notes, edits or tag names, 1000 ids or tag names to delete, and under 4 MiB of body), send them one after the other, wait and retry on `503`, `429` and `423` (honouring `Retry-After`), and return one answer with the entries in the order you gave them.
|
|
52
|
+
|
|
53
|
+
```ts
|
|
54
|
+
import { createNotes, deleteCards } from "@ankinize/sdk";
|
|
55
|
+
|
|
56
|
+
const { created, failed, results } = await createNotes(notes);
|
|
57
|
+
for (const r of results) if (!r.ok) console.log(r.index, r.error);
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
An entry the collection refuses is reported in `results`; the rest are written. `{ atomic: true }` writes all or nothing, and only works when everything fits one call (more throws a `RangeError` before sending). A call that cannot go on (a bad key, say) throws; if earlier calls had already gone through, it throws a `PartialBatchError` with their answers in `completed`. Options: `signal`, `client`, `maxRetries`, `maxRetryWaitMs`.
|
|
61
|
+
|
|
62
|
+
A failed call throws an `AnkinizeError` (`status`, `code`, `message`, `response`). The user guide is at `/docs/sdk` on any Ankinize server.
|
|
63
|
+
|
|
64
|
+
## Working on the SDK
|
|
65
|
+
|
|
66
|
+
`openapi.json` is a snapshot of the document the REST API serves; `src/gen` is generated from it by [hey-api](https://heyapi.dev). Only `src/index.ts`, `src/runtime.ts`, `src/typed.ts` (the type-safe names) and `src/batch.ts` (the batch helpers) are written by hand. `scripts/idempotency-key.ts` runs after the generator and renames the `Idempotency-Key` parameter to `idempotencyKey`; the wire header keeps its name. `scripts/public-api.ts` then writes `src/gen/public.gen.ts`, the list `index.ts` exports, which leaves out the batch operations.
|
|
67
|
+
|
|
68
|
+
```sh
|
|
69
|
+
# After changing the REST API (a test fails when the snapshot is stale):
|
|
70
|
+
bun run --filter @ankinize/web openapi:export
|
|
71
|
+
bun run --filter @ankinize/sdk generate
|
|
72
|
+
|
|
73
|
+
bun run --filter @ankinize/sdk test # fetch-stub tests
|
|
74
|
+
bun run --filter @ankinize/sdk check:dist # builds; no hey-api import, no dependencies, tree-shakes
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
The generator lives in `generator/`, outside the workspace, because hey-api needs the TypeScript 6 compiler API and the workspace ships the native TypeScript 7. `generator/openapi-ts.config.ts` is where the SDK's spec differs from the docs' (no `HEAD`, no signed-link parameters, files typed as bytes, JSON bodies spread into the call, `ApiError` instead of `Error`).
|
|
78
|
+
|
|
79
|
+
The generated client is bundled into `src/gen`; nothing from `@hey-api/*` is imported at runtime.
|
package/dist/batch.d.ts
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Batch helpers: write one note or thousands with the same call, without
|
|
3
|
+
* knowing the batch endpoints' limits.
|
|
4
|
+
*
|
|
5
|
+
* Each helper takes one item or an array, cuts the array into the sizes the
|
|
6
|
+
* API accepts, sends the pieces one after the other, waits and retries when the
|
|
7
|
+
* server is busy or the rate limit asks to, and answers with the entries of all
|
|
8
|
+
* pieces in the order they were given.
|
|
9
|
+
*
|
|
10
|
+
* A piece that is refused for good (a bad key, a body that does not validate)
|
|
11
|
+
* stops the run by throwing. Pieces before it stay written; the error says which
|
|
12
|
+
* ones, see {@link PartialBatchError}.
|
|
13
|
+
*/
|
|
14
|
+
import { createNotesBatch } from "./gen/sdk.gen.js";
|
|
15
|
+
import type { CreateNotesBatchItem, CreateTagsBatchItem, UpdateCardInput, UpdateCardsBatchItem, UpdateNotesBatchEntry, UpdateNotesBatchItem, UpdateTagsBatchEntry, UpdateTagsBatchItem } from "./gen/types.gen.js";
|
|
16
|
+
import type { NoteInput } from "./typed.js";
|
|
17
|
+
/** What the API accepts in one call, per endpoint. */
|
|
18
|
+
export declare const BATCH_LIMITS: {
|
|
19
|
+
readonly notes: 200;
|
|
20
|
+
readonly noteUpdates: 200;
|
|
21
|
+
readonly tags: 200;
|
|
22
|
+
readonly cards: 1000;
|
|
23
|
+
readonly deletes: 1000;
|
|
24
|
+
};
|
|
25
|
+
type GenOptions = NonNullable<Parameters<typeof createNotesBatch<true>>[1]>;
|
|
26
|
+
export interface BatchOptions {
|
|
27
|
+
/**
|
|
28
|
+
* Write all or nothing. Only possible when everything fits one call
|
|
29
|
+
* (`BATCH_LIMITS`): a larger array throws a `RangeError` before anything is
|
|
30
|
+
* sent. When an entry is refused, the answer has `committed: false` and
|
|
31
|
+
* every entry in `results`, as the API sends it.
|
|
32
|
+
*/
|
|
33
|
+
atomic?: boolean;
|
|
34
|
+
/** Stops waiting and sending. The piece in flight is not recalled. */
|
|
35
|
+
signal?: AbortSignal;
|
|
36
|
+
/** A client other than the shared one. */
|
|
37
|
+
client?: GenOptions["client"];
|
|
38
|
+
/** How often one piece is retried after `503`, `429` or `423`. Default 5. */
|
|
39
|
+
maxRetries?: number;
|
|
40
|
+
/**
|
|
41
|
+
* The longest wait for a retry, in milliseconds. A `Retry-After` beyond it
|
|
42
|
+
* (a daily rate limit, say) throws the error instead. Default 60 000.
|
|
43
|
+
*/
|
|
44
|
+
maxRetryWaitMs?: number;
|
|
45
|
+
}
|
|
46
|
+
/** A run that stopped at a piece the API refused. The pieces before it were written. */
|
|
47
|
+
export declare class PartialBatchError extends Error {
|
|
48
|
+
/**
|
|
49
|
+
* The answers of the pieces that went through, in order: the
|
|
50
|
+
* `results` of a create, edit or card update, the id lists of a delete.
|
|
51
|
+
*/
|
|
52
|
+
readonly completed: unknown[];
|
|
53
|
+
/** How many of the given items those pieces held. */
|
|
54
|
+
readonly completedItems: number;
|
|
55
|
+
/** The error that stopped the run. */
|
|
56
|
+
readonly cause: unknown;
|
|
57
|
+
readonly name = "PartialBatchError";
|
|
58
|
+
constructor(
|
|
59
|
+
/**
|
|
60
|
+
* The answers of the pieces that went through, in order: the
|
|
61
|
+
* `results` of a create, edit or card update, the id lists of a delete.
|
|
62
|
+
*/
|
|
63
|
+
completed: unknown[],
|
|
64
|
+
/** How many of the given items those pieces held. */
|
|
65
|
+
completedItems: number,
|
|
66
|
+
/** The error that stopped the run. */
|
|
67
|
+
cause: unknown);
|
|
68
|
+
}
|
|
69
|
+
export interface CreateNotesResult {
|
|
70
|
+
/** Whether anything was written. */
|
|
71
|
+
committed: boolean;
|
|
72
|
+
created: number;
|
|
73
|
+
failed: number;
|
|
74
|
+
/** One entry per note given, in order; `index` is the position in what you passed. */
|
|
75
|
+
results: CreateNotesBatchItem[];
|
|
76
|
+
}
|
|
77
|
+
/** Creates one note or many. Entries the collection refuses are reported in `results`. */
|
|
78
|
+
export declare function createNotes(notes: NoteInput | readonly NoteInput[], options?: BatchOptions): Promise<CreateNotesResult>;
|
|
79
|
+
export interface UpdateNotesResult {
|
|
80
|
+
committed: boolean;
|
|
81
|
+
/** How many notes differed from what was stored and were written. */
|
|
82
|
+
changed: number;
|
|
83
|
+
failed: number;
|
|
84
|
+
/** One entry per update given, in order; `index` is the position in what you passed. */
|
|
85
|
+
results: UpdateNotesBatchItem[];
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Edits one note or many. Each entry names the note by `id` and may carry
|
|
89
|
+
* `fields`, `tags`, or `addTags` and `removeTags`. A note may appear once.
|
|
90
|
+
*/
|
|
91
|
+
export declare function updateNotes(updates: UpdateNotesBatchEntry | readonly UpdateNotesBatchEntry[], options?: BatchOptions): Promise<UpdateNotesResult>;
|
|
92
|
+
export interface DeleteNotesResult {
|
|
93
|
+
deleted: number;
|
|
94
|
+
deletedNoteIds: number[];
|
|
95
|
+
/** Every card that was made from them. */
|
|
96
|
+
deletedCardIds: number[];
|
|
97
|
+
/** Ids that did not exist and were ignored. */
|
|
98
|
+
missingNoteIds: number[];
|
|
99
|
+
}
|
|
100
|
+
/** Deletes one note or many, with their cards. Missing ids are listed, not errors. */
|
|
101
|
+
export declare function deleteNotes(ids: number | readonly number[], options?: BatchOptions): Promise<DeleteNotesResult>;
|
|
102
|
+
export interface UpdateCardsResult {
|
|
103
|
+
committed: boolean;
|
|
104
|
+
/** How many cards differed from what was stored and were written. */
|
|
105
|
+
changed: number;
|
|
106
|
+
failed: number;
|
|
107
|
+
/** One entry per distinct id given, in the order of first appearance. */
|
|
108
|
+
results: UpdateCardsBatchItem[];
|
|
109
|
+
}
|
|
110
|
+
/** Applies one change (a flag, a deck, suspended, buried) to one card or many. */
|
|
111
|
+
export declare function updateCards(ids: number | readonly number[], patch: UpdateCardInput, options?: BatchOptions): Promise<UpdateCardsResult>;
|
|
112
|
+
export interface DeleteCardsResult {
|
|
113
|
+
deleted: number;
|
|
114
|
+
deletedCardIds: number[];
|
|
115
|
+
/** Notes deleted because their last card went. */
|
|
116
|
+
deletedNoteIds: number[];
|
|
117
|
+
/** Ids that did not exist and were ignored. */
|
|
118
|
+
missingCardIds: number[];
|
|
119
|
+
}
|
|
120
|
+
/** Deletes one card or many. A note whose last card goes is deleted too. */
|
|
121
|
+
export declare function deleteCards(ids: number | readonly number[], options?: BatchOptions): Promise<DeleteCardsResult>;
|
|
122
|
+
export interface CreateTagsResult {
|
|
123
|
+
committed: boolean;
|
|
124
|
+
/** How many tags did not exist and were made. */
|
|
125
|
+
created: number;
|
|
126
|
+
failed: number;
|
|
127
|
+
/** One entry per name given, in order; `created: false` marks a tag that already existed. */
|
|
128
|
+
results: CreateTagsBatchItem[];
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Creates one tag or many, without putting them on a note. A name that exists
|
|
132
|
+
* (ignoring case) is reported with `created: false`, not an error.
|
|
133
|
+
*/
|
|
134
|
+
export declare function createTags(names: string | readonly string[], options?: BatchOptions): Promise<CreateTagsResult>;
|
|
135
|
+
export interface UpdateTagsResult {
|
|
136
|
+
committed: boolean;
|
|
137
|
+
/** How many renames were made. */
|
|
138
|
+
renamed: number;
|
|
139
|
+
failed: number;
|
|
140
|
+
/** One entry per rename given, in order; `index` is the position in what you passed. */
|
|
141
|
+
results: UpdateTagsBatchItem[];
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Renames one tag or many, each with its nested tags. Renames in one run may
|
|
145
|
+
* not overlap: a tag and one nested under it cannot both be renamed.
|
|
146
|
+
*/
|
|
147
|
+
export declare function updateTags(updates: UpdateTagsBatchEntry | readonly UpdateTagsBatchEntry[], options?: BatchOptions): Promise<UpdateTagsResult>;
|
|
148
|
+
export interface DeleteTagsResult {
|
|
149
|
+
deleted: number;
|
|
150
|
+
/** The tags removed, nested ones included. */
|
|
151
|
+
deletedTags: string[];
|
|
152
|
+
/** Names that matched no tag and were ignored. */
|
|
153
|
+
missingNames: string[];
|
|
154
|
+
}
|
|
155
|
+
/** Deletes one tag or many, with their nested tags, and takes them off every note. */
|
|
156
|
+
export declare function deleteTags(names: string | readonly string[], options?: BatchOptions): Promise<DeleteTagsResult>;
|
|
157
|
+
export {};
|
package/dist/batch.js
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Batch helpers: write one note or thousands with the same call, without
|
|
3
|
+
* knowing the batch endpoints' limits.
|
|
4
|
+
*
|
|
5
|
+
* Each helper takes one item or an array, cuts the array into the sizes the
|
|
6
|
+
* API accepts, sends the pieces one after the other, waits and retries when the
|
|
7
|
+
* server is busy or the rate limit asks to, and answers with the entries of all
|
|
8
|
+
* pieces in the order they were given.
|
|
9
|
+
*
|
|
10
|
+
* A piece that is refused for good (a bad key, a body that does not validate)
|
|
11
|
+
* stops the run by throwing. Pieces before it stay written; the error says which
|
|
12
|
+
* ones, see {@link PartialBatchError}.
|
|
13
|
+
*/
|
|
14
|
+
import { createNotesBatch, createTagsBatch, deleteCardsBatch, deleteNotesBatch, deleteTagsBatch, updateCardsBatch, updateNotesBatch, updateTagsBatch, } from "./gen/sdk.gen.js";
|
|
15
|
+
import { AnkinizeError } from "./runtime.js";
|
|
16
|
+
/** What the API accepts in one call, per endpoint. */
|
|
17
|
+
export const BATCH_LIMITS = {
|
|
18
|
+
notes: 200,
|
|
19
|
+
noteUpdates: 200,
|
|
20
|
+
tags: 200,
|
|
21
|
+
cards: 1000,
|
|
22
|
+
deletes: 1000,
|
|
23
|
+
};
|
|
24
|
+
/** A run that stopped at a piece the API refused. The pieces before it were written. */
|
|
25
|
+
export class PartialBatchError extends Error {
|
|
26
|
+
completed;
|
|
27
|
+
completedItems;
|
|
28
|
+
cause;
|
|
29
|
+
name = "PartialBatchError";
|
|
30
|
+
constructor(
|
|
31
|
+
/**
|
|
32
|
+
* The answers of the pieces that went through, in order: the
|
|
33
|
+
* `results` of a create, edit or card update, the id lists of a delete.
|
|
34
|
+
*/
|
|
35
|
+
completed,
|
|
36
|
+
/** How many of the given items those pieces held. */
|
|
37
|
+
completedItems,
|
|
38
|
+
/** The error that stopped the run. */
|
|
39
|
+
cause) {
|
|
40
|
+
super(`Stopped after ${completedItems} items were written: ${cause instanceof Error ? cause.message : String(cause)}`);
|
|
41
|
+
this.completed = completed;
|
|
42
|
+
this.completedItems = completedItems;
|
|
43
|
+
this.cause = cause;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
const RETRY_STATUSES = new Set([423, 429, 503]);
|
|
47
|
+
function abortableSleep(ms, signal) {
|
|
48
|
+
return new Promise((resolve, reject) => {
|
|
49
|
+
if (signal?.aborted)
|
|
50
|
+
return reject(signal.reason);
|
|
51
|
+
const onAbort = () => {
|
|
52
|
+
clearTimeout(timer);
|
|
53
|
+
reject(signal?.reason);
|
|
54
|
+
};
|
|
55
|
+
const timer = setTimeout(() => {
|
|
56
|
+
signal?.removeEventListener("abort", onAbort);
|
|
57
|
+
resolve();
|
|
58
|
+
}, ms);
|
|
59
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
/** Seconds from a `Retry-After` header, or null when it is not a number of seconds. */
|
|
63
|
+
function retryAfterMs(error) {
|
|
64
|
+
const raw = error.response.headers.get("Retry-After");
|
|
65
|
+
if (raw === null)
|
|
66
|
+
return null;
|
|
67
|
+
const seconds = Number(raw);
|
|
68
|
+
return Number.isFinite(seconds) && seconds >= 0 ? seconds * 1000 : null;
|
|
69
|
+
}
|
|
70
|
+
async function sendWithRetry(send, options) {
|
|
71
|
+
const maxRetries = options.maxRetries ?? 5;
|
|
72
|
+
const maxWait = options.maxRetryWaitMs ?? 60_000;
|
|
73
|
+
for (let attempt = 0;; attempt++) {
|
|
74
|
+
try {
|
|
75
|
+
return await send();
|
|
76
|
+
}
|
|
77
|
+
catch (error) {
|
|
78
|
+
if (!(error instanceof AnkinizeError) ||
|
|
79
|
+
!RETRY_STATUSES.has(error.status) ||
|
|
80
|
+
attempt >= maxRetries) {
|
|
81
|
+
throw error;
|
|
82
|
+
}
|
|
83
|
+
// These answers mean nothing was written, so sending again is safe.
|
|
84
|
+
const wait = retryAfterMs(error) ?? Math.min(2 ** attempt * 1000, maxWait);
|
|
85
|
+
if (wait > maxWait)
|
|
86
|
+
throw error;
|
|
87
|
+
await abortableSleep(wait, options.signal);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* An atomic call that an entry spoiled is answered `422` with the result as its
|
|
93
|
+
* body, which the client throws like any error. Hands the result back instead.
|
|
94
|
+
*/
|
|
95
|
+
function refusedResult(error) {
|
|
96
|
+
if (!(error instanceof AnkinizeError) || error.status !== 422)
|
|
97
|
+
return null;
|
|
98
|
+
try {
|
|
99
|
+
const body = JSON.parse(error.message);
|
|
100
|
+
return Array.isArray(body.results) ? body : null;
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
/** A call's body is limited to 8 MiB; pieces stay well under it. */
|
|
107
|
+
const MAX_PIECE_BYTES = 4 * 1024 * 1024;
|
|
108
|
+
const encoder = new TextEncoder();
|
|
109
|
+
/** Cuts at `size` items, and for items that carry content also before a piece outgrows `MAX_PIECE_BYTES`. */
|
|
110
|
+
function pieces(items, size, bySize) {
|
|
111
|
+
const out = [];
|
|
112
|
+
let current = [];
|
|
113
|
+
let bytes = 0;
|
|
114
|
+
for (const item of items) {
|
|
115
|
+
const weight = bySize ? encoder.encode(JSON.stringify(item)).length : 0;
|
|
116
|
+
if (current.length > 0 &&
|
|
117
|
+
(current.length >= size || bytes + weight > MAX_PIECE_BYTES)) {
|
|
118
|
+
out.push(current);
|
|
119
|
+
current = [];
|
|
120
|
+
bytes = 0;
|
|
121
|
+
}
|
|
122
|
+
current.push(item);
|
|
123
|
+
bytes += weight;
|
|
124
|
+
}
|
|
125
|
+
if (current.length > 0)
|
|
126
|
+
out.push(current);
|
|
127
|
+
return out;
|
|
128
|
+
}
|
|
129
|
+
/** Sends every piece in turn and returns the answers, or throws a `PartialBatchError`. */
|
|
130
|
+
async function run(items, size, options, send, bySize = false) {
|
|
131
|
+
const parts = pieces(items, size, bySize);
|
|
132
|
+
if (options.atomic && parts.length > 1) {
|
|
133
|
+
throw new RangeError(`atomic needs everything in one call (at most ${size} items and ${MAX_PIECE_BYTES} bytes); got ${items.length} items in ${parts.length} calls`);
|
|
134
|
+
}
|
|
135
|
+
const answers = [];
|
|
136
|
+
const offsets = [];
|
|
137
|
+
let done = 0;
|
|
138
|
+
for (const piece of parts) {
|
|
139
|
+
options.signal?.throwIfAborted();
|
|
140
|
+
let answer;
|
|
141
|
+
try {
|
|
142
|
+
answer = await sendWithRetry(() => send(piece), options);
|
|
143
|
+
}
|
|
144
|
+
catch (error) {
|
|
145
|
+
const refused = options.atomic ? refusedResult(error) : null;
|
|
146
|
+
if (!refused) {
|
|
147
|
+
if (answers.length === 0)
|
|
148
|
+
throw error;
|
|
149
|
+
throw new PartialBatchError(answers, done, error);
|
|
150
|
+
}
|
|
151
|
+
answer = refused;
|
|
152
|
+
}
|
|
153
|
+
answers.push(answer);
|
|
154
|
+
offsets.push(done);
|
|
155
|
+
done += piece.length;
|
|
156
|
+
}
|
|
157
|
+
return { answers, offsets };
|
|
158
|
+
}
|
|
159
|
+
const many = (items) => Array.isArray(items) ? items : [items];
|
|
160
|
+
const distinct = (items) => [
|
|
161
|
+
...new Set(many(items)),
|
|
162
|
+
];
|
|
163
|
+
const request = (options) => ({
|
|
164
|
+
client: options.client,
|
|
165
|
+
signal: options.signal,
|
|
166
|
+
});
|
|
167
|
+
/** Creates one note or many. Entries the collection refuses are reported in `results`. */
|
|
168
|
+
export async function createNotes(notes, options = {}) {
|
|
169
|
+
const { answers, offsets } = await run(many(notes), BATCH_LIMITS.notes, options, (piece) => createNotesBatch({ notes: piece, atomic: options.atomic }, request(options)), true);
|
|
170
|
+
return {
|
|
171
|
+
committed: answers.some((a) => a.committed),
|
|
172
|
+
created: answers.reduce((n, a) => n + a.created, 0),
|
|
173
|
+
failed: answers.reduce((n, a) => n + a.failed, 0),
|
|
174
|
+
results: answers.flatMap((a, i) => a.results.map((r) => ({ ...r, index: r.index + (offsets[i] ?? 0) }))),
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Edits one note or many. Each entry names the note by `id` and may carry
|
|
179
|
+
* `fields`, `tags`, or `addTags` and `removeTags`. A note may appear once.
|
|
180
|
+
*/
|
|
181
|
+
export async function updateNotes(updates, options = {}) {
|
|
182
|
+
const { answers, offsets } = await run(many(updates), BATCH_LIMITS.noteUpdates, options, (piece) => updateNotesBatch({ updates: piece, atomic: options.atomic }, request(options)), true);
|
|
183
|
+
return {
|
|
184
|
+
committed: answers.some((a) => a.committed),
|
|
185
|
+
changed: answers.reduce((n, a) => n + a.changed, 0),
|
|
186
|
+
failed: answers.reduce((n, a) => n + a.failed, 0),
|
|
187
|
+
results: answers.flatMap((a, i) => a.results.map((r) => ({ ...r, index: r.index + (offsets[i] ?? 0) }))),
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
/** Deletes one note or many, with their cards. Missing ids are listed, not errors. */
|
|
191
|
+
export async function deleteNotes(ids, options = {}) {
|
|
192
|
+
const { answers } = await run(distinct(ids), BATCH_LIMITS.deletes, { ...options, atomic: false }, async (piece) => {
|
|
193
|
+
const a = await deleteNotesBatch({ ids: piece }, request(options));
|
|
194
|
+
return { ...a, results: [] };
|
|
195
|
+
});
|
|
196
|
+
return {
|
|
197
|
+
deleted: answers.reduce((n, a) => n + a.deleted, 0),
|
|
198
|
+
deletedNoteIds: answers.flatMap((a) => a.deletedNoteIds),
|
|
199
|
+
deletedCardIds: answers.flatMap((a) => a.deletedCardIds),
|
|
200
|
+
missingNoteIds: answers.flatMap((a) => a.missingNoteIds),
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
/** Applies one change (a flag, a deck, suspended, buried) to one card or many. */
|
|
204
|
+
export async function updateCards(ids, patch, options = {}) {
|
|
205
|
+
const { answers } = await run(distinct(ids), BATCH_LIMITS.cards, options, (piece) => updateCardsBatch({ ids: piece, patch, atomic: options.atomic }, request(options)));
|
|
206
|
+
return {
|
|
207
|
+
committed: answers.some((a) => a.committed),
|
|
208
|
+
changed: answers.reduce((n, a) => n + a.changed, 0),
|
|
209
|
+
failed: answers.reduce((n, a) => n + a.failed, 0),
|
|
210
|
+
results: answers.flatMap((a) => a.results),
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
/** Deletes one card or many. A note whose last card goes is deleted too. */
|
|
214
|
+
export async function deleteCards(ids, options = {}) {
|
|
215
|
+
const { answers } = await run(distinct(ids), BATCH_LIMITS.deletes, { ...options, atomic: false }, async (piece) => {
|
|
216
|
+
const a = await deleteCardsBatch({ ids: piece }, request(options));
|
|
217
|
+
return { ...a, results: [] };
|
|
218
|
+
});
|
|
219
|
+
return {
|
|
220
|
+
deleted: answers.reduce((n, a) => n + a.deleted, 0),
|
|
221
|
+
deletedCardIds: answers.flatMap((a) => a.deletedCardIds),
|
|
222
|
+
deletedNoteIds: answers.flatMap((a) => a.deletedNoteIds),
|
|
223
|
+
missingCardIds: answers.flatMap((a) => a.missingCardIds),
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Creates one tag or many, without putting them on a note. A name that exists
|
|
228
|
+
* (ignoring case) is reported with `created: false`, not an error.
|
|
229
|
+
*/
|
|
230
|
+
export async function createTags(names, options = {}) {
|
|
231
|
+
const { answers, offsets } = await run(many(names), BATCH_LIMITS.tags, options, (piece) => createTagsBatch({ names: piece, atomic: options.atomic }, request(options)));
|
|
232
|
+
return {
|
|
233
|
+
committed: answers.some((a) => a.committed),
|
|
234
|
+
created: answers.reduce((n, a) => n + a.created, 0),
|
|
235
|
+
failed: answers.reduce((n, a) => n + a.failed, 0),
|
|
236
|
+
results: answers.flatMap((a, i) => a.results.map((r) => ({ ...r, index: r.index + (offsets[i] ?? 0) }))),
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* Renames one tag or many, each with its nested tags. Renames in one run may
|
|
241
|
+
* not overlap: a tag and one nested under it cannot both be renamed.
|
|
242
|
+
*/
|
|
243
|
+
export async function updateTags(updates, options = {}) {
|
|
244
|
+
const { answers, offsets } = await run(many(updates), BATCH_LIMITS.tags, options, (piece) => updateTagsBatch({ updates: piece, atomic: options.atomic }, request(options)));
|
|
245
|
+
return {
|
|
246
|
+
committed: answers.some((a) => a.committed),
|
|
247
|
+
renamed: answers.reduce((n, a) => n + a.renamed, 0),
|
|
248
|
+
failed: answers.reduce((n, a) => n + a.failed, 0),
|
|
249
|
+
results: answers.flatMap((a, i) => a.results.map((r) => ({ ...r, index: r.index + (offsets[i] ?? 0) }))),
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
/** Deletes one tag or many, with their nested tags, and takes them off every note. */
|
|
253
|
+
export async function deleteTags(names, options = {}) {
|
|
254
|
+
const { answers } = await run(distinct(names), BATCH_LIMITS.deletes, { ...options, atomic: false }, async (piece) => {
|
|
255
|
+
const a = await deleteTagsBatch({ names: piece }, request(options));
|
|
256
|
+
return { ...a, results: [] };
|
|
257
|
+
});
|
|
258
|
+
return {
|
|
259
|
+
deleted: answers.reduce((n, a) => n + a.deleted, 0),
|
|
260
|
+
deletedTags: answers.flatMap((a) => a.deletedTags),
|
|
261
|
+
missingNames: answers.flatMap((a) => a.missingNames),
|
|
262
|
+
};
|
|
263
|
+
}
|