@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.
Files changed (50) hide show
  1. package/corpus/README.md +2 -2
  2. package/corpus/core/CHANGELOG.md +12 -0
  3. package/corpus/core/docs/content/getting-started-actions.mdx +235 -0
  4. package/corpus/core/docs/content/getting-started-database.mdx +253 -0
  5. package/corpus/core/docs/content/getting-started-pages.mdx +190 -0
  6. package/corpus/core/docs/content/getting-started.mdx +57 -613
  7. package/corpus/core/package.json +1 -1
  8. package/corpus/core/src/sharing/access.ts +44 -2
  9. package/corpus/templates/clips/actions/add-comment.ts +6 -2
  10. package/corpus/templates/clips/app/components/player/comments-panel.tsx +9 -2
  11. package/corpus/templates/clips/app/components/player/playback-comment-overlay.tsx +44 -27
  12. package/corpus/templates/clips/app/components/player/scrubber.tsx +10 -2
  13. package/corpus/templates/clips/app/components/player/video-player.tsx +4 -0
  14. package/corpus/templates/clips/app/routes/r.$recordingId.tsx +22 -15
  15. package/corpus/templates/clips/app/routes/share.$shareId.tsx +1 -0
  16. package/corpus/templates/content/actions/_database-utils.ts +113 -4
  17. package/corpus/templates/content/actions/get-document.ts +84 -3
  18. package/corpus/templates/content/app/components/editor/BuilderBodySyncingNotice.tsx +9 -2
  19. package/corpus/templates/content/app/components/editor/DocumentEditor.tsx +29 -13
  20. package/corpus/templates/content/app/components/editor/DocumentToolbar.tsx +5 -12
  21. package/corpus/templates/content/app/components/editor/body-hydration.ts +12 -6
  22. package/corpus/templates/content/app/components/editor/database/DatabaseView.tsx +2 -3
  23. package/corpus/templates/content/app/components/sidebar/DocumentSidebar.tsx +5 -12
  24. package/corpus/templates/content/app/hooks/use-content-database.ts +16 -27
  25. package/corpus/templates/content/app/hooks/use-create-page.ts +3 -6
  26. package/corpus/templates/content/app/hooks/use-document-properties.ts +9 -16
  27. package/corpus/templates/content/app/hooks/use-document-versions.ts +3 -3
  28. package/corpus/templates/content/app/hooks/use-documents.ts +85 -29
  29. package/corpus/templates/content/app/hooks/use-notion.ts +15 -13
  30. package/corpus/templates/content/app/i18n-data.ts +32 -1
  31. package/corpus/templates/content/app/lib/document-query.ts +36 -0
  32. package/corpus/templates/content/changelog/2026-08-02-pages-opened-from-a-database-now-keep-that-database-s-fields.md +6 -0
  33. package/corpus/templates/content/server/lib/document-context.ts +11 -6
  34. package/corpus/templates/content/shared/api.ts +8 -0
  35. package/dist/collab/struct-routes.d.ts +1 -1
  36. package/dist/notifications/routes.d.ts +3 -3
  37. package/dist/observability/routes.d.ts +1 -1
  38. package/dist/progress/routes.d.ts +1 -1
  39. package/dist/provider-api/actions/custom-provider-registration.d.ts +6 -6
  40. package/dist/provider-api/actions/provider-api.d.ts +4 -4
  41. package/dist/secrets/routes.d.ts +9 -9
  42. package/dist/sharing/access.d.ts.map +1 -1
  43. package/dist/sharing/access.js +32 -2
  44. package/dist/sharing/access.js.map +1 -1
  45. package/docs/content/getting-started-actions.mdx +235 -0
  46. package/docs/content/getting-started-database.mdx +253 -0
  47. package/docs/content/getting-started-pages.mdx +190 -0
  48. package/docs/content/getting-started.mdx +57 -613
  49. package/package.json +1 -1
  50. package/src/sharing/access.ts +44 -2
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-native/core",
3
- "version": "0.135.0",
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, orgId } = ctx;
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
- if (resource.visibility === "org" && orgId && resource.orgId === orgId) {
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: args.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} @ ${args.videoTimestampMs}ms (thread: ${threadId})`,
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 { useActionMutation } from "@agent-native/core/client/hooks";
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
- export const PLAYBACK_COMMENT_VISIBLE_MS = 4_000;
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
- <div className="flex w-full max-w-xl flex-col items-center gap-2">
72
- {activeComments.map((comment) => {
73
- const author = displayAuthor(comment);
74
- return (
75
- <div
76
- key={comment.id}
77
- className="animate-in fade-in slide-in-from-bottom-2 flex max-w-full items-start gap-2.5 rounded-xl bg-black/85 px-3 py-2.5 text-white shadow-2xl ring-1 ring-white/15 backdrop-blur-md duration-200"
78
- >
79
- <span
80
- aria-hidden="true"
81
- className="flex size-7 shrink-0 items-center justify-center rounded-full bg-white/15 text-[10px] font-semibold text-white"
82
- >
83
- {initials(author)}
84
- </span>
85
- <div className="min-w-0">
86
- <p className="truncate text-xs font-semibold text-white/80">
87
- {author}
88
- </p>
89
- <p className="line-clamp-3 break-words text-sm leading-5 text-white">
90
- {comment.content}
91
- </p>
92
- </div>
93
- </div>
94
- );
95
- })}
96
- </div>
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 -top-1 -translate-x-1/2 h-3.5 w-3.5 rounded-full bg-yellow-400 border-2 border-black/50 hover:scale-125 transition-transform"
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 openInsightsPanel = useCallback(() => {
265
- setPanel("insights");
266
- if (!isCompactLayout) return;
267
- requestAnimationFrame(() => {
268
- document
269
- .getElementById("clip-activity-panel")
270
- ?.scrollIntoView({ block: "start" });
271
- });
272
- }, [isCompactLayout]);
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
- setPanel("comments");
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
- eq(
1266
- schema.contentDatabaseBodyHydrationQueue.databaseItemId,
1267
- schema.contentDatabaseItems.id,
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 = await getDatabaseItemByDocumentId(doc.id);
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 className="rounded-lg border border-dashed border-border bg-muted/35 px-4 py-5 text-sm">
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 className="size-4 animate-spin text-muted-foreground" />
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">