mindoodb-app-sdk 0.0.14 → 0.0.17

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
@@ -49,7 +49,7 @@ The bridge carries RPC calls (documents, views, database info), binary attachmen
49
49
  ## Install
50
50
 
51
51
  ```bash
52
- npm install mindoodb-app-sdk
52
+ pnpm add mindoodb-app-sdk
53
53
  ```
54
54
 
55
55
  ## Get started in 5 minutes
@@ -59,8 +59,8 @@ The fastest way to start building is to clone the **example app**, run it locall
59
59
  ```bash
60
60
  git clone https://github.com/klehmann/mindoodb-app-example.git
61
61
  cd mindoodb-app-example
62
- npm install
63
- npm run dev:local
62
+ pnpm install
63
+ pnpm dev:local
64
64
  ```
65
65
 
66
66
  This spins up a local Vite dev server on `http://localhost:4200` with hot module reload.
@@ -90,9 +90,12 @@ const session = await bridge.connect();
90
90
 
91
91
  // 2. Read the launch context
92
92
  const ctx = await session.getLaunchContext();
93
- console.log("Runtime:", ctx.runtime); // "iframe" | "window"
94
- console.log("Theme:", ctx.theme.mode); // "light" | "dark"
95
- console.log("Databases:", ctx.databases.map((db) => db.title));
93
+ console.log("Runtime:", ctx.runtime); // "iframe" | "window"
94
+ console.log("Theme:", ctx.theme.mode); // "light" | "dark"
95
+ console.log(
96
+ "Databases:",
97
+ ctx.databases.map((db) => db.title),
98
+ );
96
99
 
97
100
  // 3. Subscribe to live host events
