@agent-native/core-corpus 0.201.0-nightly-20261005153421 → 0.201.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (235) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/analytics/.agents/skills/security/SKILL.md +9 -2
  3. package/corpus/templates/analytics/.agents/skills/session-replay/SKILL.md +14 -4
  4. package/corpus/templates/analytics/.agents/skills/storing-data/SKILL.md +12 -7
  5. package/corpus/templates/analytics/CHANGELOG.md +6 -0
  6. package/corpus/templates/analytics/README.md +2 -2
  7. package/corpus/templates/analytics/changelog/2026-10-02-events-with-a-very-long-name-app-or-page-path-no-longer-caus.md +6 -0
  8. package/corpus/templates/analytics/package.json +1 -0
  9. package/corpus/templates/analytics/server/handlers/first-party-analytics.ts +9 -19
  10. package/corpus/templates/analytics/server/handlers/session-replay.ts +34 -47
  11. package/corpus/templates/analytics/server/lib/first-party-analytics-rollups.ts +9 -6
  12. package/corpus/templates/analytics/server/lib/first-party-analytics.ts +57 -36
  13. package/corpus/templates/analytics/server/lib/indexed-text.ts +53 -0
  14. package/corpus/templates/analytics/server/lib/request-errors.ts +73 -0
  15. package/corpus/templates/analytics/server/lib/session-event-index.ts +6 -16
  16. package/corpus/templates/analytics/server/lib/session-replay.ts +12 -2
  17. package/corpus/templates/analytics/server/plugins/auth.ts +2 -1
  18. package/corpus/templates/analytics/server/plugins/otel.ts +6 -0
  19. package/corpus/templates/assets/.agents/skills/security/SKILL.md +9 -2
  20. package/corpus/templates/assets/.agents/skills/storing-data/SKILL.md +12 -7
  21. package/corpus/templates/assets/CHANGELOG.md +7 -0
  22. package/corpus/templates/assets/README.md +2 -2
  23. package/corpus/templates/assets/package.json +1 -0
  24. package/corpus/templates/assets/server/plugins/auth.ts +2 -1
  25. package/corpus/templates/assets/server/plugins/otel.ts +6 -0
  26. package/corpus/templates/brain/.agents/skills/brain/RUNBOOK.md +2 -1
  27. package/corpus/templates/brain/.agents/skills/brain/SKILL.md +5 -4
  28. package/corpus/templates/brain/.agents/skills/security/SKILL.md +9 -2
  29. package/corpus/templates/brain/.agents/skills/storing-data/SKILL.md +12 -7
  30. package/corpus/templates/brain/CHANGELOG.md +6 -0
  31. package/corpus/templates/brain/README.md +12 -8
  32. package/corpus/templates/brain/changelog/2026-10-05-private-slack-channels-are-searchable-org-wide.md +6 -0
  33. package/corpus/templates/brain/package.json +1 -0
  34. package/corpus/templates/brain/server/lib/audiences.ts +1 -98
  35. package/corpus/templates/brain/server/lib/connectors.ts +2 -166
  36. package/corpus/templates/brain/server/plugins/otel.ts +6 -0
  37. package/corpus/templates/calendar/.agents/skills/security/SKILL.md +9 -2
  38. package/corpus/templates/calendar/.agents/skills/storing-data/SKILL.md +12 -7
  39. package/corpus/templates/calendar/CHANGELOG.md +7 -0
  40. package/corpus/templates/calendar/README.md +2 -2
  41. package/corpus/templates/calendar/package.json +1 -0
  42. package/corpus/templates/calendar/server/plugins/auth.ts +2 -1
  43. package/corpus/templates/calendar/server/plugins/otel.ts +6 -0
  44. package/corpus/templates/chat/.agents/skills/security/SKILL.md +9 -2
  45. package/corpus/templates/chat/.agents/skills/storing-data/SKILL.md +12 -7
  46. package/corpus/templates/chat/README.md +2 -2
  47. package/corpus/templates/clips/.agents/skills/ai-video-tools/SKILL.md +9 -0
  48. package/corpus/templates/clips/.agents/skills/security/SKILL.md +9 -2
  49. package/corpus/templates/clips/CHANGELOG.md +24 -0
  50. package/corpus/templates/clips/README.md +2 -2
  51. package/corpus/templates/clips/actions/lib/audio-chunk-list.ts +23 -0
  52. package/corpus/templates/clips/actions/lib/audio-only-transcription.ts +187 -292
  53. package/corpus/templates/clips/actions/lib/chunked-transcription.ts +151 -0
  54. package/corpus/templates/clips/actions/lib/recording-audio-source.ts +191 -0
  55. package/corpus/templates/clips/actions/request-transcript.ts +81 -135
  56. package/corpus/templates/clips/actions/search-recordings.ts +19 -5
  57. package/corpus/templates/clips/app/components/clips-command-menu.tsx +5 -0
  58. package/corpus/templates/clips/app/components/library/library-layout.tsx +3 -1
  59. package/corpus/templates/clips/app/components/library/search-bar.tsx +5 -0
  60. package/corpus/templates/clips/app/hooks/use-library.ts +1 -0
  61. package/corpus/templates/clips/app/lib/local-recording-upload.ts +13 -6
  62. package/corpus/templates/clips/app/lib/recording-section.ts +1 -1
  63. package/corpus/templates/clips/app/routes/_app.r.$recordingId.tsx +7 -5
  64. package/corpus/templates/clips/app/routes/record.tsx +16 -6
  65. package/corpus/templates/clips/changelog/2026-10-02-long-uploads-an-hour-or-more-now-transcribe-reliably-instead.md +6 -0
  66. package/corpus/templates/clips/changelog/2026-10-05-confirming-discard-recording-now-returns-you-to-your-library.md +6 -0
  67. package/corpus/templates/clips/changelog/2026-10-05-discarding-a-recording-now-removes-its-failed-upload-card-fr.md +6 -0
  68. package/corpus/templates/clips/changelog/2026-10-05-discarding-a-recovered-recording-no-longer-warns-that-it-s-o.md +6 -0
  69. package/corpus/templates/clips/changelog/2026-10-05-search-results-identify-recordings-in-trash-and-open-them-wi.md +6 -0
  70. package/corpus/templates/clips/package.json +1 -0
  71. package/corpus/templates/clips/server/lib/builder-cdn.ts +5 -0
  72. package/corpus/templates/clips/server/lib/builder-media-compression.ts +2 -2
  73. package/corpus/templates/clips/server/plugins/auth.ts +2 -1
  74. package/corpus/templates/clips/server/plugins/otel.ts +6 -0
  75. package/corpus/templates/clips/shared/transcript-failure.ts +5 -0
  76. package/corpus/templates/content/.agents/skills/document-editing/references/databases.md +8 -2
  77. package/corpus/templates/content/.agents/skills/security/SKILL.md +9 -2
  78. package/corpus/templates/content/.agents/skills/storing-data/SKILL.md +12 -7
  79. package/corpus/templates/content/CHANGELOG.md +41 -0
  80. package/corpus/templates/content/README.md +2 -2
  81. package/corpus/templates/content/actions/_database-navigation.ts +523 -344
  82. package/corpus/templates/content/actions/_database-row-mutation.ts +44 -10
  83. package/corpus/templates/content/actions/_database-setup-discovery.ts +9 -3
  84. package/corpus/templates/content/actions/_database-utils.ts +299 -271
  85. package/corpus/templates/content/actions/_property-utils.ts +293 -190
  86. package/corpus/templates/content/actions/_relation-values.ts +3 -1
  87. package/corpus/templates/content/actions/get-content-database.ts +1 -0
  88. package/corpus/templates/content/actions/query-content-database-items.ts +32 -5
  89. package/corpus/templates/content/actions/set-document-property.ts +17 -11
  90. package/corpus/templates/content/app/components/editor/DocumentBlockFields.tsx +21 -6
  91. package/corpus/templates/content/app/components/editor/DocumentEditor.tsx +127 -43
  92. package/corpus/templates/content/app/components/editor/DocumentEditorSkeleton.tsx +159 -50
  93. package/corpus/templates/content/app/components/editor/DocumentToolbar.tsx +12 -28
  94. package/corpus/templates/content/app/components/editor/PageDraftRecovery.tsx +6 -1
  95. package/corpus/templates/content/app/components/editor/TableHoverControls.tsx +139 -70
  96. package/corpus/templates/content/app/components/editor/VisualEditor.tsx +16 -1
  97. package/corpus/templates/content/app/components/editor/database/DatabaseView.tsx +180 -75
  98. package/corpus/templates/content/app/components/editor/database/DatabaseViewSkeleton.tsx +76 -0
  99. package/corpus/templates/content/app/components/editor/database/navigation-state.ts +60 -2
  100. package/corpus/templates/content/app/components/editor/database/sidebar.tsx +41 -42
  101. package/corpus/templates/content/app/components/editor/document-editor-layout.ts +18 -0
  102. package/corpus/templates/content/app/components/editor/extensions/NotionExtensions.tsx +11 -3
  103. package/corpus/templates/content/app/components/editor/suggestions/draft-session.ts +15 -7
  104. package/corpus/templates/content/app/components/editor/suggestions/model.ts +13 -0
  105. package/corpus/templates/content/app/components/editor/suggestions/read-only-blocks.ts +136 -0
  106. package/corpus/templates/content/app/components/editor/table-view.ts +73 -0
  107. package/corpus/templates/content/app/components/layout/ContentStartupShell.tsx +1 -1
  108. package/corpus/templates/content/app/components/layout/Layout.tsx +5 -1
  109. package/corpus/templates/content/app/components/sidebar/DocumentSidebar.tsx +57 -16
  110. package/corpus/templates/content/app/components/sidebar/MovePageDialog.tsx +4 -4
  111. package/corpus/templates/content/app/components/sidebar/SidebarNavigationRow.tsx +34 -10
  112. package/corpus/templates/content/app/global.css +16 -8
  113. package/corpus/templates/content/app/hooks/content-action-refresh.ts +14 -13
  114. package/corpus/templates/content/app/hooks/use-content-database.ts +72 -9
  115. package/corpus/templates/content/app/hooks/use-content-personal-navigation.ts +7 -3
  116. package/corpus/templates/content/app/hooks/use-documents.ts +27 -0
  117. package/corpus/templates/content/app/i18n-data.ts +3 -2
  118. package/corpus/templates/content/app/lib/content-landing.ts +3 -31
  119. package/corpus/templates/content/app/lib/files-navigation.ts +182 -10
  120. package/corpus/templates/content/app/lib/files-root-hint.ts +20 -32
  121. package/corpus/templates/content/app/lib/page-startup-hints.ts +108 -0
  122. package/corpus/templates/content/app/lib/private-link-redirect.ts +25 -0
  123. package/corpus/templates/content/app/lib/sidebar-layout-hint.ts +34 -1
  124. package/corpus/templates/content/app/lib/startup-timing.ts +2 -0
  125. package/corpus/templates/content/app/root.tsx +3 -1
  126. package/corpus/templates/content/app/routes/_app.home.tsx +10 -19
  127. package/corpus/templates/content/app/routes/p.$id.tsx +23 -5
  128. package/corpus/templates/content/changelog/2026-10-02-a-page-link-you-cant-open-now-says-so-instead-of-opening-an.md +6 -0
  129. package/corpus/templates/content/changelog/2026-10-02-after-a-refresh-the-files-sidebar-shows-the-open-page-s-fold.md +6 -0
  130. package/corpus/templates/content/changelog/2026-10-02-large-collections-open-faster.md +6 -0
  131. package/corpus/templates/content/changelog/2026-10-02-pages-stay-inside-the-window-at-every-width-long-inline-code.md +6 -0
  132. package/corpus/templates/content/changelog/2026-10-02-pages-with-open-comments-and-collections-with-sorted-or-filt.md +6 -0
  133. package/corpus/templates/content/changelog/2026-10-02-suggest-edits-works-on-pages-written-by-agents-including-pag.md +6 -0
  134. package/corpus/templates/content/changelog/2026-10-02-while-you-suggest-edits-tables-and-other-blocks-that-suggest.md +6 -0
  135. package/corpus/templates/content/changelog/2026-10-05-collection-view-context-now-survives-quick-page-changes-when.md +6 -0
  136. package/corpus/templates/content/changelog/2026-10-05-collection-views-keep-their-saved-rows-accurate-during-refre.md +6 -0
  137. package/corpus/templates/content/changelog/2026-10-05-exact-saved-view-links-keep-collection-retry-available.md +6 -0
  138. package/corpus/templates/content/changelog/2026-10-05-expanding-a-files-folder-now-shares-one-navigation-read-acro.md +6 -0
  139. package/corpus/templates/content/changelog/2026-10-05-files-navigation-retries-temporary-failures-consistently.md +6 -0
  140. package/corpus/templates/content/changelog/2026-10-05-shared-page-owners-keep-edit-and-manage-controls-in-files-an.md +6 -0
  141. package/corpus/templates/content/changelog/2026-10-05-tagging-an-ai-in-a-comment-works-again-instead-of-failing-ri.md +6 -0
  142. package/corpus/templates/content/changelog/2026-10-05-the-home-loading-shell-keeps-the-layout-of-your-last-opened-.md +6 -0
  143. package/corpus/templates/content/changelog/2026-10-05-typing-and-a-space-or-inserting-a-toggle-or-callout-no-longe.md +6 -0
  144. package/corpus/templates/content/docs/product/capabilities/content.workspace.session-restore.md +4 -1
  145. package/corpus/templates/content/e2e/playwright.config.ts +1 -1
  146. package/corpus/templates/content/package.json +2 -1
  147. package/corpus/templates/content/scripts/seed-perf-database.mjs +600 -0
  148. package/corpus/templates/content/scripts/trace-startup.mjs +25 -3
  149. package/corpus/templates/content/server/db/index.ts +1 -1
  150. package/corpus/templates/content/server/lib/suggested-edits.ts +23 -26
  151. package/corpus/templates/content/server/plugins/auth.ts +2 -1
  152. package/corpus/templates/content/server/plugins/otel.ts +6 -0
  153. package/corpus/templates/content/shared/api.ts +17 -0
  154. package/corpus/templates/content/shared/suggestion-diff.ts +124 -38
  155. package/corpus/templates/content/shared/suggestion-formatting.ts +134 -3
  156. package/corpus/templates/crm/.agents/skills/security/SKILL.md +9 -2
  157. package/corpus/templates/crm/.agents/skills/storing-data/SKILL.md +12 -7
  158. package/corpus/templates/crm/package.json +1 -0
  159. package/corpus/templates/crm/server/plugins/otel.ts +6 -0
  160. package/corpus/templates/design/.agents/skills/security/SKILL.md +9 -2
  161. package/corpus/templates/design/.agents/skills/storing-data/SKILL.md +12 -7
  162. package/corpus/templates/design/CHANGELOG.md +41 -0
  163. package/corpus/templates/design/README.md +2 -2
  164. package/corpus/templates/design/actions/list-designs.ts +11 -4
  165. package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +110 -32
  166. package/corpus/templates/design/app/components/design/multi-screen/cross-screen-drop.ts +28 -0
  167. package/corpus/templates/design/app/components/design/multi-screen/types.ts +1 -0
  168. package/corpus/templates/design/app/components/home/DesignHomeLibrary.tsx +79 -0
  169. package/corpus/templates/design/app/components/layout/Header.tsx +2 -4
  170. package/corpus/templates/design/app/components/ui/tabs.tsx +59 -1
  171. package/corpus/templates/design/app/global.css +1 -34
  172. package/corpus/templates/design/app/i18n/en-US.ts +4 -0
  173. package/corpus/templates/design/app/lib/design-filter.ts +4 -2
  174. package/corpus/templates/design/app/pages/Index.tsx +91 -59
  175. package/corpus/templates/design/app/pages/design-editor/commands/cross-screen-element-drop.ts +58 -17
  176. package/corpus/templates/design/changelog/2026-10-01-filter-designs-by-owner.md +6 -0
  177. package/corpus/templates/design/changelog/2026-10-03-layers-dropped-into-visible-canvas-space-beyond-a-screen-now.md +6 -0
  178. package/corpus/templates/design/changelog/2026-10-03-visible-screen-overflow-no-longer-routes-drops-to-the-canvas.md +6 -0
  179. package/corpus/templates/design/changelog/2026-10-05-the-recent-tab-shows-loading-placeholders-until-your-designs.md +6 -0
  180. package/corpus/templates/design/package.json +1 -0
  181. package/corpus/templates/design/server/plugins/auth.ts +2 -1
  182. package/corpus/templates/design/server/plugins/otel.ts +6 -0
  183. package/corpus/templates/dispatch/.agents/skills/security/SKILL.md +9 -2
  184. package/corpus/templates/dispatch/.agents/skills/storing-data/SKILL.md +12 -7
  185. package/corpus/templates/dispatch/README.md +2 -2
  186. package/corpus/templates/dispatch/package.json +1 -0
  187. package/corpus/templates/dispatch/server/plugins/otel.ts +6 -0
  188. package/corpus/templates/dispatch/server/plugins/setup-dispatch.ts +2 -1
  189. package/corpus/templates/factory/.agents/skills/security/SKILL.md +9 -2
  190. package/corpus/templates/factory/.agents/skills/storing-data/SKILL.md +12 -7
  191. package/corpus/templates/factory/package.json +1 -0
  192. package/corpus/templates/factory/server/plugins/otel.ts +6 -0
  193. package/corpus/templates/forms/.agents/skills/security/SKILL.md +9 -2
  194. package/corpus/templates/forms/.agents/skills/storing-data/SKILL.md +12 -7
  195. package/corpus/templates/forms/README.md +2 -2
  196. package/corpus/templates/forms/package.json +1 -0
  197. package/corpus/templates/forms/server/plugins/auth.ts +2 -1
  198. package/corpus/templates/forms/server/plugins/otel.ts +6 -0
  199. package/corpus/templates/mail/.agents/skills/security/SKILL.md +9 -2
  200. package/corpus/templates/mail/.agents/skills/storing-data/SKILL.md +12 -7
  201. package/corpus/templates/mail/CHANGELOG.md +6 -0
  202. package/corpus/templates/mail/README.md +2 -2
  203. package/corpus/templates/mail/package.json +1 -0
  204. package/corpus/templates/mail/server/plugins/auth.ts +2 -1
  205. package/corpus/templates/mail/server/plugins/otel.ts +6 -0
  206. package/corpus/templates/plan/.agents/skills/security/SKILL.md +9 -2
  207. package/corpus/templates/plan/.agents/skills/storing-data/SKILL.md +12 -7
  208. package/corpus/templates/plan/CHANGELOG.md +1 -0
  209. package/corpus/templates/plan/README.md +2 -2
  210. package/corpus/templates/plan/package.json +1 -0
  211. package/corpus/templates/plan/server/plugins/auth.ts +2 -1
  212. package/corpus/templates/plan/server/plugins/otel.ts +6 -0
  213. package/corpus/templates/slides/.agents/skills/security/SKILL.md +9 -2
  214. package/corpus/templates/slides/.agents/skills/storing-data/SKILL.md +12 -7
  215. package/corpus/templates/slides/CHANGELOG.md +23 -0
  216. package/corpus/templates/slides/README.md +2 -2
  217. package/corpus/templates/slides/actions/list-decks.ts +11 -4
  218. package/corpus/templates/slides/app/components/deck/DeckFilterMenu.tsx +44 -36
  219. package/corpus/templates/slides/app/components/home/SlidesHomeLibrary.tsx +79 -0
  220. package/corpus/templates/slides/app/components/layout/Header.tsx +2 -15
  221. package/corpus/templates/slides/app/components/ui/tabs.tsx +59 -0
  222. package/corpus/templates/slides/app/global.css +3 -54
  223. package/corpus/templates/slides/app/i18n/en-US.ts +4 -0
  224. package/corpus/templates/slides/app/lib/deck-filter.ts +5 -2
  225. package/corpus/templates/slides/app/pages/Index.tsx +102 -88
  226. package/corpus/templates/slides/changelog/2026-10-01-filter-decks-by-owner.md +6 -0
  227. package/corpus/templates/slides/changelog/2026-10-02-hide-recent-until-decks-available.md +6 -0
  228. package/corpus/templates/slides/changelog/2026-10-05-the-recent-tab-shows-loading-placeholders-until-your-decks-a.md +6 -0
  229. package/corpus/templates/slides/package.json +1 -0
  230. package/corpus/templates/slides/server/handlers/request-auth-context.ts +8 -0
  231. package/corpus/templates/slides/server/plugins/auth.ts +2 -1
  232. package/corpus/templates/slides/server/plugins/otel.ts +6 -0
  233. package/corpus/templates/tasks/.agents/skills/security/SKILL.md +9 -2
  234. package/corpus/templates/tasks/.agents/skills/storing-data/SKILL.md +12 -7
  235. package/package.json +1 -1
