js-bao-wss-client 2.2.0-alpha.8 → 3.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/README.md +184 -940
  2. package/dist/JsBaoClient.d.ts +1326 -79
  3. package/dist/JsBaoClient.js +3418 -515
  4. package/dist/agents/sessionModelRuntime.d.ts +124 -0
  5. package/dist/agents/sessionModelRuntime.js +291 -0
  6. package/dist/agents/sessionModels.generated.d.ts +413 -0
  7. package/dist/agents/sessionModels.generated.js +414 -0
  8. package/dist/api/cronTriggersApi.d.ts +26 -0
  9. package/dist/api/cronTriggersApi.js +26 -0
  10. package/dist/api/databaseTypeConfigsApi.d.ts +3 -5
  11. package/dist/api/databasesApi.d.ts +87 -21
  12. package/dist/api/databasesApi.js +76 -14
  13. package/dist/api/documentsApi.d.ts +503 -4
  14. package/dist/api/documentsApi.js +211 -3
  15. package/dist/api/functionsApi.d.ts +110 -46
  16. package/dist/api/functionsApi.js +205 -79
  17. package/dist/api/invitationsApi.d.ts +7 -4
  18. package/dist/api/invitationsApi.js +3 -1
  19. package/dist/api/locksApi.d.ts +26 -0
  20. package/dist/api/locksApi.js +13 -5
  21. package/dist/browser.umd.js +11244 -5099
  22. package/dist/errors.d.ts +11 -4
  23. package/dist/errors.js +10 -3
  24. package/dist/index.d.ts +1 -0
  25. package/dist/index.js +3 -0
  26. package/dist/internal/authController.d.ts +20 -1
  27. package/dist/internal/authController.js +24 -4
  28. package/dist/internal/blobManager.js +7 -6
  29. package/dist/internal/documentManager.d.ts +527 -11
  30. package/dist/internal/documentManager.js +1110 -153
  31. package/dist/internal/functionRunStatus.d.ts +247 -0
  32. package/dist/internal/functionRunStatus.js +271 -0
  33. package/dist/internal/httpClient.d.ts +28 -0
  34. package/dist/internal/httpClient.js +66 -16
  35. package/dist/internal/large-documents/catch-up-ledger.d.ts +9 -0
  36. package/dist/internal/large-documents/catch-up-ledger.js +38 -1
  37. package/dist/internal/large-documents/document-format.d.ts +67 -0
  38. package/dist/internal/large-documents/document-format.js +106 -0
  39. package/dist/internal/large-documents/outbound-ack.d.ts +23 -1
  40. package/dist/internal/large-documents/outbound-ack.js +41 -3
  41. package/dist/internal/large-documents/persisted-naming.d.ts +45 -0
  42. package/dist/internal/large-documents/persisted-naming.js +70 -0
  43. package/dist/internal/notFound.d.ts +16 -0
  44. package/dist/internal/notFound.js +25 -0
  45. package/dist/internal/offlineStore.d.ts +56 -0
  46. package/dist/internal/offlineStore.js +151 -18
  47. package/dist/internal/storage/indexedDbStorageProvider.d.ts +16 -0
  48. package/dist/internal/storage/indexedDbStorageProvider.js +71 -0
  49. package/dist/internal/storage/memoryStorageProvider.d.ts +9 -0
  50. package/dist/internal/storage/memoryStorageProvider.js +24 -0
  51. package/dist/internal/storage/sqliteStorageProvider.d.ts +16 -0
  52. package/dist/internal/storage/sqliteStorageProvider.js +59 -0
  53. package/dist/internal/storage/types.d.ts +64 -5
  54. package/dist/internal/workflowRunError.d.ts +78 -0
  55. package/dist/internal/workflowRunError.js +69 -0
  56. package/dist/internal/workflowSlice.d.ts +91 -0
  57. package/dist/internal/workflowSlice.js +110 -0
  58. package/dist/internal/y-indexeddb/src/y-indexeddb.d.ts +25 -2
  59. package/dist/internal/y-indexeddb/src/y-indexeddb.js +439 -80
  60. package/package.json +2 -2
  61. package/dist/api/geminiApi.d.ts +0 -209
  62. package/dist/api/geminiApi.js +0 -212
  63. package/dist/api/llmApi.d.ts +0 -101
  64. package/dist/api/llmApi.js +0 -86
package/README.md CHANGED
@@ -17,9 +17,7 @@ A TypeScript/JavaScript client library for js-bao-wss that provides HTTP APIs an
17
17
  - **Token Management**: Proactive refresh in HTTP calls
18
18
  - **Analytics**: Buffered event logging API with optional automatic lifecycle events
19
19
  - **Blob Storage**: Upload/list/get/downloadUrl/delete per document with offline cache
20
- - **LLM**: Chat API and model listing _(deprecated — use managed prompts (`client.prompts.execute`) or workflow LLM steps instead)_
21
- - **Workflows**: Server-side multi-step processes with LLM, delays, and transformations
22
- - **Workflow Apply**: Single-client claim/confirm flow for applying workflow results to Yjs documents
20
+ - **Server Functions**: Invoke your app's server functions, start and wait on long-running runs, and join the channels they authorize
23
21
  - **Offline-first Open**: Non-blocking open with IndexedDB-backed cache
24
22
  - **Offline Blob Cache**: Cache API + IndexedDB backed uploads/reads with eviction and retry
25
23
  - **Network Controls**: Online/offline modes, reachability, connection control
