mindoodb-app-sdk 0.0.49 → 0.0.53
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 +185 -1
- package/TESTING.md +58 -0
- package/dist/{chunk-TPYDERAK.js → chunk-6NTVTL6G.js} +151 -3
- package/dist/index.d.ts +138 -3
- package/dist/index.js +414 -18
- package/dist/testing/index.d.ts +107 -2
- package/dist/testing/index.js +620 -135
- package/dist/{types-C-eyC839.d.ts → types-CsKw_niD.d.ts} +250 -5
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -229,6 +229,35 @@ Important constraints:
|
|
|
229
229
|
- Call `session.menus.hide()` if your app needs to explicitly dismiss a pending host menu.
|
|
230
230
|
- Host-rendered menus are intended for `runtime === "iframe"`. Window-mode apps can usually show local menus instead.
|
|
231
231
|
|
|
232
|
+
### Host focus and notifications
|
|
233
|
+
|
|
234
|
+
A click inside the iframe already asks Haven to focus this launch. Call `requestHostFocus()` when the app should take focus without a click.
|
|
235
|
+
|
|
236
|
+
`hasHostFocus()` is true only when this launch is the surface the user is looking at: the focused tile on the active workspace page, or this launch open as a standalone runner. A background tile, another page, or another app is false. `onHostFocusChange()` fires when that answer changes, so a long task can decide whether to notify without polling.
|
|
237
|
+
|
|
238
|
+
```ts
|
|
239
|
+
const stop = session.onHostFocusChange((focused) => {
|
|
240
|
+
if (!focused) {
|
|
241
|
+
void session.notify({
|
|
242
|
+
id: "sync",
|
|
243
|
+
severity: "info",
|
|
244
|
+
text: "Sync finished",
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
await session.notify({
|
|
250
|
+
id: "progress",
|
|
251
|
+
severity: "info",
|
|
252
|
+
text: "10%",
|
|
253
|
+
durationMs: 1500,
|
|
254
|
+
});
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
Show a notice when the app does not have host focus, or when a long task finishes and the user may have looked away. Do not notify while `hasHostFocus()` is true and the user is already looking at the app.
|
|
258
|
+
|
|
259
|
+
Haven uses the app's registration label as the headline and ignores any title from the app. Pass the same `id` to update that notice in place. Omit `id` to add another notice. An id only matches notices from this launch. Omit `durationMs` and the notice stays until the user dismisses it or clicks it. A duration is clamped to about 1–8 seconds, which fits a short progress step such as "10%". Clicking the notice brings that launch back. The close button only dismisses it.
|
|
260
|
+
|
|
232
261
|
### Host-owned drag
|
|
233
262
|
|
|
234
263
|
When two apps run as visible workspace chicklets, native HTML5 drag dies at the iframe boundary. The SDK therefore hands the gesture to Haven: the source starts it, Haven draws a PNG ghost above every frame, and the target hit-tests the drop.
|
|
@@ -458,7 +487,7 @@ A snapshot also carries `decryptionKeyId?: string` — the named shared key its
|
|
|
458
487
|
|
|
459
488
|
- `set` -- assign top-level fields (shallow JSON merge).
|
|
460
489
|
- `unset` -- remove top-level fields entirely.
|
|
461
|
-
- `json` -- a **granular JSON patch** of `set` / `unset` / `listInsert` / `listDelete` operations applied at specific paths inside `document.data`, against a `baseHeads` version. See [Granular JSON edits](#granular-json-edits) below.
|
|
490
|
+
- `json` -- a **granular JSON patch** of `set` / `unset` / `listInsert` / `listDelete` operations (plus text splices/marks and `counterIncrement`) applied at specific paths inside `document.data`, against a `baseHeads` version. See [Granular JSON edits](#granular-json-edits) and [Typed values](#typed-values-atomic-strings-counters-timestamps) below.
|
|
462
491
|
- `text` -- one or more **granular text patches** applied to specific string 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.
|
|
463
492
|
- `richText` -- one or more **rich-text span snapshots** for formatted-document fields (Word/.docx style). See [Collaborative rich-text editing](#collaborative-rich-text-editing) below.
|
|
464
493
|
|
|
@@ -614,6 +643,109 @@ Haven applies the patch causally at `baseHeads` and merges it with any concurren
|
|
|
614
643
|
|
|
615
644
|
Patch flavors compose. `set`/`unset` (top-level fields), `json`, `text`, and `richText` can all appear in the same `documents.update()` call and are applied atomically to one new document revision. Prefer small, intentional operations over whole-document rewrites so MindooDB change tracking stays meaningful and Automerge produces cleaner merges.
|
|
616
645
|
|
|
646
|
+
### Typed values: atomic strings, counters, timestamps
|
|
647
|
+
|
|
648
|
+
`document.data` is JSON, and JSON has one kind of string and one kind of number. For collaborative data that is not enough, so the SDK adds three **typed values**. You create them with `MindooDBAppValue` and use them anywhere you write a value: `documents.create({ set })`, `documents.update({ set })`, and `set` / `listInsert` values of a JSON patch, also nested inside objects and arrays.
|
|
649
|
+
|
|
650
|
+
```ts
|
|
651
|
+
import { MindooDBAppValue } from "mindoodb-app-sdk";
|
|
652
|
+
|
|
653
|
+
const task = await db.documents.create({
|
|
654
|
+
set: {
|
|
655
|
+
title: "Write release notes", // collaborative text
|
|
656
|
+
status: MindooDBAppValue.atomic("open"), // atomic string
|
|
657
|
+
votes: MindooDBAppValue.counter(0), // counter
|
|
658
|
+
dueAt: MindooDBAppValue.timestamp("2026-10-01T12:00:00Z"),
|
|
659
|
+
assignee: { userId: MindooDBAppValue.atomic("u-42"), name: "Ada" },
|
|
660
|
+
},
|
|
661
|
+
});
|
|
662
|
+
|
|
663
|
+
task.data.status; // "open"
|
|
664
|
+
task.data.votes; // 0
|
|
665
|
+
task.data.dueAt; // "2026-10-01T12:00:00.000Z"
|
|
666
|
+
```
|
|
667
|
+
|
|
668
|
+
| Helper | Use it for | Reads back as |
|
|
669
|
+
|---|---|---|
|
|
670
|
+
| `MindooDBAppValue.atomic(string)` | ids, status and enum values, URLs, hashes, stored text cursors | `string` |
|
|
671
|
+
| `MindooDBAppValue.counter(initial = 0)` | tallies several people change: votes, likes, stock, seats | `number` |
|
|
672
|
+
| `MindooDBAppValue.timestamp(Date \| ms \| ISO string)` | dates and times | ISO 8601 string (UTC, with ms) |
|
|
673
|
+
|
|
674
|
+
Why they matter:
|
|
675
|
+
|
|
676
|
+
- **Plain strings are collaborative text.** Concurrent edits are merged character by character, which is right for titles, notes and bodies. For a status that one user changes from `"open"` to `"closed"` while another changes it to `"blocked"`, text merging can produce a mix of both words. An **atomic** string is replaced as a whole: one of the two values wins. It is also cheaper to store.
|
|
677
|
+
- **Plain numbers are last-writer-wins.** If two users both read `votes: 4` and each save `5`, one vote is lost. A **counter** sums concurrent increments, so the result is `6`.
|
|
678
|
+
- **An ISO string written as a plain string is text**, not a date. A **timestamp** stays a date for everyone who reads the document.
|
|
679
|
+
|
|
680
|
+
The helpers produce plain JSON (`{ "$mindoo": "atomic", "value": "open" }`), so the values pass the bridge unchanged, and Haven converts them into the matching collaborative type. The key `$mindoo` is reserved: an object carrying it must be one of these values, otherwise the write is rejected.
|
|
681
|
+
|
|
682
|
+
Reads return plain JSON, so **a value keeps its type only if you write it again with the helper.** Writing `status: "done"` stores text; write `status: MindooDBAppValue.atomic("done")`. Haven's database browser keeps the type when a user edits such a field in the JSON view.
|
|
683
|
+
|
|
684
|
+
#### Counters
|
|
685
|
+
|
|
686
|
+
Create a counter once, then change it only with `counterIncrement`:
|
|
687
|
+
|
|
688
|
+
```ts
|
|
689
|
+
interface MindooDBAppJsonCounterIncrementPatch {
|
|
690
|
+
path: Array<string | number>;
|
|
691
|
+
delta: number; // safe integer, negative to decrement
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
await db.documents.update(docId, {
|
|
695
|
+
json: { counterIncrement: [{ path: ["votes"], delta: 1 }] },
|
|
696
|
+
});
|
|
697
|
+
```
|
|
698
|
+
|
|
699
|
+
- Writing `MindooDBAppValue.counter(n)` or a plain number over an existing counter resets it. That is an assignment and does not merge with concurrent increments, so use it for creation and deliberate resets only.
|
|
700
|
+
- `counterIncrement` on a missing field creates the counter with value `delta`. If two devices do that concurrently for the same field, only one creation survives, so create shared counters up front, ideally in `documents.create`.
|
|
701
|
+
- `counterIncrement` on a plain number is rejected: the field is not a counter.
|
|
702
|
+
- `counterIncrement` runs after the other operations of the same JSON patch, so a patch may create an object with `set` and increment a counter inside it.
|
|
703
|
+
- In Haven's database browser a counter can be edited in the JSON view: the new value is saved as the difference to the loaded value, so increments made by others in the meantime are kept.
|
|
704
|
+
|
|
705
|
+
### Text cursors (anchors for comments and highlights)
|
|
706
|
+
|
|
707
|
+
An index like "character 120" breaks as soon as another user inserts text before it. A **text cursor** identifies a character instead of a position, so it keeps pointing at the same spot while the text around it changes. Use cursors for comment and review anchors, highlights, bookmarks, "continue reading here" markers or links into a long text.
|
|
708
|
+
|
|
709
|
+
```ts
|
|
710
|
+
// Anchor a comment to characters 120..134 of the body (end exclusive: 135).
|
|
711
|
+
// The end cursor names the LAST character of the range, see below.
|
|
712
|
+
const doc = await db.documents.get(docId);
|
|
713
|
+
const { cursors } = await db.documents.getTextCursors(docId, ["body"], [120, 134], {
|
|
714
|
+
heads: doc!.heads, // the version the user selected in
|
|
715
|
+
});
|
|
716
|
+
await db.documents.update(docId, {
|
|
717
|
+
json: {
|
|
718
|
+
listInsert: [{
|
|
719
|
+
path: ["comments"],
|
|
720
|
+
index: 0,
|
|
721
|
+
values: [{
|
|
722
|
+
text: "Source?",
|
|
723
|
+
from: MindooDBAppValue.atomic(cursors[0]!),
|
|
724
|
+
to: MindooDBAppValue.atomic(cursors[1]!),
|
|
725
|
+
}],
|
|
726
|
+
}],
|
|
727
|
+
},
|
|
728
|
+
});
|
|
729
|
+
|
|
730
|
+
// Later, after other users edited the body
|
|
731
|
+
const current = (await db.documents.get(docId))!;
|
|
732
|
+
const comment = current.data.comments[0];
|
|
733
|
+
const { positions } = await db.documents.resolveTextCursors(docId, ["body"], [comment.from, comment.to], {
|
|
734
|
+
heads: current.heads, // resolve in the version you are about to render
|
|
735
|
+
});
|
|
736
|
+
const [start, last] = positions;
|
|
737
|
+
const quoted = current.data.body.slice(start, last + 1);
|
|
738
|
+
```
|
|
739
|
+
|
|
740
|
+
Details:
|
|
741
|
+
|
|
742
|
+
- A cursor names the character at its index. Text inserted exactly at that index lands in front of the character, so the cursor moves right. For a range, anchor the start on its first character and the end on its **last** character, then add 1 after resolving. An end cursor on the character after the range would grow the range whenever someone types at its end.
|
|
743
|
+
- `positions` accepts character indexes (clamped to the text length) and `"start"` / `"end"`. An `"end"` cursor stays at the end when text is appended.
|
|
744
|
+
- A cursor whose character was deleted resolves to where the character used to be. `move: "before"` makes it resolve towards the start of the text instead of the end.
|
|
745
|
+
- `heads` (or `revisionId`) creates or resolves cursors against that document version. Pass the heads your editor rendered from so positions the user selected are interpreted in the text they saw.
|
|
746
|
+
- Cursors only work on collaborative text: plain strings and rich text. Atomic strings, numbers and missing fields are rejected. Store the cursor strings themselves as atomic strings.
|
|
747
|
+
- Both calls need the `read` capability. The mock bridge from `mindoodb-app-sdk/testing` returns cursors that do not follow later edits; test anchoring behavior against Haven.
|
|
748
|
+
|
|
617
749
|
### Collaborative text editing
|
|
618
750
|
|
|
619
751
|
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.
|
|
@@ -1060,6 +1192,18 @@ const setup = await db.getFulltextSetup(); // null when never configured
|
|
|
1060
1192
|
- `attachments: true` indexes attachment text under the synthetic `_attachments` field using the host's extractors (Haven ships anydoc for Office/PDF/… plus plain-text extractors). Restrict a query to attachment content with `text: { query, fields: ["_attachments"] }`.
|
|
1061
1193
|
- Changing the configuration (including `language`) rebuilds the index in the background; queries during the rebuild report `coverage: "rebuilding"`.
|
|
1062
1194
|
|
|
1195
|
+
**Making nested fields queryable** — `query()` only sees fields in the database's summary buffer. By default that is every top-level scalar or array of scalars up to 1 KB; nested objects and arrays of objects are skipped. Include what your list UIs need so they don't have to `documents.get` each document:
|
|
1196
|
+
|
|
1197
|
+
```ts
|
|
1198
|
+
// Idempotent, synced via the `dbsetup` document. Requires the `update` capability.
|
|
1199
|
+
const current = await db.getSummarySetup(); // null = default auto-include
|
|
1200
|
+
const include = [...new Set([...(current?.include ?? []), "phases"])];
|
|
1201
|
+
await db.setSummarySetup({ ...current, include });
|
|
1202
|
+
```
|
|
1203
|
+
|
|
1204
|
+
- `include` paths may be nested (`"meta.author"`) or non-scalar and bypass the size cap; `exclude` wins over both.
|
|
1205
|
+
- A change re-extracts all documents in the background; queries report `coverage: "rebuilding"` until the backfill finishes, so keep a fallback for missing fields.
|
|
1206
|
+
|
|
1063
1207
|
**Extracting text from bytes you already have** (requires `attachments`):
|
|
1064
1208
|
|
|
1065
1209
|
```ts
|
|
@@ -1535,6 +1679,40 @@ wrangler deploy
|
|
|
1535
1679
|
|
|
1536
1680
|
Other static hosting options work just as well: Netlify, Vercel, any web server serving your `dist/` folder.
|
|
1537
1681
|
|
|
1682
|
+
### Sharing an app by its URL
|
|
1683
|
+
|
|
1684
|
+
Anyone who opens your app's address directly — not from inside Haven — has no host to talk to. Instead of letting the bridge handshake time out into an error, branch on `isLaunchedByHaven()` before mounting and show a landing page:
|
|
1685
|
+
|
|
1686
|
+
```ts
|
|
1687
|
+
import { isLaunchedByHaven, renderHavenAppLandingPage } from "mindoodb-app-sdk";
|
|
1688
|
+
|
|
1689
|
+
if (isLaunchedByHaven()) {
|
|
1690
|
+
createApp(App).mount("#app");
|
|
1691
|
+
} else {
|
|
1692
|
+
void renderHavenAppLandingPage();
|
|
1693
|
+
}
|
|
1694
|
+
```
|
|
1695
|
+
|
|
1696
|
+
Haven puts `mindoodbAppLaunchId` into every launch URL, so the check is synchronous. `renderHavenAppLandingPage()` reads `haven-app.json` from the same origin and shows the app's name, summary, description and screenshots, plus a button to `https://haven.mindoodb.com/?app=<your app URL>` (pass `havenUrl` for another Haven instance). Haven then walks a new user through setup with your app installed at the end, or offers an existing user to add it.
|
|
1697
|
+
|
|
1698
|
+
What the page shows comes from the optional `listing` in `haven-app.json`. Haven's setup wizard shows the same fields:
|
|
1699
|
+
|
|
1700
|
+
```json
|
|
1701
|
+
{
|
|
1702
|
+
"listing": {
|
|
1703
|
+
"summary": { "en": "Plan trips together.", "de": "Reisen gemeinsam planen." },
|
|
1704
|
+
"description": { "en": "First paragraph.\n\nSecond paragraph." },
|
|
1705
|
+
"icon": "icon.svg",
|
|
1706
|
+
"screenshots": [{ "file": "screenshots/board.webp", "caption": { "en": "The trip board" } }],
|
|
1707
|
+
"publisher": { "name": "Mindoo GmbH", "url": "https://mindoo.de" }
|
|
1708
|
+
}
|
|
1709
|
+
}
|
|
1710
|
+
```
|
|
1711
|
+
|
|
1712
|
+
Texts are a string or a per-locale map with an `en` fallback. Image paths are relative to the app origin or absolute `https:` URLs. The listing is display-only and grants nothing.
|
|
1713
|
+
|
|
1714
|
+
For automated and manual tests use a separate test URL that frames the app with a mock host instead — see "Browser test host" in [`TESTING.md`](./TESTING.md). End users never see that page, and they never see mock data.
|
|
1715
|
+
|
|
1538
1716
|
### Haven-hosted bundles
|
|
1539
1717
|
|
|
1540
1718
|
Instead of Haven loading your app from your server on every launch, you can hand Haven the build itself. Haven stores it in its own cache and serves it from a service worker on an opaque origin, so the app loads offline and runs in a stricter sandbox than an external URL gets.
|
|
@@ -1636,6 +1814,10 @@ Connect options: `launchId?`, `targetOrigin?`, `connectTimeoutMs?`.
|
|
|
1636
1814
|
| `closeSealedChannel(channelId)` | `Promise<void>` |
|
|
1637
1815
|
| `menus.show(input)` | `Promise<MindooDBAppShowMenuResult>` |
|
|
1638
1816
|
| `menus.hide()` | `Promise<void>` |
|
|
1817
|
+
| `requestHostFocus()` | `Promise<void>` |
|
|
1818
|
+
| `hasHostFocus()` | `Promise<boolean>` |
|
|
1819
|
+
| `onHostFocusChange(listener)` | `() => void` (unsubscribe) |
|
|
1820
|
+
| `notify(input)` | `Promise<{ id: string }>` |
|
|
1639
1821
|
| `drag.setProfile(profile)` | `Promise<void>` |
|
|
1640
1822
|
| `drag.start(input)` | `Promise<MindooDBAppDragStartResult>` |
|
|
1641
1823
|
| `drag.cancel()` | `Promise<void>` |
|
|
@@ -1670,6 +1852,8 @@ Host-kept key/value state, the only durable storage a hosted app has. See [Stora
|
|
|
1670
1852
|
| `attachments` | `MindooDBAppAttachmentApi` |
|
|
1671
1853
|
| `getFulltextSetup()` | `Promise<MindooDBAppFulltextSetup \| null>` |
|
|
1672
1854
|
| `setFulltextSetup(config)` | `Promise<void>` |
|
|
1855
|
+
| `getSummarySetup()` | `Promise<MindooDBAppSummarySetup \| null>` |
|
|
1856
|
+
| `setSummarySetup(config)` | `Promise<void>` |
|
|
1673
1857
|
|
|
1674
1858
|
### MindooDBAppDocumentApi
|
|
1675
1859
|
|
package/TESTING.md
CHANGED
|
@@ -107,6 +107,7 @@ Each database entry can provide:
|
|
|
107
107
|
- `methods.views` for session-level `createView()` and `openView()` calls (overrides the default evaluating navigator)
|
|
108
108
|
- `methods.attachments`
|
|
109
109
|
- `fulltextSetup` — the initial config returned by `db.getFulltextSetup()`; `db.setFulltextSetup()` overwrites it for the lifetime of the handle. Use this to test your app's full-text bootstrap logic. Note the mock evaluates `text` query clauses regardless of this config.
|
|
110
|
+
- `summarySetup` — the initial config returned by `db.getSummarySetup()`; `db.setSummarySetup()` overwrites it for the lifetime of the handle. The mock's `query()` reads seeded documents directly and ignores this config; use it to test your app's summary bootstrap logic.
|
|
110
111
|
|
|
111
112
|
### Evaluating VirtualViews
|
|
112
113
|
|
|
@@ -218,6 +219,30 @@ expect(result.rows[0].includes?.customer?.fields.name).toBe("Acme");
|
|
|
218
219
|
An include slot naming a database that was not seeded fails, the same way Haven
|
|
219
220
|
rejects a database the app is not mapped to.
|
|
220
221
|
|
|
222
|
+
### Host focus and notifications
|
|
223
|
+
|
|
224
|
+
A mock launch starts without host focus. `session.requestHostFocus()` sets it
|
|
225
|
+
and fires `onHostFocusChange` listeners; `mock.emitHostFocusChange(focused)`
|
|
226
|
+
simulates Haven moving focus to or away from the launch, so you can test code
|
|
227
|
+
that only notifies while the user is looking elsewhere. `session.notify()` shows
|
|
228
|
+
nothing and resolves with the passed `id`, or a fresh one when `id` is omitted:
|
|
229
|
+
|
|
230
|
+
```ts
|
|
231
|
+
const mock = createMockMindooDBAppSession();
|
|
232
|
+
|
|
233
|
+
const seen: boolean[] = [];
|
|
234
|
+
const stop = mock.session.onHostFocusChange((focused) => seen.push(focused));
|
|
235
|
+
|
|
236
|
+
await mock.session.requestHostFocus();
|
|
237
|
+
mock.emitHostFocusChange(false);
|
|
238
|
+
expect(await mock.session.hasHostFocus()).toBe(false);
|
|
239
|
+
expect(seen).toEqual([true, false]);
|
|
240
|
+
stop();
|
|
241
|
+
|
|
242
|
+
const { id } = await mock.session.notify({ id: "sync", severity: "info", text: "Sync finished" });
|
|
243
|
+
expect(id).toBe("sync");
|
|
244
|
+
```
|
|
245
|
+
|
|
221
246
|
## Level 2 example
|
|
222
247
|
|
|
223
248
|
This pattern keeps the real `createMindooDBAppBridge()` code path and replaces only the host side.
|
|
@@ -287,12 +312,45 @@ Useful Level 2 methods:
|
|
|
287
312
|
- `emitViewportChange()`
|
|
288
313
|
- `emitQueryResult()` — push a `query-result` message to live query subscribers
|
|
289
314
|
- `emitViewChanged()` — push a `view-changed` message to navigator `onDidUpdate` listeners
|
|
315
|
+
- `emitHostFocusChange()` — set host focus and push a `host-focus-changed` message to `session.onHostFocusChange()` listeners
|
|
290
316
|
- `setRequestHandler()`
|
|
291
317
|
- `clearRequestHandler()`
|
|
292
318
|
- `postPortMessage()`
|
|
293
319
|
|
|
294
320
|
The built-in request handling also covers `documents.query` and the `documents.liveQuery.*` RPCs against the seeded documents, so `db.documents.query()` / `db.documents.liveQuery()` and `navigator.onDidUpdate()` work end-to-end over the real bridge transport in Level 2 tests.
|
|
295
321
|
|
|
322
|
+
## Browser test host
|
|
323
|
+
|
|
324
|
+
Level 1 and 2 run in Node/jsdom. To run the real app in a real browser — for Playwright, or to click through it yourself — mount the browser test host on a separate page. It frames the app exactly as Haven does (an iframe with `?mindoodbAppLaunchId=…`) and answers the bridge with the same mock state as the Vitest helpers:
|
|
325
|
+
|
|
326
|
+
```ts
|
|
327
|
+
// src/testHost/main.ts, loaded by /__haven-test/index.html
|
|
328
|
+
import { mockDatabasesFromDefinition, mountHavenTestHost } from "mindoodb-app-sdk/testing";
|
|
329
|
+
import definition from "../../public/haven-app.json";
|
|
330
|
+
|
|
331
|
+
mountHavenTestHost({
|
|
332
|
+
appUrl: "/",
|
|
333
|
+
title: definition.label,
|
|
334
|
+
databases: mockDatabasesFromDefinition(definition, {
|
|
335
|
+
main: [{ id: "welcome", data: { type: "note", title: "Seeded note" } }],
|
|
336
|
+
}),
|
|
337
|
+
});
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
The page shows the app next to a control panel: theme and host-focus toggles, a file picker that decides what the app's next `attachments.scan()` returns (none means the user cancelled), and a live log of notifications, attachment previews, scans and every RPC request. The same controls are scriptable as `window.__havenTestHost`:
|
|
341
|
+
|
|
342
|
+
```ts
|
|
343
|
+
await page.goto("/__haven-test/");
|
|
344
|
+
const app = page.frameLocator('[data-testid="haven-test-app-frame"]');
|
|
345
|
+
await page.evaluate(() => window.__havenTestHost!.setNextScan({ fileName: "receipt.pdf", mimeType: "application/pdf", size: 1024 }));
|
|
346
|
+
await app.getByRole("button", { name: "Scan" }).click();
|
|
347
|
+
expect(await page.evaluate(() => window.__havenTestHost!.log.scans)).toHaveLength(1);
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
Lower-level pieces, if you build your own page: `createBrowserTestHost({ frame, appUrl, ...mockOptions })` wires one iframe to a fake host, and `host.acceptConnection(message, ports)` answers a handshake you received yourself.
|
|
351
|
+
|
|
352
|
+
Keep the test page out of production builds. The starter template builds it only for `vite dev` and when `HAVEN_TEST_HOST=1` is set (for preview deployments), so the app's public URL keeps showing its landing page.
|
|
353
|
+
|
|
296
354
|
## When to use which level
|
|
297
355
|
|
|
298
356
|
Choose Level 1 when:
|
|
@@ -1,3 +1,45 @@
|
|
|
1
|
+
// src/values.ts
|
|
2
|
+
var MINDOODB_APP_VALUE_TAG = "$mindoo";
|
|
3
|
+
var MindooDBAppValue = {
|
|
4
|
+
/**
|
|
5
|
+
* An atomic string for identifiers, status and enum values, URLs, hashes
|
|
6
|
+
* and stored text cursors. Plain strings are collaborative text, where two
|
|
7
|
+
* concurrent changes of `"open"` to `"closed"` and `"blocked"` can
|
|
8
|
+
* interleave into a mix of both words.
|
|
9
|
+
*/
|
|
10
|
+
atomic(value) {
|
|
11
|
+
if (typeof value !== "string") {
|
|
12
|
+
throw new Error("MindooDBAppValue.atomic value must be a string");
|
|
13
|
+
}
|
|
14
|
+
return { $mindoo: "atomic", value };
|
|
15
|
+
},
|
|
16
|
+
/**
|
|
17
|
+
* A collaborative counter with an initial value (a safe integer). Create it
|
|
18
|
+
* once, then change it only with `json.counterIncrement`: writing a new
|
|
19
|
+
* counter is an assignment that does not merge with concurrent increments.
|
|
20
|
+
*/
|
|
21
|
+
counter(value = 0) {
|
|
22
|
+
if (!Number.isSafeInteger(value)) {
|
|
23
|
+
throw new Error("MindooDBAppValue.counter value must be a safe integer");
|
|
24
|
+
}
|
|
25
|
+
return { $mindoo: "counter", value };
|
|
26
|
+
},
|
|
27
|
+
/** A timestamp from a `Date`, epoch milliseconds or an ISO 8601 string. */
|
|
28
|
+
timestamp(value) {
|
|
29
|
+
const date = value instanceof Date ? new Date(value.getTime()) : new Date(value);
|
|
30
|
+
if (typeof value === "string" && value.trim() === "" || Number.isNaN(date.getTime())) {
|
|
31
|
+
throw new Error(
|
|
32
|
+
"MindooDBAppValue.timestamp value must be a Date, epoch milliseconds or an ISO 8601 date-time string"
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
return { $mindoo: "timestamp", value: date.toISOString() };
|
|
36
|
+
},
|
|
37
|
+
/** Whether `value` is a tagged typed value (any kind). */
|
|
38
|
+
isTyped(value) {
|
|
39
|
+
return value !== null && typeof value === "object" && !Array.isArray(value) && Object.prototype.hasOwnProperty.call(value, MINDOODB_APP_VALUE_TAG);
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
|
|
1
43
|
// src/drag.ts
|
|
2
44
|
var MINDOODB_APP_MAX_DRAG_TYPES = 8;
|
|
3
45
|
var MINDOODB_APP_MAX_DRAG_PAYLOAD_BYTES = 32 * 1024;
|
|
@@ -392,11 +434,13 @@ function bindDragSource(element, options, start, follow) {
|
|
|
392
434
|
// src/hostShortcuts.ts
|
|
393
435
|
var DEFAULT_HAVEN_HOST_SHORTCUTS = [
|
|
394
436
|
{ action: "open-workspace", code: "Enter", shiftKey: true, metaOrCtrl: true },
|
|
395
|
-
{ action: "toggle-app-drawer", code: "Space", shiftKey: true, metaOrCtrl: true }
|
|
437
|
+
{ action: "toggle-app-drawer", code: "Space", shiftKey: true, metaOrCtrl: true },
|
|
438
|
+
{ action: "toggle-workspace-maximize", code: "KeyM", shiftKey: true, metaOrCtrl: true }
|
|
396
439
|
];
|
|
397
440
|
var HOST_SHORTCUT_ACTIONS = /* @__PURE__ */ new Set([
|
|
398
441
|
"open-workspace",
|
|
399
|
-
"toggle-app-drawer"
|
|
442
|
+
"toggle-app-drawer",
|
|
443
|
+
"toggle-workspace-maximize"
|
|
400
444
|
]);
|
|
401
445
|
function normalizeShortcutCode(code) {
|
|
402
446
|
return code === "NumpadEnter" ? "Enter" : code;
|
|
@@ -450,7 +494,7 @@ function applyHostedDocumentOverscrollContain(doc = globalThis.document) {
|
|
|
450
494
|
if (!doc?.documentElement) {
|
|
451
495
|
return;
|
|
452
496
|
}
|
|
453
|
-
doc.documentElement.style.overscrollBehavior = "
|
|
497
|
+
doc.documentElement.style.overscrollBehavior = "none";
|
|
454
498
|
if (doc.body) {
|
|
455
499
|
doc.body.style.overscrollBehavior = "contain";
|
|
456
500
|
}
|
|
@@ -627,6 +671,21 @@ var PortRpcClient = class {
|
|
|
627
671
|
}
|
|
628
672
|
};
|
|
629
673
|
|
|
674
|
+
// src/hostFocus.ts
|
|
675
|
+
function installHostFocusCapture(options) {
|
|
676
|
+
const target = options.target ?? (typeof document === "undefined" ? void 0 : document);
|
|
677
|
+
if (!target) {
|
|
678
|
+
return () => void 0;
|
|
679
|
+
}
|
|
680
|
+
const onPointerDown = () => {
|
|
681
|
+
options.onPointerDown();
|
|
682
|
+
};
|
|
683
|
+
target.addEventListener("pointerdown", onPointerDown, true);
|
|
684
|
+
return () => {
|
|
685
|
+
target.removeEventListener("pointerdown", onPointerDown, true);
|
|
686
|
+
};
|
|
687
|
+
}
|
|
688
|
+
|
|
630
689
|
// src/client/createMindooDBAppBridge.ts
|
|
631
690
|
var PROTOCOL2 = "mindoodb-app-bridge";
|
|
632
691
|
var DEFAULT_CONNECT_TIMEOUT_MS = 1e4;
|
|
@@ -1226,6 +1285,23 @@ var MindooDBAppDatabaseImpl = class {
|
|
|
1226
1285
|
path,
|
|
1227
1286
|
revisionId: options?.revisionId
|
|
1228
1287
|
}),
|
|
1288
|
+
getTextCursors: async (docId, path, positions, options) => await this.rpc.call("documents.textCursors.get", {
|
|
1289
|
+
databaseId: this.databaseId,
|
|
1290
|
+
docId,
|
|
1291
|
+
path,
|
|
1292
|
+
positions,
|
|
1293
|
+
heads: options?.heads,
|
|
1294
|
+
move: options?.move,
|
|
1295
|
+
revisionId: options?.revisionId
|
|
1296
|
+
}),
|
|
1297
|
+
resolveTextCursors: async (docId, path, cursors, options) => await this.rpc.call("documents.textCursors.resolve", {
|
|
1298
|
+
databaseId: this.databaseId,
|
|
1299
|
+
docId,
|
|
1300
|
+
path,
|
|
1301
|
+
cursors,
|
|
1302
|
+
heads: options?.heads,
|
|
1303
|
+
revisionId: options?.revisionId
|
|
1304
|
+
}),
|
|
1229
1305
|
getAutomergeSnapshot: async (docId, options) => await this.rpc.call("documents.automerge.getSnapshot", {
|
|
1230
1306
|
databaseId: this.databaseId,
|
|
1231
1307
|
docId,
|
|
@@ -1236,6 +1312,26 @@ var MindooDBAppDatabaseImpl = class {
|
|
|
1236
1312
|
docId,
|
|
1237
1313
|
patch
|
|
1238
1314
|
}),
|
|
1315
|
+
applyAutomergeChangesBatch: async (items) => {
|
|
1316
|
+
if (items.length === 0) return [];
|
|
1317
|
+
try {
|
|
1318
|
+
return await this.rpc.call("documents.automerge.applyChangesBatch", {
|
|
1319
|
+
databaseId: this.databaseId,
|
|
1320
|
+
items
|
|
1321
|
+
});
|
|
1322
|
+
} catch (error) {
|
|
1323
|
+
if (!isMethodNotFoundError(error)) throw error;
|
|
1324
|
+
const results = [];
|
|
1325
|
+
for (const item of items) {
|
|
1326
|
+
results.push(await this.rpc.call("documents.automerge.applyChanges", {
|
|
1327
|
+
databaseId: this.databaseId,
|
|
1328
|
+
docId: item.docId,
|
|
1329
|
+
patch: item.patch
|
|
1330
|
+
}));
|
|
1331
|
+
}
|
|
1332
|
+
return results;
|
|
1333
|
+
}
|
|
1334
|
+
},
|
|
1239
1335
|
create: async (input) => await this.rpc.call("documents.create", {
|
|
1240
1336
|
databaseId: this.databaseId,
|
|
1241
1337
|
input
|
|
@@ -1576,6 +1672,21 @@ var MindooDBAppDatabaseImpl = class {
|
|
|
1576
1672
|
config
|
|
1577
1673
|
});
|
|
1578
1674
|
}
|
|
1675
|
+
/** Reads the summary buffer configuration from the synced `dbsetup` document. */
|
|
1676
|
+
async getSummarySetup() {
|
|
1677
|
+
const result = await this.rpc.call(
|
|
1678
|
+
"database.getSummarySetup",
|
|
1679
|
+
{ databaseId: this.databaseId }
|
|
1680
|
+
);
|
|
1681
|
+
return result.config;
|
|
1682
|
+
}
|
|
1683
|
+
/** Writes (or removes, with `null`) the summary buffer configuration. */
|
|
1684
|
+
async setSummarySetup(config) {
|
|
1685
|
+
await this.rpc.call("database.setSummarySetup", {
|
|
1686
|
+
databaseId: this.databaseId,
|
|
1687
|
+
config
|
|
1688
|
+
});
|
|
1689
|
+
}
|
|
1579
1690
|
};
|
|
1580
1691
|
var MindooDBAppDragApiImpl = class {
|
|
1581
1692
|
constructor(rpc) {
|
|
@@ -1718,6 +1829,18 @@ var MindooDBAppSessionImpl = class {
|
|
|
1718
1829
|
}
|
|
1719
1830
|
}
|
|
1720
1831
|
}) : () => void 0;
|
|
1832
|
+
this.stopHostFocus = installHostFocusCapture({
|
|
1833
|
+
onPointerDown: () => {
|
|
1834
|
+
try {
|
|
1835
|
+
this.rpc.postMessage({
|
|
1836
|
+
protocol: PROTOCOL2,
|
|
1837
|
+
kind: "workspace-focus-requested"
|
|
1838
|
+
});
|
|
1839
|
+
} catch (error) {
|
|
1840
|
+
console.warn("[mindoodb-app-sdk] Could not request host focus.", error);
|
|
1841
|
+
}
|
|
1842
|
+
}
|
|
1843
|
+
});
|
|
1721
1844
|
}
|
|
1722
1845
|
rpc;
|
|
1723
1846
|
onDisconnect;
|
|
@@ -1727,6 +1850,7 @@ var MindooDBAppSessionImpl = class {
|
|
|
1727
1850
|
beforeCloseListeners = /* @__PURE__ */ new Set();
|
|
1728
1851
|
hostShortcuts = DEFAULT_HAVEN_HOST_SHORTCUTS;
|
|
1729
1852
|
stopHostShortcuts;
|
|
1853
|
+
stopHostFocus;
|
|
1730
1854
|
/**
|
|
1731
1855
|
* Lets every listener finish, then tells the host it may proceed.
|
|
1732
1856
|
*
|
|
@@ -1862,6 +1986,27 @@ var MindooDBAppSessionImpl = class {
|
|
|
1862
1986
|
}
|
|
1863
1987
|
});
|
|
1864
1988
|
}
|
|
1989
|
+
/** Ask Haven to treat this launch as the focused surface. */
|
|
1990
|
+
async requestHostFocus() {
|
|
1991
|
+
await this.rpc.call("session.requestHostFocus", {});
|
|
1992
|
+
}
|
|
1993
|
+
/** Whether this launch is the surface the user is looking at. */
|
|
1994
|
+
async hasHostFocus() {
|
|
1995
|
+
const result = await this.rpc.call("session.hasHostFocus", {});
|
|
1996
|
+
return result?.focused === true;
|
|
1997
|
+
}
|
|
1998
|
+
/** Fires when the host-focus answer for this launch changes. */
|
|
1999
|
+
onHostFocusChange(listener) {
|
|
2000
|
+
return this.rpc.addMessageListener((message) => {
|
|
2001
|
+
if (message.kind === "host-focus-changed") {
|
|
2002
|
+
listener(message.focused);
|
|
2003
|
+
}
|
|
2004
|
+
});
|
|
2005
|
+
}
|
|
2006
|
+
/** Ask Haven to show or update a notice for this launch. */
|
|
2007
|
+
async notify(input) {
|
|
2008
|
+
return await this.rpc.call("notifications.show", input);
|
|
2009
|
+
}
|
|
1865
2010
|
/** Register work to run before the host tears this launch down. */
|
|
1866
2011
|
onBeforeClose(listener) {
|
|
1867
2012
|
this.beforeCloseListeners.add(listener);
|
|
@@ -1872,6 +2017,7 @@ var MindooDBAppSessionImpl = class {
|
|
|
1872
2017
|
/** Disconnects from the host and always disposes the underlying port client. */
|
|
1873
2018
|
async disconnect() {
|
|
1874
2019
|
this.stopHostShortcuts();
|
|
2020
|
+
this.stopHostFocus();
|
|
1875
2021
|
this.drag.dispose();
|
|
1876
2022
|
try {
|
|
1877
2023
|
await this.rpc.call("session.disconnect", {});
|
|
@@ -1925,6 +2071,8 @@ function createMindooDBAppBridge() {
|
|
|
1925
2071
|
}
|
|
1926
2072
|
|
|
1927
2073
|
export {
|
|
2074
|
+
MINDOODB_APP_VALUE_TAG,
|
|
2075
|
+
MindooDBAppValue,
|
|
1928
2076
|
MINDOODB_APP_MAX_DRAG_TYPES,
|
|
1929
2077
|
MINDOODB_APP_MAX_DRAG_PAYLOAD_BYTES,
|
|
1930
2078
|
MINDOODB_APP_MAX_DRAG_PREVIEW_BYTES,
|