rmapi-js 11.1.2 → 11.2.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/dist/index.js CHANGED
@@ -56,12 +56,77 @@ import { v4 as uuid4 } from "uuid";
56
56
  import { z } from "zod";
57
57
  import { HashNotFoundError, ValidationError } from "./error.js";
58
58
  import { LruCache } from "./lru.js";
59
- import { RawRemarkable, } from "./raw.js";
59
+ import { parseMetadata, RawRemarkable, } from "./raw.js";
60
60
  export { deviceScreens, } from "./devices.js";
61
61
  export { HashNotFoundError, ValidationError } from "./error.js";
62
+ export { decodeBrush, rmColors } from "./rm5.js";
63
+ export { crdtKey, END_MARKER, parseRmScene, ROOT_ID } from "./rm6.js";
62
64
  const AUTH_HOST = "https://webapp-prod.cloud.remarkable.engineering";
63
65
  const RAW_HOST = "https://eu.tectonic.remarkable.com";
64
66
  const UPLOAD_HOST = "https://internal.cloud.remarkable.com";
67
+ /** the parent id of the root directory */
68
+ const ROOT_ID = "";
69
+ /** the parent id of the trash */
70
+ const TRASH_ID = "trash";
71
+ /** the id of the root entry list */
72
+ const ROOT_LIST = "root";
73
+ /** the file name of the root entry index */
74
+ const ROOT_SCHEMA = `${ROOT_LIST}.docSchema`;
75
+ /** base backoff in milliseconds for transient request retries */
76
+ const TRANSIENT_BASE_MS = 200;
77
+ /** base backoff in milliseconds for generation-conflict retries */
78
+ const GENERATION_BASE_MS = 25;
79
+ /** resolve after a number of milliseconds */
80
+ function sleep(ms) {
81
+ return new Promise((resolve) => {
82
+ const timer = setTimeout(resolve, ms);
83
+ // don't let a pending backoff keep a node process alive
84
+ timer.unref?.();
85
+ });
86
+ }
87
+ /** exponential backoff with full jitter, capped at 30 seconds */
88
+ function backoffMs(attempt, baseMs) {
89
+ const capped = Math.min(baseMs * 2 ** attempt, 30_000);
90
+ return Math.random() * capped;
91
+ }
92
+ /**
93
+ * a mutex acquired by async iteration
94
+ *
95
+ * `for await (const _lock of mutex) { ... }` runs the body with the lock held
96
+ * and releases it when the body exits — by return, throw, or break — the way a
97
+ * `using` block would. Waiters are served in acquisition order.
98
+ */
99
+ class Mutex {
100
+ #tail = Promise.resolve();
101
+ async *[Symbol.asyncIterator]() {
102
+ const previous = this.#tail;
103
+ let release;
104
+ this.#tail = new Promise((resolve) => {
105
+ release = resolve;
106
+ });
107
+ await previous;
108
+ try {
109
+ yield;
110
+ }
111
+ finally {
112
+ release();
113
+ }
114
+ }
115
+ }
116
+ /** the ordered page ids of a document's content, from cPages or the legacy list */
117
+ function pageOrder(content) {
118
+ if ("cPages" in content && content.cPages) {
119
+ return content.cPages.pages
120
+ .filter((page) => page.deleted === undefined)
121
+ .map((page) => page.id);
122
+ }
123
+ else if ("pages" in content && content.pages) {
124
+ return content.pages;
125
+ }
126
+ else {
127
+ return [];
128
+ }
129
+ }
65
130
  // The section has all the types that are stored in the remarkable cloud.
66
131
  const idReg = /^([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}||trash)$/;
67
132
  /** An error that gets thrown when the backend while trying to update
@@ -128,18 +193,29 @@ class Remarkable {
128
193
  /** the same cache that underlies the raw api, allowing us to modify it */
129
194
  #cache;
130
195
  raw;
196
+ #maxGenerationRetries;
197
+ #maxTransientRetries;
131
198
  #lastHashGen;
132
199
  #schemaVersion;
