@agent-native/core 0.135.0 → 0.135.2
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 +12 -0
- package/corpus/core/docs/content/getting-started-actions.mdx +235 -0
- package/corpus/core/docs/content/getting-started-database.mdx +253 -0
- package/corpus/core/docs/content/getting-started-pages.mdx +190 -0
- package/corpus/core/docs/content/getting-started.mdx +57 -613
- package/corpus/core/package.json +1 -1
- package/corpus/core/src/sharing/access.ts +44 -2
- package/corpus/templates/clips/actions/add-comment.ts +6 -2
- package/corpus/templates/clips/app/components/player/comments-panel.tsx +9 -2
- package/corpus/templates/clips/app/components/player/playback-comment-overlay.tsx +44 -27
- package/corpus/templates/clips/app/components/player/scrubber.tsx +10 -2
- package/corpus/templates/clips/app/components/player/video-player.tsx +4 -0
- package/corpus/templates/clips/app/routes/r.$recordingId.tsx +22 -15
- package/corpus/templates/clips/app/routes/share.$shareId.tsx +1 -0
- package/corpus/templates/content/actions/_database-utils.ts +113 -4
- package/corpus/templates/content/actions/get-document.ts +84 -3
- package/corpus/templates/content/app/components/editor/BuilderBodySyncingNotice.tsx +9 -2
- package/corpus/templates/content/app/components/editor/DocumentEditor.tsx +29 -13
- package/corpus/templates/content/app/components/editor/DocumentToolbar.tsx +5 -12
- package/corpus/templates/content/app/components/editor/body-hydration.ts +12 -6
- package/corpus/templates/content/app/components/editor/database/DatabaseView.tsx +2 -3
- package/corpus/templates/content/app/components/sidebar/DocumentSidebar.tsx +5 -12
- package/corpus/templates/content/app/hooks/use-content-database.ts +16 -27
- package/corpus/templates/content/app/hooks/use-create-page.ts +3 -6
- package/corpus/templates/content/app/hooks/use-document-properties.ts +9 -16
- package/corpus/templates/content/app/hooks/use-document-versions.ts +3 -3
- package/corpus/templates/content/app/hooks/use-documents.ts +85 -29
- package/corpus/templates/content/app/hooks/use-notion.ts +15 -13
- package/corpus/templates/content/app/i18n-data.ts +32 -1
- package/corpus/templates/content/app/lib/document-query.ts +36 -0
- package/corpus/templates/content/changelog/2026-08-02-pages-opened-from-a-database-now-keep-that-database-s-fields.md +6 -0
- package/corpus/templates/content/server/lib/document-context.ts +11 -6
- package/corpus/templates/content/shared/api.ts +8 -0
- package/dist/collab/struct-routes.d.ts +1 -1
- package/dist/notifications/routes.d.ts +3 -3
- package/dist/observability/routes.d.ts +1 -1
- package/dist/progress/routes.d.ts +1 -1
- package/dist/provider-api/actions/custom-provider-registration.d.ts +6 -6
- package/dist/provider-api/actions/provider-api.d.ts +4 -4
- package/dist/secrets/routes.d.ts +9 -9
- package/dist/sharing/access.d.ts.map +1 -1
- package/dist/sharing/access.js +32 -2
- package/dist/sharing/access.js.map +1 -1
- package/docs/content/getting-started-actions.mdx +235 -0
- package/docs/content/getting-started-database.mdx +253 -0
- package/docs/content/getting-started-pages.mdx +190 -0
- package/docs/content/getting-started.mdx +57 -613
- package/package.json +1 -1
- package/src/sharing/access.ts +44 -2
package/corpus/core/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agent-native/core",
|
|
3
|
-
"version": "0.135.
|
|
3
|
+
"version": "0.135.2",
|
|
4
4
|
"description": "Framework for agent-native application development — where AI agents and UI share SQL state, actions, and context",
|
|
5
5
|
"homepage": "https://github.com/BuilderIO/agent-native#readme",
|
|
6
6
|
"bugs": {
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
|
|
16
16
|
import { and, eq, or, sql, type SQL } from "drizzle-orm";
|
|
17
17
|
|
|
18
|
+
import { orgMembers } from "../org/schema.js";
|
|
18
19
|
import {
|
|
19
20
|
getRequestAuthCapability,
|
|
20
21
|
getRequestUserEmail,
|
|
@@ -87,6 +88,35 @@ function emailColumnMatches(column: any, email: string): SQL {
|
|
|
87
88
|
return sql`lower(${column}) = ${email}`;
|
|
88
89
|
}
|
|
89
90
|
|
|
91
|
+
/**
|
|
92
|
+
* Real `org_members` membership, independent of the caller's currently
|
|
93
|
+
* active org (`ctx.orgId`). `org`-visibility access must key off actual
|
|
94
|
+
* membership — a user's active-org selection is a UI convenience, not a
|
|
95
|
+
* statement of which orgs they belong to.
|
|
96
|
+
*
|
|
97
|
+
* Queries through the resource's own `reg.getDb()` — the same connection
|
|
98
|
+
* every other lookup in this file uses — not the ambient `getDbExec()`,
|
|
99
|
+
* since `org_members` lives in that same app database.
|
|
100
|
+
*/
|
|
101
|
+
async function isOrgMember(
|
|
102
|
+
reg: ShareableResourceRegistration,
|
|
103
|
+
memberOrgId: string,
|
|
104
|
+
email: string,
|
|
105
|
+
): Promise<boolean> {
|
|
106
|
+
const db = reg.getDb() as any;
|
|
107
|
+
const rows = await db
|
|
108
|
+
.select({ id: orgMembers.id })
|
|
109
|
+
.from(orgMembers)
|
|
110
|
+
.where(
|
|
111
|
+
and(
|
|
112
|
+
eq(orgMembers.orgId, memberOrgId),
|
|
113
|
+
emailColumnMatches(orgMembers.email, email),
|
|
114
|
+
),
|
|
115
|
+
)
|
|
116
|
+
.limit(1);
|
|
117
|
+
return rows.length > 0;
|
|
118
|
+
}
|
|
119
|
+
|
|
90
120
|
/**
|
|
91
121
|
* Build a Drizzle `WHERE` clause that admits rows the current user can see.
|
|
92
122
|
* Pass the ownable resource table and its shares table; optional min role
|
|
@@ -470,7 +500,7 @@ async function resolveAccessImpl(
|
|
|
470
500
|
const resource = await loadResourceForAccess(reg, resourceId, options);
|
|
471
501
|
if (!resource) return null;
|
|
472
502
|
|
|
473
|
-
const { userEmail
|
|
503
|
+
const { userEmail } = ctx;
|
|
474
504
|
const normalizedUserEmail = normalizeEmailForAccess(userEmail);
|
|
475
505
|
|
|
476
506
|
if (
|
|
@@ -491,7 +521,19 @@ async function resolveAccessImpl(
|
|
|
491
521
|
// `visibility === "public"` on an `allowPublic: false` resource is treated
|
|
492
522
|
// as private: only owner + explicit shares grant access. Falls through to
|
|
493
523
|
// the explicit-share lookup below.
|
|
494
|
-
|
|
524
|
+
//
|
|
525
|
+
// Membership in the resource's own org, not equality with the caller's
|
|
526
|
+
// currently active org: a caller can be a genuine member of the
|
|
527
|
+
// resource's org while a *different* org is their active selection, and
|
|
528
|
+
// `org` visibility should still admit them. Still requires some active
|
|
529
|
+
// org to be set at all (`orgId`), matching the pre-existing behavior for
|
|
530
|
+
// a caller with no active org.
|
|
531
|
+
if (
|
|
532
|
+
resource.visibility === "org" &&
|
|
533
|
+
resource.orgId &&
|
|
534
|
+
normalizedUserEmail &&
|
|
535
|
+
(await isOrgMember(reg, resource.orgId, normalizedUserEmail))
|
|
536
|
+
) {
|
|
495
537
|
const role = await highestShareRole(reg, resourceId, ctx, resource);
|
|
496
538
|
return { role: role ?? "viewer", resource };
|
|
497
539
|
}
|
|
@@ -67,6 +67,10 @@ export default defineAction({
|
|
|
67
67
|
|
|
68
68
|
if (!rec) throw new Error(`Recording not found: ${args.recordingId}`);
|
|
69
69
|
|
|
70
|
+
// Floor to the nearest second so nearby comments land on the same
|
|
71
|
+
// timestamp bucket for scrubber grouping and the playback overlay.
|
|
72
|
+
const videoTimestampMs = Math.floor(args.videoTimestampMs / 1000) * 1000;
|
|
73
|
+
|
|
70
74
|
await db.insert(schema.recordingComments).values({
|
|
71
75
|
id,
|
|
72
76
|
recordingId: args.recordingId,
|
|
@@ -76,7 +80,7 @@ export default defineAction({
|
|
|
76
80
|
authorEmail,
|
|
77
81
|
authorName: args.authorName ?? null,
|
|
78
82
|
content: args.content,
|
|
79
|
-
videoTimestampMs
|
|
83
|
+
videoTimestampMs,
|
|
80
84
|
createdAt: now,
|
|
81
85
|
updatedAt: now,
|
|
82
86
|
});
|
|
@@ -94,7 +98,7 @@ export default defineAction({
|
|
|
94
98
|
await writeAppState("refresh-signal", { ts: Date.now() });
|
|
95
99
|
|
|
96
100
|
console.log(
|
|
97
|
-
`Added comment to recording ${args.recordingId} @ ${
|
|
101
|
+
`Added comment to recording ${args.recordingId} @ ${videoTimestampMs}ms (thread: ${threadId})`,
|
|
98
102
|
);
|
|
99
103
|
|
|
100
104
|
return { id, threadId, notified };
|
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
useActionMutation,
|
|
3
|
+
useAvatarUrl,
|
|
4
|
+
} from "@agent-native/core/client/hooks";
|
|
2
5
|
import { useT } from "@agent-native/core/client/i18n";
|
|
3
6
|
import {
|
|
4
7
|
IconSend,
|
|
@@ -12,7 +15,7 @@ import {
|
|
|
12
15
|
import { useQueryClient } from "@tanstack/react-query";
|
|
13
16
|
import { type Ref, useEffect, useMemo, useRef, useState } from "react";
|
|
14
17
|
|
|
15
|
-
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
|
18
|
+
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
|
16
19
|
import { Button } from "@/components/ui/button";
|
|
17
20
|
import {
|
|
18
21
|
DropdownMenu,
|
|
@@ -860,10 +863,14 @@ function CommentCard({
|
|
|
860
863
|
}
|
|
861
864
|
|
|
862
865
|
const commentContent = linkifyCommentContent(comment.content);
|
|
866
|
+
const avatarUrl = useAvatarUrl(comment.authorEmail);
|
|
863
867
|
|
|
864
868
|
return (
|
|
865
869
|
<div className={cn("flex gap-2", comment.resolved && "opacity-60")}>
|
|
866
870
|
<Avatar className="h-7 w-7 shrink-0">
|
|
871
|
+
{avatarUrl ? (
|
|
872
|
+
<AvatarImage src={avatarUrl} alt={displayName(comment)} />
|
|
873
|
+
) : null}
|
|
867
874
|
<AvatarFallback className="text-[10px] bg-primary text-primary-foreground">
|
|
868
875
|
{initials(displayName(comment))}
|
|
869
876
|
</AvatarFallback>
|
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
|
|
1
|
+
import { useAvatarUrl } from "@agent-native/core/client/hooks";
|
|
2
|
+
|
|
3
|
+
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
|
4
|
+
import { cn } from "@/lib/utils";
|
|
5
|
+
|
|
6
|
+
export const PLAYBACK_COMMENT_VISIBLE_MS = 1_000;
|
|
2
7
|
|
|
3
8
|
export function getPlaybackCommentVisibleMs(playbackRate = 1): number {
|
|
4
9
|
const safePlaybackRate =
|
|
@@ -50,50 +55,62 @@ export function PlaybackCommentOverlay({
|
|
|
50
55
|
comments,
|
|
51
56
|
currentMs,
|
|
52
57
|
playbackRate = 1,
|
|
58
|
+
onClick,
|
|
53
59
|
}: {
|
|
54
60
|
comments: PlaybackComment[] | undefined;
|
|
55
61
|
currentMs: number;
|
|
56
62
|
playbackRate?: number;
|
|
63
|
+
onClick?: () => void;
|
|
57
64
|
}) {
|
|
58
65
|
const activeComments = getActivePlaybackComments(
|
|
59
66
|
comments,
|
|
60
67
|
currentMs,
|
|
61
68
|
playbackRate,
|
|
62
69
|
);
|
|
70
|
+
const avatarUrl = useAvatarUrl(activeComments[0]?.authorEmail);
|
|
63
71
|
if (activeComments.length === 0) return null;
|
|
64
72
|
|
|
73
|
+
const [comment, ...rest] = activeComments;
|
|
74
|
+
const author = displayAuthor(comment);
|
|
75
|
+
const Card = (onClick ? "button" : "div") as "button" | "div";
|
|
76
|
+
|
|
65
77
|
return (
|
|
66
78
|
<div
|
|
67
79
|
data-player-ui
|
|
68
80
|
className="pointer-events-none absolute inset-x-3 bottom-[5.25rem] z-20 flex justify-center sm:inset-x-6"
|
|
69
81
|
aria-live="polite"
|
|
70
82
|
>
|
|
71
|
-
<
|
|
72
|
-
{
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
</
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
83
|
+
<Card
|
|
84
|
+
key={comment.id}
|
|
85
|
+
type={onClick ? "button" : undefined}
|
|
86
|
+
onClick={onClick}
|
|
87
|
+
className={cn(
|
|
88
|
+
"animate-in fade-in slide-in-from-bottom-2 flex w-full max-w-xl flex-col gap-1.5 rounded-xl bg-black/85 px-3 py-2.5 text-left text-white shadow-2xl ring-1 ring-white/15 backdrop-blur-md duration-200",
|
|
89
|
+
onClick && "pointer-events-auto cursor-pointer hover:bg-black/95",
|
|
90
|
+
)}
|
|
91
|
+
>
|
|
92
|
+
<div className="flex max-w-full items-start gap-2.5">
|
|
93
|
+
<Avatar aria-hidden="true" className="size-7 shrink-0">
|
|
94
|
+
{avatarUrl ? <AvatarImage src={avatarUrl} alt={author} /> : null}
|
|
95
|
+
<AvatarFallback className="bg-white/15 text-[10px] font-semibold text-white">
|
|
96
|
+
{initials(author)}
|
|
97
|
+
</AvatarFallback>
|
|
98
|
+
</Avatar>
|
|
99
|
+
<div className="min-w-0">
|
|
100
|
+
<p className="truncate text-xs font-semibold text-white/80">
|
|
101
|
+
{author}
|
|
102
|
+
</p>
|
|
103
|
+
<p className="line-clamp-3 break-words text-sm leading-5 text-white">
|
|
104
|
+
{comment.content}
|
|
105
|
+
</p>
|
|
106
|
+
</div>
|
|
107
|
+
</div>
|
|
108
|
+
{rest.length > 0 && (
|
|
109
|
+
<p className="pl-[2.375rem] text-xs text-white/60">
|
|
110
|
+
+{rest.length} other comment{rest.length > 1 ? "s" : ""}
|
|
111
|
+
</p>
|
|
112
|
+
)}
|
|
113
|
+
</Card>
|
|
97
114
|
</div>
|
|
98
115
|
);
|
|
99
116
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { IconMessage2Filled } from "@tabler/icons-react";
|
|
1
2
|
import { useMemo, useRef, useState } from "react";
|
|
2
3
|
|
|
3
4
|
import { cn } from "@/lib/utils";
|
|
@@ -225,10 +226,17 @@ export function Scrubber(props: ScrubberProps) {
|
|
|
225
226
|
e.stopPropagation();
|
|
226
227
|
onSeek(ms);
|
|
227
228
|
}}
|
|
228
|
-
className="absolute
|
|
229
|
+
className="absolute top-1/2 -translate-x-1/2 -translate-y-1/2 flex h-4 w-4 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-md ring-2 ring-background transition-transform hover:scale-125"
|
|
229
230
|
style={{ left: (ms / Math.max(1, durationMs)) * 100 + "%" }}
|
|
230
231
|
aria-label={`${list.length} comment${list.length > 1 ? "s" : ""}`}
|
|
231
|
-
|
|
232
|
+
>
|
|
233
|
+
<IconMessage2Filled className="h-2.5 w-2.5" />
|
|
234
|
+
{list.length > 1 && (
|
|
235
|
+
<span className="absolute -top-1.5 -right-1.5 flex h-3.5 min-w-3.5 items-center justify-center rounded-full bg-primary px-0.5 text-[8px] font-bold leading-none text-primary-foreground ring-2 ring-background">
|
|
236
|
+
{list.length}
|
|
237
|
+
</span>
|
|
238
|
+
)}
|
|
239
|
+
</button>
|
|
232
240
|
))}
|
|
233
241
|
|
|
234
242
|
{/* Reaction dots */}
|
|
@@ -218,6 +218,8 @@ export interface VideoPlayerProps {
|
|
|
218
218
|
* lifecycle instead of polling an imperative-handle getter.
|
|
219
219
|
*/
|
|
220
220
|
onVideoElementChange?: (video: HTMLVideoElement | null) => void;
|
|
221
|
+
/** Called when the viewer clicks the timestamped-comment overlay. */
|
|
222
|
+
onCommentClick?: () => void;
|
|
221
223
|
}
|
|
222
224
|
|
|
223
225
|
export const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(
|
|
@@ -255,6 +257,7 @@ export const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(
|
|
|
255
257
|
recordingId,
|
|
256
258
|
role,
|
|
257
259
|
onVideoElementChange,
|
|
260
|
+
onCommentClick,
|
|
258
261
|
} = props;
|
|
259
262
|
|
|
260
263
|
const resolvedVideoSrc = useMemo(
|
|
@@ -1651,6 +1654,7 @@ export const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(
|
|
|
1651
1654
|
comments={comments}
|
|
1652
1655
|
currentMs={currentMs}
|
|
1653
1656
|
playbackRate={speed}
|
|
1657
|
+
onClick={onCommentClick}
|
|
1654
1658
|
/>
|
|
1655
1659
|
) : null}
|
|
1656
1660
|
|
|
@@ -261,15 +261,26 @@ export default function RecordingPage() {
|
|
|
261
261
|
// The compact layout stacks the panel below the video, so switching tabs
|
|
262
262
|
// alone leaves the user looking at the player. Desktop always renders the
|
|
263
263
|
// side aside, so nothing to scroll there.
|
|
264
|
-
const
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
264
|
+
const openSidePanel = useCallback(
|
|
265
|
+
(next: SidePanel) => {
|
|
266
|
+
setPanel(next);
|
|
267
|
+
if (!isCompactLayout) return;
|
|
268
|
+
requestAnimationFrame(() => {
|
|
269
|
+
document
|
|
270
|
+
.getElementById("clip-activity-panel")
|
|
271
|
+
?.scrollIntoView({ block: "start" });
|
|
272
|
+
});
|
|
273
|
+
},
|
|
274
|
+
[isCompactLayout],
|
|
275
|
+
);
|
|
276
|
+
const openInsightsPanel = useCallback(
|
|
277
|
+
() => openSidePanel("insights"),
|
|
278
|
+
[openSidePanel],
|
|
279
|
+
);
|
|
280
|
+
const openCommentsPanel = useCallback(
|
|
281
|
+
() => openSidePanel("comments"),
|
|
282
|
+
[openSidePanel],
|
|
283
|
+
);
|
|
273
284
|
const transcriptKickedRef = useRef<string | null>(null);
|
|
274
285
|
// When the recording lands in the processing state but never flips to
|
|
275
286
|
// 'ready', stop spinning forever and surface an error banner so the user
|
|
@@ -1608,6 +1619,7 @@ export default function RecordingPage() {
|
|
|
1608
1619
|
cta={firstCta}
|
|
1609
1620
|
onCtaClick={() => tracking.reportCtaClick()}
|
|
1610
1621
|
onTimeUpdate={(ms) => setCurrentMs(ms)}
|
|
1622
|
+
onCommentClick={openCommentsPanel}
|
|
1611
1623
|
className="h-full w-full rounded-none sm:rounded-xl"
|
|
1612
1624
|
/>
|
|
1613
1625
|
{commentOpen ? (
|
|
@@ -1667,12 +1679,7 @@ export default function RecordingPage() {
|
|
|
1667
1679
|
className="shrink-0"
|
|
1668
1680
|
onOpen={() => {
|
|
1669
1681
|
if (isCompactLayout) {
|
|
1670
|
-
|
|
1671
|
-
requestAnimationFrame(() => {
|
|
1672
|
-
document
|
|
1673
|
-
.getElementById("clip-activity-panel")
|
|
1674
|
-
?.scrollIntoView({ block: "start" });
|
|
1675
|
-
});
|
|
1682
|
+
openCommentsPanel();
|
|
1676
1683
|
return;
|
|
1677
1684
|
}
|
|
1678
1685
|
setCommentAtMs(resolvePlaybackMs());
|
|
@@ -997,6 +997,7 @@ export default function ShareRoute() {
|
|
|
997
997
|
cta={firstCta}
|
|
998
998
|
onCtaClick={() => tracking.reportCtaClick()}
|
|
999
999
|
onTimeUpdate={(ms) => setCurrentMs(ms)}
|
|
1000
|
+
onCommentClick={() => setPanel("comments")}
|
|
1000
1001
|
className="h-full w-full rounded-none sm:rounded-xl"
|
|
1001
1002
|
/>
|
|
1002
1003
|
</div>
|
|
@@ -1234,10 +1234,15 @@ export async function getDatabaseByDocumentId(
|
|
|
1234
1234
|
|
|
1235
1235
|
export async function getDatabaseItemByDocumentId(
|
|
1236
1236
|
documentId: string,
|
|
1237
|
-
options: { includeDeleted?: boolean } = {},
|
|
1237
|
+
options: { includeDeleted?: boolean; databaseId?: string } = {},
|
|
1238
1238
|
db = getDb(),
|
|
1239
1239
|
) {
|
|
1240
1240
|
const clauses = [eq(schema.contentDatabaseItems.documentId, documentId)];
|
|
1241
|
+
if (options.databaseId) {
|
|
1242
|
+
clauses.push(
|
|
1243
|
+
eq(schema.contentDatabaseItems.databaseId, options.databaseId),
|
|
1244
|
+
);
|
|
1245
|
+
}
|
|
1241
1246
|
if (!options.includeDeleted) {
|
|
1242
1247
|
clauses.push(isNull(schema.contentDatabases.deletedAt));
|
|
1243
1248
|
}
|
|
@@ -1262,13 +1267,33 @@ export async function getDatabaseItemByDocumentId(
|
|
|
1262
1267
|
)
|
|
1263
1268
|
.leftJoin(
|
|
1264
1269
|
schema.contentDatabaseBodyHydrationQueue,
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1270
|
+
and(
|
|
1271
|
+
eq(
|
|
1272
|
+
schema.contentDatabaseBodyHydrationQueue.databaseItemId,
|
|
1273
|
+
schema.contentDatabaseItems.id,
|
|
1274
|
+
),
|
|
1275
|
+
eq(
|
|
1276
|
+
schema.contentDatabaseBodyHydrationQueue.sourceId,
|
|
1277
|
+
schema.contentDatabaseSourceRows.sourceId,
|
|
1278
|
+
),
|
|
1279
|
+
eq(
|
|
1280
|
+
schema.contentDatabaseBodyHydrationQueue.sourceRowId,
|
|
1281
|
+
schema.contentDatabaseSourceRows.sourceRowId,
|
|
1282
|
+
),
|
|
1268
1283
|
),
|
|
1269
1284
|
)
|
|
1270
1285
|
.where(and(...clauses))
|
|
1271
1286
|
.orderBy(
|
|
1287
|
+
sql`CASE
|
|
1288
|
+
WHEN ${schema.contentDatabaseSourceRows.sourceId} IS NOT NULL
|
|
1289
|
+
AND (
|
|
1290
|
+
${schema.contentDatabaseItems.bodyHydrationStatus} IN ('pending', 'hydrating')
|
|
1291
|
+
OR ${schema.contentDatabaseBodyHydrationQueue.id} IS NOT NULL
|
|
1292
|
+
) THEN 0
|
|
1293
|
+
WHEN ${schema.contentDatabaseSourceRows.sourceId} IS NOT NULL
|
|
1294
|
+
AND ${schema.contentDatabaseItems.bodyHydrationStatus} = 'error' THEN 1
|
|
1295
|
+
ELSE 2
|
|
1296
|
+
END`,
|
|
1272
1297
|
sql`CASE WHEN ${schema.contentDatabaseSourceRows.sourceId} IS NOT NULL THEN 0 ELSE 1 END`,
|
|
1273
1298
|
sql`CASE WHEN ${schema.contentDatabases.systemRole} IS NULL THEN 0 ELSE 1 END`,
|
|
1274
1299
|
sql`CASE WHEN ${schema.contentDatabases.systemRole} = 'files' THEN 0 ELSE 1 END`,
|
|
@@ -1277,6 +1302,90 @@ export async function getDatabaseItemByDocumentId(
|
|
|
1277
1302
|
return row ?? null;
|
|
1278
1303
|
}
|
|
1279
1304
|
|
|
1305
|
+
export async function getBuilderBodyHydrationMembershipByDocumentId(
|
|
1306
|
+
documentId: string,
|
|
1307
|
+
db = getDb(),
|
|
1308
|
+
) {
|
|
1309
|
+
const rows = await db
|
|
1310
|
+
.select({
|
|
1311
|
+
item: schema.contentDatabaseItems,
|
|
1312
|
+
database: schema.contentDatabases,
|
|
1313
|
+
sourceId: schema.contentDatabaseSourceRows.sourceId,
|
|
1314
|
+
sourceRowId: schema.contentDatabaseSourceRows.sourceRowId,
|
|
1315
|
+
bodyHydrationQueueId: schema.contentDatabaseBodyHydrationQueue.id,
|
|
1316
|
+
queueSourceId: schema.contentDatabaseBodyHydrationQueue.sourceId,
|
|
1317
|
+
queueSourceRowId: schema.contentDatabaseBodyHydrationQueue.sourceRowId,
|
|
1318
|
+
})
|
|
1319
|
+
.from(schema.contentDatabaseSourceRows)
|
|
1320
|
+
.innerJoin(
|
|
1321
|
+
schema.contentDatabaseSources,
|
|
1322
|
+
eq(
|
|
1323
|
+
schema.contentDatabaseSources.id,
|
|
1324
|
+
schema.contentDatabaseSourceRows.sourceId,
|
|
1325
|
+
),
|
|
1326
|
+
)
|
|
1327
|
+
.innerJoin(
|
|
1328
|
+
schema.contentDatabaseItems,
|
|
1329
|
+
eq(
|
|
1330
|
+
schema.contentDatabaseItems.id,
|
|
1331
|
+
schema.contentDatabaseSourceRows.databaseItemId,
|
|
1332
|
+
),
|
|
1333
|
+
)
|
|
1334
|
+
.innerJoin(
|
|
1335
|
+
schema.contentDatabases,
|
|
1336
|
+
eq(schema.contentDatabases.id, schema.contentDatabaseItems.databaseId),
|
|
1337
|
+
)
|
|
1338
|
+
.leftJoin(
|
|
1339
|
+
schema.contentDatabaseBodyHydrationQueue,
|
|
1340
|
+
eq(
|
|
1341
|
+
schema.contentDatabaseBodyHydrationQueue.databaseItemId,
|
|
1342
|
+
schema.contentDatabaseItems.id,
|
|
1343
|
+
),
|
|
1344
|
+
)
|
|
1345
|
+
.where(
|
|
1346
|
+
and(
|
|
1347
|
+
eq(schema.contentDatabaseSourceRows.documentId, documentId),
|
|
1348
|
+
eq(schema.contentDatabaseItems.documentId, documentId),
|
|
1349
|
+
eq(schema.contentDatabaseSources.sourceType, "builder-cms"),
|
|
1350
|
+
isNull(schema.contentDatabases.deletedAt),
|
|
1351
|
+
),
|
|
1352
|
+
);
|
|
1353
|
+
|
|
1354
|
+
const boundRows = rows.filter(
|
|
1355
|
+
(row) =>
|
|
1356
|
+
!row.bodyHydrationQueueId ||
|
|
1357
|
+
(row.queueSourceId === row.sourceId &&
|
|
1358
|
+
row.queueSourceRowId === row.sourceRowId),
|
|
1359
|
+
);
|
|
1360
|
+
if (boundRows.length === 0) return null;
|
|
1361
|
+
|
|
1362
|
+
const priority = (row: (typeof boundRows)[number]) => {
|
|
1363
|
+
if (
|
|
1364
|
+
row.bodyHydrationQueueId ||
|
|
1365
|
+
row.item.bodyHydrationStatus === "pending" ||
|
|
1366
|
+
row.item.bodyHydrationStatus === "hydrating"
|
|
1367
|
+
) {
|
|
1368
|
+
return 0;
|
|
1369
|
+
}
|
|
1370
|
+
if (row.item.bodyHydrationStatus === "error") return 1;
|
|
1371
|
+
return 2;
|
|
1372
|
+
};
|
|
1373
|
+
const topPriority = Math.min(...boundRows.map(priority));
|
|
1374
|
+
const candidates = boundRows
|
|
1375
|
+
.filter((row) => priority(row) === topPriority)
|
|
1376
|
+
.sort((left, right) =>
|
|
1377
|
+
`${left.database.id}:${left.sourceId}:${left.sourceRowId}`.localeCompare(
|
|
1378
|
+
`${right.database.id}:${right.sourceId}:${right.sourceRowId}`,
|
|
1379
|
+
),
|
|
1380
|
+
);
|
|
1381
|
+
const sourceIds = new Set(candidates.map((row) => row.sourceId));
|
|
1382
|
+
|
|
1383
|
+
return {
|
|
1384
|
+
membership: candidates[0]!,
|
|
1385
|
+
hydrationSourceId: sourceIds.size === 1 ? candidates[0]!.sourceId : null,
|
|
1386
|
+
};
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1280
1389
|
export async function deleteDatabaseDataForDocument(
|
|
1281
1390
|
documentId: string,
|
|
1282
1391
|
ownerEmail: string,
|
|
@@ -11,6 +11,7 @@ import { favoriteDocumentIds } from "./_content-favorites.js";
|
|
|
11
11
|
import { resolveContentSpaceAccess } from "./_content-space-access.js";
|
|
12
12
|
import {
|
|
13
13
|
getDatabaseByDocumentId,
|
|
14
|
+
getBuilderBodyHydrationMembershipByDocumentId,
|
|
14
15
|
getDocumentContextPath,
|
|
15
16
|
getDatabaseItemByDocumentId,
|
|
16
17
|
isSoftDeletedDatabaseDocument,
|
|
@@ -18,7 +19,9 @@ import {
|
|
|
18
19
|
} from "./_database-utils.js";
|
|
19
20
|
import { serializeDocumentSource } from "./_document-source.js";
|
|
20
21
|
import {
|
|
22
|
+
getDatabaseById,
|
|
21
23
|
listPropertiesForDocument,
|
|
24
|
+
resolvePropertyDatabaseForDocument,
|
|
22
25
|
serializeDatabase,
|
|
23
26
|
} from "./_property-utils.js";
|
|
24
27
|
|
|
@@ -54,6 +57,14 @@ export default defineAction({
|
|
|
54
57
|
description: "Get a single document by ID with full content.",
|
|
55
58
|
schema: z.object({
|
|
56
59
|
id: z.string().optional().describe("Document ID (required)"),
|
|
60
|
+
databaseId: z
|
|
61
|
+
.string()
|
|
62
|
+
.optional()
|
|
63
|
+
.describe("Exact Database context for membership-local data"),
|
|
64
|
+
databaseDocumentId: z
|
|
65
|
+
.string()
|
|
66
|
+
.optional()
|
|
67
|
+
.describe("Backing document ID for the exact Database context"),
|
|
57
68
|
}),
|
|
58
69
|
http: { method: "GET" },
|
|
59
70
|
readOnly: true,
|
|
@@ -79,8 +90,54 @@ export default defineAction({
|
|
|
79
90
|
});
|
|
80
91
|
}
|
|
81
92
|
const doc = access.resource;
|
|
93
|
+
if (args.databaseDocumentId && !args.databaseId) {
|
|
94
|
+
throw Object.assign(new Error("databaseDocumentId requires databaseId"), {
|
|
95
|
+
statusCode: 404,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
82
99
|
const database = await getDatabaseByDocumentId(doc.id);
|
|
83
|
-
const databaseMembership =
|
|
100
|
+
const databaseMembership = args.databaseId
|
|
101
|
+
? await getDatabaseItemByDocumentId(doc.id, {
|
|
102
|
+
databaseId: args.databaseId,
|
|
103
|
+
})
|
|
104
|
+
: await getDatabaseItemByDocumentId(doc.id);
|
|
105
|
+
const propertyDatabase = args.databaseId
|
|
106
|
+
? await getDatabaseById(args.databaseId)
|
|
107
|
+
: await resolvePropertyDatabaseForDocument(doc);
|
|
108
|
+
const propertyDatabaseAccess =
|
|
109
|
+
args.databaseId && propertyDatabase
|
|
110
|
+
? await resolveDocumentAccess(propertyDatabase.documentId)
|
|
111
|
+
: null;
|
|
112
|
+
if (
|
|
113
|
+
args.databaseId &&
|
|
114
|
+
(!propertyDatabase ||
|
|
115
|
+
!propertyDatabaseAccess ||
|
|
116
|
+
(propertyDatabase.documentId !== doc.id && !databaseMembership))
|
|
117
|
+
) {
|
|
118
|
+
throw Object.assign(new Error("Database context not found"), {
|
|
119
|
+
statusCode: 404,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
if (
|
|
123
|
+
args.databaseDocumentId &&
|
|
124
|
+
propertyDatabase?.documentId !== args.databaseDocumentId
|
|
125
|
+
) {
|
|
126
|
+
throw Object.assign(new Error("Database context not found"), {
|
|
127
|
+
statusCode: 404,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
const bodyHydrationTarget =
|
|
131
|
+
await getBuilderBodyHydrationMembershipByDocumentId(doc.id);
|
|
132
|
+
const bodyHydrationMembership = bodyHydrationTarget?.membership;
|
|
133
|
+
const bodyHydrationAccess = bodyHydrationTarget?.hydrationSourceId
|
|
134
|
+
? await resolveDocumentAccess(
|
|
135
|
+
bodyHydrationMembership!.database.documentId,
|
|
136
|
+
)
|
|
137
|
+
: null;
|
|
138
|
+
const bodyHydration = bodyHydrationMembership
|
|
139
|
+
? serializeDatabaseMembership(bodyHydrationMembership).bodyHydration
|
|
140
|
+
: null;
|
|
84
141
|
const userEmail = getRequestUserEmail();
|
|
85
142
|
const favoriteIds = userEmail
|
|
86
143
|
? await favoriteDocumentIds(getDb(), userEmail, [doc.id])
|
|
@@ -112,10 +169,34 @@ export default defineAction({
|
|
|
112
169
|
databaseMembership: databaseMembership
|
|
113
170
|
? serializeDatabaseMembership(databaseMembership)
|
|
114
171
|
: undefined,
|
|
172
|
+
bodyHydration: bodyHydrationMembership
|
|
173
|
+
? {
|
|
174
|
+
hydration: bodyHydrationAccess
|
|
175
|
+
? bodyHydration!
|
|
176
|
+
: {
|
|
177
|
+
status: bodyHydration!.status,
|
|
178
|
+
attemptedAt: null,
|
|
179
|
+
error: null,
|
|
180
|
+
version: null,
|
|
181
|
+
},
|
|
182
|
+
...(bodyHydrationAccess &&
|
|
183
|
+
canEditRole(bodyHydrationAccess.role) &&
|
|
184
|
+
bodyHydrationTarget?.hydrationSourceId
|
|
185
|
+
? {
|
|
186
|
+
provider: "builder" as const,
|
|
187
|
+
sourceId: bodyHydrationTarget.hydrationSourceId,
|
|
188
|
+
databaseDocumentId:
|
|
189
|
+
bodyHydrationMembership.database.documentId,
|
|
190
|
+
}
|
|
191
|
+
: {}),
|
|
192
|
+
}
|
|
193
|
+
: undefined,
|
|
115
194
|
createdAt: doc.createdAt,
|
|
116
195
|
updatedAt: doc.updatedAt,
|
|
117
|
-
properties: await listPropertiesForDocument(doc),
|
|
118
|
-
contextPath: await getDocumentContextPath(doc
|
|
196
|
+
properties: await listPropertiesForDocument(doc, args.databaseId),
|
|
197
|
+
contextPath: await getDocumentContextPath(doc, {
|
|
198
|
+
databaseId: args.databaseId,
|
|
199
|
+
}),
|
|
119
200
|
};
|
|
120
201
|
},
|
|
121
202
|
link: ({ result }) => {
|
|
@@ -8,9 +8,16 @@ export function BuilderBodySyncingNotice({
|
|
|
8
8
|
description: string;
|
|
9
9
|
}) {
|
|
10
10
|
return (
|
|
11
|
-
<div
|
|
11
|
+
<div
|
|
12
|
+
className="rounded-lg border border-dashed border-border bg-muted/35 px-4 py-5 text-sm"
|
|
13
|
+
role="status"
|
|
14
|
+
aria-live="polite"
|
|
15
|
+
>
|
|
12
16
|
<div className="flex items-center gap-2 font-medium text-foreground">
|
|
13
|
-
<IconLoader2
|
|
17
|
+
<IconLoader2
|
|
18
|
+
className="size-4 animate-spin text-muted-foreground"
|
|
19
|
+
aria-hidden="true"
|
|
20
|
+
/>
|
|
14
21
|
{title}
|
|
15
22
|
</div>
|
|
16
23
|
<p className="mt-2 max-w-2xl leading-6 text-muted-foreground">
|