@agent-native/core 0.119.5 → 0.120.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 (118) hide show
  1. package/corpus/README.md +2 -2
  2. package/corpus/core/CHANGELOG.md +18 -0
  3. package/corpus/core/package.json +1 -1
  4. package/corpus/core/src/agent/durable-background.ts +1 -0
  5. package/corpus/core/src/cli/index.ts +64 -3
  6. package/corpus/core/src/deploy/build.ts +144 -2
  7. package/corpus/core/src/deploy/workspace-deploy.ts +123 -1
  8. package/corpus/core/src/integrations/integration-durable-dispatch-config.ts +22 -0
  9. package/corpus/core/src/integrations/integration-durable-dispatch.ts +190 -0
  10. package/corpus/core/src/integrations/pending-tasks-retry-job.ts +175 -87
  11. package/corpus/core/src/integrations/pending-tasks-store.ts +114 -11
  12. package/corpus/core/src/integrations/plugin.ts +113 -60
  13. package/corpus/core/src/integrations/task-queue-stats.ts +68 -15
  14. package/corpus/core/src/integrations/webhook-handler.ts +21 -46
  15. package/corpus/core/src/server/auth.ts +6 -0
  16. package/corpus/core/src/templates/default/.agents/skills/integration-webhooks/SKILL.md +63 -22
  17. package/corpus/core/src/templates/default/package.json +1 -1
  18. package/corpus/core/src/templates/headless/.agents/skills/integration-webhooks/SKILL.md +63 -22
  19. package/corpus/core/src/templates/workspace-core/.agents/skills/integration-webhooks/SKILL.md +63 -22
  20. package/corpus/templates/assets/package.json +1 -1
  21. package/corpus/templates/brain/package.json +1 -1
  22. package/corpus/templates/calendar/package.json +1 -1
  23. package/corpus/templates/clips/package.json +1 -1
  24. package/corpus/templates/content/actions/add-database-item.ts +1 -0
  25. package/corpus/templates/content/actions/set-document-property.ts +0 -4
  26. package/corpus/templates/content/app/components/editor/DocumentBlockFields.tsx +10 -1
  27. package/corpus/templates/content/app/components/editor/DocumentEditor.tsx +10 -4
  28. package/corpus/templates/content/app/components/editor/DocumentInfoPanel.tsx +5 -1
  29. package/corpus/templates/content/app/components/editor/DocumentProperties.tsx +6 -0
  30. package/corpus/templates/content/app/components/editor/database/DatabaseView.tsx +52 -33
  31. package/corpus/templates/content/app/components/editor/previewDocumentSaveController.ts +16 -4
  32. package/corpus/templates/content/app/components/sidebar/DocumentSidebar.tsx +16 -7
  33. package/corpus/templates/content/app/hooks/use-create-page.ts +8 -3
  34. package/corpus/templates/content/app/hooks/use-document-properties.ts +1 -0
  35. package/corpus/templates/content/app/hooks/use-documents.ts +3 -1
  36. package/corpus/templates/content/app/lib/optimistic-document.ts +29 -0
  37. package/corpus/templates/content/changelog/2026-07-21-editing-a-database-property-no-longer-refreshes-unrelated-co.md +6 -0
  38. package/corpus/templates/content/changelog/2026-07-23-new-database-ready.md +6 -0
  39. package/corpus/templates/content/package.json +1 -1
  40. package/corpus/templates/content/shared/api.ts +1 -0
  41. package/corpus/templates/design/package.json +1 -1
  42. package/corpus/templates/dispatch/changelog/2026-07-21-recover-stalled-slack-agent-work.md +6 -0
  43. package/corpus/templates/dispatch/package.json +1 -1
  44. package/corpus/templates/forms/package.json +1 -1
  45. package/corpus/templates/macros/package.json +1 -1
  46. package/corpus/templates/mail/package.json +1 -1
  47. package/corpus/templates/slides/package.json +1 -1
  48. package/dist/agent/durable-background.d.ts +1 -0
  49. package/dist/agent/durable-background.d.ts.map +1 -1
  50. package/dist/agent/durable-background.js +1 -0
  51. package/dist/agent/durable-background.js.map +1 -1
  52. package/dist/cli/index.js +53 -2
  53. package/dist/cli/index.js.map +1 -1
  54. package/dist/collab/routes.d.ts +1 -1
  55. package/dist/deploy/build.d.ts +3 -0
  56. package/dist/deploy/build.d.ts.map +1 -1
  57. package/dist/deploy/build.js +106 -3
  58. package/dist/deploy/build.js.map +1 -1
  59. package/dist/deploy/workspace-deploy.d.ts.map +1 -1
  60. package/dist/deploy/workspace-deploy.js +103 -2
  61. package/dist/deploy/workspace-deploy.js.map +1 -1
  62. package/dist/file-upload/actions/upload-image.d.ts +1 -1
  63. package/dist/integrations/integration-durable-dispatch-config.d.ts +7 -0
  64. package/dist/integrations/integration-durable-dispatch-config.d.ts.map +1 -0
  65. package/dist/integrations/integration-durable-dispatch-config.js +16 -0
  66. package/dist/integrations/integration-durable-dispatch-config.js.map +1 -0
  67. package/dist/integrations/integration-durable-dispatch.d.ts +23 -0
  68. package/dist/integrations/integration-durable-dispatch.d.ts.map +1 -0
  69. package/dist/integrations/integration-durable-dispatch.js +115 -0
  70. package/dist/integrations/integration-durable-dispatch.js.map +1 -0
  71. package/dist/integrations/pending-tasks-retry-job.d.ts +13 -1
  72. package/dist/integrations/pending-tasks-retry-job.d.ts.map +1 -1
  73. package/dist/integrations/pending-tasks-retry-job.js +133 -74
  74. package/dist/integrations/pending-tasks-retry-job.js.map +1 -1
  75. package/dist/integrations/pending-tasks-store.d.ts +14 -2
  76. package/dist/integrations/pending-tasks-store.d.ts.map +1 -1
  77. package/dist/integrations/pending-tasks-store.js +75 -11
  78. package/dist/integrations/pending-tasks-store.js.map +1 -1
  79. package/dist/integrations/plugin.d.ts.map +1 -1
  80. package/dist/integrations/plugin.js +88 -55
  81. package/dist/integrations/plugin.js.map +1 -1
  82. package/dist/integrations/task-queue-stats.d.ts +15 -3
  83. package/dist/integrations/task-queue-stats.d.ts.map +1 -1
  84. package/dist/integrations/task-queue-stats.js +45 -15
  85. package/dist/integrations/task-queue-stats.js.map +1 -1
  86. package/dist/integrations/webhook-handler.d.ts +1 -1
  87. package/dist/integrations/webhook-handler.d.ts.map +1 -1
  88. package/dist/integrations/webhook-handler.js +18 -42
  89. package/dist/integrations/webhook-handler.js.map +1 -1
  90. package/dist/observability/routes.d.ts +6 -6
  91. package/dist/provider-api/actions/custom-provider-registration.d.ts +5 -5
  92. package/dist/provider-api/actions/provider-api.d.ts +4 -4
  93. package/dist/resources/handlers.d.ts +1 -1
  94. package/dist/server/auth.d.ts.map +1 -1
  95. package/dist/server/auth.js +5 -0
  96. package/dist/server/auth.js.map +1 -1
  97. package/dist/server/transcribe-voice.d.ts +1 -1
  98. package/dist/templates/default/.agents/skills/integration-webhooks/SKILL.md +63 -22
  99. package/dist/templates/default/package.json +1 -1
  100. package/dist/templates/headless/.agents/skills/integration-webhooks/SKILL.md +63 -22
  101. package/dist/templates/workspace-core/.agents/skills/integration-webhooks/SKILL.md +63 -22
  102. package/package.json +1 -1
  103. package/src/agent/durable-background.ts +1 -0
  104. package/src/cli/index.ts +64 -3
  105. package/src/deploy/build.ts +144 -2
  106. package/src/deploy/workspace-deploy.ts +123 -1
  107. package/src/integrations/integration-durable-dispatch-config.ts +22 -0
  108. package/src/integrations/integration-durable-dispatch.ts +190 -0
  109. package/src/integrations/pending-tasks-retry-job.ts +175 -87
  110. package/src/integrations/pending-tasks-store.ts +114 -11
  111. package/src/integrations/plugin.ts +113 -60
  112. package/src/integrations/task-queue-stats.ts +68 -15
  113. package/src/integrations/webhook-handler.ts +21 -46
  114. package/src/server/auth.ts +6 -0
  115. package/src/templates/default/.agents/skills/integration-webhooks/SKILL.md +63 -22
  116. package/src/templates/default/package.json +1 -1
  117. package/src/templates/headless/.agents/skills/integration-webhooks/SKILL.md +63 -22
  118. package/src/templates/workspace-core/.agents/skills/integration-webhooks/SKILL.md +63 -22