133
- constructor(sessionToken, rawHost, uploadHost, cache) {
200
+ /** serializes root updates on this instance so they don't self-conflict */
201
+ #rootMutex = new Mutex();
202
+ constructor(sessionToken, rawHost, uploadHost, cache, maxGenerationRetries, maxTransientRetries) {
134
203
  this.#sessionToken = sessionToken;
135
204
  this.#cache = cache;
205
+ this.#maxGenerationRetries = maxGenerationRetries;
206
+ this.#maxTransientRetries = maxTransientRetries;
136
207
  this.raw = new RawRemarkable((method, url, { body, headers } = {}) => this.#authedFetch(url, { method, body, headers }), cache, rawHost, uploadHost);
137
208
  }
138
209
  async #getRootHash(refresh = false) {
139
210
  if (refresh || this.#lastHashGen === undefined) {
140
211
  const [hash, generation, schemaVersion] = await this.raw.getRootHash();
141
- this.#lastHashGen = [hash, generation];
142
- this.#schemaVersion = schemaVersion;
212
+ // a slow older fetch can resolve after a newer write; only accept it if
213
+ // it doesn't regress the cached generation past a committed root
214
+ if (this.#lastHashGen === undefined ||
215
+ generation >= this.#lastHashGen[1]) {
216
+ this.#lastHashGen = [hash, generation];
217
+ this.#schemaVersion = schemaVersion;
218
+ }
143
219
  }
144
220
  return [...this.#lastHashGen, this.#schemaVersion];
145
221
  }
@@ -156,28 +232,99 @@ class Remarkable {
156
232
  throw ex;
157
233
  }
158
234
  }
159
- async #authedFetch(url, { body, method = "POST", headers = {}, }) {
160
- const resp = await fetch(url, {
161
- method,
162
- headers: {
163
- Authorization: `Bearer ${this.#sessionToken}`,
164
- ...headers,
165
- },
166
- // fetch works correctly with uint8 arrays, but is not hinted correctly
167
- body: body,
235
+ /**
236
+ * run a root-mutating operation, retrying on generation conflicts
237
+ *
238
+ * On a {@link GenerationError | `GenerationError`} the cached generation was
239
+ * already invalidated by {@link #putRootHash}, so re-running `op` re-reads the
240
+ * latest root and re-applies the change. Callers must resolve any random ids
241
+ * before this so retries reuse the same (cached) blobs.
242
+ */
243
+ async #withRetry(op) {
244
+ // hold the root lock across the whole read-merge-write so concurrent
245
+ // mutators serialize instead of sharing a generation and forcing each
246
+ // other into avoidable conflicts
247
+ for await (const _lock of this.#rootMutex) {
248
+ for (let attempt = 0;; attempt++) {
249
+ try {
250
+ return await op();
251
+ }
252
+ catch (ex) {
253
+ if (ex instanceof GenerationError &&
254
+ attempt < this.#maxGenerationRetries) {
255
+ await sleep(backoffMs(attempt, GENERATION_BASE_MS));
256
+ }
257
+ else {
258
+ throw ex;
259
+ }
260
+ }
261
+ }
262
+ }
263
+ // the mutex yields exactly once, so the loop always returns or throws
264
+ throw new Error("unreachable");
265
+ }
266
+ /**
267
+ * splice an already-uploaded item entry into the root
268
+ *
269
+ * The entry and all its blobs must already be uploaded; only the
270
+ * generation-dependent root merge is retried, so a conflict re-reads the
271
+ * latest root and re-appends the (stable) entry without re-uploading blobs.
272
+ */
273
+ async #commit(entry) {
274
+ await this.#withRetry(async () => {
275
+ const [rootHash, generation] = await this.#getRootHash();
276
+ const { entries } = await this.raw.getEntries(ROOT_SCHEMA, rootHash);
277
+ entries.push(entry);
278
+ const [rootEntry, uploadRoot] = await this.raw.putEntries(ROOT_LIST, entries, 4);
279
+ await uploadRoot;
280
+ await this.#putRootHash(rootEntry.hash, generation);
168
281
  });
169
- if (!resp.ok) {
282
+ }
283
+ async #authedFetch(url, { body, method = "POST", headers = {}, }) {
284
+ // the root PUT is a compare-and-set; retrying a lost-but-applied response
285
+ // would resurface as a false generation conflict and be double-applied by
286
+ // #withRetry, so never transient-retry it (GETs and content-addressed file
287
+ // PUTs are idempotent and safe to retry)
288
+ const transientRetries = method === "PUT" && url.endsWith("/sync/v3/root")
289
+ ? 0
290
+ : this.#maxTransientRetries;
291
+ for (let attempt = 0;; attempt++) {
292
+ let resp;
293
+ try {
294
+ resp = await fetch(url, {
295
+ method,
296
+ headers: {
297
+ Authorization: `Bearer ${this.#sessionToken}`,
298
+ ...headers,
299
+ },
300
+ // fetch works correctly with uint8 arrays, but is not hinted correctly
301
+ body: body,
302
+ });
303
+ }
304
+ catch (ex) {
305
+ // a network-level failure, retry if we have attempts left
306
+ if (attempt < transientRetries) {
307
+ await sleep(backoffMs(attempt, TRANSIENT_BASE_MS));
308
+ continue;
309
+ }
310
+ throw ex;
311
+ }
312
+ if (resp.ok) {
313
+ return resp;
314
+ }
170
315
  const msg = await resp.text();
171
316
  if (msg === '{"message":"precondition failed"}\n') {
317
+ // a generation conflict; handled by #withRetry at the high level
172
318
  throw new GenerationError();
173
319
  }
320
+ else if ((resp.status >= 500 || resp.status === 429) &&
321
+ attempt < transientRetries) {
322
+ await sleep(backoffMs(attempt, TRANSIENT_BASE_MS));
323
+ }
174
324
  else {
175
325
  throw new ResponseError(resp.status, resp.statusText, `failed reMarkable request: ${msg}`);
176
326
  }
177
327
  }