@@ -124,9 +122,10 @@ const client = await initializeClient({
124
122
  appId: "my-app",
125
123
  models: [Task, Project],
126
124
 
127
- // Yjs document persistence for Node.js
128
- yjsPersistence: (docId, ydoc, { appId, userId }) => {
129
- return new SqlitePersistence(docId, ydoc, {
125
+ // Yjs document persistence for Node.js. The first argument is the store
126
+ // name — see "What the first argument is" below.
127
+ yjsPersistence: (storeName, ydoc, { appId, userId }) => {
128
+ return new SqlitePersistence(storeName, ydoc, {
130
129
  dbPath: `${process.env.HOME}/.my-app/${appId}/${userId}/yjs.sqlite`
131
130
  });
132
131
  },
@@ -138,6 +137,34 @@ const { ydoc } = await client.documents.open("doc-123", {
138
137
  });
139
138
  ```
140
139
 
140
+ **What the first argument is**
141
+
142
+ The factory's first argument is the **store name** to persist this Yjs state
143
+ under, not necessarily the document id. For an ordinary document, and for a
144
+ large document (`documentFormat: 2`) that has not rotated yet, the two are the
145
+ same value.
146
+
147
+ A large document's Yjs state is an epoch *overlay* rather than the document
148
+ itself, so once it rotates each epoch gets its own store name,
149
+ `{documentId}@{epoch}`. A rotation attaches a provider for the fresh epoch and
150
+ then clears the sealed one, so a factory that put both epochs under one name
151
+ would lose every write made since the rotation to that clear. Keying your
152
+ storage on the first argument — as the example above does — is correct for both
153
+ kinds of document, and needs no change when a document grows large.
154
+
155
+ The third argument carries the rest of what the store is for:
156
+
157
+ | Field | Meaning |
158
+ |-------|---------|
159
+ | `appId` | The application id |
160
+ | `userId` | The authenticated user, or `"anon"` |
161
+ | `documentId` | The plain document id, without the epoch suffix |
162
+ | `documentFormat` | `2` for a large document, `1` for an ordinary one — absent on the very first open of a document created elsewhere, whose format nothing local knows yet |
163
+ | `epoch` | The epoch the store name is scoped to, or `null` when the name is the plain document id |
164
+
165
+ Use `context.documentId` when you want to group every store belonging to one
166
+ document; use the first argument whenever you are naming storage.
167
+
141
168
  **Why two storage systems?**
142
169
 
143
170
  - `storageConfig` → General key-value data (auth, cache, metadata)
@@ -505,7 +532,7 @@ const client = await bootstrap();
505
532
  #### Default behaviors
506
533
 
507
534
  - `offline` mode is enabled unless you pass `offline: false`.
508
- - `databaseConfig` defaults to `{ type: "sqljs" }`. Supply a different engine only if you need it.
535
+ - `databaseConfig` defaults to `{ type: "sqljs" }`. Supply a different engine only if you need it. A web app that opens large documents (`documentFormat: 2`) passes `{ type: "opfs", options: { workerURL?, brokerURL?, locateFile? } }` — an OPFS-backed store in a dedicated worker that survives a reload; ordinary documents keep the SQL.js engine underneath. `brokerURL` points at `js-bao/format2-broker`, a small SharedWorker that lets several tabs of the same app share one document's store: one tab hosts the engine and the others reach it through a port the broker hands over. Without it a second tab of the same document is refused. Needs js-bao 0.8.0 or newer; with an older js-bao the client throws `OPFS_ENGINE_UNAVAILABLE`.
509
536
 
510
537
  ### 2. Listen to Connection Events
511
538
 
@@ -567,7 +594,6 @@ The client exposes a buffered analytics queue that batches events, retries on re
567
594
  - `client.analytics.logEvent({ action, feature, context_json?, ... })`: enqueue a single event. `context_json` accepts an object (auto-serialized) or a JSON string.
568
595
  - `client.analytics.flush()`: attempt to send the queue immediately; also runs automatically on reconnect and right before unload/destroy.
569
596
  - `client.analytics.setPlanOverride(plan)` and `.setAppVersionOverride(version)`: stamp metadata onto every subsequent event until you clear or replace it.
570
- - `client.getLlmAnalyticsContext()`: returns `{ logEvent, isEnabled }` when any LLM auto phases are enabled so higher-level features can coordinate their own analytics.
571
597
 
572
598
  Queued events are persisted in IndexedDB when offline storage is active, so short offline windows or reloads do not drop data. Everything funnels through the same `analytics.batch` WebSocket channel used by the live event test.
573
599
 
@@ -580,9 +606,6 @@ All automatic emitters are on by default; pass `analyticsAutoEvents` when constr
580
606
  - `sync_error` (`"sync"`, toggle: `analyticsAutoEvents.syncErrors.enabled`): records the `documentId` and `reason` when flush/send attempts fail (with interval throttling).
581
607
  - `blob_upload_started` / `blob_upload_succeeded` / `blob_upload_failed` (`"blobs"`, toggles: `analyticsAutoEvents.blobUploads.{start|success|failure}`): include blob/document identifiers, attempt counts, byte size, and retry details (with truncated error text on failure).
582
608
  - `session_end` (`"session"`, toggle: `analyticsAutoEvents.sessionEnd`): emitted on `beforeunload` and `client.destroy()`, including `duration_ms` and exit reason.
583
- - `prompt_started` / `prompt_succeeded` / `prompt_failed` (`"llm"` or `"gemini"`, toggles: `analyticsAutoEvents.llm.{start|success|failure}` and `analyticsAutoEvents.gemini.{start|success|failure}`): unified prompt lifecycle events emitted by `client.llm.chat()` and `client.gemini.generate()`/`client.gemini.countTokens()`. The `feature` field distinguishes the provider.
584
-
585
- When `analyticsAutoEvents.llm` enables any of `start`, `success`, or `failure`, `client.getLlmAnalyticsContext()` becomes non-null so LLM helpers can emit structured events without guessing configuration. `analyticsAutoEvents.gemini` controls `client.getGeminiAnalyticsContext()`, which powers automatic logging inside the Gemini namespace.
586
609
 
587
610
  **Removed events** (no longer emitted, and their option flags have been removed from `analyticsAutoEvents`): `client_boot` (`boot`), `first_doc_open` (`firstDocOpen`), `first_doc_edit` (`firstDocEdit`), `offline_recovery` (`offlineRecovery`), `service_worker_control` / `service_worker_token_update` (`serviceWorker`), `llm_request_*`, `gemini_request_*`.
588
611
 
@@ -593,7 +616,7 @@ const client = await initializeClient({
593
616
  ...options,
594
617
  analyticsAutoEvents: {
595
618
  blobUploads: { start: false, success: true, failure: true },
596
- llm: { start: true, success: true, failure: false },
619
+ sessionEnd: false,
597
620
  },
598
621
  });
599
622
  ```
@@ -1160,6 +1183,26 @@ const { metadata } = await client.documents.create({
1160
1183
  });
1161
1184
  console.log("Created document:", metadata.documentId);
1162
1185
 
1186
+ // Create a LARGE document (`documentFormat: 2`) — records live in a persisted
1187
+ // local store and in the server's own table, so it scales far past an ordinary
1188
+ // document's ~10 MB. The choice is made here and is never migrated. The
1189
+ // returned metadata reports the format immediately, before the server commit
1190
+ // lands, and `documents.get(id).documentFormat` reports it afterwards.
1191
+ const large = await client.documents.create({
1192
+ title: "Ledger",
1193
+ documentFormat: 2,
1194
+ });
1195
+ console.log("Format:", large.metadata.documentFormat); // 2
1196
+
1197
+ // Omitting `documentFormat` (or passing `1`) creates an ordinary document and
1198
+ // sends nothing extra. Any other value is refused before anything is created,
1199
+ // with `JsBaoError` code `INVALID_ARGUMENT`; `localOnly: true` cannot be
1200
+ // combined with `documentFormat: 2` (`LOCAL_ONLY_UNSUPPORTED_OPTION`), because
1201
+ // a large document's records live in a store only the server's room opens.
1202
+ // `documents.createWithAlias` takes the same option with the same effect.
1203
+ // A large document needs a local store that outlives the session: Node has
1204
+ // one, and a browser needs `databaseConfig: { type: "opfs", ... }`.
1205
+
1163
1206
  // List documents. There are two readers, and they are disjoint: the documents
1164
1207
  // the user owns, and the documents shared with them. Reading both is what it
1165
1208
  // takes to see everything the user has access to.
@@ -1232,6 +1275,20 @@ console.log(result.documentId); // The created document ID
1232
1275
  console.log(result.alias.aliasKey); // "current-draft"
1233
1276
  console.log(result.alias.documentId); // Same as documentId
1234
1277
 
1278
+ // The alias create takes the whole create vocabulary — `tags`, `metadata` and
1279
+ // `documentFormat` — with the same meaning they have on `create()`: applied when
1280
+ // the call creates the document. The response echoes what it applied, and omits
1281
+ // `documentFormat` for an ordinary document.
1282
+ const ledger = await client.documents.createWithAlias({
1283
+ title: "Ledger",
1284
+ alias: { scope: "user", aliasKey: "ledger" },
1285
+ tags: ["ledger", "q3"],
1286
+ metadata: { owner: "finance" },
1287
+ documentFormat: 2,
1288
+ });
1289
+ console.log(ledger.documentFormat); // 2
1290
+ console.log(ledger.tags); // ["ledger", "q3"]
1291
+
1235
1292
  // Attempting to create with an existing alias throws HTTP 409
1236
1293
  try {
1237
1294
  await client.documents.createWithAlias({
@@ -1252,6 +1309,27 @@ try {
1252
1309
 
1253
1310
  Use `createWithAlias()` when you need guaranteed uniqueness based on an alias (e.g., "only one home page per app"). Use regular `create()` + `aliases.set()` when offline support is needed or when the document should be created regardless of alias conflicts.
1254
1311
 
1312
+ #### Get-or-Create with Alias
1313
+
1314
+ When you want the document to exist rather than to be new — "this user's ledger" — `getOrCreateWithAlias()` is the idempotent form: the first call creates it, every later one returns the same document. It takes the same options, applied only when the call creates the document:
1315
+
1316
+ ```typescript
1317
+ const ledger = await client.documents.getOrCreateWithAlias({
1318
+ title: "Ledger",
1319
+ alias: { scope: "user", aliasKey: "ledger" },
1320
+ tags: ["ledger"],
1321
+ documentFormat: 2,
1322
+ });
1323
+
1324
+ console.log(ledger.created); // true on the call that made it, false after
1325
+ console.log(ledger.documentFormat); // 2 either way
1326
+ console.log(ledger.tags); // ["ledger"] on the create; absent afterwards
1327
+ ```
1328
+
1329
+ A stated `documentFormat` is also a statement about a document the alias already names. If the stored format **differs**, the call is refused with `DOCUMENT_FORMAT_MISMATCH` (409) naming the document and both formats, and nothing is created — being handed an ordinary document when you asked for a large one is the mistake this route exists to prevent. If it agrees, the format is echoed. Omit it and an existing binding is answered exactly as it always was.
1330
+
1331
+ > All three create routes refuse a top-level body key they do not read, with 400 `VALIDATION_FAILED` and a `details` entry naming it. The typed methods above send only keys the routes read; this reaches you only if you build a request body by hand.
1332
+
1255
1333
  ### Manage Permissions
1256
1334
 
1257
1335
  ```typescript
@@ -1719,888 +1797,138 @@ const user = await client.users.getBasic("u01H...");
1719
1797
  console.log(user.name, user.email, user.appRole);
1720
1798
  ```
1721
1799
 
1722
- ## Large Language Models (LLM)
1723
-
1724
- > **Deprecated.** The direct LLM client API (`client.llm.*`) is deprecated and will be removed in a future major release. Use managed prompts (`client.prompts.execute`) or a workflow `llm.chat` step instead.
1725
-
1726
- ```typescript
1727
- // List available models
1728
- const { models, defaultModel } = await client.llm.models();
1729
- console.log("Default model:", defaultModel);
1730
-
1731
- // Basic chat
1732
- const reply = await client.llm.chat({
1733
- // model is optional; uses server default when omitted
1734
- messages: [
1735
- { role: "system", content: "You are a helpful assistant." },
1736
- { role: "user", content: "Summarize: Collaborative editing with Yjs." },
1737
- ],
1738
- temperature: 0.2,
1739
- max_tokens: 512,
1740
- reasoning: {
1741
- effort: "medium",
1742
- exclude: false,
1743
- },
1744
- });
1745
- console.log(reply.content);
1746
-
1747
- // Chat with image attachment (base64-encoded PNG)
1748
- const imageBase64 = await loadImageAsBase64("/path/to/screenshot.png");
1749
- const imageReply = await client.llm.chat({
1750
- messages: [
1751
- {
1752
- role: "system",
1753
- content: "You analyze screenshots and respond helpfully.",
1754
- },
1755
- { role: "user", content: "Describe what you see in this image." },
1756
- ],
1757
- attachments: [
1758
- {
1759
- type: "image",
1760
- mime: "image/png",
1761
- base64: imageBase64,
1762
- },
1763
- ],
1764
- });
1765
- console.log(imageReply.content);
1766
- ```
1767
-
1768
- ## Gemini
1769
-
1770
- > **Deprecated.** The direct Gemini client API (`client.gemini.*`) is deprecated and will be removed in a future major release. Use managed prompts (`client.prompts.execute`) or a workflow `gemini.generate` step instead.
1771
-
1772
- ```typescript
1773
- // Text generation with optional structured output
1774
- const result = await client.gemini.generate({
1775
- messages: [
1776
- {
1777
- role: "system",
1778
- parts: [{ type: "text", text: "Reply in JSON with keys title and summary." }],
1779
- },
1780
- {
1781
- role: "user",
1782
- parts: [{ type: "text", text: "Summarize collaborative editing with Yjs." }],
1783
- },
1784
- ],
1785
- structuredOutput: {
1786
- responseMimeType: "application/json",
1787
- responseJsonSchema: {
1788
- type: "object",
1789
- properties: {
1790
- title: { type: "string" },
1791
- summary: { type: "string" },
1792
- },
1793
- required: ["title", "summary"],
1794
- },
1795
- },
1796
- });
1797
- console.log(result.message.parts[0].text);
1798
-
1799
- // Multimodal prompt with inline image data
1800
- const screenshot = await loadImageAsBase64("/path/to/screenshot.png");
1801
- await client.gemini.generate({
1802
- messages: [
1803
- {
1804
- role: "user",
1805
- parts: [
1806
- { type: "text", text: "Describe this screenshot." },
1807
- { type: "image", mimeType: "image/png", base64Data: screenshot },
1808
- ],
1809
- },
1810
- ],
1811
- });
1812
-
1813
- // Raw passthrough using Google's payload schema
1814
- const rawPayload = {
1815
- contents: [
1816
- {
1817
- role: "user",
1818
- parts: [
1819
- {
1820
- text: "Summarize this JSON.",
1821
- },
1822
- {
1823
- inline_data: {
1824
- mimeType: "application/json",
1825
- data: someBase64Json,
1826
- },
1827
- },
1828
- ],
1829
- },
1830
- ],
1831
- };
1832
- const rawResponse = await client.gemini.generateRaw({
1833
- model: "models/gemini-2.5-flash",
1834
- body: rawPayload,
1835
- });
1836
- console.log(rawResponse.candidates?.[0]?.content);
1837
-
1838
- // Token usage estimation
1839
- const tokens = await client.gemini.countTokens({
1840
- model: "models/gemini-2.5-flash",
1841
- messages: [{ role: "user", parts: [{ type: "text", text: "How many tokens?" }] }],
1842
- });
1843
- console.log(tokens.totalTokens);
1844
- ```
1845
-
1846
- ### Gemini Configuration & Notes
1847
-
1848
- - `client.gemini` proxies to server-side routes:
1849
- - `GET /gemini/models` → `client.gemini.models()`
1850
- - `POST /gemini/generate` → `client.gemini.generate(...)`
1851
- - `POST /gemini/count-tokens` → `client.gemini.countTokens(...)`
1852
- - `POST /gemini/generate-raw` → `client.gemini.generateRaw({ model, body })`
1853
- These endpoints run server-side, so browser clients never see Google credentials.
1854
- - Deployments must configure a Gemini key in the worker environment (`GEMINI_API_KEY`, `GEMINI_API_TOKEN`, or `GEMINI_KEY`). Optional helpers:
1855
- - `GEMINI_DEFAULT_MODEL` (e.g. `models/gemini-2.5-flash`)
1856
- - `GEMINI_ALLOWED_MODELS` (comma-separated allowlist such as `models/gemini-2.5-flash,models/gemini-2.5-pro`).
1857
- - Inline multimodal content uses base64 `parts` (text, image, file). Large file uploads can be added later via the Gemini Files API; V1 augments prompts with base64-inlined payloads up to ~25 MB.
1858
- - Structured output leverages `generationConfig.responseMimeType` plus optional `responseSchema` / `responseJsonSchema` so Gemini can return deterministic JSON. The full server response is always available via the `raw` field when you need annotations or safety metadata.
1859
- - Error handling surfaces `JsBaoError` with `code: "GEMINI_ERROR"`; the `details` property contains the raw upstream payload so you can log or render troubleshooting info.
1860
- - See `.dev.local.example` for sample environment values and `docs/gemini-direct-plan.md` for architectural details.
1861
-
1862
- ## Workflows
1863
-
1864
- Workflows allow you to execute server-side, multi-step processes that can include LLM calls, delays, transformations, and more. The client provides APIs to start workflows, monitor their status, and receive real-time completion events.
1865
-
1866
- ### Starting a Workflow
1867
-
1868
- ```typescript
1869
- // Start a workflow with input data
1870
- const result = await client.workflows.start({
1871
- workflowKey: "my-workflow-key",
1872
- input: {
1873
- message: "Hello world",
1874
- value: 42,
1875
- },
1876
- });
1877
-
1878
- console.log("Run started:", result.runKey);
1879
- console.log("Run ID:", result.runId);
1880
- console.log("Status:", result.status);
1881
- ```
1882
-
1883
- #### Start Options
1884
-
1885
- ```typescript
1886
- const result = await client.workflows.start({
1887
- // The workflow key (required)
1888
- workflowKey: "my-workflow-key",
1889
- // Input data passed to the workflow (optional, defaults to {})
1890
- input: { message: "Hello" },
1891
- // Provide a custom runKey for idempotency (auto-generated if omitted)
1892
- runKey: "unique-run-identifier",
1893
- // Associate the run with a document
1894
- contextDocId: "doc-123",
1895
- // Pass additional metadata (max 1KB, returned in listRuns/getStatus)
1896
- meta: { source: "user-action", priority: "high" },
1897
- });
1898
- ```
1899
-
1900
- ### Document-Scoped Workflows
1901
-
1902
- Workflows can be scoped to a specific document using `contextDocId`. This is useful when:
1903
- - Processing document-specific data
1904
- - Running workflows in the context of a particular document
1905
- - Allowing the same `runKey` to be used across different documents
1906
-
1907
- The workflow instance ID is built from `{appId}:{contextDocId}:{runKey}`, so a workflow started with one `contextDocId` cannot be accessed with a different one.
1908
-
1909
- ```typescript
1910
- // Start a workflow scoped to a document
1911
- const result = await client.workflows.start({
1912
- workflowKey: "process-document",
1913
- input: { action: "analyze" },
1914
- contextDocId: "doc-123",
1915
- runKey: "analyze-v1",
1916
- });
1917
-
1918
- // Must use the same contextDocId for all operations
1919
- const status = await client.workflows.getStatus({
1920
- workflowKey: "process-document",
1921
- runKey: "analyze-v1",
1922
- contextDocId: "doc-123",
1923
- });
1924
- await client.workflows.terminate({
1925
- workflowKey: "process-document",
1926
- runKey: "analyze-v1",
1927
- contextDocId: "doc-123",
1928
- });
1929
-
1930
- // List runs for this document
1931
- const runs = await client.workflows.listRuns({ contextDocId: "doc-123" });
1932
- ```
1933
-
1934
- If `contextDocId` is omitted, the user's root document is used by default.
1935
-
1936
- ### Duplicate Workflow Protection (Idempotency)
1937
-
1938
- When you provide a `runKey`, the server ensures only one workflow run exists for that key. If you call `start()` again with the same `runKey`, the existing run is returned instead of creating a new one:
1939
-
1940
- ```typescript
1941
- // First call creates the workflow
1942
- const first = await client.workflows.start({
1943
- workflowKey: "process-document",
1944
- input: { docId: "abc" },
1945
- runKey: "process-abc-v1",
1946
- });
1947
- console.log(first.existing); // false - new run created
1948
-
1949
- // Second call with same runKey returns existing run
1950
- const second = await client.workflows.start({
1951
- workflowKey: "process-document",
1952
- input: { docId: "abc" },
1953
- runKey: "process-abc-v1",
1954
- });
1955
- console.log(second.existing); // true - existing run returned
1956
- console.log(second.runId === first.runId); // true - same run
1957
- ```
1958
-
1959
- This is useful for:
1960
- - Preventing duplicate processing when users click a button multiple times
1961
- - Safely retrying failed requests without creating duplicate work
1962
- - Implementing exactly-once semantics for critical operations
1963
-
1964
- ### Checking Workflow Status
1965
-
1966
- Poll the status of a running workflow:
1967
-
1968
- ```typescript
1969
- const status = await client.workflows.getStatus({
1970
- workflowKey: "my-workflow-key",
1971
- runKey,
1972
- });
1973
-
1974
- // One canonical value: "queued" | "running" | "apply_pending" | "apply_claimed"
1975
- // | "completed" | "failed" | "terminated" | "missing" | "skipped"
1976
- console.log("Status:", status.status);
1977
-
1978
- if (status.status === "completed") {
1979
- console.log("Output:", status.output);
1980
- }
1981
-
1982
- if (status.status === "failed") {
1983
- console.log("Error:", status.error);
1984
- // A platform-classified failure also carries a machine-readable code, so you
1985
- // never have to match `error` text: "LOCK_CONTENTION" | "LOCK_TIMEOUT" | null
1986
- if (status.run?.errorCode === "LOCK_CONTENTION") {
1987
- console.log("An import is already in progress");
1988
- }
1989
- }
1990
-
1991
- if (status.status === "skipped") {
1992
- // The run did not run and did not fail: its declarative lock was held and the
1993
- // workflow declared `onContention = "ignore"`. `skipReason` says why.
1994
- console.log("Did not run:", status.skipReason); // "LOCK_CONTENTION"
1995
- }
1996
- ```
1997
-
1998
- If you started the workflow with a specific `contextDocId`, you must provide the same `contextDocId` when checking status:
1999
-
2000
- ```typescript
2001
- // Start workflow with contextDocId
2002
- const result = await client.workflows.start({
2003
- workflowKey: "my-workflow",
2004
- input: { data: "..." },
2005
- contextDocId: "doc-123",
2006
- });
2007
-
2008
- // Get status - must provide the same contextDocId
2009
- const status = await client.workflows.getStatus({
2010
- workflowKey: "my-workflow",
2011
- runKey: result.runKey,
2012
- contextDocId: "doc-123",
2013
- });
2014
- ```
1800
+ ## Server Functions
2015
1801
 
2016
- If `contextDocId` is omitted, the user's root document is used by default.
1802
+ A server function is TypeScript your app's team writes in its config tree and
1803
+ pushes with `primitive config push`. The client calls it by key, and the
1804
+ function's `access` gate decides who may. Inside, the function works on the
1805
+ app's authority: it reads and writes databases and documents, runs saved
1806
+ prompts, calls integrations, and publishes to channels. The Server Functions
1807
+ guide covers writing one; this section covers calling one.
2017
1808
 
2018
- ### Waiting for Workflow Completion
1809
+ ### Invoking a Function
2019
1810
 
2020
- For an async run, `waitFor(runId)` resolves once the run reaches a terminal
2021
- state — no manual subscribe/poll/timeout loop:
1811
+ `client.functions.invoke` runs the function inside the request and resolves
1812
+ with its result:
2022
1813
 
2023
1814
  ```typescript
2024
- const { runId } = await client.workflows.start({
2025
- workflowKey: "process-document",
2026
- input: { action: "analyze" },
1815
+ const result = await client.functions.invoke<{ message: string }>("greet", {
1816
+ input: { name: "Ada" }, // what the handler receives as `input`
1817
+ timeoutMs: 10_000, // default 5 000, clamped to 30 000
2027
1818
  });
2028
1819
 
2029
- const result = await client.workflows.waitFor(runId);
2030
- // result: { status, output?, error? }
2031
-
2032
1820
  if (result.status === "completed") {
2033
- console.log("Output:", result.output);
2034
- } else if (result.status === "failed") {
2035
- console.log("Error:", result.error); // a failed run RESOLVES — it does not reject
2036
- }
2037
- ```
2038
-
2039
- `waitFor` is event-driven: it subscribes to the same `workflowStatus`
2040
- WebSocket frame (no polling) and does a single reconcile fetch right after
2041
- subscribing to catch a run that already finished before you called it. On a
2042
- WebSocket reconnect it re-runs that single reconcile — there is no interval
2043
- timer.
2044
-
2045
- - **Resolves on every terminal state**: `completed`, `failed`, `terminated`,
2046
- `skipped`, and the apply-ready states `apply_pending` / `apply_claimed` (the
2047
- run finished and its result is available for the client-side apply step). The
2048
- server reconciles a run's status before reporting it, so the reported
2049
- terminal state is the run's real one — a terminated run resolves as
2050
- `"terminated"`, never as `"completed"`.
2051
- - **`skipped` needs this client at 2.1.0 or newer.** It is the outcome of a run
2052
- whose declarative lock was held under `onContention = "ignore"`: the run did
2053
- not run and did not fail, and `result.skipReason` says why
2054
- (`"LOCK_CONTENTION"`). An older client does not recognise it as terminal, so
2055
- a wait on an elided run keeps waiting until its own timeout instead of
2056
- returning — upgrade before you switch a workflow to `ignore`.
2057
- - **Rejects only** on two conditions: the run is unknown or not readable by the
2058
- caller (a `NOT_FOUND` `JsBaoError`, surfaced immediately — it does not wait
2059
- for the timeout), or the timeout elapses (a `WORKFLOW_WAIT_TIMEOUT`
2060
- `JsBaoError`).
2061
-
2062
- ```typescript
2063
- import { isJsBaoError } from "js-bao-wss-client";
2064
-
2065
- try {
2066
- const result = await client.workflows.waitFor(runId, { timeoutMs: 60000 });
2067
- // ...
2068
- } catch (err) {
2069
- if (isJsBaoError(err) && err.code === "WORKFLOW_WAIT_TIMEOUT") {
2070
- // still running after the deadline
2071
- } else if (isJsBaoError(err) && err.code === "NOT_FOUND") {
2072
- // unknown or not-owned runId
2073
- }
1821
+ console.log(result.output?.message); // "hello Ada"
1822
+ } else {
1823
+ // "failed" — the handler threw or its output failed outputSchema — or "timeout"
1824
+ console.error(result.status, result.error, result.errorCode);
2074
1825
  }
2075
1826
  ```
2076
1827
 
2077
- The default `timeoutMs` is 15 minutes. Set it to `0` or `Infinity` to disable
2078
- the timeout — but then the returned promise and its listener live until the run
2079
- terminates, so only do this for runs you know will end.
2080
-
2081
- To receive the resolving `workflowStatus` frame promptly you need an active
2082
- WebSocket connection (see [Listening for Workflow
2083
- Events](#listening-for-workflow-events) below); without one, `waitFor` still
2084
- resolves via its reconcile fetch, just less immediately.
2085
-
2086
- ### Listening for Workflow Events
2087
-
2088
- Subscribe to real-time workflow events via WebSocket:
2089
-
2090
- ```typescript
2091
- // Listen for when a workflow starts
2092
- client.on("workflowStarted", (event) => {
2093
- console.log("Workflow started:", event.workflowKey, event.runKey);
2094
- console.log("Instance:", event.instanceId);
2095
- console.log("Meta:", event.meta);
2096
- });
2097
-
2098
- // Listen for workflow completion/failure
2099
- client.on("workflowStatus", (event) => {
2100
- console.log("Workflow event:", event.workflowKey, event.runKey);
2101
- // "completed" | "failed" | "terminated" | "skipped"
2102
- console.log("Status:", event.status);
2103
-
2104
- if (event.status === "completed") {
2105
- console.log("Output:", event.output);
2106
- }
2107
-
2108
- if (event.status === "failed") {
2109
- console.log("Error:", event.error);
2110
- // Classified failures also carry a code, so you branch on a constant
2111
- // instead of matching `error` text: "LOCK_CONTENTION" | "LOCK_TIMEOUT".
2112
- if (event.errorCode === "LOCK_CONTENTION") {
2113
- console.log("An import is already in progress");
2114
- }
2115
- }
2116
-
2117
- if (event.status === "skipped") {
2118
- // The run did not run and did not fail: its declarative lock was held and
2119
- // the workflow declared `onContention = "ignore"`. No `error` is sent.
2120
- console.log("Did not run:", event.skipReason); // "LOCK_CONTENTION"
2121
- }
2122
- });
2123
- ```
2124
-
2125
- **Note**: To receive workflow events, you must have an active WebSocket connection. Opening a document establishes this connection:
2126
-
2127
- ```typescript
2128
- // Open a document to establish WebSocket for receiving notifications
2129
- await client.documents.open(documentId);
2130
-
2131
- // Now workflow events will be delivered
2132
- const result = await client.workflows.start({ workflowKey: "my-workflow", input: { data: "..." } });
2133
- ```
2134
-
2135
- #### Event Payload
2136
-
2137
- ```typescript
2138
- interface WorkflowStatusEvent {
2139
- type: "workflowStatus";
2140
- workflowKey: string;
2141
- workflowId: string;
2142
- runKey: string;
2143
- runId: string;
2144
- // "skipped" needs this client at 2.1.0 or newer (see Waiting for Workflow
2145
- // Completion). It is the outcome of a run whose declarative lock was held
2146
- // and whose workflow declared `onContention = "ignore"`.
2147
- status: "completed" | "failed" | "terminated" | "skipped";
2148
- output?: any;
2149
- error?: string;
2150
- // Platform classification of a FAILED run. Absent on every other status, and
2151
- // absent on a failure the platform did not classify.
2152
- errorCode?: "LOCK_CONTENTION" | "LOCK_TIMEOUT";
2153
- // Why a "skipped" run did not run. Absent on every other status; a skipped
2154
- // frame carries no `error`.
2155
- skipReason?: "LOCK_CONTENTION";
2156
- contextDocId?: string;
2157
- needsApply?: boolean; // true when workflow requires client-side apply
2158
- startedByUserId?: string;
2159
- meta?: Record<string, any>; // custom metadata passed to workflows.start()
2160
- }
2161
- ```
1828
+ - `status` is `completed`, `failed`, or `timeout`. All three resolve: the call
1829
+ reached the function and the function has an answer. The promise rejects only
1830
+ when the platform refused before the code ran — the `access` gate or a
1831
+ disabled function (`403`), an unknown key (`404`), a function with no pushed
1832
+ code (`409`), or the rate ceiling (`429`, or `503` when the ceiling cannot be
1833
+ checked).
1834
+ - Options are all optional: `input`, `contextDocId` (a document the call is
1835
+ about), `meta` (caller metadata, at most 1 KB encoded), and `timeoutMs`.
1836
+ - `invocationId` names the invocation-log record for the call. Pass it to
1837
+ `primitive functions logs <functionId> --invocation <id>` to read what the
1838
+ function printed and the error it threw.
2162
1839
 
2163
- ### Listing Workflow Runs
1840
+ ### Starting a Long-Running Run
2164
1841
 
2165
- View all workflow runs for the current user:
1842
+ `client.functions.start` runs the same function as a task: it answers with a
1843
+ run id immediately and the code keeps running. Every pushed function accepts
1844
+ both `invoke` and `start`; the caller chooses.
2166
1845
 
2167
1846
  ```typescript
2168
- // List all runs
2169
- const runs = await client.workflows.listRuns();
2170
- console.log("Total runs:", runs.items.length);
2171
-
2172
- runs.items.forEach((run) => {
2173
- console.log(run.runKey, run.status, run.startedAt);
2174
- });
2175
-
2176
- // Filter by workflow
2177
- const filtered = await client.workflows.listRuns({
2178
- workflowKey: "my-workflow",
1847
+ const run = await client.functions.start("order-sync", {
1848
+ input: { orderId },
1849
+ runKey: `order-${orderId}`, // a repeat replays the existing run (existing: true)
2179
1850
  });
2180
1851
 
2181
- // Filter by status
2182
- const running = await client.workflows.listRuns({
2183
- status: "running",
2184
- });
1852
+ // Poll once…
1853
+ const now = await client.functions.getStatus(run.runId);
1854
+ console.log(now.status);
2185
1855
 
2186
- // Filter by document - list runs associated with a specific document
2187
- const docRuns = await client.workflows.listRuns({
2188
- contextDocId: "doc-123",
1856
+ // …or wait for a terminal state.
1857
+ const settled = await client.functions.waitFor<{ confirmedAt: number }>(run.runId, {
1858
+ timeoutMs: 60_000, // default 15 minutes; throws WORKFLOW_WAIT_TIMEOUT past it
2189
1859
  });
2190
-
2191
- // Pagination
2192
- const page1 = await client.workflows.listRuns({ limit: 10 });
2193
- if (page1.cursor) {
2194
- const page2 = await client.workflows.listRuns({
2195
- limit: 10,
2196
- cursor: page1.cursor,
2197
- });
1860
+ if (settled.status === "failed") {
1861
+ console.error("order-sync failed:", settled.error?.message);
1862
+ } else {
1863
+ console.log(settled.status, settled.output?.confirmedAt);
2198
1864
  }
2199
1865
 
2200
- // Sort order - default is newest first (descending by modifiedAt)
2201
- const newestFirst = await client.workflows.listRuns(); // default
2202
- const oldestFirst = await client.workflows.listRuns({ forward: true });
2203
-
2204
- // Combine filters
2205
- const combined = await client.workflows.listRuns({
2206
- contextDocId: "doc-123",
2207
- status: "completed",
2208
- forward: true,
2209
- limit: 20,
2210
- });
2211
- ```
2212
-
2213
- #### Run Record Fields
2214
-
2215
- ```typescript
2216
- type WorkflowRunStatus =
2217
- | "queued"
2218
- | "running"
2219
- | "apply_pending"
2220
- | "apply_claimed"
2221
- | "completed"
2222
- | "failed"
2223
- | "terminated"
2224
- | "missing"
2225
- | "skipped";
2226
-
2227
- interface WorkflowRun {
2228
- runId: string;
2229
- runKey: string;
2230
- instanceId: string;
2231
- workflowId: string;
2232
- workflowKey: string;
2233
- revisionId: string;
2234
- contextDocId?: string;
2235
- status: WorkflowRunStatus;
2236
- startedAt?: string; // ISO timestamp stamped when the run started
2237
- endedAt?: string;
2238
- errorMessage?: string | null; // set when status is "failed", null otherwise
2239
- // The platform's own classification of a failure, when it has one:
2240
- // "LOCK_CONTENTION" (a declarative lock was held under onContention = "fail")
2241
- // or "LOCK_TIMEOUT" (an onContention = "block" budget ran out). null for every
2242
- // unclassified failure. Branch on this rather than on errorMessage text.
2243
- errorCode?: "LOCK_CONTENTION" | "LOCK_TIMEOUT" | null;
2244
- // Why a "skipped" run did not run: "LOCK_CONTENTION". null for any other
2245
- // status. Such a run carries no errorMessage and emits no error analytics.
2246
- skipReason?: "LOCK_CONTENTION" | null;
2247
- meta?: Record<string, any>; // custom metadata passed to workflows.start()
2248
- }
1866
+ // Stop a run that is still going.
1867
+ await client.functions.terminate({ functionKey: "order-sync", runKey: run.runKey });
2249
1868
  ```
2250
1869
 
2251
- ### Terminating a Workflow
1870
+ - `runKey` makes the start idempotent per caller and `contextDocId`: repeating
1871
+ it answers the run that already exists, with `existing: true`, instead of
1872
+ starting a second one.
1873
+ - `waitFor` settles on `completed`, `failed`, or `terminated`. A failed run
1874
+ resolves with `status: "failed"` and its `error`; it does not reject.
1875
+ - `getStatus` is one read. A run id that is not this app's, or does not exist,
1876
+ rejects with `NOT_FOUND`.
2252
1877
 
2253
- Cancel a running workflow:
1878
+ ### Channels
2254
1879
 
2255
- ```typescript
2256
- const result = await client.workflows.terminate({
2257
- workflowKey: "my-workflow-key",
2258
- runKey,
2259
- });
2260
- console.log("Terminated, final status:", result.status);
2261
- ```
2262
-
2263
- If you started the workflow with a specific `contextDocId`, you must provide the same `contextDocId` when terminating:
2264
-
2265
- ```typescript
2266
- // Terminate a workflow started with contextDocId
2267
- const result = await client.workflows.terminate({
2268
- workflowKey: "my-workflow-key",
2269
- runKey,
2270
- contextDocId: "doc-123",
2271
- });
2272
- ```
2273
-
2274
- ### Applying Workflow Results to Documents
2275
-
2276
- When a workflow completes, its result can be applied to a Yjs document by exactly one connected client. This prevents duplicate writes when multiple tabs or devices are connected to the same document.
2277
-
2278
- Use `workflows.define()` to register an apply handler at initialization. The client automatically claims, applies, and confirms workflow results:
2279
-
2280
- ```typescript
2281
- // Define a workflow with its apply handler (call once at startup)
2282
- client.workflows.define("my-workflow-key", {
2283
- onApply: async ({ output, workflowKey, runKey, contextDocId, meta }) => {
2284
- // Use models to apply the workflow result to the document
2285
- const contact = new Contact({
2286
- name: output.contactName,
2287
- email: output.contactEmail,
2288
- });
2289
- await contact.save({ targetDocument: contextDocId });
2290
-
2291
- // `meta` contains custom metadata passed to workflows.start()
2292
- if (meta?.notify) {
2293
- console.log("Apply completed for", meta.source);
2294
- }
2295
- },
2296
- });
2297
- ```
2298
-
2299
- **How it works:**
2300
-
2301
- 1. Workflow completes → server sets status to `apply_pending`
2302
- 2. All connected clients receive a `workflowStatus` event with `needsApply: true`
2303
- 3. The first client to call `claimApply` wins (conditional DynamoDB update)
2304
- 4. The claiming client runs the registered `onApply` handler
2305
- 5. On success, `confirmApply` marks the run as `completed`
2306
- 6. On failure, `releaseApply` releases the claim so another client can retry
2307
- 7. A 30-second lease timeout handles crashed clients
2308
- 8. On reconnect, the server automatically pushes any pending applies — no client-side polling needed
2309
-
2310
- The claim/confirm/release cycle is handled automatically when you use `workflows.define()`. For manual control:
2311
-
2312
- ```typescript
2313
- // Manual claim/confirm flow
2314
- const claim = await client.workflows.claimApply({
2315
- workflowKey: "my-workflow-key",
2316
- runKey: "run-123",
2317
- contextDocId: "doc-456",
2318
- });
2319
-
2320
- if (claim.claimed) {
2321
- try {
2322
- // Apply result to document...
2323
- await client.workflows.confirmApply({
2324
- workflowKey: "my-workflow-key",
2325
- runKey: "run-123",
2326
- contextDocId: "doc-456",
2327
- });
2328
- } catch (err) {
2329
- // Release so another client can retry
2330
- // (handled automatically with workflows.define)
2331
- }
2332
- }
2333
- ```
2334
-
2335
- ### Sending File Attachments (PDFs, Images)
2336
-
2337
- Workflows can process files like PDFs and images. Files must be base64-encoded before sending:
1880
+ A channel delivers what server functions publish to whoever has joined it. A
1881
+ function authorizes the caller for one channel with `ctx.channels.authorize`
1882
+ and returns the grant; the client presents it with `subscribeToChannel`, and
1883
+ every `ctx.channels.publish` to that channel arrives as a `channelMessage`
1884
+ event:
2338
1885
 
2339
1886
  ```typescript
2340
- /**
2341
- * Load a file and convert to base64.
2342
- * Works in browsers with fetch + FileReader or ArrayBuffer.
2343
- */
2344
- async function loadFileAsBase64(url: string): Promise<string> {
2345
- const response = await fetch(url);
2346
- const arrayBuffer = await response.arrayBuffer();
2347
- const bytes = new Uint8Array(arrayBuffer);
2348
- let binary = "";
2349
- for (let i = 0; i < bytes.length; i++) {
2350
- binary += String.fromCharCode(bytes[i]);
2351
- }
2352
- return btoa(binary);
2353
- }
2354
-
2355
- // Load a PDF and send to workflow
2356
- const pdfBase64 = await loadFileAsBase64("/path/to/document.pdf");
2357
-
2358
- const result = await client.workflows.start({
2359
- workflowKey: "extract-pdf-data",
2360
- input: {
2361
- attachments: [
2362
- {
2363
- data: pdfBase64,
2364
- type: "application/pdf",
2365
- },
2366
- ],
2367
- },
2368
- });
2369
- ```
2370
-
2371
- #### Loading from File Input (Browser)
1887
+ // 1. Ask the authorizing function for a grant; it names this caller and one channel.
1888
+ const result = await client.functions.invoke<{ grant: string; expiresAt: number }>(
1889
+ "order-room",
1890
+ { input: { orderId } }
1891
+ );
1892
+ if (result.status !== "completed" || !result.output) return;
2372
1893
 
2373
- ```typescript
2374
- async function fileToBase64(file: File): Promise<string> {
2375
- return new Promise((resolve, reject) => {
2376
- const reader = new FileReader();
2377
- reader.onload = () => {
2378
- const dataUrl = reader.result as string;
2379
- // Remove the data URL prefix (e.g., "data:application/pdf;base64,")
2380
- const base64 = dataUrl.split(",")[1];
2381
- resolve(base64);
2382
- };
2383
- reader.onerror = reject;
2384
- reader.readAsDataURL(file);
2385
- });
2386
- }
1894
+ // 2. Present the grant on the socket the client already has.
1895
+ const subscription = await client.subscribeToChannel(
1896
+ `orders:${orderId}`,
1897
+ result.output.grant
1898
+ );
2387
1899
 
2388
- // Handle file input
2389
- const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
2390
- fileInput.addEventListener("change", async () => {
2391
- const file = fileInput.files?.[0];
2392
- if (!file) return;
2393
-
2394
- const base64Data = await fileToBase64(file);
2395
-
2396
- const result = await client.workflows.start({
2397
- workflowKey: "process-upload",
2398
- input: {
2399
- attachments: [
2400
- {
2401
- data: base64Data,
2402
- type: file.type, // e.g., "image/png", "application/pdf"
2403
- filename: file.name,
2404
- },
2405
- ],
2406
- },
2407
- });
1900
+ client.on("channelMessage", (event) => {
1901
+ // event.channel, event.payload, event.functionKey, event.sentAt
1902
+ console.log(event.channel, event.payload);
2408
1903
  });
2409
- ```
2410
-
2411
- #### Loading from URL (Node.js)
2412
-
2413
- ```typescript
2414
- import * as fs from "fs";
2415
- import * as path from "path";
2416
1904
 
2417
- function loadFileAsBase64Sync(filePath: string): string {
2418
- const buffer = fs.readFileSync(filePath);
2419
- return buffer.toString("base64");
2420
- }
2421
-
2422
- const pdfPath = path.join(__dirname, "document.pdf");
2423
- const pdfBase64 = loadFileAsBase64Sync(pdfPath);
2424
-
2425
- const result = await client.workflows.start({
2426
- workflowKey: "analyze-document",
2427
- input: {
2428
- attachments: [
2429
- {
2430
- data: pdfBase64,
2431
- type: "application/pdf",
2432
- },
2433
- ],
2434
- },
1905
+ // A grant that expired while the socket was down is refused on reconnect —
1906
+ // invoke the function again and subscribe again.
1907
+ client.on("channelSubscribeFailed", (event) => {
1908
+ console.warn("renew", event.channel, event.message);
2435
1909
  });
2436
- ```
2437
-
2438
- ### Complete Example: PDF Processing Workflow
2439
-
2440
- ```typescript
2441
- import { initializeClient } from "js-bao-wss-client";
2442
-
2443
- async function processPDF(pdfUrl: string) {
2444
- const client = await initializeClient({
2445
- apiUrl: "https://api.example.com",
2446
- wsUrl: "wss://ws.example.com",
2447
- appId: "my-app",
2448
- token: "jwt-token",
2449
- databaseConfig: { type: "sqljs" },
2450
- });
2451
-
2452
- // Set up event listener for completion
2453
- const completionPromise = new Promise<any>((resolve) => {
2454
- client.on("workflowStatus", (event) => {
2455
- if (event.status === "completed") {
2456
- resolve(event.output);
2457
- }
2458
- });
2459
- });
2460
-
2461
- // Open a document to establish WebSocket connection
2462
- const { metadata } = await client.documents.create({ title: "temp" });
2463
- await client.documents.open(metadata.documentId);
2464
-
2465
- // Load and encode the PDF
2466
- const response = await fetch(pdfUrl);
2467
- const arrayBuffer = await response.arrayBuffer();
2468
- const bytes = new Uint8Array(arrayBuffer);
2469
- let binary = "";
2470
- for (let i = 0; i < bytes.length; i++) {
2471
- binary += String.fromCharCode(bytes[i]);
2472
- }
2473
- const pdfBase64 = btoa(binary);
2474
-
2475
- // Start the workflow
2476
- const result = await client.workflows.start({
2477
- workflowKey: "extract-pdf-data",
2478
- input: {
2479
- attachments: [
2480
- {
2481
- data: pdfBase64,
2482
- type: "application/pdf",
2483
- },
2484
- ],
2485
- },
2486
- });
2487
-
2488
- console.log("Workflow started:", result.runKey);
2489
-
2490
- // Wait for completion (or poll with getStatus)
2491
- const output = await completionPromise;
2492
- console.log("Extracted data:", output);
2493
1910
 
2494
- // Cleanup
2495
- await client.documents.delete(metadata.documentId);
2496
- await client.destroy();
2497
-
2498
- return output;
2499
- }
1911
+ // Later: leave. Idempotent — the same as client.unsubscribeFromChannel(channel).
1912
+ subscription.unsubscribe();
2500
1913
  ```
2501
1914
 
2502
- ### Polling for Completion
1915
+ A membership lasts until the grant's `expiresAt` (epoch milliseconds, also on
1916
+ the returned subscription). To keep listening, invoke the authorizing function
1917
+ again before then and call `subscribeToChannel` with the new grant; that
1918
+ replaces the membership rather than adding a second one.
2503
1919
 
2504
- If you prefer polling over WebSocket events:
1920
+ ### Deprecated Client APIs
2505
1921
 
2506
- ```typescript
2507
- async function waitForCompletion(
2508
- client: JsBaoClient,
2509
- workflowKey: string,
2510
- runKey: string,
2511
- timeoutMs = 60000,
2512
- intervalMs = 2000
2513
- ): Promise<any> {
2514
- const startTime = Date.now();
1922
+ These client APIs still work but are deprecated. Each has a server-function
1923
+ replacement:
2515
1924
 
2516
- while (Date.now() - startTime < timeoutMs) {
2517
- const status = await client.workflows.getStatus({ workflowKey, runKey });
1925
+ - `client.workflows.*` — call a server function: `client.functions.invoke` for a result, or `client.functions.start` / `getStatus` / `waitFor` for a long-running run.
1926
+ - `client.prompts.execute` — call a server function that runs the prompt with `ctx.prompts.run`.
1927
+ - `client.integrations.call` — call a server function that calls the integration with `ctx.integrations.call`.
1928
+ - `client.databases.*` — call a server function that reads and writes records with `ctx.db` and manages databases with `ctx.api.databases`; for live updates, join a channel the function publishes to.
1929
+ - `client.cronTriggers.*` — schedule a server function by declaring `[[function.triggers.cron]]` in its config.
2518
1930
 
2519
- if (status.status === "completed") {
2520
- return status.output;
2521
- }
2522
-
2523
- if (status.status === "failed") {
2524
- throw new Error(`Workflow failed: ${status.error}`);
2525
- }
2526
-
2527
- if (status.status === "terminated") {
2528
- throw new Error("Workflow was terminated");
2529
- }
2530
-
2531
- // Still running, wait and retry
2532
- await new Promise((r) => setTimeout(r, intervalMs));
2533
- }
2534
-
2535
- throw new Error("Workflow timed out");
2536
- }
2537
-
2538
- // Usage
2539
- const result = await client.workflows.start({ workflowKey: "my-workflow", input: { data: "..." } });
2540
- const output = await waitForCompletion(client, "my-workflow", result.runKey);
2541
- ```
2542
-
2543
- ## Online Databases
2544
-
2545
- Online databases provide schemaless record storage with user permissions, groups, and group-based access control. All database operations are accessed through `client.databases`.
2546
-
2547
- ### Database Management
2548
-
2549
- ```typescript
2550
- // Create a database
2551
- const db = await client.databases.create({ title: "My Database" });
2552
- console.log(db.databaseId, db.title);
2553
-
2554
- // List databases accessible to the current user
2555
- const databases = await client.databases.list();
2556
-
2557
- // Get database details
2558
- const info = await client.databases.get(databaseId);
2559
-
2560
- // Delete a database (owner only)
2561
- await client.databases.delete(databaseId);
2562
- ```
2563
-
2564
- ### Connecting to a Database (Record Engine)
2565
-
2566
- Use `connect()` to get a `DoDb` record engine instance for reading and writing records:
2567
-
2568
- ```typescript
2569
- const db = client.databases.connect(databaseId);
2570
-
2571
- // Insert a record
2572
- const record = await db.insert("contacts", { name: "Alice", email: "alice@example.com" });
2573
-
2574
- // Query records
2575
- const contacts = await db.query("contacts");
2576
-
2577
- // Update a record
2578
- await db.update("contacts", record.id, { email: "new@example.com" });
2579
-
2580
- // Delete a record
2581
- await db.delete("contacts", record.id);
2582
- ```
2583
-
2584
- ### Permissions
2585
-
2586
- Control per-user access to databases. Only the database owner can add or remove managers.
2587
-
2588
- ```typescript
2589
- // List permissions on a database
2590
- const perms = await client.databases.listPermissions(databaseId);
2591
-
2592
- // Add a user as a manager
2593
- await client.databases.addManager(databaseId, {
2594
- userId: "user-123",
2595
- });
2596
-
2597
- // Remove a manager
2598
- await client.databases.removeManager(databaseId, "user-123");
2599
- ```
2600
-
2601
- > **Deprecated:** `grantPermission()` and `revokePermission()` still work but are deprecated aliases for `addManager()` and `removeManager()`.
2602
-
2603
- ### Groups
1931
+ ## Groups
2604
1932
 
2605
1933
  Groups allow batch permission grants. A group has a `groupType` and `groupId`.
2606
1934
 
@@ -2648,90 +1976,6 @@ await client.groups.removeMember("team", "engineering", { email: "alice@example.
2648
1976
  const memberships = await client.groups.listUserMemberships("user-123");
2649
1977
  ```
2650
1978
 
2651
- ### Group Permissions on Databases
2652
-
2653
- Grant database access to an entire group instead of individual users:
2654
-
2655
- ```typescript
2656
- // Grant a group permission on a database
2657
- await client.databases.grantGroupPermission(databaseId, {
2658
- groupType: "team",
2659
- groupId: "engineering",
2660
- permission: "read-write",
2661
- });
2662
-
2663
- // List group permissions
2664
- const gperms = await client.databases.listGroupPermissions(databaseId);
2665
-
2666
- // Revoke group permission
2667
- await client.databases.revokeGroupPermission(databaseId, "team", "engineering");
2668
- ```
2669
-
2670
- ## Integrations API
2671
-
2672
- Proxy HTTP calls through the tenant-specific integrations defined in the admin UI:
2673
-
2674
- ```typescript
2675
- const response = await client.integrations.call({
2676
- integrationKey: "weather-api",
2677
- method: "GET",
2678
- path: "/current",
2679
- query: { city: "San Francisco" },
2680
- headers: { "X-Debug": "true" },
2681
- });
2682
-
2683
- console.log(response.status); // Upstream status code
2684
- console.log(response.body); // JSON returned by the provider
2685
- console.log(response.traceId); // Proxy trace id (mirrors admin logs)
2686
- console.log(response.durationMs); // Milliseconds spent in the worker
2687
- ```
2688
-
2689
- - `integrationKey` – slug chosen in the admin UI. Keys are per-app.
2690
- - `method`, `path`, `query`, `headers`, `body` – forwarded exactly as provided, but the worker enforces the integration’s allowlisted methods/paths, forwarded headers/query params, max body size, and timeout.
2691
- - Success responses include the upstream `status`, `headers`, `body`, optional `traceId`, and `durationMs`.
2692
-
2693
- ### Errors
2694
-
2695
- `client.integrations.call` throws `JsBaoError` with rich codes:
2696
-
2697
- - `OFFLINE` – The SDK is forced offline (`networkMode = "offline"`).
2698
- - `ACCESS_DENIED` – Missing/expired JWT.
2699
- - `INTEGRATION_NOT_FOUND` – Integration removed/archived or typo in `integrationKey`.
2700
- - `INTEGRATION_SECRET_MISSING` – Retired. Current servers never emit this code; it is retained for compatibility with older servers.
2701
- - `INTEGRATION_REQUEST_INVALID` – Method/path/query/body violate guard rails (`DISALLOWED_METHOD`, `REQUEST_BODY_TOO_LARGE`, etc.).
2702
- - `INTEGRATION_PROXY_FAILED` – Upstream timeout/5xx. Inspect `error.details.traceId` to correlate with admin logs.
2703
-
2704
- Example handler:
2705
-
2706
- ```typescript
2707
- import { isJsBaoError } from "js-bao-wss-client";
2708
-
2709
- try {
2710
- await client.integrations.call({ integrationKey: "crm", method: "POST", path: "/contacts", body: { email: "hi" } });
2711
- } catch (error) {
2712
- if (isJsBaoError(error)) {
2713
- switch (error.code) {
2714
- case "INTEGRATION_NOT_FOUND":
2715
- alert("This integration is not available. Contact your admin.");
2716
- break;
2717
- case "INTEGRATION_SECRET_MISSING":
2718
- alert("Configuration incomplete. Try again later.");
2719
- break;
2720
- case "INTEGRATION_REQUEST_INVALID":
2721
- console.error("Request violates integration config", error.details);
2722
- break;
2723
- case "INTEGRATION_PROXY_FAILED":
2724
- console.error("Upstream failed", error.details?.traceId);
2725
- break;
2726
- default:
2727
- throw error;
2728
- }
2729
- } else {
2730
- throw error;
2731
- }
2732
- }
2733
- ```
2734
-
2735
1979
  ## Real-time Collaboration
2736
1980
 
2737
1981
  ### Open and Edit Documents
@@ -3052,7 +2296,7 @@ await client.me.update({
3052
2296
  avatarUrl: null,
3053
2297
  });
3054
2298
 
3055
- // Upload avatar image (stored in R2, proxied through the API)
2299
+ // Upload avatar image (stored by the platform, proxied through the API)
3056
2300
  const imageBlob = await fetch("/path/to/image.png").then((r) => r.blob());
3057
2301
  const { avatarUrl } = await client.me.uploadAvatar(imageBlob, "image/png");
3058
2302
  console.log("New avatar URL:", avatarUrl);