@@ -16,10 +16,10 @@ metadata:
16
16
 
17
17
  Integration webhooks (Slack, Telegram, WhatsApp, email, Google Docs, etc.) must
18
18
  **enqueue work to SQL and return 200 immediately**, then process the work in a
19
- **separate fresh function execution** kicked off by a self-fired HTTP POST. A
20
- recurring retry job sweeps anything that gets stuck. This pattern works on every
21
- serverless host (Netlify, Vercel, Cloudflare Workers, Fly, Render, Node) without
22
- relying on platform-specific background-execution features.
19
+ **separate fresh function execution** kicked off by a self-fired HTTP POST. SQL
20
+ is always the source of truth. Long-lived hosts use an in-process retry loop as
21
+ a best-effort safety net; serverless deployments must use an external durable
22
+ sweep when recovery cannot depend on a process staying alive.
23
23
 
24
24
  Do not run agent loops inside the webhook handler itself. Do not rely on
25
25
  fire-and-forget `Promise`s after `return`ing from a serverless handler — they get
@@ -38,8 +38,9 @@ Past attempts that don't work cross-host:
38
38
  freeze the execution context the moment the response goes out. The promise
39
39
  is silently killed, the user gets no reply, and there's no error in the
40
40
  logs.
41
- - **Netlify Background Functions** — Netlify-only, requires a `-background`
42
- filename suffix, breaks on every other host.
41
+ - **A platform background function as the queue** — host-specific execution is
42
+ useful as a delivery mechanism, but it cannot replace the SQL row or atomic
43
+ claim shared by every host.
43
44
  - **Cloudflare `event.waitUntil()`** — CF Workers only, not portable.
