@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.
- package/corpus/README.md +2 -2
- package/corpus/core/CHANGELOG.md +18 -0
- package/corpus/core/package.json +1 -1
- package/corpus/core/src/agent/durable-background.ts +1 -0
- package/corpus/core/src/cli/index.ts +64 -3
- package/corpus/core/src/deploy/build.ts +144 -2
- package/corpus/core/src/deploy/workspace-deploy.ts +123 -1
- package/corpus/core/src/integrations/integration-durable-dispatch-config.ts +22 -0
- package/corpus/core/src/integrations/integration-durable-dispatch.ts +190 -0
- package/corpus/core/src/integrations/pending-tasks-retry-job.ts +175 -87
- package/corpus/core/src/integrations/pending-tasks-store.ts +114 -11
- package/corpus/core/src/integrations/plugin.ts +113 -60
- package/corpus/core/src/integrations/task-queue-stats.ts +68 -15
- package/corpus/core/src/integrations/webhook-handler.ts +21 -46
- package/corpus/core/src/server/auth.ts +6 -0
- package/corpus/core/src/templates/default/.agents/skills/integration-webhooks/SKILL.md +63 -22
- package/corpus/core/src/templates/default/package.json +1 -1
- package/corpus/core/src/templates/headless/.agents/skills/integration-webhooks/SKILL.md +63 -22
- package/corpus/core/src/templates/workspace-core/.agents/skills/integration-webhooks/SKILL.md +63 -22
- package/corpus/templates/assets/package.json +1 -1
- package/corpus/templates/brain/package.json +1 -1
- package/corpus/templates/calendar/package.json +1 -1
- package/corpus/templates/clips/package.json +1 -1
- package/corpus/templates/content/actions/add-database-item.ts +1 -0
- package/corpus/templates/content/actions/set-document-property.ts +0 -4
- package/corpus/templates/content/app/components/editor/DocumentBlockFields.tsx +10 -1
- package/corpus/templates/content/app/components/editor/DocumentEditor.tsx +10 -4
- package/corpus/templates/content/app/components/editor/DocumentInfoPanel.tsx +5 -1
- package/corpus/templates/content/app/components/editor/DocumentProperties.tsx +6 -0
- package/corpus/templates/content/app/components/editor/database/DatabaseView.tsx +52 -33
- package/corpus/templates/content/app/components/editor/previewDocumentSaveController.ts +16 -4
- package/corpus/templates/content/app/components/sidebar/DocumentSidebar.tsx +16 -7
- package/corpus/templates/content/app/hooks/use-create-page.ts +8 -3
- package/corpus/templates/content/app/hooks/use-document-properties.ts +1 -0
- package/corpus/templates/content/app/hooks/use-documents.ts +3 -1
- package/corpus/templates/content/app/lib/optimistic-document.ts +29 -0
- package/corpus/templates/content/changelog/2026-07-21-editing-a-database-property-no-longer-refreshes-unrelated-co.md +6 -0
- package/corpus/templates/content/changelog/2026-07-23-new-database-ready.md +6 -0
- package/corpus/templates/content/package.json +1 -1
- package/corpus/templates/content/shared/api.ts +1 -0
- package/corpus/templates/design/package.json +1 -1
- package/corpus/templates/dispatch/changelog/2026-07-21-recover-stalled-slack-agent-work.md +6 -0
- package/corpus/templates/dispatch/package.json +1 -1
- package/corpus/templates/forms/package.json +1 -1
- package/corpus/templates/macros/package.json +1 -1
- package/corpus/templates/mail/package.json +1 -1
- package/corpus/templates/slides/package.json +1 -1
- package/dist/agent/durable-background.d.ts +1 -0
- package/dist/agent/durable-background.d.ts.map +1 -1
- package/dist/agent/durable-background.js +1 -0
- package/dist/agent/durable-background.js.map +1 -1
- package/dist/cli/index.js +53 -2
- package/dist/cli/index.js.map +1 -1
- package/dist/collab/routes.d.ts +1 -1
- package/dist/deploy/build.d.ts +3 -0
- package/dist/deploy/build.d.ts.map +1 -1
- package/dist/deploy/build.js +106 -3
- package/dist/deploy/build.js.map +1 -1
- package/dist/deploy/workspace-deploy.d.ts.map +1 -1
- package/dist/deploy/workspace-deploy.js +103 -2
- package/dist/deploy/workspace-deploy.js.map +1 -1
- package/dist/file-upload/actions/upload-image.d.ts +1 -1
- package/dist/integrations/integration-durable-dispatch-config.d.ts +7 -0
- package/dist/integrations/integration-durable-dispatch-config.d.ts.map +1 -0
- package/dist/integrations/integration-durable-dispatch-config.js +16 -0
- package/dist/integrations/integration-durable-dispatch-config.js.map +1 -0
- package/dist/integrations/integration-durable-dispatch.d.ts +23 -0
- package/dist/integrations/integration-durable-dispatch.d.ts.map +1 -0
- package/dist/integrations/integration-durable-dispatch.js +115 -0
- package/dist/integrations/integration-durable-dispatch.js.map +1 -0
- package/dist/integrations/pending-tasks-retry-job.d.ts +13 -1
- package/dist/integrations/pending-tasks-retry-job.d.ts.map +1 -1
- package/dist/integrations/pending-tasks-retry-job.js +133 -74
- package/dist/integrations/pending-tasks-retry-job.js.map +1 -1
- package/dist/integrations/pending-tasks-store.d.ts +14 -2
- package/dist/integrations/pending-tasks-store.d.ts.map +1 -1
- package/dist/integrations/pending-tasks-store.js +75 -11
- package/dist/integrations/pending-tasks-store.js.map +1 -1
- package/dist/integrations/plugin.d.ts.map +1 -1
- package/dist/integrations/plugin.js +88 -55
- package/dist/integrations/plugin.js.map +1 -1
- package/dist/integrations/task-queue-stats.d.ts +15 -3
- package/dist/integrations/task-queue-stats.d.ts.map +1 -1
- package/dist/integrations/task-queue-stats.js +45 -15
- package/dist/integrations/task-queue-stats.js.map +1 -1
- package/dist/integrations/webhook-handler.d.ts +1 -1
- package/dist/integrations/webhook-handler.d.ts.map +1 -1
- package/dist/integrations/webhook-handler.js +18 -42
- package/dist/integrations/webhook-handler.js.map +1 -1
- package/dist/observability/routes.d.ts +6 -6
- package/dist/provider-api/actions/custom-provider-registration.d.ts +5 -5
- package/dist/provider-api/actions/provider-api.d.ts +4 -4
- package/dist/resources/handlers.d.ts +1 -1
- package/dist/server/auth.d.ts.map +1 -1
- package/dist/server/auth.js +5 -0
- package/dist/server/auth.js.map +1 -1
- package/dist/server/transcribe-voice.d.ts +1 -1
- package/dist/templates/default/.agents/skills/integration-webhooks/SKILL.md +63 -22
- package/dist/templates/default/package.json +1 -1
- package/dist/templates/headless/.agents/skills/integration-webhooks/SKILL.md +63 -22
- package/dist/templates/workspace-core/.agents/skills/integration-webhooks/SKILL.md +63 -22
- package/package.json +1 -1
- package/src/agent/durable-background.ts +1 -0
- package/src/cli/index.ts +64 -3
- package/src/deploy/build.ts +144 -2
- package/src/deploy/workspace-deploy.ts +123 -1
- package/src/integrations/integration-durable-dispatch-config.ts +22 -0
- package/src/integrations/integration-durable-dispatch.ts +190 -0
- package/src/integrations/pending-tasks-retry-job.ts +175 -87
- package/src/integrations/pending-tasks-store.ts +114 -11
- package/src/integrations/plugin.ts +113 -60
- package/src/integrations/task-queue-stats.ts +68 -15
- package/src/integrations/webhook-handler.ts +21 -46
- package/src/server/auth.ts +6 -0
- package/src/templates/default/.agents/skills/integration-webhooks/SKILL.md +63 -22
- package/src/templates/default/package.json +1 -1
- package/src/templates/headless/.agents/skills/integration-webhooks/SKILL.md +63 -22
- package/src/templates/workspace-core/.agents/skills/integration-webhooks/SKILL.md +63 -22
package/corpus/core/src/templates/workspace-core/.agents/skills/integration-webhooks/SKILL.md
CHANGED
|
@@ -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.
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
- **
|
|
42
|
-
|
|
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.
|
|
66
|
-
— fire
|
|
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
|
-
│
|
|
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 | `/
|
|
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
|
|
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
|
-
- **
|
|
249
|
-
|
|
250
|
-
|
|
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
|
-
|
|
266
|
-
|
|
267
|
-
complete before the function freezes.
|
|
268
|
-
|
|
269
|
-
|
|
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
|
|
282
|
-
|
|
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 —
|
|
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
|
|
@@ -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
|
-
{
|
|
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
|
|
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 =
|
|
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
|
-
|
|
4248
|
-
|
|
4249
|
-
|
|
4250
|
-
|
|
4251
|
-
|
|
4252
|
-
|
|
4253
|
-
|
|
4254
|
-
|
|
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
|
|
134
|
-
*
|
|
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
|
-
|
|
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
|
};
|