@@ -24,11 +24,12 @@ import { getDb, schema } from "../server/db/index.js";
24
24
  import type {
25
25
  ContentDatabaseFilter,
26
26
  ContentDatabaseFilterMode,
27
+ ContentDatabaseNavigationPage,
27
28
  ContentDatabaseNavigationPageResponse,
28
29
  ContentDatabaseNavigationSort,
30
+ ContentDatabasePersonalViewOverrides,
29
31
  } from "../shared/api.js";
30
- import { readPersonalDatabaseViewOverrides } from "./_content-database-personal-view.js";
31
- import { favoriteDocumentIds } from "./_content-favorites.js";
32
+ import { favoritesSystemIds } from "./_content-favorites.js";
32
33
  import { softDeletedDatabaseDocumentExclusions } from "./_document-discovery-query.js";
33
34
  import { parseDatabaseViewConfig } from "./_property-utils.js";
34
35
 
@@ -309,14 +310,21 @@ function combineRevisions(parts: NavigationRevision[]) {
309
310
  )}`;
310
311
  }
311
312
 
313
+ // Expanded branches one read returns with its page, and how many expanded
314
+ // document IDs it accepts.
315
+ export const MAX_NAVIGATION_EXPANDED_BRANCHES = 32;
316
+ export const MAX_NAVIGATION_EXPAND_IDS = 100;
317
+
312
318
  export async function getContentDatabaseNavigationPage(args: {
313
319
  database: typeof schema.contentDatabases.$inferSelect;
314
320
  userEmail: string;
321
+ overrides: ContentDatabasePersonalViewOverrides | null;
315
322
  parentId: string | null;
316
323
  sort: ContentDatabaseNavigationSort;
317
324
  viewId?: string;
318
325
  limit: number;
319
326
  cursor?: string;
327
+ expand?: readonly string[];
320
328
  }): Promise<ContentDatabaseNavigationPageResponse> {
321
329
  if (args.database.systemRole !== "files" || !args.database.spaceId) {
322
330
  fail("Navigation mode is only available for a Files database.", {
@@ -331,33 +339,7 @@ export async function getContentDatabaseNavigationPage(args: {
331
339
  userEmail: args.userEmail,
332
340
  orgId: args.database.orgId ?? undefined,
333
341
  };
334
- const [overrides, parentRows] = await Promise.all([
335
- readPersonalDatabaseViewOverrides(args.userEmail, args.database.id),
336
- args.parentId === null
337
- ? null
338
- : db
339
- .select({ id: schema.documents.id })
340
- .from(schema.contentDatabaseItems)
341
- .innerJoin(
342
- schema.documents,
343
- eq(schema.documents.id, schema.contentDatabaseItems.documentId),
344
- )
345
- .where(
346
- and(
347
- eq(schema.contentDatabaseItems.databaseId, args.database.id),
348
- eq(schema.documents.id, args.parentId),
349
- eq(schema.documents.spaceId, args.database.spaceId),
350
- isNull(schema.documents.trashedAt),
351
- accessFilter(
352
- schema.documents,
353
- schema.documentShares,
354
- accessContext,
355
- ),
356
- ...softDeletedDatabaseDocumentExclusions(schema.documents.id),
357
- ),
358
- )
359
- .limit(1),
360
- ]);
342
+ const overrides = args.overrides;
361
343
  const sharedConfig = parseDatabaseViewConfig(args.database.viewConfigJson);
362
344
  const viewId =
363
345
  args.viewId ?? overrides?.activeViewId ?? sharedConfig.activeViewId;
@@ -411,11 +393,16 @@ export async function getContentDatabaseNavigationPage(args: {
411
393
  descending,
412
394
  }),
413
395
  ]);
414
- if (parentRows && parentRows.length === 0) {
415
- fail("The Files navigation parent is unavailable.", {
416
- errorCode: "invalid_navigation_parent",
417
- statusCode: 400,
418
- });
396
+ if (
397
+ cursor &&
398
+ (cursor.databaseId !== args.database.id ||
399
+ cursor.parentId !== args.parentId ||
400
+ cursor.sort !== effectiveSort ||
401
+ cursor.viewId !== viewId ||
402
+ cursor.orderHash !== orderHash ||
403
+ cursor.configHash !== configHash)
404
+ ) {
405
+ invalidCursor();
419
406
  }
420
407
 
421
408
  const definitionById = await navigationFilterDefinitions(
@@ -435,37 +422,32 @@ export async function getContentDatabaseNavigationPage(args: {
435
422
  updatedAt: sql`${schema.documents.updatedAt}`,
436
423
  },
437
424
  });
438
- if (
439
- cursor &&
440
- (cursor.databaseId !== args.database.id ||
441
- cursor.parentId !== args.parentId ||
442
- cursor.sort !== effectiveSort ||
443
- cursor.viewId !== viewId ||
444
- cursor.orderHash !== orderHash ||
445
- cursor.configHash !== configHash)
446
- ) {
447
- invalidCursor();
448
- }
449
425
 
450
426
  const childDocuments = alias(schema.documents, "navigation_child_documents");
451
427
  const childItems = alias(
452
428
  schema.contentDatabaseItems,
453
429
  "navigation_child_memberships",
454
430
  );
431
+ const favoriteItems = alias(
432
+ schema.contentDatabaseItems,
433
+ "navigation_favorite_memberships",
434
+ );
435
+ const roleShares = alias(schema.documentShares, "navigation_role_shares");
455
436
  const databaseDocuments = alias(
456
437
  schema.contentDatabases,
457
438
  "navigation_database_documents",
458
439
  );
459
- const siblingFilter = and(
460
- eq(schema.contentDatabaseItems.databaseId, args.database.id),
461
- args.parentId === null
462
- ? isNull(schema.documents.parentId)
463
- : eq(schema.documents.parentId, args.parentId),
464
- isNull(schema.documents.trashedAt),
465
- accessFilter(schema.documents, schema.documentShares, accessContext),
466
- ...softDeletedDatabaseDocumentExclusions(schema.documents.id),
467
- effectiveFilter,
468
- );
440
+ const siblingsOf = (parentId: string | null) =>
441
+ and(
442
+ eq(schema.contentDatabaseItems.databaseId, args.database.id),
443
+ parentId === null
444
+ ? isNull(schema.documents.parentId)
445
+ : eq(schema.documents.parentId, parentId),
446
+ isNull(schema.documents.trashedAt),
447
+ accessFilter(schema.documents, schema.documentShares, accessContext),
448
+ ...softDeletedDatabaseDocumentExclusions(schema.documents.id),
449
+ effectiveFilter,
450
+ );
469
451
  const accessRevision = sql<string>`coalesce((
470
452
  SELECT string_agg(ds.role || ':' || ds.principal_type || ':' || ds.principal_id, ',' ORDER BY ds.role, ds.principal_type, ds.principal_id)
471
453
  FROM document_shares ds
@@ -490,6 +472,72 @@ export async function getContentDatabaseNavigationPage(args: {
490
472
  isNull(databaseDocuments.deletedAt),
491
473
  ),
492
474
  );
475
+ const visibleChild = db
476
+ .select({ id: childItems.id })
477
+ .from(childItems)
478
+ .innerJoin(childDocuments, eq(childDocuments.id, childItems.documentId))
479
+ .where(
480
+ and(
481
+ eq(childItems.databaseId, args.database.id),
482
+ eq(childDocuments.parentId, schema.documents.id),
483
+ isNull(childDocuments.trashedAt),
484
+ accessFilter(childDocuments, schema.documentShares, accessContext),
485
+ ...softDeletedDatabaseDocumentExclusions(childDocuments.id),
486
+ navigationFilterSql({
487
+ filters: effectiveFilters,
488
+ filterMode: effectiveFilterMode,
489
+ definitionById,
490
+ document: {
491
+ id: sql`${childDocuments.id}`,
492
+ title: sql`${childDocuments.title}`,
493
+ parentId: sql`${childDocuments.parentId}`,
494
+ ownerEmail: sql`${childDocuments.ownerEmail}`,
495
+ createdAt: sql`${childDocuments.createdAt}`,
496
+ updatedAt: sql`${childDocuments.updatedAt}`,
497
+ },
498
+ }),
499
+ ),
500
+ );
501
+ const favoriteMembership = db
502
+ .select({ id: favoriteItems.id })
503
+ .from(favoriteItems)
504
+ .where(
505
+ and(
506
+ eq(
507
+ favoriteItems.databaseId,
508
+ favoritesSystemIds(args.userEmail).databaseId,
509
+ ),
510
+ eq(favoriteItems.documentId, schema.documents.id),
511
+ ),
512
+ );
513
+ const sharedWithRole = (roles: Array<"admin" | "editor">) =>
514
+ db
515
+ .select({ id: roleShares.id })
516
+ .from(roleShares)
517
+ .where(
518
+ and(
519
+ eq(roleShares.resourceId, schema.documents.id),
520
+ or(
521
+ inArray(roleShares.role, roles),
522
+ // Legacy share rows can carry `owner` even though new writes use ShareRole.
523
+ sql`${roleShares.role} = 'owner'`,
524
+ ),
525
+ or(
526
+ and(
527
+ eq(roleShares.principalType, "user"),
528
+ eq(roleShares.principalId, args.userEmail),
529
+ ),
530
+ args.database.orgId
531
+ ? and(
532
+ eq(roleShares.principalType, "org"),
533
+ eq(roleShares.principalId, args.database.orgId),
534
+ )
535
+ : undefined,
536
+ ),
537
+ ),
538
+ );
539
+ // The per-row lookups below are subqueries in the select list, so Postgres
540
+ // runs them only for the rows the page returns, after its sort and limit.
493
541
  const rowFields = {
494
542
  membershipId: schema.contentDatabaseItems.id,
495
543
  membershipPosition: schema.contentDatabaseItems.position,
@@ -505,25 +553,125 @@ export async function getContentDatabaseNavigationPage(args: {
505
553
  type: sql<"page" | "database">`CASE WHEN ${exists(
506
554
  activeDatabaseDocument,
507
555
  )} THEN 'database' ELSE 'page' END`,
556
+ hasChildren: sql<boolean>`${exists(visibleChild)}`,
557
+ isFavorite: sql<boolean>`${exists(favoriteMembership)}`,
558
+ sharedEditor: sql<boolean>`${exists(sharedWithRole(["admin", "editor"]))}`,
559
+ sharedManager: sql<boolean>`${exists(sharedWithRole(["admin"]))}`,
508
560
  rowHash: sql<string>`${rowHash}::text`,
509
561
  };
562
+ const membershipRows = (where: SQL | undefined) =>
563
+ db
564
+ .select(rowFields)
565
+ .from(schema.contentDatabaseItems)
566
+ .innerJoin(
567
+ schema.documents,
568
+ eq(schema.documents.id, schema.contentDatabaseItems.documentId),
569
+ )
570
+ .where(where);
571
+ type NavigationRow = Awaited<ReturnType<typeof membershipRows>>[number] & {
572
+ sortValue: string | number;
573
+ };
574
+
575
+ // A cursor stays valid while every visible sibling up to it is unchanged:
576
+ // rows after it are read fresh by the next page anyway. The revision is the
577
+ // count and hash sum of that prefix, so it grows with the pages read, not
578
+ // with the workspace.
579
+ const revisionOf = async (
580
+ query: Promise<Array<{ count: number; checksum: string }>>,
581
+ ): Promise<NavigationRevision> => {
582
+ const [row] = await query;
583
+ return { count: row?.count ?? 0, checksum: BigInt(row?.checksum ?? "0") };
584
+ };
585
+ const revisionFields = {
586
+ count: sql<number>`count(*)::int`,
587
+ checksum: sql<string>`coalesce(sum((${rowHash})::numeric), 0)::text`,
588
+ };
589
+ const membershipRevision = (where: SQL | undefined) =>
590
+ db
591
+ .select(revisionFields)
592
+ .from(schema.contentDatabaseItems)
593
+ .innerJoin(
594
+ schema.documents,
595
+ eq(schema.documents.id, schema.contentDatabaseItems.documentId),
596
+ )
597
+ .where(where);
598
+
599
+ type NavigationOrder = {
600
+ page: (
601
+ siblings: SQL | undefined,
602
+ after: NavigationCursor | null,
603
+ ) => Promise<NavigationRow[]>;
604
+ through: (
605
+ siblings: SQL | undefined,
606
+ cursor: NavigationCursor,
607
+ ) => Promise<NavigationRevision[]>;
608
+ };
609
+
610
+ const sortValue: SQL<string> =
611
+ effectiveSort === "name"
612
+ ? sql<string>`${schema.documents.title}`
613
+ : effectiveSort === "created"
614
+ ? sql<string>`${schema.documents.createdAt}`
615
+ : sql<string>`${schema.documents.updatedAt}`;
616
+ const ascending = !descending;
617
+ const sortedAfter = (key: { sortValue: string | number; itemId: string }) =>
618
+ or(
619
+ ascending ? gt(sortValue, key.sortValue) : lt(sortValue, key.sortValue),
620
+ and(
621
+ eq(sortValue, key.sortValue),
622
+ gt(schema.contentDatabaseItems.id, key.itemId),
623
+ ),
624
+ );
625
+ const sortedOrder: NavigationOrder = {
626
+ page: (siblings, after) =>
627
+ db
628
+ .select({ ...rowFields, sortValue })
629
+ .from(schema.contentDatabaseItems)
630
+ .innerJoin(
631
+ schema.documents,
632
+ eq(schema.documents.id, schema.contentDatabaseItems.documentId),
633
+ )
634
+ .where(and(siblings, after ? sortedAfter(after) : undefined))
635
+ .orderBy(
636
+ ascending ? asc(sortValue) : desc(sortValue),
637
+ asc(schema.contentDatabaseItems.id),
638
+ )
639
+ .limit(args.limit + 1),
640
+ through: (siblings, cursor) =>
641
+ Promise.all([
642
+ revisionOf(
643
+ membershipRevision(
644
+ and(
645
+ siblings,
646
+ ascending
647
+ ? lte(sortValue, cursor.sortValue)
648
+ : gte(sortValue, cursor.sortValue),
649
+ sql`not (${sortedAfter(cursor)!})`,
650
+ ),
651
+ ),
652
+ ),
653
+ ]),
654
+ };
510
655
 
511
656
  // Custom order puts the saved item IDs first, in saved order, and the rest
512
- // after them by position. The ranked page walks the saved list in order
513
- // through primary-key lookups; the unranked page walks the
514
- // (database_id, position, id) index. Neither sorts the whole Files database.
657
+ // after them by position.
515
658
  const rankById = new Map<string, number>();
516
659
  itemIds.forEach((itemId, index) => {
517
660
  if (!rankById.has(itemId)) rankById.set(itemId, index + 1);
518
661
  });
519
662
  const unrankedValue = itemIds.length + 1;
663
+ const allRanks = [...rankById];
664
+
665
+ // The root holds most of a workspace, and a saved order mostly lists root
666
+ // items, so the root walks the saved list in order through primary-key
667
+ // lookups and the unranked rest through the (database_id, position, id)
668
+ // index. Neither sorts the whole Files database.
520
669
  const savedRanks = (ranks: Array<[string, number]>) =>
521
670
  sql`jsonb_to_recordset(${JSON.stringify(
522
671
  ranks.map(([itemId, rank]) => ({ item_id: itemId, rank })),
523
672
  )}::jsonb) as saved_rank(item_id text, rank integer)`;
524
673
  const rankedSource = (ranks: Array<[string, number]>) =>
525
674
  sql`(select item_id, rank from ${savedRanks(ranks)} order by rank) as navigation_rank`;
526
- const allRanks = [...rankById];
527
675
  // NOT IN over a subquery is a hashed lookup; NOT EXISTS here would rescan
528
676
  // the saved list for every candidate row.
529
677
  const notRanked = allRanks.length
@@ -541,117 +689,133 @@ export async function getContentDatabaseNavigationPage(args: {
541
689
  schema.documents,
542
690
  eq(schema.documents.id, schema.contentDatabaseItems.documentId),
543
691
  );
544
- const unrankedRows = () =>
545
- db
546
- .select(rowFields)
547
- .from(schema.contentDatabaseItems)
548
- .innerJoin(
549
- schema.documents,
550
- eq(schema.documents.id, schema.contentDatabaseItems.documentId),
551
- );
552
- const sortValue: SQL<string> =
553
- effectiveSort === "name"
554
- ? sql<string>`${schema.documents.title}`
555
- : effectiveSort === "created"
556
- ? sql<string>`${schema.documents.createdAt}`
557
- : sql<string>`${schema.documents.updatedAt}`;
558
- const ascending = !descending;
559
- const sortedAfter = (key: { sortValue: string | number; itemId: string }) =>
560
- or(
561
- ascending ? gt(sortValue, key.sortValue) : lt(sortValue, key.sortValue),
562
- and(
563
- eq(sortValue, key.sortValue),
564
- gt(schema.contentDatabaseItems.id, key.itemId),
565
- ),
566
- );
692
+ const positionAfter = (key: { position?: number; itemId: string }) =>
693
+ sql`(${schema.contentDatabaseItems.position}, ${schema.contentDatabaseItems.id}) > (${key.position!}, ${key.itemId})`;
567
694
  const positionThrough = (key: { position?: number; itemId: string }) =>
568
695
  sql`(${schema.contentDatabaseItems.position}, ${schema.contentDatabaseItems.id}) <= (${key.position!}, ${key.itemId})`;
569
-
570
- let rows: Array<
571
- Awaited<ReturnType<typeof unrankedRows>>[number] & {
572
- sortValue: string | number;
573
- }
574
- >;
575
- if (effectiveSort === "custom") {
576
- const cursorRank = cursor ? Number(cursor.sortValue) : 0;
577
- const rankedAfterCursor = allRanks.filter(([, rank]) => rank > cursorRank);
578
- const rankedPage = (ranks: Array<[string, number]>) =>
579
- ranks.length
580
- ? rankedRows(ranks)
581
- .where(siblingFilter)
582
- .orderBy(sql`navigation_rank.rank`)
583
- .limit(args.limit + 1)
584
- : Promise.resolve([]);
585
- // A saved order can hold thousands of IDs, but a page usually fills from
586
- // the first few, so the whole list is only sent when that window falls
587
- // short. Rows past the last saved sibling come from the unranked side.
588
- const window = rankedAfterCursor.slice(0, (args.limit + 1) * 4);
589
- let ranked = await rankedPage(window);
590
- let unranked: Awaited<ReturnType<typeof unrankedRows>> = [];
591
- if (ranked.length <= args.limit) {
592
- const [rest, tail] = await Promise.all([
593
- rankedPage(rankedAfterCursor.slice(window.length)),
594
- unrankedRows()
595
- .where(
696
+ const savedListOrder: NavigationOrder = {
697
+ page: async (siblings, after) => {
698
+ const cursorRank = after ? Number(after.sortValue) : 0;
699
+ const rankedAfterCursor = allRanks.filter(
700
+ ([, rank]) => rank > cursorRank,
701
+ );
702
+ const rankedPage = (ranks: Array<[string, number]>) =>
703
+ ranks.length
704
+ ? rankedRows(ranks)
705
+ .where(siblings)
706
+ .orderBy(sql`navigation_rank.rank`)
707
+ .limit(args.limit + 1)
708
+ : Promise.resolve([]);
709
+ // A saved order can hold thousands of IDs, but a page usually fills
710
+ // from the first few, so the whole list is only sent when that window
711
+ // falls short. Rows past the last saved sibling come from the unranked
712
+ // side.
713
+ const window = rankedAfterCursor.slice(0, (args.limit + 1) * 4);
714
+ let ranked = await rankedPage(window);
715
+ let unranked: Awaited<ReturnType<typeof membershipRows>> = [];
716
+ if (ranked.length <= args.limit) {
717
+ const [rest, tail] = await Promise.all([
718
+ rankedPage(rankedAfterCursor.slice(window.length)),
719
+ membershipRows(
596
720
  and(
597
- siblingFilter,
721
+ siblings,
598
722
  notRanked,
599
- cursor && cursorRank === unrankedValue
600
- ? sql`(${schema.contentDatabaseItems.position}, ${schema.contentDatabaseItems.id}) > (${cursor.position!}, ${cursor.itemId})`
723
+ after && cursorRank === unrankedValue
724
+ ? positionAfter(after)
601
725
  : undefined,
602
726
  ),
603
727
  )
604
- .orderBy(
605
- asc(schema.contentDatabaseItems.position),
606
- asc(schema.contentDatabaseItems.id),
607
- )
608
- .limit(args.limit + 1),
728
+ .orderBy(
729
+ asc(schema.contentDatabaseItems.position),
730
+ asc(schema.contentDatabaseItems.id),
731
+ )
732
+ .limit(args.limit + 1),
733
+ ]);
734
+ ranked = [...ranked, ...rest].slice(0, args.limit + 1);
735
+ unranked = tail.slice(0, args.limit + 1 - ranked.length);
736
+ }
737
+ return [
738
+ ...ranked,
739
+ ...unranked.map((row) => ({ ...row, sortValue: unrankedValue })),
740
+ ];
741
+ },
742
+ through: (siblings, cursor) => {
743
+ const cursorRank = Number(cursor.sortValue);
744
+ const ranksThrough = allRanks.filter(([, rank]) => rank <= cursorRank);
745
+ return Promise.all([
746
+ ...(ranksThrough.length
747
+ ? [
748
+ revisionOf(
749
+ db
750
+ .select(revisionFields)
751
+ .from(rankedSource(ranksThrough))
752
+ .innerJoin(
753
+ schema.contentDatabaseItems,
754
+ eq(
755
+ schema.contentDatabaseItems.id,
756
+ sql`navigation_rank.item_id`,
757
+ ),
758
+ )
759
+ .innerJoin(
760
+ schema.documents,
761
+ eq(
762
+ schema.documents.id,
763
+ schema.contentDatabaseItems.documentId,
764
+ ),
765
+ )
766
+ .where(siblings),
767
+ ),
768
+ ]
769
+ : []),
770
+ ...(cursorRank === unrankedValue
771
+ ? [
772
+ revisionOf(
773
+ membershipRevision(
774
+ and(siblings, notRanked, positionThrough(cursor)),
775
+ ),
776
+ ),
777
+ ]
778
+ : []),
609
779
  ]);
610
- ranked = [...ranked, ...rest].slice(0, args.limit + 1);
611
- unranked = tail.slice(0, args.limit + 1 - ranked.length);
612
- }
613
- rows = [
614
- ...ranked,
615
- ...unranked.map((row) => ({ ...row, sortValue: unrankedValue })),
616
- ];
617
- } else {
618
- rows = await db
619
- .select({ ...rowFields, sortValue })
620
- .from(schema.contentDatabaseItems)
621
- .innerJoin(
622
- schema.documents,
623
- eq(schema.documents.id, schema.contentDatabaseItems.documentId),
624
- )
625
- .where(and(siblingFilter, cursor ? sortedAfter(cursor) : undefined))
626
- .orderBy(
627
- ascending ? asc(sortValue) : desc(sortValue),
628
- asc(schema.contentDatabaseItems.id),
629
- )
630
- .limit(args.limit + 1);
631
- }
632
-
633
- const hasMore = rows.length > args.limit;
634
- const pageRows = rows.slice(0, args.limit);
635
- const pageDocumentIds = pageRows.map((row) => row.documentId);
636
-
637
- // A cursor stays valid while every visible sibling up to it is unchanged:
638
- // rows after it are read fresh by the next page anyway. The revision is the
639
- // count and hash sum of that prefix, so it grows with the pages read, not
640
- // with the workspace.
641
- const revisionOf = async (
642
- query: Promise<Array<{ count: number; checksum: string }>>,
643
- ): Promise<NavigationRevision> => {
644
- const [row] = await query;
645
- return { count: row?.count ?? 0, checksum: BigInt(row?.checksum ?? "0") };
646
- };
647
- const revisionFields = {
648
- count: sql<number>`count(*)::int`,
649
- checksum: sql<string>`coalesce(sum((${rowHash})::numeric), 0)::text`,
780
+ },
650
781
  };
651
- const cursorPrefixRevision = (): Promise<NavigationRevision[]> => {
652
- if (!cursor) return Promise.resolve([]);
653
- if (effectiveSort !== "custom") {
654
- return Promise.all([
782
+
783
+ // A folder's own children are found through the parent_id index, so a
784
+ // child branch ranks just those rows by looking each one up in the saved
785
+ // order, instead of walking a saved list that mostly holds other branches.
786
+ // The order is one jsonb object bound once; a joined set of saved IDs would
787
+ // be estimated at 100 rows and planned as a rescan per sibling.
788
+ const savedOrder = sql`(select ${JSON.stringify(
789
+ Object.fromEntries(rankById),
790
+ )}::jsonb as ranks) as navigation_saved_order`;
791
+ const siblingRank = sql<number>`coalesce((navigation_saved_order.ranks ->> ${schema.contentDatabaseItems.id})::int, ${unrankedValue})`;
792
+ const siblingKey = sql`(${siblingRank}, ${schema.contentDatabaseItems.position}, ${schema.contentDatabaseItems.id})`;
793
+ const cursorKey = (key: NavigationCursor) =>
794
+ sql`(${Number(key.sortValue)}, ${key.position!}, ${key.itemId})`;
795
+ const siblingRankOrder: NavigationOrder = {
796
+ page: (siblings, after) =>
797
+ db
798
+ .select({ ...rowFields, sortValue: siblingRank })
799
+ .from(schema.contentDatabaseItems)
800
+ .innerJoin(
801
+ schema.documents,
802
+ eq(schema.documents.id, schema.contentDatabaseItems.documentId),
803
+ )
804
+ .innerJoin(savedOrder, sql`true`)
805
+ .where(
806
+ and(
807
+ siblings,
808
+ after ? sql`${siblingKey} > ${cursorKey(after)}` : undefined,
809
+ ),
810
+ )
811
+ .orderBy(
812
+ siblingRank,
813
+ asc(schema.contentDatabaseItems.position),
814
+ asc(schema.contentDatabaseItems.id),
815
+ )
816
+ .limit(args.limit + 1),
817
+ through: (siblings, cursor) =>
818
+ Promise.all([
655
819
  revisionOf(
656
820
  db
657
821
  .select(revisionFields)
@@ -660,195 +824,210 @@ export async function getContentDatabaseNavigationPage(args: {
660
824
  schema.documents,
661
825
  eq(schema.documents.id, schema.contentDatabaseItems.documentId),
662
826
  )
663
- .where(
664
- and(
665
- siblingFilter,
666
- ascending
667
- ? lte(sortValue, cursor.sortValue)
668
- : gte(sortValue, cursor.sortValue),
669
- sql`not (${sortedAfter(cursor)!})`,
670
- ),
671
- ),
827
+ .innerJoin(savedOrder, sql`true`)
828
+ .where(and(siblings, sql`${siblingKey} <= ${cursorKey(cursor)}`)),
672
829
  ),
673
- ]);
674
- }
675
- const cursorRank = Number(cursor.sortValue);
676
- const ranksThrough = allRanks.filter(([, rank]) => rank <= cursorRank);
677
- return Promise.all([
678
- ...(ranksThrough.length
679
- ? [
680
- revisionOf(
681
- db
682
- .select(revisionFields)
683
- .from(rankedSource(ranksThrough))
684
- .innerJoin(
685
- schema.contentDatabaseItems,
686
- eq(
687
- schema.contentDatabaseItems.id,
688
- sql`navigation_rank.item_id`,
689
- ),
690
- )
691
- .innerJoin(
692
- schema.documents,
693
- eq(
694
- schema.documents.id,
695
- schema.contentDatabaseItems.documentId,
696
- ),
697
- )
698
- .where(siblingFilter),
699
- ),
700
- ]
701
- : []),
702
- ...(cursorRank === unrankedValue
703
- ? [
704
- revisionOf(
705
- db
706
- .select(revisionFields)
707
- .from(schema.contentDatabaseItems)
708
- .innerJoin(
709
- schema.documents,
710
- eq(
711
- schema.documents.id,
712
- schema.contentDatabaseItems.documentId,
713
- ),
714
- )
715
- .where(and(siblingFilter, notRanked, positionThrough(cursor))),
716
- ),
717
- ]
718
- : []),
719
- ]);
830
+ ]),
720
831
  };
721
832
 
722
- const [favoriteIds, shareRows, parentsWithChildren, prefixRevision] =
723
- await Promise.all([
724
- favoriteDocumentIds(db, args.userEmail, pageDocumentIds),
725
- pageRows.length
726
- ? db
727
- .select({
728
- resourceId: schema.documentShares.resourceId,
729
- role: schema.documentShares.role,
730
- })
731
- .from(schema.documentShares)
732
- .where(
733
- and(
734
- inArray(schema.documentShares.resourceId, pageDocumentIds),
735
- or(
736
- and(
737
- eq(schema.documentShares.principalType, "user"),
738
- eq(schema.documentShares.principalId, args.userEmail),
739
- ),
740
- ...(args.database.orgId
741
- ? [
742
- and(
743
- eq(schema.documentShares.principalType, "org"),
744
- eq(
745
- schema.documentShares.principalId,
746
- args.database.orgId,
747
- ),
748
- ),
749
- ]
750
- : []),
751
- ),
752
- ),
753
- )
754
- : [],
755
- pageRows.length
756
- ? db
757
- .selectDistinct({ parentId: childDocuments.parentId })
758
- .from(childItems)
759
- .innerJoin(
760
- childDocuments,
761
- eq(childDocuments.id, childItems.documentId),
762
- )
763
- .where(
764
- and(
765
- eq(childItems.databaseId, args.database.id),
766
- inArray(childDocuments.parentId, pageDocumentIds),
767
- isNull(childDocuments.trashedAt),
768
- accessFilter(
769
- childDocuments,
770
- schema.documentShares,
771
- accessContext,
772
- ),
773
- ...softDeletedDatabaseDocumentExclusions(childDocuments.id),
774
- navigationFilterSql({
775
- filters: effectiveFilters,
776
- filterMode: effectiveFilterMode,
777
- definitionById,
778
- document: {
779
- id: sql`${childDocuments.id}`,
780
- title: sql`${childDocuments.title}`,
781
- parentId: sql`${childDocuments.parentId}`,
782
- ownerEmail: sql`${childDocuments.ownerEmail}`,
783
- createdAt: sql`${childDocuments.createdAt}`,
784
- updatedAt: sql`${childDocuments.updatedAt}`,
833
+ const readBranch = async (
834
+ parentId: string | null,
835
+ after: NavigationCursor | null,
836
+ ): Promise<ContentDatabaseNavigationPage> => {
837
+ const order =
838
+ effectiveSort !== "custom"
839
+ ? sortedOrder
840
+ : parentId === null
841
+ ? savedListOrder
842
+ : siblingRankOrder;
843
+ const siblings = siblingsOf(parentId);
844
+ const [rows, prefixRevision] = await Promise.all([
845
+ order.page(siblings, after),
846
+ after ? order.through(siblings, after) : [],
847
+ ]);
848
+ if (after && combineRevisions(prefixRevision) !== after.revision) {
849
+ invalidCursor();
850
+ }
851
+ const hasMore = rows.length > args.limit;
852
+ const pageRows = rows.slice(0, args.limit);
853
+ const last = pageRows[pageRows.length - 1];
854
+ return {
855
+ items: pageRows.map(
856
+ ({
857
+ sortValue: _sortValue,
858
+ rowHash: _rowHash,
859
+ ownerEmail,
860
+ sharedEditor,
861
+ sharedManager,
862
+ ...row
863
+ }) => {
864
+ const isOwner =
865
+ ownerEmail.toLowerCase() === args.userEmail.toLowerCase();
866
+ return {
867
+ ...row,
868
+ canEdit: isOwner || sharedEditor,
869
+ canManage: isOwner || sharedManager,
870
+ };
871
+ },
872
+ ),
873
+ pagination: {
874
+ limit: args.limit,
875
+ hasMore,
876
+ nextCursor:
877
+ hasMore && last
878
+ ? encodeCursor({
879
+ version: CURSOR_VERSION,
880
+ databaseId: args.database.id,
881
+ parentId,
882
+ sort: effectiveSort,
883
+ viewId,
884
+ orderHash,
885
+ revision: combineRevisions([
886
+ ...prefixRevision,
887
+ {
888
+ count: pageRows.length,
889
+ checksum: pageRows.reduce(
890
+ (total, row) => total + BigInt(row.rowHash),
891
+ 0n,
892
+ ),
785
893
  },
786
- }),
894
+ ]),
895
+ configHash,
896
+ sortValue: last.sortValue,
897
+ position:
898
+ effectiveSort === "custom"
899
+ ? last.membershipPosition
900
+ : undefined,
901
+ itemId: last.membershipId,
902
+ })
903
+ : null,
904
+ },
905
+ };
906
+ };
907
+
908
+ // The requested parent and the expanded folders pass the same access,
909
+ // trash, and deletion checks before any of their children are read.
910
+ const checkedIds = [
911
+ ...(args.parentId === null ? [] : [args.parentId]),
912
+ ...new Set(args.expand),
913
+ ];
914
+ const checked =
915
+ checkedIds.length === 0
916
+ ? Promise.resolve([])
917
+ : db
918
+ .select({
919
+ id: schema.documents.id,
920
+ parentId: schema.documents.parentId,
921
+ })
922
+ .from(schema.contentDatabaseItems)
923
+ .innerJoin(
924
+ schema.documents,
925
+ eq(schema.documents.id, schema.contentDatabaseItems.documentId),
926
+ )
927
+ .where(
928
+ and(
929
+ eq(schema.contentDatabaseItems.databaseId, args.database.id),
930
+ inArray(schema.documents.id, checkedIds),
931
+ eq(schema.documents.spaceId, args.database.spaceId),
932
+ isNull(schema.documents.trashedAt),
933
+ accessFilter(
934
+ schema.documents,
935
+ schema.documentShares,
936
+ accessContext,
787
937
  ),
788
- )
789
- : [],
790
- cursorPrefixRevision(),
791
- ]);
792
- if (cursor && combineRevisions(prefixRevision) !== cursor.revision) {
793
- invalidCursor();
938
+ ...softDeletedDatabaseDocumentExclusions(schema.documents.id),
939
+ ),
940
+ );
941
+ const pageRead = readBranch(args.parentId, cursor);
942
+ const parentFound = (rows: Array<{ id: string }>) =>
943
+ args.parentId === null || rows.some((row) => row.id === args.parentId);
944
+
945
+ // Expanded folders under the requested parent return their first pages
946
+ // with it. Which ones open follows from the checked parent links alone, so
947
+ // every branch reads alongside the page instead of one level per query. A
948
+ // first page also opens expanded folders that are not on it: the sidebar
949
+ // shows the open page's ancestors under their parent's first page wherever
950
+ // they sort.
951
+ const branchReads = checked.then(async (rows) => {
952
+ if (!parentFound(rows)) return { branches: [], truncated: false };
953
+ const expand = new Set(args.expand);
954
+ const parentOf = new Map<string, string | null>();
955
+ const expandedChildren = new Map<string | null, string[]>();
956
+ for (const row of rows) {
957
+ if (!expand.has(row.id)) continue;
958
+ parentOf.set(row.id, row.parentId);
959
+ const siblings = expandedChildren.get(row.parentId) ?? [];
960
+ siblings.push(row.id);
961
+ expandedChildren.set(row.parentId, siblings);
962
+ }
963
+ let level = expandedChildren.get(args.parentId) ?? [];
964
+ if (cursor && level.length > 0) {
965
+ const listed = new Set(
966
+ (await pageRead).items.map((item) => item.documentId),
967
+ );
968
+ level = level.filter((documentId) => listed.has(documentId));
969
+ }
970
+ // Stored parent links can form a cycle through the requested parent, so
971
+ // a folder is reached once and the parent itself never opens.
972
+ const reachable = new Set<string>();
973
+ while (level.length > 0) {
974
+ for (const documentId of level) reachable.add(documentId);
975
+ level = level.flatMap((documentId) =>
976
+ (expandedChildren.get(documentId) ?? []).filter(
977
+ (child) => child !== args.parentId && !reachable.has(child),
978
+ ),
979
+ );
980
+ }
981
+ // The cap keeps folders in the order the caller asked for them, each with
982
+ // the open folders above it, so the open page's ancestors, asked for
983
+ // first, are not cut for breadth.
984
+ const opened = new Set<string>();
985
+ for (const documentId of args.expand ?? []) {
986
+ const chain: string[] = [];
987
+ for (
988
+ let id: string | null | undefined = documentId;
989
+ typeof id === "string" && reachable.has(id) && !opened.has(id);
990
+ id = parentOf.get(id)
991
+ ) {
992
+ chain.push(id);
993
+ }
994
+ if (opened.size + chain.length > MAX_NAVIGATION_EXPANDED_BRANCHES) {
995
+ continue;
996
+ }
997
+ for (const id of chain) opened.add(id);
998
+ }
999
+ const openedIds = [...opened];
1000
+ const pages = await Promise.all(
1001
+ openedIds.map((documentId) => readBranch(documentId, null)),
1002
+ );
1003
+ return {
1004
+ // A folder with no children the caller can see draws no branch.
1005
+ branches: openedIds.flatMap((documentId, index) =>
1006
+ pages[index]!.items.length > 0
1007
+ ? [[documentId, pages[index]!] as const]
1008
+ : [],
1009
+ ),
1010
+ truncated: opened.size < reachable.size,
1011
+ };
1012
+ });
1013
+
1014
+ const [checkedRows, page, expanded] = await Promise.all([
1015
+ checked,
1016
+ pageRead,
1017
+ branchReads,
1018
+ ]);
1019
+ if (!parentFound(checkedRows)) {
1020
+ fail("The Files navigation parent is unavailable.", {
1021
+ errorCode: "invalid_navigation_parent",
1022
+ statusCode: 400,
1023
+ });
794
1024
  }
795
- const withChildren = new Set(parentsWithChildren.map((row) => row.parentId));
796
- const editableIds = new Set(
797
- shareRows
798
- .filter((row) => ["owner", "admin", "editor"].includes(row.role))
799
- .map((row) => row.resourceId),
800
- );
801
- const manageableIds = new Set(
802
- shareRows
803
- .filter((row) => ["owner", "admin"].includes(row.role))
804
- .map((row) => row.resourceId),
805
- );
806
- const last = pageRows[pageRows.length - 1];
1025
+ if (!args.expand?.length) return page;
807
1026
  return {
808
- items: pageRows.map(
809
- ({ sortValue: _sortValue, rowHash: _rowHash, ownerEmail, ...row }) => {
810
- const isOwner =
811
- ownerEmail.toLowerCase() === args.userEmail.toLowerCase();
812
- return {
813
- ...row,
814
- hasChildren: withChildren.has(row.documentId),
815
- isFavorite: favoriteIds.has(row.documentId),
816
- canEdit: isOwner || editableIds.has(row.documentId),
817
- canManage: isOwner || manageableIds.has(row.documentId),
818
- };
819
- },
820
- ),
821
- pagination: {
822
- limit: args.limit,
823
- hasMore,
824
- nextCursor:
825
- hasMore && last
826
- ? encodeCursor({
827
- version: CURSOR_VERSION,
828
- databaseId: args.database.id,
829
- parentId: args.parentId,
830
- sort: effectiveSort,
831
- viewId,
832
- orderHash,
833
- revision: combineRevisions([
834
- ...prefixRevision,
835
- {
836
- count: pageRows.length,
837
- checksum: pageRows.reduce(
838
- (total, row) => total + BigInt(row.rowHash),
839
- 0n,
840
- ),
841
- },
842
- ]),
843
- configHash,
844
- sortValue: last.sortValue,
845
- position:
846
- effectiveSort === "custom"
847
- ? last.membershipPosition
848
- : undefined,
849
- itemId: last.membershipId,
850
- })
851
- : null,
852
- },
1027
+ ...page,
1028
+ ...(expanded.branches.length > 0
1029
+ ? { branches: Object.fromEntries(expanded.branches) }
1030
+ : {}),
1031
+ branchesTruncated: expanded.truncated,
853
1032
  };
854
1033
  }