44
45
  - **Vercel Fluid / `after()`** — Vercel-only, gated behind specific runtimes.
45
46
  - **A long-lived in-process queue** — fine on a single Node box, but on
@@ -62,8 +63,9 @@ and a recurring job is the safety net.
62
63
  2. INSERT INTO integration_pending_tasks
63
64
  (status='pending', payload=...)
64
65
 
65
- 3. fetch(POST /integrations/_process-task)
66
- — fire-and-forget, NO await on body
66
+ 3. dispatch POST /integrations/process-task
67
+ portable self-fire by default
68
+ — acknowledged background handoff when enabled
67
69
 
68
70
  4. return 200 to platform ◄───────────┘
69
71
 
@@ -79,7 +81,7 @@ and a recurring job is the safety net.
79
81
 
80
82
 
81
83
  ┌──────────────────────────────────────────────┐
82
- Recurring job (every 60s) — safety net
84
+ Recovery sweep (every 60s) — safety net
83
85
  │ Re-fires processor for tasks stuck in │
84
86
  │ 'pending' or 'processing' beyond timeout. │
85
87
  │ Caps retries at 3 then marks 'failed'. │
@@ -98,6 +100,7 @@ loop.
98
100
  | `packages/core/src/integrations/webhook-handler.ts` | Verifies signature, parses, enqueues task, fires processor |
99
101
  | `packages/core/src/integrations/pending-tasks-store.ts` | SQL queue: `insertPendingTask`, `claimPendingTask`, `markTaskCompleted`, `markTaskFailed` |
100
102
  | `packages/core/src/integrations/pending-tasks-retry-job.ts` | Recurring retry sweep (`startPendingTasksRetryJob`, `retryStuckPendingTasks`) |
103
+ | `packages/core/src/integrations/integration-durable-dispatch.ts` | Default-off acknowledged dispatch, scoped rollout, and outcome recording |
101
104
  | `packages/core/src/integrations/types.ts` | `PlatformAdapter`, `IncomingMessage`, `OutgoingMessage` |
102
105
  | `packages/core/src/integrations/adapters/{slack,telegram,whatsapp,email,google-docs}.ts` | One adapter per platform |
103
106
 
@@ -108,7 +111,8 @@ All under `/_agent-native/integrations/`:
108
111
  | Method | Path | Purpose |
109
112
  | ------ | -------------------------- | ------------------------------------------------------------- |
110
113
  | POST | `/:platform/webhook` | Platform pings this. Verifies, enqueues, returns 200 quickly. |
111
- | POST | `/_process-task` | Self-webhook target. Claims a task and runs the agent loop. |
114
+ | POST | `/process-task` | Processor target. Claims a task and runs the agent loop. |
115
+ | POST | `/retry-stuck-tasks` | Signed, bounded recovery sweep for durable schedulers. |
112
116
  | GET | `/status` | All integrations status (settings UI). |
113
117
  | GET | `/:platform/status` | One platform's status. |
114
118
  | POST | `/:platform/enable` | Enable an integration. |