98
101
  session.onThemeChange((theme) => {
@@ -230,15 +233,15 @@ Important constraints:
230
233
 
231
234
  Each database mapped to your app carries a set of **capabilities** that Haven controls. Your app should check capabilities before attempting operations and adapt its UI accordingly.
232
235
 
233
- | Capability | Allows |
234
- |---|---|
235
- | `read` | List and read documents |
236
- | `create` | Create new documents |
237
- | `update` | Update existing documents |
238
- | `delete` | Delete documents |
239
- | `history` | Access document revision history and historical snapshots |
236
+ | Capability | Allows |
237
+ | ------------- | ------------------------------------------------------------ |
238
+ | `read` | List and read documents |
239
+ | `create` | Create new documents |
240
+ | `update` | Update existing documents |
241
+ | `delete` | Delete documents |
242
+ | `history` | Access document revision history and historical snapshots |
240
243
  | `attachments` | List, upload, download, remove, and preview file attachments |
241
- | `views` | Create app-defined virtual views for this database |
244
+ | `views` | Create app-defined virtual views for this database |
242
245
 
243
246
  ```ts
244
247
  const db = ctx.databases[0]!;
@@ -298,7 +301,11 @@ console.log(result.nextCursor); // opaque changefeed checkpoint or null
298
301
 
299
302
  // Continue from the last checkpoint
300
303
  const nextPage = result.nextCursor
301
- ? await db.documents.list({ cursor: result.nextCursor, limit: 50, fields: ["title"] })
304
+ ? await db.documents.list({
305
+ cursor: result.nextCursor,
306
+ limit: 50,
307
+ fields: ["title"],
308
+ })
302
309
  : null;
303
310
 
304
311
  // Fast metadata-only listing (IDs + deletion state only)
@@ -319,54 +326,259 @@ const window = await db.documents.list({
319
326
  // Read
320
327
  const doc = await db.documents.get(docId);
321
328
 
322
- // Create (optionally with a named document key)
329
+ // Create (MindooDB generates a UUID7 id; optionally use a named document key)
323
330
  const created = await db.documents.create({
324
331
  set: { title: "Meeting notes", content: "..." },
325
- decryptionKeyId: "team-key", // omit to use "default"
332
+ decryptionKeyId: "team-key", // omit to use "default"
326
333
  });
327
334
 
328
- // Update
335
+ // Create with a caller-provided id (e.g. a known well-known document the app
336
+ // always loads on launch, or migrating an id from another system).
337
+ const settings = await db.documents.create({
338
+ id: "AppSettings",
339
+ set: { theme: "dark" },
340
+ });
341
+ // Subsequent calls with the same id return the existing document and DO NOT
342
+ // overwrite `set`, so `documents.create({ id })` is safe to call on every app
343
+ // launch as a "create-if-missing" primitive. Custom ids must match
344
+ // /^[A-Za-z][A-Za-z0-9_]*$/ (first char must be a letter, subsequent chars
345
+ // may be letters, digits, or `_`). Documents created with the same custom id
346
+ // on different replicas merge correctly when synced.
347
+
348
+ // Update top-level fields
329
349
  const updated = await db.documents.update(docId, {
330
350
  set: { title: "Updated title" },
331
351
  unset: ["legacyField"],
332
352
  });
333
353
 
354
+ // Update with a granular text patch (see "Collaborative text editing" below).
355
+ // `baseHeads` is the document version this edit was authored against; Haven
356
+ // merges these splices with any concurrent changes since `baseHeads`.
357
+ const merged = await db.documents.update(docId, {
358
+ text: [
359
+ {
360
+ path: ["body"],
361
+ baseHeads: doc!.heads,
362
+ edits: [
363
+ { index: 0, deleteCount: 0, insert: "Hello, " },
364
+ { index: 12, deleteCount: 5, insert: "team" },
365
+ ],
366
+ },
367
+ ],
368
+ });
369
+
334
370
  // Delete
335
371
  await db.documents.delete(docId);
372
+
373
+ // Undelete (resurrect a deleted document)
374
+ await db.documents.undelete(docId);
336
375
  ```
337
376
 
377
+ Every `MindooDBAppDocument` snapshot also exposes a `heads?: string[]` field. Those heads identify the document's current Automerge version and are the value you pass to `baseHeads` when issuing the next text patch. After Haven applies and merges your patch, the returned document carries the new `heads` you should use for the following edit cycle.
378
+
338
379
  `documents.list()` is backed by Haven's internal changefeed, not by a positional offset. The query object supports:
339
380
 
340
- | Field | Meaning |
341
- |---|---|
342
- | `cursor?: string \| null` | Opaque changefeed checkpoint previously returned by `nextCursor` |
343
- | `limit?: number` | Maximum number of matching entries to return (default `50`) |
344
- | `skip?: number` | Skip matching entries after the cursor without loading full document bodies |
345
- | `status?: "all" \| "existing" \| "deleted"` | Filter by deletion state (default `"existing"`) |
346
- | `metadataOnly?: boolean` | Return only `{ id, isDeleted }` for speed |
347
- | `fields?: string[]` | Project specific JSON fields when `metadataOnly` is `false` |
348
- | `filter?: Record<string, unknown>` | Simple equality filter applied to document data when `metadataOnly` is `false` |
381
+ | Field | Meaning |
382
+ | ------------------------------------------- | ------------------------------------------------------------------------------ |
383
+ | `cursor?: string \| null` | Opaque changefeed checkpoint previously returned by `nextCursor` |
384
+ | `limit?: number` | Maximum number of matching entries to return (default `50`) |
385
+ | `skip?: number` | Skip matching entries after the cursor without loading full document bodies |
386
+ | `status?: "all" \| "existing" \| "deleted"` | Filter by deletion state (default `"existing"`) |
387
+ | `metadataOnly?: boolean` | Return only `{ id, isDeleted }` for speed |
388
+ | `fields?: string[]` | Project specific JSON fields when `metadataOnly` is `false` |
389
+ | `filter?: Record<string, unknown>` | Simple equality filter applied to document data when `metadataOnly` is `false` |
349
390
 
350
391
  `nextCursor` is the latest checkpoint reached by the page. Persist it after each successful call when you are building your own index or sync loop. If there were no changes after the supplied cursor, `nextCursor` is `null`.
351
392
 
352
- `documents.update()` accepts top-level `set` and `unset` operations. Prefer sending small, intentional field-level changes instead of rewriting whole documents when only a few fields changed. This keeps MindooDB change tracking more meaningful and helps Automerge produce cleaner merge results.
393
+ `documents.update()` accepts three independent operations on the same call:
394
+
395
+ - `set` -- assign top-level fields (shallow JSON merge).
396
+ - `unset` -- remove top-level fields entirely.
397
+ - `text` -- one or more **granular text patches** applied to specific JSON paths. Each patch carries the `baseHeads` version it was authored against and is merged on the Haven side with any concurrent changes that arrived since. This is the recommended way to edit collaborative text fields like a markdown body. See [Collaborative text editing](#collaborative-text-editing) below.
398
+
399
+ Prefer sending small, intentional field-level changes instead of rewriting whole documents when only a few fields changed. This keeps MindooDB change tracking more meaningful and helps Automerge produce cleaner merge results.
353
400
 
354
401
  When Haven can resolve the signing identity from the tenant directory, list items may also include `identityLabel` and `publicKeyFingerprint` for the latest visible change. Apps can use this to show who last touched a document without loading the full revision history first.
355
402
 
356
403
  ### Document history
357
404
 
358
- When the `history` capability is granted, you can walk the full revision timeline of any document:
405
+ When the `history` capability is granted, you can walk the document revision list and load historical snapshots. Prefer `revisionId` for follow-up reads: it is a stable, DAG-backed identifier for the exact document revision, while timestamps are mainly useful for display and backwards-compatible point-in-time lookups.
359
406
 
360
407
  ```ts
361
408
  // List all revisions
362
409
  const history = await db.documents.listHistory(docId);
363
- // Each entry: { timestamp, publicKey, identityLabel?, isDeleted, isCurrent, summary? }
410
+ // Each entry:
411
+ // {
412
+ // revisionId,
413
+ // timestamp,
414
+ // heads?,
415
+ // publicKey,
416
+ // identityLabel?,
417
+ // isDeleted,
418
+ // isCurrent,
419
+ // summary?
420
+ // }
421
+
422
+ // Load the document state at an exact DAG revision
423
+ const snapshot = await db.documents.getAtRevision(
424
+ docId,
425
+ history[0]!.revisionId,
426
+ );
427
+ // {
428
+ // id,
429
+ // revisionId,
430
+ // timestamp,
431
+ // heads?,
432
+ // state: "exists" | "deleted" | "missing",
433
+ // data,
434
+ // attachments?,
435
+ // attachmentSnapshotRevisionId?
436
+ // }
437
+
438
+ // Timestamp lookup is still available when all you have is a timestamp.
439
+ const pointInTime = await db.documents.getAtTimestamp(
440
+ docId,
441
+ history[0]!.timestamp,
442
+ );
443
+ ```
444
+
445
+ Historical snapshots include the document data payload as it existed at that revision. When the snapshot has attachments, `attachments` reflects the historical `_attachments` array for that same revision, including the attachment reference that Haven needs to stop at the correct historical `lastChunkId`.
446
+
447
+ ### Collaborative text editing
448
+
449
+ MindooDB stores collaborative text fields as Automerge text CRDTs internally, but apps never touch Automerge directly -- the SDK bridge is JSON-only. Instead, your app describes text changes as **granular splice operations** against a known document version, and Haven applies and merges them on its side.
450
+
451
+ Two layers are available:
452
+
453
+ - **Low level**: raw text patches via `documents.update({ text: [...] })`.
454
+ - **High level**: `createMindooDBTextBuffer(...)` -- a small editor-friendly helper that tracks local edits and flushes them as one patch.
455
+
456
+ The end-to-end flow is the same in both cases:
457
+
458
+ ```
459
+ ┌────────────────────────────────────────────────────┐
460
+ edit ───► │ App buffer (local string + pending splices) │ ──► flush
461
+ └─────────────────────────┬──────────────────────────┘
462
+ │ documents.update({
463
+ │ text: [{ path, baseHeads, edits }]
464
+ │ })
465
+ ▼
466
+ ┌────────────────────────────────────────────────────┐
467
+ │ Haven applies edits at `baseHeads` and merges with │
468
+ │ concurrent changes via Automerge │
469
+ └─────────────────────────┬──────────────────────────┘
470
+ │ canonical merged document
471
+ ▼
472
+ ┌────────────────────────────────────────────────────┐
473
+ │ App reconciles its editor with the canonical text │
474
+ └────────────────────────────────────────────────────┘
475
+ ```
476
+
477
+ #### Text patch shape
478
+
479
+ ```ts
480
+ interface MindooDBAppTextEdit {
481
+ index: number; // 0-based position in the current string at baseHeads
482
+ deleteCount: number; // number of characters to remove at `index`
483
+ insert?: string; // text to insert at `index` after deletion
484
+ }
485
+
486
+ interface MindooDBAppTextPatch {
487
+ path: Array<string | number>; // path inside `document.data`, e.g. ["body"]
488
+ baseHeads?: string[]; // document version the edits were authored against
489
+ edits: MindooDBAppTextEdit[]; // applied in order
490
+ }
491
+ ```
492
+
493
+ Each edit is a JavaScript-style `splice(index, deleteCount, insert)`. Multiple patches in a single `update()` call are applied atomically.
494
+
495
+ #### Low-level: `documents.update({ text: [...] })`
496
+
497
+ If your editor already exposes raw text operations (e.g. ProseMirror or CodeMirror transactions), forward them directly:
498
+
499
+ ```ts
500
+ const doc = await db.documents.get(docId);
501
+
502
+ // User typed "Hello, " at the beginning of the body.
503
+ const result = await db.documents.update(docId, {
504
+ text: [
505
+ {
506
+ path: ["body"],
507
+ baseHeads: doc!.heads,
508
+ edits: [{ index: 0, deleteCount: 0, insert: "Hello, " }],
509
+ },
510
+ ],
511
+ });
512
+
513
+ // `result.data.body` -- canonical merged text
514
+ // `result.heads` -- new heads to use as `baseHeads` next time
515
+ ```
516
+
517
+ When two app instances send patches concurrently against the same `baseHeads`, Haven merges both via Automerge and returns the canonical text. If the merged result differs from what your editor currently displays, replace the editor content with `result.data[path]`.
518
+
519
+ #### High-level: `createMindooDBTextBuffer`
520
+
521
+ For WYSIWYG editors that emit a full markdown / plain-text string after each transaction (Milkdown, TinyMCE, etc.), the SDK ships a small buffer helper that does the splice diffing and flush bookkeeping for you:
364
522
 
365
- // Load the document state at a specific point in time
366
- const snapshot = await db.documents.getAtTimestamp(docId, history[0]!.timestamp);
367
- // { id, timestamp, state: "exists" | "deleted" | "missing", data }
523
+ ```ts
524
+ import { createMindooDBTextBuffer } from "mindoodb-app-sdk";
525
+
526
+ const doc = await db.documents.get(docId);
527
+ const buffer = createMindooDBTextBuffer({
528
+ database: db,
529
+ document: doc!,
530
+ path: ["body"], // string field inside `document.data`
531
+ });
532
+
533
+ editor.value = buffer.value;
534
+
535
+ // Editor reports a new full markdown value -- the buffer turns it into one splice.
536
+ editor.on("update", (next: string) => {
537
+ buffer.replaceText(next);
538
+ });
539
+
540
+ // On save (e.g. Cmd+S), flush all pending edits to Haven.
541
+ async function save() {
542
+ const result = await buffer.flush();
543
+ if (result.reconciled) {
544
+ // Haven merged in concurrent changes; show the canonical merged text.
545
+ editor.value = result.value;
546
+ }
547
+ }
548
+
549
+ // Re-read the document without saving (e.g. a Refresh button).
550
+ async function refresh() {
551
+ const fresh = await db.documents.get(docId);
552
+ if (fresh && buffer.reconcile(fresh)) {
553
+ editor.value = buffer.value;
554
+ }
555
+ }
368
556
  ```
369
557
 
558
+ Buffer API at a glance:
559
+
560
+ | Member | Description |
561
+ | ------------------------------------- | -------------------------------------------------------------------------- |
562
+ | `value` / `toString()` | Current local text value |
563
+ | `dirty` / `pendingCount` | Whether/how many local splices are buffered |
564
+ | `splice(index, deleteCount, insert?)` | Record an exact text operation |
565
+ | `replaceText(next)` | Diff `value` against `next` and record one minimal splice |
566
+ | `reconcile(document)` | Adopt a fresh document snapshot, drop pending edits |
567
+ | `flush()` | Send pending edits to Haven; resolves to `{ document, value, reconciled }` |
568
+
569
+ `flush()` always sends the original `baseHeads` captured when the buffer was created (or last reconciled), so two windows editing the same document at the same heads will both produce a clean merge -- they will not overwrite each other.
570
+
571
+ #### Concurrent-edit walkthrough
572
+
573
+ 1. Window A and window B both `documents.get(docId)` and create a buffer at the same `baseHeads`.
574
+ 2. Window A inserts text at the top, window B deletes a paragraph in the middle.
575
+ 3. Window A calls `buffer.flush()` first. Haven applies its patch, advances the document heads, and returns the canonical text to A.
576
+ 4. Window B calls `buffer.flush()` next. Haven still applies B's patch causally at B's original `baseHeads`, then merges with A's already-stored change. B's `result.reconciled` is `true` and `result.value` contains both A's and B's edits.
577
+
578
+ #### End-to-end sample
579
+
580
+ The [`mindoodb-app-teamedit`](https://github.com/klehmann/mindoodb-app-teamedit) sample wires `MindooDBTextBuffer` to a Milkdown markdown editor with Save / Refresh / preview pane, and is the recommended starting point for building your own collaborative text app.
581
+
370
582
  ### Incremental sync
371
583
 
372
584
  The changefeed-backed `documents.list()` API is also the right primitive for app-side indexes, search, and other derived caches.
@@ -449,31 +661,64 @@ while (chunk !== null) {
449
661
  await reader.close();
450
662
 
451
663
  // Upload (push-based write stream)
452
- const writer = await db.attachments.openWriteStream(docId, "photo.jpg", "image/jpeg");
664
+ const writer = await db.attachments.openWriteStream(
665
+ docId,
666
+ "photo.jpg",
667
+ "image/jpeg",
668
+ );
453
669
  await writer.write(fileBytes);
454
670
  await writer.close();
455
671
 
672
+ // Host-provided document scanner
673
+ const scan = await db.attachments.scan(docId, {
674
+ defaultFileName: "invoice.pdf",
675
+ preset: "a4-portrait",
676
+ mimeType: "application/pdf",
677
+ });
678
+
456
679
  // Remove
457
680
  await db.attachments.remove(docId, "old-file.txt");
458
681
  ```
459
682
 
683
+ `attachments.scan()` asks Haven to open its document scanner UI. Haven handles camera/file selection, edge detection, manual corner correction, output sizing, and filename confirmation, then writes the resulting image as a normal attachment. Hosts that do not provide scanner UI may reject this method or return `{ ok: false }`.
684
+
685
+ For historical reads, pass the same `revisionId` you received from `documents.listHistory()` or from a `MindooDBAppHistoricalDocument`. This tells Haven to resolve the attachment from that revision's document payload instead of the latest document state:
686
+
687
+ ```ts
688
+ const history = await db.documents.listHistory(docId);
689
+ const revisionId = history[0]!.revisionId;
690
+ const historical = await db.documents.getAtRevision(docId, revisionId);
691
+
692
+ const historicalFiles = await db.attachments.list(docId, {
693
+ revisionId,
694
+ });
695
+
696
+ const historicalReader = await db.attachments.openReadStream(
697
+ docId,
698
+ historicalFiles[0]!.fileName,
699
+ { revisionId },
700
+ );
701
+ ```
702
+
703
+ Use `revisionId` instead of `timestamp` whenever possible. Attachment blobs can grow over time by appending chunks and changing the attachment's `lastChunkId` in newer document revisions; `revisionId` ensures Haven reads from the historical attachment reference and does not accidentally include chunks added later.
704
+
460
705
  ### Attachment previews
461
706
 
462
707
  Haven includes a **built-in file viewer** that your app can open for common attachment formats. This is one of the most powerful SDK features -- it works with both online and offline data, and media formats support streaming playback with skip/seek.
463
708
 
464
709
  **Supported preview formats:**
465
710
 
466
- | Mode | Formats |
467
- |---|---|
468
- | `image` | All common image formats (JPEG, PNG, GIF, WebP, SVG, ...) |
469
- | `pdf` | PDF documents |
470
- | `markdown` | Markdown rendered to HTML (.md, .markdown, .mdown, .mkd, .mkdn) |
471
- | `text` | Plain text, CSV, JSON, XML, YAML, SVG, log files |
472
- | `docx` | Microsoft Word (.docx) |
473
- | `pptx` | Microsoft PowerPoint (.pptx) |
474
- | `spreadsheet` | Microsoft Excel (.xls, .xlsx, .xlsm, .xlsb) |
475
- | `video` | Video files with embedded player, streaming + seek (.mp4, .m4v, .webm, .ogv, .ogg, plus any `video/*`) |
476
- | `audio` | Audio files with embedded player, streaming + seek (.mp3, .m4a, .wav, .aac, .oga, plus any `audio/*`) |
711
+ | Mode | Formats |
712
+ | ------------- | ------------------------------------------------------------------------------------------------------ |
713
+ | `image` | All common image formats (JPEG, PNG, GIF, WebP, SVG, ...) |
714
+ | `pdf` | PDF documents |
715
+ | `markdown` | Markdown rendered to HTML (.md, .markdown, .mdown, .mkd, .mkdn) |
716
+ | `text` | Plain text, CSV, JSON, XML, YAML, SVG, log files |
717
+ | `docx` | Microsoft Word (.docx) |
718
+ | `pptx` | Microsoft PowerPoint (.pptx) |
719
+ | `spreadsheet` | Microsoft Excel (.xls, .xlsx, .xlsm, .xlsb) |
720
+ | `video` | Video files with embedded player, streaming + seek (.mp4, .m4v, .webm, .ogv, .ogg, plus any `video/*`) |
721
+ | `audio` | Audio files with embedded player, streaming + seek (.mp3, .m4a, .wav, .aac, .oga, plus any `audio/*`) |
477
722
 
478
723
  **Open the Haven preview dialog:**
479
724
 
@@ -485,10 +730,12 @@ You can also preview attachments from a **historical document snapshot**:
485
730
 
486
731
  ```ts
487
732
  await db.attachments.openPreview(docId, "report.pdf", {
488
- timestamp: historyEntry.timestamp,
733
+ revisionId: historyEntry.revisionId,
489
734
  });
490
735
  ```
491
736
 
737
+ `timestamp` is still accepted in attachment options for compatibility, but `revisionId` is the preferred identifier because it targets an exact DAG revision.
738
+
492
739
  **Check preview support locally** before showing a preview button:
493
740
 
494
741
  ```ts
@@ -497,7 +744,10 @@ import { canPreviewAttachment } from "mindoodb-app-sdk";
497
744
  const mode = canPreviewAttachment("slides.pptx", "application/octet-stream");
498
745
  // Returns "pptx" -- Haven can preview this file
499
746
 
500
- const unsupported = canPreviewAttachment("data.bin", "application/octet-stream");
747
+ const unsupported = canPreviewAttachment(
748
+ "data.bin",
749
+ "application/octet-stream",
750
+ );
501
751
  // Returns null -- no built-in preview available
502
752
  ```
503
753
 
@@ -585,7 +835,10 @@ do {
585
835
  ```ts
586
836
  const page1 = await navigator.entriesForward({ limit: 50 });
587
837
  const page2 = page1.nextPosition
588
- ? await navigator.entriesForward({ limit: 50, startPosition: page1.nextPosition })
838
+ ? await navigator.entriesForward({
839
+ limit: 50,
840
+ startPosition: page1.nextPosition,
841
+ })
589
842
  : null;
590
843
  ```
591
844
 
@@ -604,7 +857,11 @@ if (category) {
604
857
  ```ts
605
858
  const category = await navigator.findCategoryEntryByParts(["Ada"]);
606
859
  if (category) {
607
- const exact = await navigator.childDocumentsByKey(category.key, "2026-04-14", true);
860
+ const exact = await navigator.childDocumentsByKey(
861
+ category.key,
862
+ "2026-04-14",
863
+ true,
864
+ );
608
865
  const range = await navigator.childDocumentsBetween(category.key, {
609
866
  startKey: "2026-04-01",
610
867
  endKey: "2026-04-30",
@@ -667,7 +924,7 @@ MindooDB Apps are standard web applications -- deploy them anywhere you can serv
667
924
  **Cloudflare Pages / Workers** is one of the easiest options. The [example app](https://github.com/klehmann/mindoodb-app-example) is deployed this way at https://app-example.mindoodb.com using a simple `wrangler.jsonc` configuration:
668
925
 
669
926
  ```bash
670
- npm run build
927
+ pnpm build
671
928
  wrangler deploy
672
929
  ```
673
930
 
@@ -680,7 +937,7 @@ Other static hosting options work just as well: Netlify, Vercel, any web server
680
937
  For interactive development with the full Haven host bridge:
681
938
 
682
939
  1. Run Haven locally.
683
- 2. Run your app on its own dev server (e.g. `npm run dev` with Vite).
940
+ 2. Run your app on its own dev server (e.g. `pnpm dev` with Vite).
684
941
  3. Register the dev server URL in Haven's Application settings.
685
942
  4. Map databases and optionally views to the app.
686
943
  5. Launch from Haven -- you get hot reload, real bridge communication, and live theme/viewport events.
@@ -691,111 +948,132 @@ This is separate from the automated testing workflow above. Running Haven is use
691
948
 
692
949
  ### MindooDBAppBridge
693
950
 
694
- | Method | Returns |
695
- |---|---|
951
+ | Method | Returns |
952
+ | ------------------- | ----------------------------- |
696
953
  | `connect(options?)` | `Promise<MindooDBAppSession>` |
697
954
 
698
955
  Connect options: `launchId?`, `targetOrigin?`, `connectTimeoutMs?`.
699
956
 
700
957
  ### MindooDBAppSession
701
958
 
702
- | Method | Returns |
703
- |---|---|
704
- | `getLaunchContext()` | `Promise<MindooDBAppLaunchContext>` |
705
- | `listDatabases()` | `Promise<MindooDBAppDatabaseInfo[]>` |
706
- | `openDatabase(databaseId)` | `Promise<MindooDBAppDatabase>` |
707
- | `createViewNavigator(input)` | `Promise<MindooDBAppViewNavigator>` |
708
- | `openViewNavigator(viewId, options?)` | `Promise<MindooDBAppViewNavigator>` |
709
- | `menus.show(input)` | `Promise<MindooDBAppShowMenuResult>` |
710
- | `menus.hide()` | `Promise<void>` |
711
- | `onThemeChange(listener)` | `() => void` (unsubscribe) |
712
- | `onViewportChange(listener)` | `() => void` (unsubscribe) |
713
- | `disconnect()` | `Promise<void>` |
959
+ | Method | Returns |
960
+ | ------------------------------------- | ------------------------------------ |
961
+ | `getLaunchContext()` | `Promise<MindooDBAppLaunchContext>` |
962
+ | `listDatabases()` | `Promise<MindooDBAppDatabaseInfo[]>` |
963
+ | `openDatabase(databaseId)` | `Promise<MindooDBAppDatabase>` |
964
+ | `createViewNavigator(input)` | `Promise<MindooDBAppViewNavigator>` |
965
+ | `openViewNavigator(viewId, options?)` | `Promise<MindooDBAppViewNavigator>` |
966
+ | `menus.show(input)` | `Promise<MindooDBAppShowMenuResult>` |
967
+ | `menus.hide()` | `Promise<void>` |
968
+ | `onThemeChange(listener)` | `() => void` (unsubscribe) |
969
+ | `onViewportChange(listener)` | `() => void` (unsubscribe) |
970
+ | `disconnect()` | `Promise<void>` |
714
971
 
715
972
  ### MindooDBAppDatabase
716
973
 
717
- | Property / Method | Returns |
718
- |---|---|
719
- | `info()` | `Promise<MindooDBAppDatabaseInfo>` |
720
- | `documents` | `MindooDBAppDocumentApi` |
721
- | `attachments` | `MindooDBAppAttachmentApi` |
974
+ | Property / Method | Returns |
975
+ | ----------------- | ---------------------------------- |
976
+ | `info()` | `Promise<MindooDBAppDatabaseInfo>` |
977
+ | `documents` | `MindooDBAppDocumentApi` |
978
+ | `attachments` | `MindooDBAppAttachmentApi` |
722
979
 
723
980
  ### MindooDBAppDocumentApi
724
981
 
725
- | Method | Returns |
726
- |---|---|
727
- | `list(query?)` | `Promise<MindooDBAppDocumentListResult>` |
728
- | `get(docId)` | `Promise<MindooDBAppDocument \| null>` |
729
- | `create(input)` | `Promise<MindooDBAppDocument>` |
730
- | `update(docId, patch)` | `Promise<MindooDBAppDocument>` |
731
- | `delete(docId)` | `Promise<{ ok: true }>` |
732
- | `listHistory(docId)` | `Promise<MindooDBAppDocumentHistoryEntry[]>` |
733
- | `getAtTimestamp(docId, timestamp)` | `Promise<MindooDBAppHistoricalDocument>` |
982
+ | Method | Returns |
983
+ | ---------------------------------- | -------------------------------------------- |
984
+ | `list(query?)` | `Promise<MindooDBAppDocumentListResult>` |
985
+ | `get(docId)` | `Promise<MindooDBAppDocument \| null>` |
986
+ | `create(input)` | `Promise<MindooDBAppDocument>` |
987
+ | `update(docId, patch)` | `Promise<MindooDBAppDocument>` |
988
+ | `delete(docId)` | `Promise<{ ok: true }>` |
989
+ | `undelete(docId)` | `Promise<{ ok: true }>` |
990
+ | `listHistory(docId)` | `Promise<MindooDBAppDocumentHistoryEntry[]>` |
991
+ | `getAtTimestamp(docId, timestamp)` | `Promise<MindooDBAppHistoricalDocument>` |
992
+ | `getAtRevision(docId, revisionId)` | `Promise<MindooDBAppHistoricalDocument>` |
734
993
 
735
994
  `list(query?)` accepts the changefeed query options documented above. The `cursor` value is an opaque checkpoint string managed by Haven and should be stored and passed back unchanged.
736
995
 
737
- For `update(docId, patch)`, use `{ set?: Record<string, unknown>; unset?: string[] }`. Both operations are shallow and apply to top-level document fields only.
996
+ For `create(input)`, use `MindooDBAppCreateDocumentInput` with shape `{ set: Record<string, unknown>; decryptionKeyId?: string; id?: string }`. The optional `id` lets the app pick the document id instead of letting MindooDB generate a UUID7; when provided it must match `/^[A-Za-z][A-Za-z0-9_]*$/` and existing live documents with that id are returned as-is (idempotent create-if-missing). If an existing custom-id document is deleted, `create({ id })` undeletes it and preserves the previous document body. Documents created with the same custom id on different replicas converge correctly when synced.
997
+
998
+ `delete(docId)` writes a lifecycle tombstone; it does not erase the document body from the append-only history. Use `undelete(docId)` to make the latest document state live again. If you want to record a deletion reason, first call `update(docId, { set: { intendedDeletionReason: "..." } })`, then call `delete(docId)`.
999
+
1000
+ For `update(docId, patch)`, use `MindooDBAppUpdateDocumentInput`:
1001
+
1002
+ ```ts
1003
+ interface MindooDBAppUpdateDocumentInput {
1004
+ set?: Record<string, unknown>; // shallow assign top-level fields
1005
+ unset?: string[]; // remove top-level fields entirely
1006
+ text?: MindooDBAppTextPatch[]; // granular collaborative text edits
1007
+ }
1008
+ ```
1009
+
1010
+ `set` and `unset` apply to top-level fields only. `text` patches operate on string fields at any JSON path inside `document.data` and are merged on the Haven side using the document's Automerge heads. See [Collaborative text editing](#collaborative-text-editing).
738
1011
 
739
1012
  ### MindooDBAppAttachmentApi
740
1013
 
741
- | Method | Returns |
742
- |---|---|
743
- | `list(docId)` | `Promise<MindooDBAppAttachmentInfo[]>` |
744
- | `remove(docId, attachmentName)` | `Promise<{ ok: true }>` |
745
- | `openReadStream(docId, attachmentName)` | `Promise<MindooDBAppReadableAttachmentStream>` |
746
- | `openWriteStream(docId, attachmentName, contentType?)` | `Promise<MindooDBAppWritableAttachmentStream>` |
747
- | `openPreview(docId, attachmentName, options?)` | `Promise<{ ok: true }>` |
1014
+ | Method | Returns |
1015
+ | -------------------------------------------------------- | ---------------------------------------------- |
1016
+ | `list(docId, options?)` | `Promise<MindooDBAppAttachmentInfo[]>` |
1017
+ | `remove(docId, attachmentName)` | `Promise<{ ok: true }>` |
1018
+ | `openReadStream(docId, attachmentName, options?)` | `Promise<MindooDBAppReadableAttachmentStream>` |
1019
+ | `openWriteStream(docId, attachmentName, contentType?)` | `Promise<MindooDBAppWritableAttachmentStream>` |
1020
+ | `scan(docId, options?)` | `Promise<MindooDBAppScanAttachmentResult>` |
1021
+ | `preparePreviewSession(docId, attachmentName, options?)` | `Promise<MindooDBAppAttachmentPreviewSession>` |
1022
+ | `openPreview(docId, attachmentName, options?)` | `Promise<{ ok: true }>` |
1023
+
1024
+ `options` accepts `{ timestamp?: number; revisionId?: string }` for read-only historical attachment access. Mutation methods (`openWriteStream`, `remove`) always target the current document.
748
1025
 
749
1026
  ### MindooDBAppViewNavigator
750
1027
 
751
- | Method | Returns |
752
- |---|---|
753
- | `getDefinition()` | `Promise<MindooDBAppViewDefinition>` |
754
- | `refresh()` | `Promise<void>` |
755
- | `getCurrentEntry()` | `Promise<MindooDBAppViewEntry \| null>` |
756
- | `gotoFirst()` / `gotoLast()` | `Promise<boolean>` |
757
- | `gotoNext()` / `gotoPrev()` | `Promise<boolean>` |
758
- | `gotoNextSibling()` / `gotoPrevSibling()` | `Promise<boolean>` |
759
- | `gotoParent()` | `Promise<boolean>` |
760
- | `gotoFirstChild()` / `gotoLastChild()` | `Promise<boolean>` |
761
- | `gotoPos(position)` | `Promise<boolean>` |
762
- | `getPos(position)` | `Promise<MindooDBAppViewEntry \| null>` |
763
- | `findCategoryEntryByParts(parts)` | `Promise<MindooDBAppViewEntry \| null>` |
764
- | `entriesForward(options?)` | `Promise<MindooDBAppViewNavigatorPageResult>` |
765
- | `entriesBackward(options?)` | `Promise<MindooDBAppViewNavigatorPageResult>` |
766
- | `gotoNextSelected()` / `gotoPrevSelected()` | `Promise<boolean>` |
767
- | `select(origin, docId, selectParentCategories?)` | `Promise<void>` |
768
- | `deselect(origin, docId)` | `Promise<void>` |
769
- | `selectAllEntries()` / `deselectAllEntries()` | `Promise<void>` |
770
- | `isSelected(origin, docId)` | `Promise<boolean>` |
771
- | `getSelectionState()` | `Promise<MindooDBAppViewNavigatorSelectionState>` |
772
- | `setSelectionState(state)` | `Promise<void>` |
773
- | `expand(origin, docId)` / `collapse(origin, docId)` | `Promise<void>` |
774
- | `expandAll()` / `collapseAll()` | `Promise<void>` |
775
- | `expandToLevel(level)` | `Promise<void>` |
776
- | `isExpanded(entryKey)` | `Promise<boolean>` |
777
- | `getExpansionState()` | `Promise<MindooDBAppViewNavigatorExpansionState>` |
778
- | `setExpansionState(state)` | `Promise<void>` |
779
- | `childEntries(entryKey, descending?)` | `Promise<MindooDBAppViewEntry[]>` |
780
- | `childCategories(entryKey, descending?)` | `Promise<MindooDBAppViewEntry[]>` |
781
- | `childDocuments(entryKey, descending?)` | `Promise<MindooDBAppViewEntry[]>` |
782
- | `childCategoriesByKey(entryKey, key, exact?, descending?)` | `Promise<MindooDBAppViewEntry[]>` |
783
- | `childDocumentsByKey(entryKey, key, exact?, descending?)` | `Promise<MindooDBAppViewEntry[]>` |
784
- | `childCategoriesBetween(entryKey, range)` | `Promise<MindooDBAppViewEntry[]>` |
785
- | `childDocumentsBetween(entryKey, range)` | `Promise<MindooDBAppViewEntry[]>` |
786
- | `getSortedDocIds(descending?)` | `Promise<MindooDBAppScopedDocId[]>` |
787
- | `getSortedDocIdsScoped(entryKey, descending?)` | `Promise<MindooDBAppScopedDocId[]>` |
788
- | `dispose()` | `Promise<void>` |
1028
+ | Method | Returns |
1029
+ | ---------------------------------------------------------- | ------------------------------------------------- |
1030
+ | `getDefinition()` | `Promise<MindooDBAppViewDefinition>` |
1031
+ | `refresh()` | `Promise<void>` |
1032
+ | `getCurrentEntry()` | `Promise<MindooDBAppViewEntry \| null>` |
1033
+ | `gotoFirst()` / `gotoLast()` | `Promise<boolean>` |
1034
+ | `gotoNext()` / `gotoPrev()` | `Promise<boolean>` |
1035
+ | `gotoNextSibling()` / `gotoPrevSibling()` | `Promise<boolean>` |
1036
+ | `gotoParent()` | `Promise<boolean>` |
1037
+ | `gotoFirstChild()` / `gotoLastChild()` | `Promise<boolean>` |
1038
+ | `gotoPos(position)` | `Promise<boolean>` |
1039
+ | `getPos(position)` | `Promise<MindooDBAppViewEntry \| null>` |
1040
+ | `findCategoryEntryByParts(parts)` | `Promise<MindooDBAppViewEntry \| null>` |
1041
+ | `entriesForward(options?)` | `Promise<MindooDBAppViewNavigatorPageResult>` |
1042
+ | `entriesBackward(options?)` | `Promise<MindooDBAppViewNavigatorPageResult>` |
1043
+ | `gotoNextSelected()` / `gotoPrevSelected()` | `Promise<boolean>` |
1044
+ | `select(origin, docId, selectParentCategories?)` | `Promise<void>` |
1045
+ | `deselect(origin, docId)` | `Promise<void>` |
1046
+ | `selectAllEntries()` / `deselectAllEntries()` | `Promise<void>` |
1047
+ | `isSelected(origin, docId)` | `Promise<boolean>` |
1048
+ | `getSelectionState()` | `Promise<MindooDBAppViewNavigatorSelectionState>` |
1049
+ | `setSelectionState(state)` | `Promise<void>` |
1050
+ | `expand(origin, docId)` / `collapse(origin, docId)` | `Promise<void>` |
1051
+ | `expandAll()` / `collapseAll()` | `Promise<void>` |
1052
+ | `expandToLevel(level)` | `Promise<void>` |
1053
+ | `isExpanded(entryKey)` | `Promise<boolean>` |
1054
+ | `getExpansionState()` | `Promise<MindooDBAppViewNavigatorExpansionState>` |
1055
+ | `setExpansionState(state)` | `Promise<void>` |
1056
+ | `childEntries(entryKey, descending?)` | `Promise<MindooDBAppViewEntry[]>` |
1057
+ | `childCategories(entryKey, descending?)` | `Promise<MindooDBAppViewEntry[]>` |
1058
+ | `childDocuments(entryKey, descending?)` | `Promise<MindooDBAppViewEntry[]>` |
1059
+ | `childCategoriesByKey(entryKey, key, exact?, descending?)` | `Promise<MindooDBAppViewEntry[]>` |
1060
+ | `childDocumentsByKey(entryKey, key, exact?, descending?)` | `Promise<MindooDBAppViewEntry[]>` |
1061
+ | `childCategoriesBetween(entryKey, range)` | `Promise<MindooDBAppViewEntry[]>` |
1062
+ | `childDocumentsBetween(entryKey, range)` | `Promise<MindooDBAppViewEntry[]>` |
1063
+ | `getSortedDocIds(descending?)` | `Promise<MindooDBAppScopedDocId[]>` |
1064
+ | `getSortedDocIdsScoped(entryKey, descending?)` | `Promise<MindooDBAppScopedDocId[]>` |
1065
+ | `dispose()` | `Promise<void>` |
789
1066
 
790
1067
  ### Exported functions
791
1068
 
792
- | Function | Description |
793
- |---|---|
794
- | `createMindooDBAppBridge()` | Create the bridge object used to connect to Haven |
795
- | `canPreviewAttachment(fileName, mimeType)` | Check if Haven can preview a file (returns mode or `null`) |
796
- | `createViewLanguage<T>()` | Create a typed expression builder for view definitions |
797
- | `abbreviateCanonicalName(value)` | Convert a canonical Notes-style name like `cn=Jane/ou=Dev/o=Mindoo` to `Jane/Dev/Mindoo` |
798
- | `expandAbbreviatedName(value)` | Convert an abbreviated Notes-style name like `Jane/Dev/Mindoo` to `cn=Jane/ou=Dev/o=Mindoo` |
1069
+ | Function | Description |
1070
+ | ------------------------------------------ | ------------------------------------------------------------------------------------------- |
1071
+ | `createMindooDBAppBridge()` | Create the bridge object used to connect to Haven |
1072
+ | `createMindooDBTextBuffer(options)` | Create an editor-friendly buffer for granular collaborative text edits on a document field |
1073
+ | `canPreviewAttachment(fileName, mimeType)` | Check if Haven can preview a file (returns mode or `null`) |
1074
+ | `createViewLanguage<T>()` | Create a typed expression builder for view definitions |
1075
+ | `abbreviateCanonicalName(value)` | Convert a canonical Notes-style name like `cn=Jane/ou=Dev/o=Mindoo` to `Jane/Dev/Mindoo` |
1076
+ | `expandAbbreviatedName(value)` | Convert an abbreviated Notes-style name like `Jane/Dev/Mindoo` to `cn=Jane/ou=Dev/o=Mindoo` |
799
1077
 
800
1078
  ## Permissions and mappings
801
1079