mindoodb-app-sdk 0.0.4 → 0.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,29 +1,50 @@
1
1
  # mindoodb-app-sdk
2
2
 
3
- `mindoodb-app-sdk` is the browser-side SDK for building apps that run inside MindooDB Haven and talk to the Haven host bridge.
3
+ Build apps that run inside **MindooDB Haven** -- the browser-based workspace for end-to-end encrypted, offline-first data.
4
4
 
5
- Use it when your app is launched by Haven in an iframe or in a separate window and needs:
5
+ `mindoodb-app-sdk` is the TypeScript SDK that connects your web application to Haven through a secure message bridge. Your app runs in a sandboxed iframe (or a separate browser window) and gains access to databases, documents, attachments, virtual views, and live host events -- all without ever touching encryption keys or raw sync state.
6
6
 
7
- - launch metadata such as user, runtime, theme, and current iframe viewport
8
- - access to the databases Haven mapped into the app session
9
- - document, history, attachment, and view operations through a stable bridge API
10
- - host-driven UI events such as theme and viewport changes
7
+ ## What is a MindooDB App?
11
8
 
12
- ## What the Haven host bridge provides
9
+ [MindooDB](https://mindoodb.com) is an **end-to-end encrypted, offline-first sync database**. Data is encrypted on the client before it ever leaves the device. Under the hood it uses [Automerge](https://automerge.org/) CRDTs so multiple users can edit concurrently and conflicts resolve automatically.
13
10
 
14
- The Haven host bridge is a `postMessage` + `MessageChannel` / `MessagePort` connection between your app and Haven.
11
+ **Haven** is the browser-based workspace that sits on top of MindooDB. Users organize databases, applications, notes, and media on a visual grid -- think of it as a customizable home screen for encrypted data that works offline and syncs in the background.
15
12
 
16
- Once connected, Haven provides:
13
+ A **MindooDB App** is any web application that uses this SDK to communicate with Haven. When Haven launches your app, it opens it inside a sandboxed iframe (or a new browser tab) and establishes a `postMessage` + `MessagePort` bridge. Through this bridge your app can:
17
14
 
18
- - a `MindooDBAppSession` for the current launch
19
- - a `launchContext` snapshot with app id, user, runtime, theme, launch parameters, and current viewport
20
- - database handles that are already permission-scoped and mapped by Haven
21
- - live host events:
22
- - `onThemeChange()` when Haven switches between light and dark mode or theme presets
23
- - `onViewportChange()` when the iframe size changes inside Haven
24
- - CRUD, history, attachments, and app-defined virtual views over the same bridge
15
+ - read launch metadata (user, theme, viewport, mapped databases, configured views)
16
+ - perform CRUD operations on JSON documents with full change history
17
+ - upload, download, and preview file attachments
18
+ - query virtual views with categorization, sorting, and aggregation
19
+ - react to live theme and viewport events from Haven
25
20
 
26
- Your app does not open arbitrary MindooDB databases directly. Haven decides which logical databases are visible and what permissions each one has.
21
+ **Security model:** Every app runs on a **separate origin** inside a sandboxed iframe. It cannot access Haven's storage, cookies, or other apps. Haven decides exactly which databases and permissions each app receives -- your app only sees data that was explicitly shared with it.
22
+
23
+ ## Architecture
24
+
25
+ ```
26
+ ┌─────────────────────────────────────────────────────────┐
27
+ │ Haven (browser tab) │
28
+ │ │
29
+ │ ┌──────────────┐ ┌─────────────┐ ┌─────────────┐ │
30
+ │ │ Haven UI │──▶│ Bridge Host │──▶│ MindooDB │ │
31
+ │ │ (theme, │ │ (RPC + │ │ (encrypted │ │
32
+ │ │ viewport) │ │ streams) │ │ databases) │ │
33
+ │ └──────────────┘ └──────┬──────┘ └─────────────┘ │
34
+ │ │ │
35
+ │ postMessage + MessagePort │
36
+ │ │ │
37
+ │ ┌─────────────────────────┼─────────────────────────┐ │
38
+ │ │ Your App (sandboxed iframe / window) │ │
39
+ │ │ │ │ │
40
+ │ │ ┌──────────▼──────────┐ │ │
41
+ │ │ │ mindoodb-app-sdk │ │ │
42
+ │ │ └─────────────────────┘ │ │
43
+ │ └───────────────────────────────────────────────────┘ │
44
+ └─────────────────────────────────────────────────────────┘
45
+ ```
46
+
47
+ The bridge carries RPC calls (documents, views, database info), binary attachment streams, and push events (theme changes, viewport resizes) over a single `MessagePort`.
27
48
 
28
49
  ## Install
29
50
 
@@ -31,302 +52,430 @@ Your app does not open arbitrary MindooDB databases directly. Haven decides whic
31
52
  npm install mindoodb-app-sdk
32
53
  ```
33
54
 
34
- For local development against an unpublished workspace copy, prefer `npm link`:
55
+ ## Get started in 5 minutes
35
56
 
36
- ```bash
37
- # In the SDK package
38
- npm link
57
+ The fastest way to start building is to clone the **example app**, run it locally, and connect it to Haven:
39
58
 
40
- # In your app project
41
- npm link mindoodb-app-sdk
59
+ ```bash
60
+ git clone https://github.com/niclas-niclas/mindoodb-app-example.git
61
+ cd mindoodb-app-example
62
+ npm install
63
+ npm run dev:local
42
64
  ```
43
65
 
44
- When you want to switch back to the published npm version, run `npm unlink mindoodb-app-sdk` in the app project and then `npm install`.
66
+ This spins up a local Vite dev server on `http://localhost:4200` with hot module reload.
67
+
68
+ Then, in Haven:
69
+
70
+ 1. Go to **Application settings** and register a new app pointing to `http://localhost:4200`.
71
+ 2. Map one or more databases (and optionally views) to the app.
72
+ 3. Launch the app -- it connects via the SDK bridge and displays live data.
73
+
74
+ From here, **fork or duplicate** the example project to start building your own app while exploring the platform in a running Haven client.
75
+
76
+ The example app is also deployed at **https://mindoodb-app-example.mindoo.de** so you can register it in Haven without running anything locally.
77
+
78
+ > See the [mindoodb-app-example README](https://github.com/niclas-niclas/mindoodb-app-example) for the full project walkthrough.
79
+
80
+ ## Quick start (from scratch)
45
81
 
46
- ## Getting started
82
+ If you prefer starting from an empty project:
47
83
 
48
- ### 1. Register an app in Haven
84
+ ```ts
85
+ import { createMindooDBAppBridge } from "mindoodb-app-sdk";
49
86
 
50
- Create an app registration in Haven that points to either:
87
+ // 1. Create the bridge and connect to Haven
88
+ const bridge = createMindooDBAppBridge();
89
+ const session = await bridge.connect();
51
90
 
52
- - an external dev server or deployment URL, such as `http://localhost:4173`
53
- - a hosted app bundle imported into Haven
91
+ // 2. Read the launch context
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));
54
96
 