@@ -129,6 +133,10 @@ CREATE TABLE IF NOT EXISTS integration_pending_tasks (
129
133
  org_id TEXT,
130
134
  status TEXT NOT NULL, -- pending | processing | completed | failed
131
135
  attempts INTEGER NOT NULL DEFAULT 0,
136
+ dispatch_attempts INTEGER NOT NULL DEFAULT 0,
137
+ last_dispatch_at INTEGER,
138
+ last_dispatch_outcome TEXT,
139
+ dispatch_scope TEXT, -- persisted channel scope for recovery
132
140
  error_message TEXT,
133
141
  created_at INTEGER NOT NULL,
134
142
  updated_at INTEGER NOT NULL,
@@ -136,6 +144,8 @@ CREATE TABLE IF NOT EXISTS integration_pending_tasks (
136
144
  );
137
145
  CREATE INDEX IF NOT EXISTS idx_pending_tasks_status_created
138
146
  ON integration_pending_tasks(status, created_at);
147
+ CREATE INDEX IF NOT EXISTS idx_pending_tasks_dispatch_scope
148
+ ON integration_pending_tasks(platform, dispatch_scope);
139
149
  ```
140
150
 
141
151
  The store layer creates this lazily on first use via `ensureTable()` and uses
@@ -143,10 +153,38 @@ The store layer creates this lazily on first use via `ensureTable()` and uses
143
153
 
144
154
  `claimPendingTask` is the critical concurrency primitive: it atomically flips
145
155
  `pending` → `processing` and increments `attempts`, returning `null` if another
146
- worker beat us to it. Both the initial fire-and-forget call and the retry job
156
+ worker beat us to it. Both the initial dispatch and every recovery sweep
147
157
  funnel through the same processor endpoint, and `claimPendingTask` is what
148
158
  prevents the same task from being processed twice.
149
159
 
160
+ ### Netlify durable recovery
161
+
162
+ The portable self-dispatch remains the default. To emit and use Netlify's
163
+ acknowledged background worker plus a one-minute scheduled recovery function,
164
+ set `AGENT_INTEGRATION_DURABLE_DISPATCH=true` at build and runtime. A production
165
+ rollout can be narrowed with the comma-separated
166
+ `AGENT_INTEGRATION_DURABLE_DISPATCH_SCOPES` allowlist. Supported values are
167
+ `<platform>:*`, `<platform>:<external-thread-id>`, and, for adapters that expose
168
+ one, `<platform>:<channel-id>` (for example `slack:C123`).
169
+ Channel-scoped handoffs persist that scope separately from the provider thread
170
+ identity so a later bounded recovery sweep applies the same allowlist decision.
171
+
172
+ Workspace deploys emit this pair only for the Dispatch control-plane app, which
173
+ owns workspace messaging integrations. Standalone templates emit their own
174
+ pair when the same flag is enabled.
175
+
176
+ The background handoff waits only for Netlify's enqueue acknowledgement, not
177
+ for the agent loop. A failed or non-2xx acknowledgement falls back to the
178
+ portable processor. The scheduled function calls the HMAC-authenticated,
179
+ bounded `/retry-stuck-tasks` route; its atomic compare-and-set update prevents
180
+ overlapping sweeps from dispatching the same stale row twice.
181
+ Rows acknowledged by the 15-minute background worker use a 16-minute stale
182
+ cutoff, while portable synchronous dispatches retain the shorter serverless
183
+ cutoff. Apply that lease predicate before `LIMIT` so healthy long runs cannot
184
+ hide recoverable work. The background processor writes its lease marker in the
185
+ same atomic update that claims the row; best-effort dispatch telemetry is not
186
+ trusted for replay safety.
187
+
150
188
  ## Adding a New Platform Adapter
151
189
 
152
190
  1. **Implement `PlatformAdapter`** in `packages/core/src/integrations/adapters/<platform>.ts`:
@@ -245,9 +283,10 @@ so a normal long-running reply is safe.
245
283
 
246
284
  ## Cross-Platform Considerations
247
285
 
248
- - **No platform-specific background APIs.** No `waitUntil`, no
249
- `-background.ts` filenames, no Vercel `after()`. The pattern works
250
- identically on every host because it only uses `fetch()` and SQL.
286
+ - **Portable correctness, optional host acceleration.** Every host uses SQL,
287
+ HMAC-authenticated processor routes, and atomic claims. A host-specific
288
+ background function or scheduler may provide a more reliable wake-up, but
289
+ disabling it must preserve the portable path.
251
290
  - **No assumed runtime.** The processor endpoint is a normal H3 handler under
252
291
  `/_agent-native/`. It runs wherever the rest of the framework runs.
253
292
  - **No persistent in-memory state.** The dedup map in the webhook handler is
@@ -262,11 +301,12 @@ so a normal long-running reply is safe.
262
301
 
263
302
  ## Why Fire-and-Forget on Serverless Is Unreliable
264
303
 
265
- Even though the webhook handler does `fetch(processorUrl, ...)` without
266
- awaiting the response body, that initial dispatch is **not** guaranteed to
267
- complete before the function freezes. In practice it usually does — the TCP
268
- connect + write happens quickly but the recurring retry job is the safety
269
- net for the cases where:
304
+ The portable webhook path gives the outbound processor request a short head
305
+ start but does not wait for the response body, so that initial dispatch is
306
+ **not** guaranteed to complete before the function freezes. On a long-lived
307
+ host, the in-process retry loop is a best-effort safety net. On Netlify with
308
+ durable dispatch enabled, the initial handoff is acknowledged and the external
309
+ scheduled function supplies the recovery wake-up for cases where:
270
310
 
271
311
  - The serverless platform froze the handler before the outbound `fetch`
272
312
  flushed its bytes.
@@ -278,8 +318,9 @@ Tasks stuck in `pending` for >90s or `processing` for >5min get re-fired up to
278
318
  3 times. After 3 attempts they're marked `failed` permanently so we stop
279
319
  spamming the processor.
280
320
 
281
- **Never assume the initial fire-and-forget succeeded.** Always rely on the
282
- queue + retry job for at-least-once delivery.
321
+ **Never assume the initial dispatch succeeded.** Rely on the SQL queue, atomic
322
+ claim, and a recovery mechanism whose lifetime is independent of the original
323
+ request. An in-process timer alone is not that mechanism on serverless.
283
324
 
284
325
  ## Debugging Checklist
285
326
 
@@ -292,7 +333,7 @@ queue + retry job for at-least-once delivery.
292
333
  4. **Status?** `pending` means the processor never picked it up — check that
293
334
  `_process-task` is reachable from the box itself (the self-fetch must work
294
335
  over the public URL). `processing` for over 5 minutes means the processor
295
- died mid-run — the retry job will pick it up.
336
+ died mid-run — a live recovery sweep will pick it up.
296
337
  5. **Failed?** Check `error_message` and `attempts`. After 3 attempts the row
297
338
  is parked at `failed` and won't be retried.
298
339
  6. **Reply not delivered?** The processor likely succeeded but
@@ -4,7 +4,7 @@
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "scripts": {
7
- "dev": "agent-native dev",
7
+ "dev": "agent-native dev --inspect",
8
8
  "build": "agent-native build",
9
9
  "start": "agent-native start",
10
10
  "test": "vitest --run",
@@ -4,7 +4,7 @@
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "scripts": {
7
- "dev": "agent-native dev",
7
+ "dev": "agent-native dev --inspect",
8
8
  "build": "agent-native build",
9
9
  "start": "agent-native start",
10
10
  "test": "vitest --run --config vitest.config.ts",
@@ -4,7 +4,7 @@
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "scripts": {
7
- "dev": "agent-native dev",
7
+ "dev": "agent-native dev --inspect",
8
8
  "build": "agent-native build",
9
9
  "start": "agent-native start",
10
10
  "test": "vitest --run",
@@ -4,7 +4,7 @@
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "scripts": {
7
- "dev": "agent-native dev",
7
+ "dev": "agent-native dev --inspect",
8
8
  "build": "agent-native build",
9
9
  "start": "agent-native start",
10
10
  "typecheck": "agent-native typecheck",
@@ -217,6 +217,7 @@ export default defineAction({
217
217
  ...(await getContentDatabaseResponse(databaseId)),
218
218
  createdItemId: itemId,
219
219
  createdDocumentId: documentId,
220
+ createdDocumentUpdatedAt: now,
220
221
  };
221
222
  },
222
223
  });
@@ -1,5 +1,4 @@
1
1
  import { defineAction } from "@agent-native/core";
2
- import { writeAppState } from "@agent-native/core/application-state";
3
2
  import { assertAccess } from "@agent-native/core/sharing";
4
3
  import { and, eq } from "drizzle-orm";
5
4
  import { z } from "zod";
@@ -87,7 +86,6 @@ export default defineAction({
87
86
  now,
88
87
  });
89
88
  }
90
- await writeAppState("refresh-signal", { ts: Date.now() });
91
89
  return {
92
90
  documentId,
93
91
  databaseId: database.id,
@@ -133,8 +131,6 @@ export default defineAction({
133
131
  });
134
132
  }
135
133
 
136
- await writeAppState("refresh-signal", { ts: Date.now() });
137
-
138
134
  return {
139
135
  documentId,
140
136
  databaseId: database.id,
@@ -38,6 +38,7 @@ const BLOCK_FIELD_DRAG_THRESHOLD = 6;
38
38
 
39
39
  interface DocumentBlockFieldsProps {
40
40
  documentId: string;
41
+ databaseDocumentId: string;
41
42
  canEdit: boolean;
42
43
  /**
43
44
  * The fully-wired collaborative body editor for the primary "Content" field.
@@ -238,6 +239,7 @@ export function blockFieldsRenderState(args: {
238
239
  */
239
240
  export function DocumentBlockFields({
240
241
  documentId,
242
+ databaseDocumentId,
241
243
  canEdit,
242
244
  primaryEditor,
243
245
  }: DocumentBlockFieldsProps) {
@@ -303,6 +305,7 @@ export function DocumentBlockFields({
303
305
  // field while SAVING to another across an identity change.
304
306
  key={`${documentId}:${state.field.definition.id}`}
305
307
  documentId={documentId}
308
+ databaseDocumentId={databaseDocumentId}
306
309
  property={state.field}
307
310
  canEdit={canEdit}
308
311
  />
@@ -318,6 +321,7 @@ export function DocumentBlockFields({
318
321
  return (
319
322
  <MultiBlockFields
320
323
  documentId={documentId}
324
+ databaseDocumentId={databaseDocumentId}
321
325
  canEdit={canEdit}
322
326
  blockFields={state.fields}
323
327
  primaryEditor={primaryEditor}
@@ -329,12 +333,14 @@ export function DocumentBlockFields({
329
333
 
330
334
  function MultiBlockFields({
331
335
  documentId,
336
+ databaseDocumentId,
332
337
  canEdit,
333
338
  blockFields,
334
339
  primaryEditor,
335
340
  t,
336
341
  }: {
337
342
  documentId: string;
343
+ databaseDocumentId: string;
338
344
  canEdit: boolean;
339
345
  blockFields: DocumentProperty[];
340
346
  primaryEditor: ReactNode;
@@ -504,6 +510,7 @@ function MultiBlockFields({
504
510
  // doc's edits to the old field's closure.
505
511
  key={`${documentId}:${property.definition.id}`}
506
512
  documentId={documentId}
513
+ databaseDocumentId={databaseDocumentId}
507
514
  property={property}
508
515
  canEdit={canEdit}
509
516
  />
@@ -789,14 +796,16 @@ export function useBlockFieldEditor({
789
796
  */
790
797
  function AdditionalBlockEditor({
791
798
  documentId,
799
+ databaseDocumentId,
792
800
  property,
793
801
  canEdit,
794
802
  }: {
795
803
  documentId: string;
804
+ databaseDocumentId: string;
796
805
  property: DocumentProperty;
797
806
  canEdit: boolean;
798
807
  }) {
799
- const setProperty = useSetDocumentProperty(documentId);
808
+ const setProperty = useSetDocumentProperty(documentId, databaseDocumentId);
800
809
  const propertyId = property.definition.id;
801
810
  const initialContent =
802
811
  typeof property.value === "string" ? property.value : "";
@@ -80,6 +80,7 @@ import {
80
80
  canWriteLinkedLocalSource,
81
81
  writeDocumentToLinkedLocalSource,
82
82
  } from "@/lib/local-content-source-files";
83
+ import { isDatabaseChoicePending } from "@/lib/optimistic-document";
83
84
  import { cn } from "@/lib/utils";
84
85
 
85
86
  import {
@@ -1519,6 +1520,10 @@ function DocumentEditorBody({ documentId, document }: DocumentEditorBodyProps) {
1519
1520
  );
1520
1521
  const defaultIconKind = documentEditorDefaultIconKind(document);
1521
1522
  const isDatabasePage = Boolean(document.database);
1523
+ const databaseChoicePending = isDatabaseChoicePending(
1524
+ document,
1525
+ createDatabase.isPending,
1526
+ );
1522
1527
  const showNewDocumentTypeChooser =
1523
1528
  canEdit &&
1524
1529
  !isLocalFileDocument &&
@@ -1791,12 +1796,10 @@ function DocumentEditorBody({ documentId, document }: DocumentEditorBodyProps) {
1791
1796
  type="button"
1792
1797
  variant="outline"
1793
1798
  className="justify-start gap-2"
1794
- disabled={
1795
- !editorCanEdit || createDatabase.isPending
1796
- }
1799
+ disabled={!editorCanEdit || databaseChoicePending}
1797
1800
  onClick={() => void handleChooseDatabase()}
1798
1801
  >
1799
- {createDatabase.isPending ? (
1802
+ {databaseChoicePending ? (
1800
1803
  <IconLoader2 className="animate-spin" />
1801
1804
  ) : (
1802
1805
  <IconDatabase />
@@ -1867,6 +1870,9 @@ function DocumentEditorBody({ documentId, document }: DocumentEditorBodyProps) {
1867
1870
  return (
1868
1871
  <DocumentBlockFields
1869
1872
  documentId={documentId}
1873
+ databaseDocumentId={
1874
+ document.databaseMembership.databaseDocumentId
1875
+ }
1870
1876
  canEdit={editorCanEdit}
1871
1877
  primaryEditor={primaryEditor}
1872
1878
  />
@@ -32,7 +32,11 @@ export function DocumentInfoPanel({
32
32
  onSave={onSaveDescription}
33
33
  />
34
34
  {document.databaseMembership && !isLocalFileDocument ? (
35
- <DocumentProperties documentId={document.id} canEdit={canEdit} />
35
+ <DocumentProperties
36
+ documentId={document.id}
37
+ databaseDocumentId={document.databaseMembership.databaseDocumentId}
38
+ canEdit={canEdit}
39
+ />
36
40
  ) : null}
37
41
  </div>
38
42
  );
@@ -165,6 +165,7 @@ function tWithFallback(
165
165
 
166
166
  interface DocumentPropertiesProps {
167
167
  documentId: string;
168
+ databaseDocumentId: string;
168
169
  canEdit: boolean;
169
170
  popoversPortalled?: boolean;
170
171
  }
@@ -796,6 +797,7 @@ function scalarPlaceholder(type: DocumentPropertyType, t: TFunction) {
796
797
 
797
798
  export function DocumentProperties({
798
799
  documentId,
800
+ databaseDocumentId,
799
801
  canEdit,
800
802
  popoversPortalled = true,
801
803
  }: DocumentPropertiesProps) {
@@ -826,6 +828,7 @@ export function DocumentProperties({
826
828
  key={property.definition.id}
827
829
  property={property}
828
830
  documentId={documentId}
831
+ databaseDocumentId={databaseDocumentId}
829
832
  canEdit={canEdit}
830
833
  popoversPortalled={popoversPortalled}
831
834
  t={t}
@@ -927,12 +930,14 @@ function HiddenPropertiesMenu({
927
930
  function PropertyRow({
928
931
  property,
929
932
  documentId,
933
+ databaseDocumentId,
930
934
  canEdit,
931
935
  popoversPortalled,
932
936
  t,
933
937
  }: {
934
938
  property: DocumentProperty;
935
939
  documentId: string;
940
+ databaseDocumentId: string;
936
941
  canEdit: boolean;
937
942
  popoversPortalled: boolean;
938
943
  t: TFunction;
@@ -978,6 +983,7 @@ function PropertyRow({
978
983
  <PropertyValuePopover
979
984
  property={property}
980
985
  documentId={documentId}
986
+ databaseDocumentId={databaseDocumentId}
981
987
  portalled={popoversPortalled}
982
988
  >
983
989
  {value}
@@ -197,6 +197,7 @@ import {
197
197
  } from "@/hooks/use-document-properties";
198
198
  import {
199
199
  isDocumentUpdateConflict,
200
+ type DocumentUpdateResult,
200
201
  useDeleteDocument,
201
202
  useDocument,
202
203
  seedDatabaseItemDocumentCaches,
@@ -254,7 +255,10 @@ import { EmojiPicker } from "../EmojiPicker";
254
255
  import {
255
256
  createPreviewDocumentSaveController,
256
257
  deferredPreviewDocumentSave,
258
+ type PreviewDocumentPayload,
257
259
  type PreviewDocumentSaveAdapter,
260
+ type PreviewDocumentSaveDeferred,
261
+ type PreviewDocumentSaveSuccess,
258
262
  } from "../previewDocumentSaveController";
259
263
  import {
260
264
  acquirePreviewDocumentSaveController,
@@ -436,7 +440,8 @@ export function databaseCreatedItemForImmediatePreview(
436
440
  if (returnedItem) return returnedItem;
437
441
  if (!response.createdItemId || !response.createdDocumentId) return null;
438
442
 
439
- const now = args.now ?? new Date().toISOString();
443
+ const now =
444
+ response.createdDocumentUpdatedAt ?? args.now ?? new Date().toISOString();
440
445
  const position = Math.max(
441
446
  0,
442
447
  (response.pagination?.totalItems ?? response.items.length + 1) - 1,
@@ -473,6 +478,42 @@ export function databaseCreatedItemForImmediatePreview(
473
478
  };
474
479
  }
475
480
 
481
+ export function previewDocumentSaveResult(args: {
482
+ result: DocumentUpdateResult;
483
+ payload: PreviewDocumentPayload;
484
+ baseline?: PreviewDocumentPayload;
485
+ contentChanged: boolean;
486
+ }): PreviewDocumentSaveDeferred | PreviewDocumentSaveSuccess {
487
+ const serverDocument = isDocumentUpdateConflict(args.result)
488
+ ? args.result.document
489
+ : args.result;
490
+ const titleSaveObservedExternalBody =
491
+ !args.contentChanged && serverDocument.content !== args.payload.content;
492
+
493
+ if (isDocumentUpdateConflict(args.result) || titleSaveObservedExternalBody) {
494
+ return deferredPreviewDocumentSave("conflict", {
495
+ lastSaved: args.baseline ?? args.payload,
496
+ pending: {
497
+ title: serverDocument.title,
498
+ content: serverDocument.content,
499
+ loadedUpdatedAt: serverDocument.updatedAt,
500
+ loadedContentWasEmpty: isEffectivelyEmptyDocumentContent(
501
+ serverDocument.content,
502
+ ),
503
+ },
504
+ deferredReason: "conflict",
505
+ });
506
+ }
507
+
508
+ return {
509
+ outcome: "saved",
510
+ loadedUpdatedAt: args.result.updatedAt,
511
+ loadedContentWasEmpty: isEffectivelyEmptyDocumentContent(
512
+ args.result.content,
513
+ ),
514
+ };
515
+ }
516
+
476
517
  export function databaseBuilderHydrationSourceForItem(
477
518
  item: ContentDatabaseItem,
478
519
  sources: ContentDatabaseSource[],
@@ -4098,8 +4139,6 @@ function DatabaseItemPreview({
4098
4139
  // ever touch its own row's state. See previewDocumentSaveRegistry.
4099
4140
  const updateDocumentRef = useRef(updateDocument);
4100
4141
  updateDocumentRef.current = updateDocument;
4101
- const queryClientRef = useRef(queryClient);
4102
- queryClientRef.current = queryClient;
4103
4142
  const bodyHydrationPendingRef = useRef(bodyHydrationPending);
4104
4143
  bodyHydrationPendingRef.current = bodyHydrationPending;
4105
4144
  const draftVersionsRef = useRef<Map<string, number | null>>(new Map());
@@ -4244,30 +4283,14 @@ function DatabaseItemPreview({
4244
4283
  },
4245
4284
  {
4246
4285
  onSuccess: (result) => {
4247
- if (isDocumentUpdateConflict(result)) {
4248
- resolve(
4249
- deferredPreviewDocumentSave("conflict", {
4250
- lastSaved: baseline ?? payload,
4251
- pending: {
4252
- title: result.document.title,
4253
- content: result.document.content,
4254
- loadedUpdatedAt: result.document.updatedAt,
4255
- loadedContentWasEmpty: isEffectivelyEmptyDocumentContent(
4256
- result.document.content,
4257
- ),
4258
- },
4259
- deferredReason: "conflict",
4260
- }),
4261
- );
4262
- return;
4263
- }
4264
- resolve({
4265
- outcome: "saved" as const,
4266
- loadedUpdatedAt: result.updatedAt,
4267
- loadedContentWasEmpty: isEffectivelyEmptyDocumentContent(
4268
- result.content,
4269
- ),
4270
- });
4286
+ resolve(
4287
+ previewDocumentSaveResult({
4288
+ result,
4289
+ payload,
4290
+ baseline,
4291
+ contentChanged,
4292
+ }),
4293
+ );
4271
4294
  },
4272
4295
  onError: reject,
4273
4296
  },
@@ -4276,12 +4299,6 @@ function DatabaseItemPreview({
4276
4299
  onSaved: (persistedPayload) => {
4277
4300
  const controller = peekPreviewDocumentSaveController(documentId);
4278
4301
  if (controller) enqueueDraftWrite(controller, "delete", persistedPayload);
4279
- void queryClientRef.current.invalidateQueries({
4280
- queryKey: contentDatabaseQueryKey(databaseDocumentId),
4281
- });
4282
- void queryClientRef.current.invalidateQueries({
4283
- queryKey: ["action", "list-documents"],
4284
- });
4285
4302
  },
4286
4303
  onError: (err) => {
4287
4304
  toast.error(dbText("failedToSavePagePreview"), {
@@ -4876,6 +4893,7 @@ function DatabaseItemPreview({
4876
4893
  {previewDocument.databaseMembership ? (
4877
4894
  <DocumentProperties
4878
4895
  documentId={previewDocument.id}
4896
+ databaseDocumentId={databaseDocumentId}
4879
4897
  canEdit={previewCanEdit}
4880
4898
  popoversPortalled={false}
4881
4899
  />
@@ -4913,6 +4931,7 @@ function DatabaseItemPreview({
4913
4931
  const editor = previewDocument.databaseMembership ? (
4914
4932
  <DocumentBlockFields
4915
4933
  documentId={previewDocument.id}
4934
+ databaseDocumentId={databaseDocumentId}
4916
4935
  canEdit={previewCanEdit}
4917
4936
  primaryEditor={primaryEditor}
4918
4937
  />
@@ -130,8 +130,8 @@ export interface PreviewDocumentSaveController {
130
130
  /** Adopt `payload` as the confirmed-saved baseline (no save scheduled). */
131
131
  mark(payload: PreviewDocumentPayload): void;
132
132
  /**
133
- * Adopt a fresher server baseline while retaining the user's pending title
134
- * and content. Used only after an explicit "keep local draft" choice.
133
+ * Adopt a fresher server baseline while retaining only fields the user
134
+ * changed locally. Used only after an explicit "keep local draft" choice.
135
135
  */
136
136
  rebasePending(payload: PreviewDocumentPayload): void;
137
137
  /** Replace callbacks captured by an older preview mount. */
@@ -261,8 +261,7 @@ export function createPreviewDocumentSaveController(
261
261
  // the controller was created/last marked — e.g. "empty" for a
262
262
  // brand-new page — forever, even after real content has been saved.
263
263
  const success = asSaveSuccess(result);
264
- lastSaved = {
265
- ...attempted,
264
+ const savedMetadata = {
266
265
  ...(success?.loadedUpdatedAt !== undefined
267
266
  ? { loadedUpdatedAt: success.loadedUpdatedAt }
268
267
  : {}),
@@ -270,6 +269,15 @@ export function createPreviewDocumentSaveController(
270
269
  ? { loadedContentWasEmpty: success.loadedContentWasEmpty }
271
270
  : {}),
272
271
  };
272
+ lastSaved = {
273
+ ...attempted,
274
+ ...savedMetadata,
275
+ };
276
+ // A later keystroke starts from `pending`, including while this save is
277
+ // in flight. Rebase that trailing payload onto our own successful write
278
+ // so its next CAS does not mistake the preceding save for an external
279
+ // change.
280
+ pending = { ...pending, ...savedMetadata };
273
281
  hasSavedLocally = true;
274
282
  deferredReason = null;
275
283
  inFlight = null;
@@ -336,9 +344,13 @@ export function createPreviewDocumentSaveController(
336
344
  },
337
345
  rebasePending(payload: PreviewDocumentPayload) {
338
346
  clearTimer();
347
+ const titleChangedLocally = pending.title !== lastSaved.title;
348
+ const contentChangedLocally = pending.content !== lastSaved.content;
339
349
  lastSaved = { ...payload };
340
350
  pending = {
341
351
  ...pending,
352
+ title: titleChangedLocally ? pending.title : payload.title,
353
+ content: contentChangedLocally ? pending.content : payload.content,
342
354
  loadedUpdatedAt: payload.loadedUpdatedAt,
343
355
  loadedContentWasEmpty: payload.loadedContentWasEmpty,
344
356
  };