178
- else {
179
- return resp;
180
- }
181
328
  }
182
329
  async #convertEntry({ hash, id }) {
183
330
  const { entries } = await this.raw.getEntries(`${id}.docSchema`, hash);
@@ -241,12 +388,12 @@ class Remarkable {
241
388
  }
242
389
  async listIds(refresh = false) {
243
390
  const [hash] = await this.#getRootHash(refresh);
244
- const { entries } = await this.raw.getEntries("root.docSchema", hash);
391
+ const { entries } = await this.raw.getEntries(ROOT_SCHEMA, hash);
245
392
  return entries.map(({ id, hash }) => ({ id, hash }));
246
393
  }
247
394
  async getContent(id, hash) {
248
395
  const { entries } = await this.raw.getEntries(`${id}.docSchema`, hash);
249
- const [cont] = entries.filter((e) => e.id.endsWith(".content"));
396
+ const cont = entries.find((e) => e.id.endsWith(".content"));
250
397
  if (cont === undefined) {
251
398
  throw new Error(`couldn't find contents for hash ${hash}`);
252
399
  }
@@ -256,7 +403,7 @@ class Remarkable {
256
403
  }
257
404
  async getMetadata(id, hash) {
258
405
  const { entries } = await this.raw.getEntries(`${id}.docSchema`, hash);
259
- const [meta] = entries.filter((e) => e.id.endsWith(".metadata"));
406
+ const meta = entries.find((e) => e.id.endsWith(".metadata"));
260
407
  if (meta === undefined) {
261
408
  throw new Error(`couldn't find metadata for hash ${hash}`);
262
409
  }
@@ -266,7 +413,7 @@ class Remarkable {
266
413
  }
267
414
  async getPdf(id, hash) {
268
415
  const { entries } = await this.raw.getEntries(`${id}.docSchema`, hash);
269
- const [pdf] = entries.filter((e) => e.id.endsWith(".pdf"));
416
+ const pdf = entries.find((e) => e.id.endsWith(".pdf"));
270
417
  if (pdf === undefined) {
271
418
  throw new Error(`couldn't find pdf for hash ${hash}`);
272
419
  }
@@ -276,7 +423,7 @@ class Remarkable {
276
423
  }
277
424
  async getEpub(id, hash) {
278
425
  const { entries } = await this.raw.getEntries(`${id}.docSchema`, hash);
279
- const [epub] = entries.filter((e) => e.id.endsWith(".epub"));
426
+ const epub = entries.find((e) => e.id.endsWith(".epub"));
280
427
  if (epub === undefined) {
281
428
  throw new Error(`couldn't find epub for hash ${hash}`);
282
429
  }
@@ -284,7 +431,31 @@ class Remarkable {
284
431
  return await this.raw.getHash(epub.id, epub.hash);
285
432
  }
286
433
  }
287
- async getDocument(id, hash) {
434
+ async getRmPage(id, hash, pageId) {
435
+ const { entries } = await this.raw.getEntries(`${id}.docSchema`, hash);
436
+ const content = await this.getContent(id, hash);
437
+ if (!pageOrder(content).includes(pageId)) {
438
+ throw new Error(`document ${id} has no page ${pageId}`);
439
+ }
440
+ const entry = entries.find((ent) => ent.id === `${id}/${pageId}.rm`);
441
+ if (entry === undefined) {
442
+ return undefined;
443
+ }
444
+ else {
445
+ return await this.raw.getRm(entry.id, entry.hash);
446
+ }
447
+ }
448
+ async getRmPages(id, hash) {
449
+ const { entries } = await this.raw.getEntries(`${id}.docSchema`, hash);
450
+ const content = await this.getContent(id, hash);
451
+ const byName = new Map(entries.map((entry) => [entry.id, entry]));
452
+ const drawn = pageOrder(content)
453
+ .map((pageId) => [pageId, byName.get(`${id}/${pageId}.rm`)])
454
+ .filter((pair) => pair[1] !== undefined);
455
+ const parsed = await Promise.all(drawn.map(([, entry]) => this.raw.getRm(entry.id, entry.hash)));
456
+ return new Map(drawn.map(([pageId], index) => [pageId, parsed[index]]));
457
+ }
458
+ async getDocumentArchive(id, hash) {
288
459
  const { entries } = await this.raw.getEntries(`${id}.docSchema`, hash);
289
460
  const zip = new JSZip();
290
461
  for (const entry of entries) {
@@ -293,7 +464,61 @@ class Remarkable {
293
464
  }
294
465
  return zip.generateAsync({ type: "uint8array" });
295
466
  }
296
- async #putFile(visibleName, fileType, buffer, { refresh, parent = "", pinned = false, zoomMode = "bestFit", viewBackgroundFilter, textScale = 1, textAlignment = "justify", fontName = "", coverPageNumber = -1, authors, title, publicationDate, publisher, extraMetadata = {}, lineHeight = -1, margins = 125, orientation = "portrait", tags, customZoomScale, customZoomCenterX, customZoomCenterY, customZoomPageWidth, customZoomPageHeight, customZoomOrientation, }) {
467
+ async putDocumentArchive(buffer, { refresh = false, parent, visibleName, id: keepId, } = {}) {
468
+ if (parent !== undefined && parent && !idReg.test(parent)) {
469
+ throw new ValidationError(parent, idReg, "parent must be a valid document id");
470
+ }
471
+ const zip = await JSZip.loadAsync(buffer);
472
+ const paths = Object.keys(zip.files).filter((path) => !zip.files[path].dir);
473
+ const metaPath = paths.find((path) => path.endsWith(".metadata"));
474
+ if (metaPath === undefined) {
475
+ throw new Error("archive did not contain a .metadata file");
476
+ }
477
+ const oldId = metaPath.slice(0, -9);
478
+ if (oldId.includes("/")) {
479
+ throw new Error(`unexpected nested .metadata path '${metaPath}'`);
480
+ }
481
+ const newId = keepId ?? uuid4();
482
+ // rewrite the old document id prefix on every archived file to the new id,
483
+ // patching the .metadata as we pass it (parent/name/lastModified). the
484
+ // blobs don't depend on the generation, so upload the rewritten files and
485
+ // the document index once, then let #commit retry only the root merge
486
+ const enc = new TextEncoder();
487
+ const dec = new TextDecoder();
488
+ const lastModified = Date.now().toFixed();
489
+ const [, , schemaVersion] = await this.#getRootHash(refresh);
490
+ const fileUploads = await Promise.all(paths.map(async (path) => {
491
+ if (!path.startsWith(oldId)) {
492
+ throw new Error(`archived file '${path}' did not start with '${oldId}'`);
493
+ }
494
+ const newPath = `${newId}${path.slice(oldId.length)}`;
495
+ let bytes = await zip.files[path].async("uint8array");
496
+ if (path === metaPath) {
497
+ const meta = parseMetadata(dec.decode(bytes));
498
+ if (parent !== undefined)
499
+ meta.parent = parent;
500
+ if (visibleName !== undefined)
501
+ meta.visibleName = visibleName;
502
+ meta.lastModified = lastModified;
503
+ bytes = enc.encode(JSON.stringify(meta));
504
+ }
505
+ return this.raw.putFile(newPath, bytes);
506
+ }));
507
+ const fileEntries = fileUploads.map(([entry]) => entry);
508
+ const [docEntry, uploadDoc] = await this.raw.putEntries(newId, fileEntries, schemaVersion);
509
+ await Promise.all([...fileUploads.map(([, upload]) => upload), uploadDoc]);
510
+ await this.#commit(docEntry);
511
+ return { id: newId, hash: docEntry.hash };
512
+ }
513
+ /** @deprecated renamed; use {@link getDocumentArchive | `getDocumentArchive`} */
514
+ async getDocument(id, hash) {
515
+ return await this.getDocumentArchive(id, hash);
516
+ }
517
+ /** @deprecated renamed; use {@link putDocumentArchive | `putDocumentArchive`} */
518
+ async putDocument(buffer, options) {
519
+ return await this.putDocumentArchive(buffer, options);
520
+ }
521
+ async #putFile(visibleName, fileType, buffer, { refresh, parent = ROOT_ID, pinned = false, zoomMode = "bestFit", viewBackgroundFilter, textScale = 1, textAlignment = "justify", fontName = "", coverPageNumber = -1, authors, title, publicationDate, publisher, extraMetadata = {}, lineHeight = -1, margins = 125, orientation = "portrait", tags, customZoomScale, customZoomCenterX, customZoomCenterY, customZoomPageWidth, customZoomPageHeight, customZoomOrientation, }) {
297
522
  if (parent && !idReg.test(parent)) {
298
523
  throw new ValidationError(parent, idReg, "parent must be a valid document id");
299
524
  }
@@ -339,36 +564,29 @@ class Remarkable {
339
564
  redirectionPageMap: [0],
340
565
  sizeInBytes: buffer.length.toFixed(),
341
566
  };
342
- // upload raw files, and get root hash
343
- const [[contentEntry, uploadContent], [metadataEntry, uploadMetadata], [pagedataEntry, uploadPagedata], [fileEntry, uploadFile], [rootHash, generation, schemaVersion],] = await Promise.all([
567
+ // the schema version is needed to encode the document index; the blobs
568
+ // themselves don't depend on the generation, so upload them once and let
569
+ // #commit retry only the root merge
570
+ const [, , schemaVersion] = await this.#getRootHash(refresh);
571
+ const [[contentEntry, uploadContent], [metadataEntry, uploadMetadata], [pagedataEntry, uploadPagedata], [fileEntry, uploadFile],] = await Promise.all([
344
572
  this.raw.putContent(`${id}.content`, content),
345
573
  this.raw.putMetadata(`${id}.metadata`, metadata),
346
574
  this.raw.putText(`${id}.pagedata`, "\n"),
347
575
  this.raw.putFile(`${id}.${fileType}`, buffer),
348
- this.#getRootHash(refresh),
349
- ]);
350
- // now fetch root entries and upload this file entry
351
- const [[collectionEntry, uploadCollection], { entries: rootEntries }] = await Promise.all([
352
- this.raw.putEntries(id, [contentEntry, metadataEntry, pagedataEntry, fileEntry], schemaVersion),
353
- this.raw.getEntries("root.docSchema", rootHash),
354
576
  ]);
355
- // now upload a new root entry
356
- rootEntries.push(collectionEntry);
357
- const [rootEntry, uploadRoot] = await this.raw.putEntries("root", rootEntries, 4);
358
- // before updating the root hash, first upload everything
577
+ const [collectionEntry, uploadCollection] = await this.raw.putEntries(id, [contentEntry, metadataEntry, pagedataEntry, fileEntry], schemaVersion);
578
+ // TODO we could return a full entry here, but we should probably decide
579
+ // what that should be, e.g. we could return more fields than the standard
580
+ // entry. Same for putFolder
581
+ // TODO we should also decide if the api should take hashes or ids...
359
582
  await Promise.all([
360
583
  uploadContent,
361
584
  uploadMetadata,
362
585
  uploadPagedata,
363
586
  uploadFile,
364
587
  uploadCollection,
365
- uploadRoot,
366
588
  ]);
367
- // TODO we could return a full entry here, but we should probably decide
368
- // what that should be, e.g. we could return more fields than the standard
369
- // entry. Same for putFolder
370
- // TODO we should also decide if the api should take hashes or ids...
371
- await this.#putRootHash(rootEntry.hash, generation);
589
+ await this.#commit(collectionEntry);
372
590
  return { id, hash: collectionEntry.hash };
373
591
  }
374
592
  async putPdf(visibleName, buffer, opts = {}) {
@@ -378,7 +596,7 @@ class Remarkable {
378
596
  return await this.#putFile(visibleName, "epub", buffer, opts);
379
597
  }
380
598
  /** create a folder */
381
- async putFolder(visibleName, { parent = "" } = {}, refresh = false) {
599
+ async putFolder(visibleName, { parent = ROOT_ID } = {}, refresh = false) {
382
600
  if (parent && !idReg.test(parent)) {
383
601
  throw new ValidationError(parent, idReg, "parent must be a valid document id");
384
602
  }
@@ -395,29 +613,16 @@ class Remarkable {
395
613
  type: "CollectionType",
396
614
  visibleName,
397
615
  };
398
- // upload folder contents
399
- const [[contentEntry, uploadContent], [metadataEntry, uploadMetadata], [rootHash, generation, schemaVersion],] = await Promise.all([
616
+ // the blobs don't depend on the generation, so upload them once and let
617
+ // #commit retry only the root merge
618
+ const [, , schemaVersion] = await this.#getRootHash(refresh);
619
+ const [[contentEntry, uploadContent], [metadataEntry, uploadMetadata]] = await Promise.all([
400
620
  this.raw.putContent(`${id}.content`, content),
401
621
  this.raw.putMetadata(`${id}.metadata`, metadata),
402
- this.#getRootHash(refresh),
403
- ]);
404
- // now fetch root entries and upload this file entry
405
- const [[collectionEntry, uploadCollection], { entries: rootEntries }] = await Promise.all([
406
- this.raw.putEntries(id, [contentEntry, metadataEntry], schemaVersion),
407
- this.raw.getEntries("root.docSchema", rootHash),
408
- ]);
409
- // now upload a new root entry
410
- rootEntries.push(collectionEntry);
411
- const [rootEntry, uploadRoot] = await this.raw.putEntries("root", rootEntries, 4);
412
- // before updating the root hash, first upload everything
413
- await Promise.all([
414
- uploadContent,
415
- uploadMetadata,
416
- uploadCollection,
417
- uploadRoot,
418
622
  ]);
419
- // put root hash and return
420
- await this.#putRootHash(rootEntry.hash, generation);
623
+ const [collectionEntry, uploadCollection] = await this.raw.putEntries(id, [contentEntry, metadataEntry], schemaVersion);
624
+ await Promise.all([uploadContent, uploadMetadata, uploadCollection]);
625
+ await this.#commit(collectionEntry);
421
626
  return { id, hash: collectionEntry.hash };
422
627
  }
423
628
  /** upload an epub */
@@ -450,25 +655,27 @@ class Remarkable {
450
655
  }
451
656
  /** fully sync a content edit */
452
657
  async #editContent(hash, update, expectedType, refresh) {
453
- const [rootHash, generation, schemaVersion] = await this.#getRootHash(refresh);
454
- const { entries } = await this.raw.getEntries("root.docSchema", rootHash);
455
- const hashInd = entries.findIndex((ent) => ent.hash === hash);
456
- const hashEnt = entries[hashInd];
457
- if (hashEnt === undefined) {
458
- throw new HashNotFoundError(hash);
459
- }
460
- const [[newEnt, uploadEnt], meta] = await Promise.all([
461
- this.#editContentRaw(hashEnt.id, hash, update, schemaVersion),
462
- this.getMetadata(hashEnt.id, hash),
463
- ]);
464
- if (meta.type !== expectedType) {
465
- throw new Error(`expected type ${expectedType} but got ${meta.type} for hash ${hash}`);
466
- }
467
- entries[hashInd] = newEnt;
468
- const [rootEntry, uploadRoot] = await this.raw.putEntries("root", entries, 4);
469
- await Promise.all([uploadEnt, uploadRoot]);
470
- await this.#putRootHash(rootEntry.hash, generation);
471
- return { hash: newEnt.hash };
658
+ return await this.#withRetry(async () => {
659
+ const [rootHash, generation, schemaVersion] = await this.#getRootHash(refresh);
660
+ const { entries } = await this.raw.getEntries(ROOT_SCHEMA, rootHash);
661
+ const hashInd = entries.findIndex((ent) => ent.hash === hash);
662
+ const hashEnt = entries[hashInd];
663
+ if (hashEnt === undefined) {
664
+ throw new HashNotFoundError(hash);
665
+ }
666
+ const [[newEnt, uploadEnt], meta] = await Promise.all([
667
+ this.#editContentRaw(hashEnt.id, hash, update, schemaVersion),
668
+ this.getMetadata(hashEnt.id, hash),
669
+ ]);
670
+ if (meta.type !== expectedType) {
671
+ throw new Error(`expected type ${expectedType} but got ${meta.type} for hash ${hash}`);
672
+ }
673
+ entries[hashInd] = newEnt;
674
+ const [rootEntry, uploadRoot] = await this.raw.putEntries(ROOT_LIST, entries, 4);
675
+ await Promise.all([uploadEnt, uploadRoot]);
676
+ await this.#putRootHash(rootEntry.hash, generation);
677
+ return { hash: newEnt.hash };
678
+ });
472
679
  }
473
680
  /** update document content */
474
681
  async updateDocument(hash, content, refresh = false) {
@@ -491,6 +698,8 @@ class Remarkable {
491
698
  }
492
699
  const meta = await this.raw.getMetadata(metaEntry.id, metaEntry.hash);
493
700
  Object.assign(meta, update);
701
+ meta.version = (meta.version ?? 0) + 1;
702
+ meta.metadatamodified = true;
494
703
  const [newMetaEntry, uploadMeta] = await this.raw.putMetadata(metaEntry.id, meta);
495
704
  entries[metaInd] = newMetaEntry;
496
705
  const [result, uploadEntries] = await this.raw.putEntries(id, entries, schemaVersion);
@@ -498,19 +707,21 @@ class Remarkable {
498
707
  return [result, upload];
499
708
  }
500
709
  async #editMeta(hash, update, refresh = false) {
501
- const [rootHash, generation, schemaVersion] = await this.#getRootHash(refresh);
502
- const { entries } = await this.raw.getEntries("root.docSchema", rootHash);
503
- const hashInd = entries.findIndex((ent) => ent.hash === hash);
504
- const hashEnt = entries[hashInd];
505
- if (hashEnt === undefined) {
506
- throw new HashNotFoundError(hash);
507
- }
508
- const [newEnt, uploadEnt] = await this.#editMetaRaw(hashEnt.id, hash, update, schemaVersion);
509
- entries[hashInd] = newEnt;
510
- const [rootEntry, uploadRoot] = await this.raw.putEntries("root", entries, 4);
511
- await Promise.all([uploadEnt, uploadRoot]);
512
- await this.#putRootHash(rootEntry.hash, generation);
513
- return { hash: newEnt.hash };
710
+ return await this.#withRetry(async () => {
711
+ const [rootHash, generation, schemaVersion] = await this.#getRootHash(refresh);
712
+ const { entries } = await this.raw.getEntries(ROOT_SCHEMA, rootHash);
713
+ const hashInd = entries.findIndex((ent) => ent.hash === hash);
714
+ const hashEnt = entries[hashInd];
715
+ if (hashEnt === undefined) {
716
+ throw new HashNotFoundError(hash);
717
+ }
718
+ const [newEnt, uploadEnt] = await this.#editMetaRaw(hashEnt.id, hash, update, schemaVersion);
719
+ entries[hashInd] = newEnt;
720
+ const [rootEntry, uploadRoot] = await this.raw.putEntries(ROOT_LIST, entries, 4);
721
+ await Promise.all([uploadEnt, uploadRoot]);
722
+ await this.#putRootHash(rootEntry.hash, generation);
723
+ return { hash: newEnt.hash };
724
+ });
514
725
  }
515
726
  /** move an entry */
516
727
  async move(hash, parent, refresh = false) {
@@ -521,46 +732,52 @@ class Remarkable {
521
732
  }
522
733
  /** delete an entry */
523
734
  async delete(hash, refresh = false) {
524
- return await this.move(hash, "trash", refresh);
735
+ return await this.move(hash, TRASH_ID, refresh);
525
736
  }
526
737
  /** rename an entry */
527
738
  async rename(hash, visibleName, refresh = false) {
528
739
  return await this.#editMeta(hash, { visibleName }, refresh);
529
740
  }
530
- /** stared */
741
+ /** star or unstar an entry */
742
+ async star(hash, starred, refresh = false) {
743
+ return await this.#editMeta(hash, { pinned: starred }, refresh);
744
+ }
745
+ /** @deprecated misspelling; use {@link star | `star`} */
531
746
  async stared(hash, stared, refresh = false) {
532
- return await this.#editMeta(hash, { pinned: stared }, refresh);
747
+ return await this.star(hash, stared, refresh);
533
748
  }
534
749
  /** move many hashes */
535
750
  async bulkMove(hashes, parent, refresh = false) {
536
751
  if (!idReg.test(parent)) {
537
752
  throw new ValidationError(parent, idReg, "parent must be a valid document id");
538
753
  }
539
- const [rootHash, generation, schemaVersion] = await this.#getRootHash(refresh);
540
- const { entries } = await this.raw.getEntries("root.docSchema", rootHash);
541
- const hashSet = new Set(hashes);
542
- const toUpdate = [];
543
- const newEntries = [];
544
- for (const entry of entries) {
545
- const part = hashSet.has(entry.hash) ? toUpdate : newEntries;
546
- part.push(entry);
547
- }
548
- const resolved = await Promise.all(toUpdate.map(({ id, hash }) => this.#editMetaRaw(id, hash, { parent }, schemaVersion)));
549
- const uploads = [];
550
- const result = {};
551
- for (const [i, [newEnt, upload]] of resolved.entries()) {
552
- newEntries.push(newEnt);
553
- uploads.push(upload);
554
- result[toUpdate[i].hash] = newEnt.hash;
555
- }
556
- const [rootEntry, uploadRoot] = await this.raw.putEntries("root", newEntries, 4);
557
- await Promise.all([Promise.all(uploads), uploadRoot]);
558
- await this.#putRootHash(rootEntry.hash, generation);
559
- return { hashes: result };
754
+ return await this.#withRetry(async () => {
755
+ const [rootHash, generation, schemaVersion] = await this.#getRootHash(refresh);
756
+ const { entries } = await this.raw.getEntries(ROOT_SCHEMA, rootHash);
757
+ const hashSet = new Set(hashes);
758
+ const toUpdate = [];
759
+ const newEntries = [];
760
+ for (const entry of entries) {
761
+ const part = hashSet.has(entry.hash) ? toUpdate : newEntries;
762
+ part.push(entry);
763
+ }
764
+ const resolved = await Promise.all(toUpdate.map(({ id, hash }) => this.#editMetaRaw(id, hash, { parent }, schemaVersion)));
765
+ const uploads = [];
766
+ const result = {};
767
+ for (const [i, [newEnt, upload]] of resolved.entries()) {
768
+ newEntries.push(newEnt);
769
+ uploads.push(upload);
770
+ result[toUpdate[i].hash] = newEnt.hash;
771
+ }
772
+ const [rootEntry, uploadRoot] = await this.raw.putEntries(ROOT_LIST, newEntries, 4);
773
+ await Promise.all([Promise.all(uploads), uploadRoot]);
774
+ await this.#putRootHash(rootEntry.hash, generation);
775
+ return { hashes: result };
776
+ });
560
777
  }
561
778
  /** delete many hashes */
562
779
  async bulkDelete(hashes, refresh = false) {
563
- return await this.bulkMove(hashes, "trash", refresh);
780
+ return await this.bulkMove(hashes, TRASH_ID, refresh);
564
781
  }
565
782
  /** dump the raw cache */
566
783
  dumpCache() {
@@ -575,7 +792,7 @@ class Remarkable {
575
792
  // should only go one step) to track all hashes encountered
576
793
  // NOTE that we could increase the cache in this process, or it's possible
577
794
  // for other calls to increase the cache with misc values.
578
- const base = await this.raw.getEntries("root.docSchema", rootHash);
795
+ const base = await this.raw.getEntries(ROOT_SCHEMA, rootHash);
579
796
  let entries = [base.entries];
580
797
  let nextEntries = [];
581
798
  while (entries.length) {
@@ -628,7 +845,7 @@ export async function auth(deviceToken, { authHost = AUTH_HOST } = {}) {
628
845
  * @param sessionToken - the session token used for authorization
629
846
  * @returns an api instance
630
847
  */
631
- export function session(sessionToken, { rawHost = RAW_HOST, uploadHost = UPLOAD_HOST, cache, maxCacheSize = Infinity, } = {}) {
848
+ export function session(sessionToken, { rawHost = RAW_HOST, uploadHost = UPLOAD_HOST, cache, maxCacheSize = Infinity, maxGenerationRetries = 10, maxTransientRetries = 3, } = {}) {
632
849
  const initCache = JSON.parse(cache ?? "{}");
633
850
  const parsedCache = cached.safeParse(initCache);
634
851
  if (parsedCache.success) {
@@ -636,7 +853,7 @@ export function session(sessionToken, { rawHost = RAW_HOST, uploadHost = UPLOAD_
636
853
  const cacheMap = maxCacheSize === Infinity
637
854
  ? new Map(entries)
638
855
  : new LruCache(maxCacheSize, entries);
639
- return new Remarkable(sessionToken, rawHost, uploadHost, cacheMap);
856
+ return new Remarkable(sessionToken, rawHost, uploadHost, cacheMap, maxGenerationRetries, maxTransientRetries);
640
857
  }
641
858
  throw new Error("cache was not a valid cache (json string mapping); your cache must be corrupted somehow. Either initialize remarkable without a cache, or fix its format.");
642
859
  }
@@ -651,12 +868,14 @@ export function session(sessionToken, { rawHost = RAW_HOST, uploadHost = UPLOAD_
651
868
  * @returns an api instance
652
869
  */
653
870
  export async function remarkable(deviceToken, options = {}) {
654
- const { authHost, rawHost, uploadHost, cache, maxCacheSize } = options ?? {};
871
+ const { authHost, rawHost, uploadHost, cache, maxCacheSize, maxGenerationRetries, maxTransientRetries, } = options ?? {};
655
872
  const sessionToken = await auth(deviceToken, { authHost });
656
873
  return session(sessionToken, {
657
874
  rawHost,
658
875
  uploadHost,
659
876
  cache,
660
877
  maxCacheSize,
878
+ maxGenerationRetries,
879
+ maxTransientRetries,
661
880
  });
662
881
  }