55
- Pick one runtime:
97
+ // 3. Subscribe to live host events
98
+ session.onThemeChange((theme) => {
99
+ document.documentElement.dataset.theme = theme.mode;
100
+ });
56
101
 
57
- - `iframe`: the app is embedded in Haven
58
- - `window`: the app opens in its own tab or popup
102
+ session.onViewportChange((viewport) => {
103
+ console.log("Iframe resized:", viewport.width, viewport.height);
104
+ });
59
105
 
60
- ### 2. Let Haven launch the app
106
+ // 4. Open a database and list documents
107
+ const db = await session.openDatabase(ctx.databases[0]!.id);
108
+ const result = await db.documents.list({ limit: 25 });
109
+ console.log("Documents:", result.items);
110
+ ```
61
111
 
62
- When Haven launches an app, it injects bridge launch parameters into the app URL:
112
+ When Haven launches your app it injects a `mindoodbAppLaunchId` query parameter into the URL. `connect()` reads it automatically -- no manual wiring needed.
63
113
 
64
- - `mindoodbAppLaunchId`
65
- - `mindoodbAppId`
66
- - `mindoodbAppRuntime`
114
+ ## Core concepts
67
115
 
68
- `createMindooDBAppBridge().connect()` reads `mindoodbAppLaunchId` from the current browser URL automatically.
116
+ ### Launch context
69
117
 
70
- ### 3. Connect to the bridge
118
+ After connecting, call `session.getLaunchContext()` to receive the initial host snapshot:
71
119
 
72
120
  ```ts
73
- import { createMindooDBAppBridge } from "mindoodb-app-sdk";
121
+ interface MindooDBAppLaunchContext {
122
+ appId: string;
123
+ appInstanceId: string;
124
+ appVersion?: string;
125
+ launchId: string;
126
+ runtime: "iframe" | "window";
127
+ theme: { mode: "light" | "dark"; preset: string };
128
+ viewport: { width: number; height: number } | null;
129
+ tenantId?: string;
130
+ preferredDatabaseId?: string;
131
+ user: { id: string; username: string };
132
+ launchParameters: Record<string, string>;
133
+ databases: MindooDBAppDatabaseInfo[];
134
+ views: MindooDBAppResolvedViewDefinition[];
135
+ }
136
+ ```
74
137
 
75
- const bridge = createMindooDBAppBridge();
76
- const session = await bridge.connect();
77
- const launchContext = await session.getLaunchContext();
138
+ `viewport` is the iframe size at launch time. It is `null` when the app runs in `window` mode (separate browser tab). Later changes arrive through `onViewportChange()`.
139
+
140
+ ### Databases and capabilities
141
+
142
+ 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.
143
+
144
+ | Capability | Allows |
145
+ |---|---|
146
+ | `read` | List and read documents |
147
+ | `create` | Create new documents |
148
+ | `update` | Update existing documents |
149
+ | `delete` | Delete documents |
150
+ | `history` | Access document revision history and historical snapshots |
151
+ | `attachments` | List, upload, download, remove, and preview file attachments |
152
+ | `views` | Create app-defined virtual views for this database |
153
+
154
+ ```ts
155
+ const db = ctx.databases[0]!;
156
+
157
+ if (db.capabilities.includes("history")) {
158
+ // show history UI
159
+ }
160
+ if (!db.capabilities.includes("delete")) {
161
+ // hide delete button
162
+ }
78
163
  ```
79
164
 
80
- If you need to override the launch id or target origin manually:
165
+ When the database is readable, `documents.list()` can also expose deleted document IDs by setting `status: "all"` or `status: "deleted"`. This is useful for app-side indexes and sync checkpoints.
166
+
167
+ ### Theme and viewport events
168
+
169
+ Haven pushes two types of live events to your app:
170
+
171
+ **Theme changes** occur when the user switches between light/dark mode or changes the UI theme preset:
81
172
 
82
173
  ```ts
83
- const session = await createMindooDBAppBridge().connect({
84
- launchId: "custom-launch-id",
85
- targetOrigin: "https://haven.example.com",
174
+ // Initial snapshot from launch context
175
+ const { mode, preset } = ctx.theme; // e.g. { mode: "dark", preset: "aura" }
176
+
177
+ // Live updates
178
+ const stopTheme = session.onThemeChange((theme) => {
179
+ document.documentElement.dataset.theme = theme.mode;
180
+ document.documentElement.dataset.themePreset = theme.preset;
86
181
  });
87
182
  ```
88
183
 
89
- ### 4. Read the launch context
90
-
91
- The launch context is your initial snapshot of the Haven host state:
184
+ **Viewport changes** fire when the iframe is resized inside Haven (e.g. the user resizes a chicklet on the workspace grid):
92
185
 
93
186
  ```ts
94
- const launchContext = await session.getLaunchContext();
187
+ // Initial snapshot (null in window mode)
188
+ const viewport = ctx.viewport; // { width: 800, height: 600 }
95
189
 
96
- console.log(launchContext.runtime); // "iframe" | "window"
97
- console.log(launchContext.theme.mode); // "light" | "dark"
98
- console.log(launchContext.viewport); // { width, height } | null
99
- console.log(launchContext.user.username);
100
- console.log(launchContext.launchParameters);
190
+ // Live updates
191
+ const stopViewport = session.onViewportChange((viewport) => {
192
+ console.log(viewport.width, viewport.height);
193
+ });
101
194
  ```
102
195
 
103
- `launchContext.viewport` is especially useful for responsive layouts inside embedded iframes. It is the initial viewport snapshot; later changes arrive through `onViewportChange()`.
196
+ Both subscription functions return an unsubscribe callback. Call it during teardown.
104
197
 
105
- ### 5. Subscribe to host-driven events
198
+ ### Documents
199
+
200
+ Open a database, then use the `documents` API for CRUD, history, and changefeed-backed document listing:
106
201
 
107
202
  ```ts
108
- const stopThemeSync = session.onThemeChange((theme) => {
109
- console.log("Theme changed:", theme.mode, theme.preset);
203
+ const db = await session.openDatabase(databaseId);
204
+
205
+ // List the first page of existing documents
206
+ const result = await db.documents.list({ limit: 50, fields: ["title"] });
207
+ console.log(result.items);
208
+ console.log(result.nextCursor); // opaque changefeed checkpoint or null
209
+
210
+ // Continue from the last checkpoint
211
+ const nextPage = result.nextCursor
212
+ ? await db.documents.list({ cursor: result.nextCursor, limit: 50, fields: ["title"] })
213
+ : null;
214
+
215
+ // Fast metadata-only listing (IDs + deletion state only)
216
+ const ids = await db.documents.list({
217
+ limit: 200,
218
+ status: "all",
219
+ metadataOnly: true,
110
220
  });
111
221
 
112
- const stopViewportSync = session.onViewportChange((viewport) => {
113
- console.log("Viewport changed:", viewport.width, viewport.height);
222
+ // Skip N matching entries before returning results
223
+ const window = await db.documents.list({
224
+ cursor: result.nextCursor,
225
+ skip: 100,
226
+ limit: 25,
227
+ status: "existing",
114
228
  });
115
229
 
116
- // Later, during teardown:
117
- stopThemeSync();
118
- stopViewportSync();
230
+ // Read
231
+ const doc = await db.documents.get(docId);
232
+
233
+ // Create (optionally with a named document key)
234
+ const created = await db.documents.create({
235
+ data: { title: "Meeting notes", content: "..." },
236
+ decryptionKeyId: "team-key", // omit to use "default"
237
+ });
238
+
239
+ // Update
240
+ const updated = await db.documents.update(docId, {
241
+ data: { title: "Updated title" },
242
+ });
243
+
244
+ // Delete
245
+ await db.documents.delete(docId);
119
246
  ```
120
247
 
121
- The intended pattern is:
248
+ `documents.list()` is backed by Haven's internal changefeed, not by a positional offset. The query object supports:
249
+
250
+ | Field | Meaning |
251
+ |---|---|
252
+ | `cursor?: string \| null` | Opaque changefeed checkpoint previously returned by `nextCursor` |
253
+ | `limit?: number` | Maximum number of matching entries to return (default `50`) |
254
+ | `skip?: number` | Skip matching entries after the cursor without loading full document bodies |
255
+ | `status?: "all" \| "existing" \| "deleted"` | Filter by deletion state (default `"existing"`) |
256
+ | `metadataOnly?: boolean` | Return only `{ id, isDeleted }` for speed |
257
+ | `fields?: string[]` | Project specific JSON fields when `metadataOnly` is `false` |
258
+ | `filter?: Record<string, unknown>` | Simple equality filter applied to document data when `metadataOnly` is `false` |
259
+
260
+ `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`.
261
+
262
+ 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.
122
263
 
123
- - use `getLaunchContext()` for the initial host snapshot
124
- - use `onThemeChange()` and `onViewportChange()` for live updates
264
+ ### Document history
125
265
 
126
- ### 6. Discover the databases Haven exposed
266
+ When the `history` capability is granted, you can walk the full revision timeline of any document:
127
267
 
128
268
  ```ts
129
- const databases = await session.listDatabases();
269
+ // List all revisions
270
+ const history = await db.documents.listHistory(docId);
271
+ // Each entry: { timestamp, publicKey, identityLabel?, isDeleted, isCurrent, summary? }
130
272
 
131
- for (const database of databases) {
132
- console.log(database.id, database.title, database.capabilities);
133
- }
273
+ // Load the document state at a specific point in time
274
+ const snapshot = await db.documents.getAtTimestamp(docId, history[0]!.timestamp);
275
+ // { id, timestamp, state: "exists" | "deleted" | "missing", data }
134
276
  ```
135
277
 
136
- Do not hard-code assumptions about available databases or permissions. Always discover them from the session.
278
+ ### Incremental sync
279
+
280
+ The changefeed-backed `documents.list()` API is also the right primitive for app-side indexes, search, and other derived caches.
137
281
 
138
- ### 7. Open a database and work with documents
282
+ **Initial scan from the beginning:**
139
283
 
140
284
  ```ts
141
- const databases = await session.listDatabases();
142
- const db = await session.openDatabase(databases[0]!.id);
285
+ let cursor: string | null = null;
286
+
287
+ while (true) {
288
+ const page = await db.documents.list({
289
+ cursor,
290
+ limit: 500,
291
+ status: "all",
292
+ metadataOnly: true,
293
+ });
294
+
295
+ for (const item of page.items) {
296
+ if (!item.isDeleted) {
297
+ const doc = await db.documents.get(item.id);
298
+ // add or rebuild the derived index entry
299
+ }
300
+ }
301
+
302
+ if (!page.nextCursor) {
303
+ break;
304
+ }
305
+ cursor = page.nextCursor;
306
+ }
143
307
 
144
- const list = await db.documents.list({ limit: 25 });
145
- const firstDoc = list.items[0] ? await db.documents.get(list.items[0].id) : null;
308
+ // Save `cursor` after the last non-empty page.
309
+ ```
146
310
 
147
- await db.documents.create({
148
- data: {
149
- employee: "Jane Doe",
150
- workDate: "2026-04-04",
151
- hours: 8,
152
- },
153
- decryptionKeyId: "payroll",
311
+ **Resume later from the saved checkpoint:**
312
+
313
+ ```ts
314
+ const page = await db.documents.list({
315
+ cursor: savedCursor,
316
+ limit: 500,
317
+ status: "all",
318
+ metadataOnly: true,
154
319
  });
320
+
321
+ let updated = 0;
322
+ let deleted = 0;
323
+
324
+ for (const item of page.items) {
325
+ if (item.isDeleted) {
326
+ deleted += 1;
327
+ // remove from index
328
+ } else {
329
+ updated += 1;
330
+ const doc = await db.documents.get(item.id);
331
+ // update index
332
+ }
333
+ }
334
+
335
+ console.log(`Updated index with ${updated} changes and ${deleted} deletions.`);
155
336
  ```
156
337
 
157
- Omit `decryptionKeyId` to keep using the tenant's `"default"` document key.
338
+ Use `status: "all"` for external indexes so deletions are visible and can be removed from your derived state.
158
339
 
159
- Document operations:
340
+ ### Attachments
341
+
342
+ When the `attachments` capability is granted, each document can carry file attachments. The SDK uses **chunked binary streams** over the bridge for uploads and downloads:
343
+
344
+ ```ts
345
+ // List attachments for a document
346
+ const files = await db.attachments.list(docId);
347
+ // Each: { attachmentId, fileName, mimeType, size }
348
+
349
+ // Download (pull-based read stream)
350
+ const reader = await db.attachments.openReadStream(docId, "report.pdf");
351
+ const chunks: Uint8Array[] = [];
352
+ let chunk = await reader.read();
353
+ while (chunk !== null) {
354
+ chunks.push(chunk);
355
+ chunk = await reader.read();
356
+ }
357
+ await reader.close();
160
358
 
161
- - `documents.list()`
162
- - `documents.get()`
163
- - `documents.create()`
164
- - `documents.update()`
165
- - `documents.delete()`
166
- - `documents.listHistory()`
167
- - `documents.getAtTimestamp()`
359
+ // Upload (push-based write stream)
360
+ const writer = await db.attachments.openWriteStream(docId, "photo.jpg", "image/jpeg");
361
+ await writer.write(fileBytes);
362
+ await writer.close();
168
363
 
169
- When creating a document, apps can optionally pass `decryptionKeyId` to use a named document key instead of `"default"`.
364
+ // Remove
365
+ await db.attachments.remove(docId, "old-file.txt");
366
+ ```
170
367
 
171
- Attachment operations:
368
+ ### Attachment previews
172
369
 
173
- - `attachments.list(docId)`
174
- - `attachments.remove(docId, attachmentName)`
175
- - `attachments.openReadStream(docId, attachmentName)`
176
- - `attachments.openWriteStream(docId, attachmentName, contentType?)`
370
+ 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.
177
371
 
178
- ## Quick start example
372
+ **Supported preview formats:**
179
373
 
180
- ```ts
181
- import { createMindooDBAppBridge, createViewLanguage } from "mindoodb-app-sdk";
374
+ | Mode | Formats |
375
+ |---|---|
376
+ | `image` | All common image formats (JPEG, PNG, GIF, WebP, SVG, ...) |
377
+ | `pdf` | PDF documents |
378
+ | `text` | Plain text, Markdown, CSV, JSON, XML, YAML, SVG, log files |
379
+ | `docx` | Microsoft Word (.docx) |
380
+ | `pptx` | Microsoft PowerPoint (.pptx) |
381
+ | `spreadsheet` | Microsoft Excel (.xls, .xlsx, .xlsm, .xlsb) |
382
+ | `video` | Video files with embedded player (streaming + seek support) |
383
+ | `audio` | Audio files with embedded player (streaming + seek support) |
182
384
 
183
- const bridge = createMindooDBAppBridge();
184
- const session = await bridge.connect();
385
+ **Open the Haven preview dialog:**
185
386
 
186
- const launchContext = await session.getLaunchContext();
187
- console.log("Connected runtime:", launchContext.runtime);
188
- console.log("Initial viewport:", launchContext.viewport);
387
+ ```ts
388
+ await db.attachments.openPreview(docId, "presentation.pptx");
389
+ ```
189
390
 
190
- session.onThemeChange((theme) => {
191
- document.documentElement.dataset.theme = theme.mode;
192
- });
391
+ You can also preview attachments from a **historical document snapshot**:
193
392
 
194
- session.onViewportChange((viewport) => {
195
- console.log("Resize:", viewport.width, viewport.height);
393
+ ```ts
394
+ await db.attachments.openPreview(docId, "report.pdf", {
395
+ timestamp: historyEntry.timestamp,
196
396
  });
397
+ ```
197
398
 
198
- const databases = await session.listDatabases();
199
- const db = await session.openDatabase(databases[0]!.id);
399
+ **Check preview support locally** before showing a preview button:
200
400
 
201
- const list = await db.documents.list({ limit: 25 });
202
- const firstDoc = list.items[0] ? await db.documents.get(list.items[0].id) : null;
401
+ ```ts
402
+ import { canPreviewAttachment } from "mindoodb-app-sdk";
203
403
 
204
- const v = createViewLanguage<{ employee: string; hours: number; rate?: number }>();
205
- const view = await db.views.create({
206
- title: "Hours by employee",
207
- defaultExpand: "collapsed",
208
- filter: {
209
- mode: "expression",
210
- expression: v.gt(v.toNumber(v.field("hours")), v.number(0)),
211
- },
212
- columns: [
213
- {
214
- name: "employee",
215
- title: "Employee",
216
- role: "category",
217
- expression: v.field("employee"),
218
- sorting: "ascending",
219
- },
220
- {
221
- name: "amount",
222
- title: "Amount",
223
- role: "display",
224
- expression: v.let(
225
- {
226
- hours: v.toNumber(v.field("hours")),
227
- rate: v.coalesce(v.toNumber(v.field("rate")), v.number(1)),
228
- },
229
- ({ hours, rate }) => v.mul(v.coalesce(hours, v.number(0)), v.coalesce(rate, v.number(0))),
230
- ),
231
- },
232
- ],
233
- });
404
+ const mode = canPreviewAttachment("slides.pptx", "application/octet-stream");
405
+ // Returns "pptx" -- Haven can preview this file
234
406
 
235
- const page = await view.page({ pageSize: 100 });
236
- console.log(page.rows);
237
- await view.dispose();
407
+ const unsupported = canPreviewAttachment("data.bin", "application/octet-stream");
408
+ // Returns null -- no built-in preview available
238
409
  ```
239
410
 
240
- ## Views and expression language
411
+ ### Virtual Views
412
+
413
+ Views let you query documents with categorization, sorting, filtering, and aggregation -- similar to a spreadsheet pivot table. There are two ways to work with views:
241
414
 
242
- Views are app-defined and session-scoped. Create a definition, page through rows, inspect categories, manage expansion state, then dispose the handle when done.
415
+ **App-defined views** -- create a view definition programmatically:
243
416
 
244
417
  ```ts
245
418
  import { createViewLanguage } from "mindoodb-app-sdk";
246
419
 
247
- const v = createViewLanguage<{
248
- employee: string;
249
- workDate: string;
250
- hours: number;
251
- rate?: number;
252
- note?: string;
253
- }>();
254
-
255
- const view = await db.views.create({
256
- title: "By employee",
257
- defaultExpand: "collapsed",
258
- filter: {
259
- mode: "expression",
260
- expression: v.gt(v.toNumber(v.field("hours")), v.number(0)),
420
+ const v = createViewLanguage<{ employee: string; hours: number }>();
421
+
422
+ const view = await session.createView({
423
+ databaseId: "main",
424
+ definition: {
425
+ title: "Hours by employee",
426
+ defaultExpand: "collapsed",
427
+ columns: [
428
+ {
429
+ name: "employee",
430
+ title: "Employee",
431
+ role: "category",
432
+ expression: v.field("employee"),
433
+ sorting: "ascending",
434
+ },
435
+ {
436
+ name: "hours",
437
+ title: "Hours",
438
+ role: "display",
439
+ expression: v.toNumber(v.field("hours")),
440
+ },
441
+ ],
261
442
  },
262
- columns: [
263
- {
264
- name: "employee",
265
- title: "Employee",
266
- role: "category",
267
- expression: v.field("employee"),
268
- sorting: "ascending",
269
- },
270
- {
271
- name: "amount",
272
- title: "Amount",
273
- role: "display",
274
- expression: v.let(
275
- {
276
- hours: v.toNumber(v.field("hours")),
277
- rate: v.coalesce(v.toNumber(v.field("rate")), v.number(1)),
278
- },
279
- ({ hours, rate }) => v.mul(v.coalesce(hours, v.number(0)), v.coalesce(rate, v.number(0))),
280
- ),
281
- sorting: "descending",
282
- },
283
- ],
284
443
  });
285
444
 
286
445
  const page = await view.page({ pageSize: 100 });
287
- const firstCategory = page.rows.find((row) => row.type === "category");
446
+ console.log(page.rows);
447
+ await view.dispose();
448
+ ```
288
449
 
289
- if (firstCategory) {
290
- await view.expand(firstCategory.key);
291
- const categoryRows = await view.pageCategory(firstCategory.key, { pageSize: 25 });
292
- const categoryDocumentIds = await view.listCategoryDocumentIds(firstCategory.key);
293
- const sameCategory = await view.getCategory({ path: firstCategory.categoryPath });
294
- }
450
+ **Haven-configured views** -- open views that the Haven administrator attached to the app registration:
451
+
452
+ ```ts
453
+ const viewDefs = ctx.views; // from launch context
454
+
455
+ const view = await session.openView(viewDefs[0]!.id);
456
+ const page = await view.page({ pageSize: 50 });
457
+
458
+ // Expand/collapse categories
459
+ await view.expandAll();
460
+ await view.collapse(someCategoryKey);
295
461
 
296
462
  await view.dispose();
297
463
  ```
298
464
 
299
- Available view-handle operations:
300
-
301
- - `view.getDefinition()`
302
- - `view.refresh()`
303
- - `view.page({ pageSize, position, expansion, rootRowKey })`
304
- - `view.getExpansionState()`
305
- - `view.setExpansionState(state)`
306
- - `view.expand(rowKey)`
307
- - `view.collapse(rowKey)`
308
- - `view.expandAll()`
309
- - `view.collapseAll()`
310
- - `view.getRow(rowKey)`
311
- - `view.getCategory({ path })`
312
- - `view.pageCategory(categoryKey, { pageSize, position })`
313
- - `view.listCategoryDocumentIds(categoryKey)`
314
- - `view.dispose()`
315
-
316
- The expression language is fully declarative. Apps define filter and column logic through a typed builder API that compiles to JSON-safe expression objects. No app-provided JavaScript runs inside the bridge host.
317
-
318
- Helpers currently include:
319
-
320
- - field/value access: `field()`, `value()`, `origin()`
321
- - literals and conversion: `literal()`, `string()`, `number()`, `boolean()`, `toNumber()`, `toString()`, `toBoolean()`
322
- - math and comparisons: `add()`, `sub()`, `mul()`, `div()`, `mod()`, `eq()`, `neq()`, `gt()`, `gte()`, `lt()`, `lte()`
323
- - boolean logic: `and()`, `or()`, `not()`
324
- - string helpers: `concat()`, `lower()`, `upper()`, `trim()`, `contains()`, `startsWith()`, `endsWith()`
325
- - null/existence helpers: `coalesce()`, `exists()`, `notExists()`
326
- - date/path helpers: `datePart()`, `pathJoin()`
327
- - control flow: `ifElse()`, `let()`
328
-
329
- The dedicated language guide lives in the `mindoodb-view-language` package. The SDK re-exports `createViewLanguage()` for convenience.
465
+ The expression language is fully declarative -- apps define filter and column logic through a typed builder API that compiles to JSON-safe expression objects. No app-provided JavaScript runs inside the bridge host.
466
+
467
+ Available expression helpers:
468
+
469
+ - **Field access:** `field()`, `value()`, `origin()`
470
+ - **Literals and conversion:** `literal()`, `string()`, `number()`, `boolean()`, `toNumber()`, `toString()`, `toBoolean()`
471
+ - **Math and comparisons:** `add()`, `sub()`, `mul()`, `div()`, `mod()`, `eq()`, `neq()`, `gt()`, `gte()`, `lt()`, `lte()`
472
+ - **Boolean logic:** `and()`, `or()`, `not()`
473
+ - **String helpers:** `concat()`, `lower()`, `upper()`, `trim()`, `contains()`, `startsWith()`, `endsWith()`
474
+ - **Null/existence:** `coalesce()`, `exists()`, `notExists()`
475
+ - **Date/path:** `datePart()`, `pathJoin()`
476
+ - **Control flow:** `ifElse()`, `let()`
477
+
478
+ The full language guide lives in the `mindoodb-view-language` package. The SDK re-exports `createViewLanguage()` for convenience.
330
479
 
331
480
  ## Testing without Haven
332
481
 
@@ -334,51 +483,142 @@ You do **not** need a full Haven environment to test apps that use `mindoodb-app
334
483
 
335
484
  The SDK ships a dedicated `mindoodb-app-sdk/testing` entrypoint with two levels of test helpers:
336
485
 
337
- - Level 1: simple app-test helpers that return a fake `MindooDBAppSession` and bridge object for unit, composable, store, and component tests
338
- - Level 2: a fake bridge host harness for tests that should exercise the real `createMindooDBAppBridge()` handshake over `postMessage` and `MessageChannel`
486
+ - **Level 1 -- app tests:** Returns a fake `MindooDBAppSession` and bridge for unit, composable, store, and component tests. No `postMessage` or `MessageChannel` involved.
487
+ - **Level 2 -- bridge protocol tests:** A fake host harness that exercises the real `createMindooDBAppBridge()` handshake over `postMessage` and `MessageChannel`. Useful for integration-style tests.
339
488
 
340
- Use Level 1 when you only need app behavior. Use Level 2 when you want to validate your bridge integration itself.
489
+ Use Level 1 when you are testing app behavior. Use Level 2 when you want to validate the bridge transport itself.
341
490
 
342
491
  See [`TESTING.md`](./TESTING.md) for setup instructions and Vitest examples.
343
492
 
344
- ## Local development workflow
493
+ ## Deployment
345
494
 
346
- The recommended local setup is:
495
+ MindooDB Apps are standard web applications -- deploy them anywhere you can serve static files.
347
496
 
348
- 1. Run Haven locally.
349
- 2. Run your app on its own dev server, for example Vite on `http://localhost:4173`.
350
- 3. Register that URL in Haven as an external app.
351
- 4. Launch the app from Haven so it receives a real `mindoodbAppLaunchId`.
497
+ **Cloudflare Pages / Workers** is one of the easiest options. The [example app](https://github.com/niclas-niclas/mindoodb-app-example) is deployed this way at https://mindoodb-app-example.mindoo.de using a simple `wrangler.jsonc` configuration:
352
498
 
353
- This gives you:
499
+ ```bash
500
+ npm run build
501
+ wrangler deploy
502
+ ```
503
+
504
+ Other static hosting options work just as well: Netlify, Vercel, any web server serving your `dist/` folder.
354
505
 
355
- - normal browser dev-server behavior
356
- - hot module reload
357
- - the full Haven host bridge
358
- - realistic iframe sizing and theme changes during development
506
+ **Haven-hosted bundles** are an alternative: import your built app directly into Haven, where it is served by a service worker on an opaque origin. Haven-hosted apps load without a network connection and receive a stricter sandbox.
359
507
 
360
- The sample `mindoodb-timerecords` app in this workspace is a good reference for this setup.
508
+ ## Local development workflow
509
+
510
+ For interactive development with the full Haven host bridge:
361
511
 
362
- This local development workflow is separate from the automated testing workflow above. Running Haven locally is useful for interactive development, but it is not required for Level 1 or Level 2 automated tests.
512
+ 1. Run Haven locally.
513
+ 2. Run your app on its own dev server (e.g. `npm run dev` with Vite).
514
+ 3. Register the dev server URL in Haven's Application settings.
515
+ 4. Map databases and optionally views to the app.
516
+ 5. Launch from Haven -- you get hot reload, real bridge communication, and live theme/viewport events.
517
+
518
+ This is separate from the automated testing workflow above. Running Haven is useful for interactive development but not required for Level 1 or Level 2 tests.
519
+
520
+ ## API reference
521
+
522
+ ### MindooDBAppBridge
523
+
524
+ | Method | Returns |
525
+ |---|---|
526
+ | `connect(options?)` | `Promise<MindooDBAppSession>` |
527
+
528
+ Connect options: `launchId?`, `targetOrigin?`, `connectTimeoutMs?`.
529
+
530
+ ### MindooDBAppSession
531
+
532
+ | Method | Returns |
533
+ |---|---|
534
+ | `getLaunchContext()` | `Promise<MindooDBAppLaunchContext>` |
535
+ | `listDatabases()` | `Promise<MindooDBAppDatabaseInfo[]>` |
536
+ | `openDatabase(databaseId)` | `Promise<MindooDBAppDatabase>` |
537
+ | `createView(input)` | `Promise<MindooDBAppViewHandle>` |
538
+ | `openView(viewId)` | `Promise<MindooDBAppViewHandle>` |
539
+ | `onThemeChange(listener)` | `() => void` (unsubscribe) |
540
+ | `onViewportChange(listener)` | `() => void` (unsubscribe) |
541
+ | `disconnect()` | `Promise<void>` |
542
+
543
+ ### MindooDBAppDatabase
544
+
545
+ | Property / Method | Returns |
546
+ |---|---|
547
+ | `info()` | `Promise<MindooDBAppDatabaseInfo>` |
548
+ | `documents` | `MindooDBAppDocumentApi` |
549
+ | `attachments` | `MindooDBAppAttachmentApi` |
550
+
551
+ ### MindooDBAppDocumentApi
552
+
553
+ | Method | Returns |
554
+ |---|---|
555
+ | `list(query?)` | `Promise<MindooDBAppDocumentListResult>` |
556
+ | `get(docId)` | `Promise<MindooDBAppDocument \| null>` |
557
+ | `create(input)` | `Promise<MindooDBAppDocument>` |
558
+ | `update(docId, patch)` | `Promise<MindooDBAppDocument>` |
559
+ | `delete(docId)` | `Promise<{ ok: true }>` |
560
+ | `listHistory(docId)` | `Promise<MindooDBAppDocumentHistoryEntry[]>` |
561
+ | `getAtTimestamp(docId, timestamp)` | `Promise<MindooDBAppHistoricalDocument>` |
562
+
563
+ `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.
564
+
565
+ ### MindooDBAppAttachmentApi
566
+
567
+ | Method | Returns |
568
+ |---|---|
569
+ | `list(docId)` | `Promise<MindooDBAppAttachmentInfo[]>` |
570
+ | `remove(docId, attachmentName)` | `Promise<{ ok: true }>` |
571
+ | `openReadStream(docId, attachmentName)` | `Promise<MindooDBAppReadableAttachmentStream>` |
572
+ | `openWriteStream(docId, attachmentName, contentType?)` | `Promise<MindooDBAppWritableAttachmentStream>` |
573
+ | `openPreview(docId, attachmentName, options?)` | `Promise<{ ok: true }>` |
574
+
575
+ ### MindooDBAppViewHandle
576
+
577
+ | Method | Returns |
578
+ |---|---|
579
+ | `getDefinition()` | `Promise<MindooDBAppViewDefinition>` |
580
+ | `refresh()` | `Promise<void>` |
581
+ | `page(input?)` | `Promise<MindooDBAppViewPageResult>` |
582
+ | `getExpansionState()` | `Promise<MindooDBAppViewExpansionState>` |
583
+ | `setExpansionState(state)` | `Promise<MindooDBAppViewExpansionState>` |
584
+ | `expand(rowKey)` | `Promise<MindooDBAppViewExpansionState>` |
585
+ | `collapse(rowKey)` | `Promise<MindooDBAppViewExpansionState>` |
586
+ | `expandAll()` | `Promise<MindooDBAppViewExpansionState>` |
587
+ | `collapseAll()` | `Promise<MindooDBAppViewExpansionState>` |
588
+ | `getRow(rowKey)` | `Promise<MindooDBAppViewRow \| null>` |
589
+ | `getCategory(input)` | `Promise<MindooDBAppViewRow \| null>` |
590
+ | `pageCategory(categoryKey, input?)` | `Promise<MindooDBAppViewPageResult>` |
591
+ | `listCategoryDocumentIds(categoryKey)` | `Promise<string[]>` |
592
+ | `dispose()` | `Promise<void>` |
593
+
594
+ ### Exported functions
595
+
596
+ | Function | Description |
597
+ |---|---|
598
+ | `createMindooDBAppBridge()` | Create the bridge object used to connect to Haven |
599
+ | `canPreviewAttachment(fileName, mimeType)` | Check if Haven can preview a file (returns mode or `null`) |
600
+ | `createViewLanguage<T>()` | Create a typed expression builder for view definitions |
601
+ | `abbreviateCanonicalName(value)` | Convert a canonical Notes-style name like `cn=Jane/ou=Dev/o=Mindoo` to `Jane/Dev/Mindoo` |
602
+ | `expandAbbreviatedName(value)` | Convert an abbreviated Notes-style name like `Jane/Dev/Mindoo` to `cn=Jane/ou=Dev/o=Mindoo` |
363
603
 
364
604
  ## Permissions and mappings
365
605
 
366
- Apps never choose arbitrary database targets themselves. Haven decides:
606
+ Apps never choose arbitrary database targets. Haven decides:
367
607
 
368
- - which logical database handles are visible to the app
369
- - whether each handle can read, create, update, delete, inspect history, access attachments, or create views
370
- - how each logical handle maps to a concrete tenant/database target
608
+ - which databases are visible to the app
609
+ - what capabilities each database has (`read`, `create`, `update`, `delete`, `history`, `attachments`, `views`)
610
+ - how each logical database maps to a concrete tenant and database target
371
611
 
372
- Your app should always react to what Haven grants instead of assuming a fixed environment.
612
+ Your app should always adapt to what Haven grants instead of assuming a fixed environment.
373
613
 
374
614
  ## When not to use this SDK
375
615
 
376
- `mindoodb-app-sdk` is for MindooDB apps that intentionally integrate with the Haven host bridge.
616
+ `mindoodb-app-sdk` is for web apps that integrate with the Haven host bridge.
377
617
 
378
- If you are embedding generic third-party web content that does not speak the MindooDB bridge protocol, use a general iframe integration such as `iframe-resizer` instead of this SDK.
618
+ If you are embedding generic third-party web content that does not speak the MindooDB bridge protocol, use a general iframe integration instead of this SDK.
379
619
 
380
620
  ## Current limitations
381
621
 
382
- - the bridge currently expects a browser environment with `window`, `postMessage`, and `MessagePort`
383
- - viewport updates are host-driven and only available when the app is actually launched by Haven
384
- - your app is still responsible for its own UI layout and responsive behavior after receiving host theme and viewport updates
622
+ - The bridge expects a browser environment with `window`, `postMessage`, and `MessagePort`.
623
+ - Viewport updates are host-driven and only available when the app is launched by Haven.
624
+ - Your app is responsible for its own UI layout and responsive behavior after receiving host theme and viewport updates.