@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.
- package/corpus/README.md +1 -1
- package/corpus/templates/analytics/.agents/skills/security/SKILL.md +9 -2
- package/corpus/templates/analytics/.agents/skills/session-replay/SKILL.md +14 -4
- package/corpus/templates/analytics/.agents/skills/storing-data/SKILL.md +12 -7
- package/corpus/templates/analytics/CHANGELOG.md +6 -0
- package/corpus/templates/analytics/README.md +2 -2
- package/corpus/templates/analytics/changelog/2026-10-02-events-with-a-very-long-name-app-or-page-path-no-longer-caus.md +6 -0
- package/corpus/templates/analytics/package.json +1 -0
- package/corpus/templates/analytics/server/handlers/first-party-analytics.ts +9 -19
- package/corpus/templates/analytics/server/handlers/session-replay.ts +34 -47
- package/corpus/templates/analytics/server/lib/first-party-analytics-rollups.ts +9 -6
- package/corpus/templates/analytics/server/lib/first-party-analytics.ts +57 -36
- package/corpus/templates/analytics/server/lib/indexed-text.ts +53 -0
- package/corpus/templates/analytics/server/lib/request-errors.ts +73 -0
- package/corpus/templates/analytics/server/lib/session-event-index.ts +6 -16
- package/corpus/templates/analytics/server/lib/session-replay.ts +12 -2
- package/corpus/templates/analytics/server/plugins/auth.ts +2 -1
- package/corpus/templates/analytics/server/plugins/otel.ts +6 -0
- package/corpus/templates/assets/.agents/skills/security/SKILL.md +9 -2
- package/corpus/templates/assets/.agents/skills/storing-data/SKILL.md +12 -7
- package/corpus/templates/assets/CHANGELOG.md +7 -0
- package/corpus/templates/assets/README.md +2 -2
- package/corpus/templates/assets/package.json +1 -0
- package/corpus/templates/assets/server/plugins/auth.ts +2 -1
- package/corpus/templates/assets/server/plugins/otel.ts +6 -0
- package/corpus/templates/brain/.agents/skills/brain/RUNBOOK.md +2 -1
- package/corpus/templates/brain/.agents/skills/brain/SKILL.md +5 -4
- package/corpus/templates/brain/.agents/skills/security/SKILL.md +9 -2
- package/corpus/templates/brain/.agents/skills/storing-data/SKILL.md +12 -7
- package/corpus/templates/brain/CHANGELOG.md +6 -0
- package/corpus/templates/brain/README.md +12 -8
- package/corpus/templates/brain/changelog/2026-10-05-private-slack-channels-are-searchable-org-wide.md +6 -0
- package/corpus/templates/brain/package.json +1 -0
- package/corpus/templates/brain/server/lib/audiences.ts +1 -98
- package/corpus/templates/brain/server/lib/connectors.ts +2 -166
- package/corpus/templates/brain/server/plugins/otel.ts +6 -0
- package/corpus/templates/calendar/.agents/skills/security/SKILL.md +9 -2
- package/corpus/templates/calendar/.agents/skills/storing-data/SKILL.md +12 -7
- package/corpus/templates/calendar/CHANGELOG.md +7 -0
- package/corpus/templates/calendar/README.md +2 -2
- package/corpus/templates/calendar/package.json +1 -0
- package/corpus/templates/calendar/server/plugins/auth.ts +2 -1
- package/corpus/templates/calendar/server/plugins/otel.ts +6 -0
- package/corpus/templates/chat/.agents/skills/security/SKILL.md +9 -2
- package/corpus/templates/chat/.agents/skills/storing-data/SKILL.md +12 -7
- package/corpus/templates/chat/README.md +2 -2
- package/corpus/templates/clips/.agents/skills/ai-video-tools/SKILL.md +9 -0
- package/corpus/templates/clips/.agents/skills/security/SKILL.md +9 -2
- package/corpus/templates/clips/CHANGELOG.md +24 -0
- package/corpus/templates/clips/README.md +2 -2
- package/corpus/templates/clips/actions/lib/audio-chunk-list.ts +23 -0
- package/corpus/templates/clips/actions/lib/audio-only-transcription.ts +187 -292
- package/corpus/templates/clips/actions/lib/chunked-transcription.ts +151 -0
- package/corpus/templates/clips/actions/lib/recording-audio-source.ts +191 -0
- package/corpus/templates/clips/actions/request-transcript.ts +81 -135
- package/corpus/templates/clips/actions/search-recordings.ts +19 -5
- package/corpus/templates/clips/app/components/clips-command-menu.tsx +5 -0
- package/corpus/templates/clips/app/components/library/library-layout.tsx +3 -1
- package/corpus/templates/clips/app/components/library/search-bar.tsx +5 -0
- package/corpus/templates/clips/app/hooks/use-library.ts +1 -0
- package/corpus/templates/clips/app/lib/local-recording-upload.ts +13 -6
- package/corpus/templates/clips/app/lib/recording-section.ts +1 -1
- package/corpus/templates/clips/app/routes/_app.r.$recordingId.tsx +7 -5
- package/corpus/templates/clips/app/routes/record.tsx +16 -6
- package/corpus/templates/clips/changelog/2026-10-02-long-uploads-an-hour-or-more-now-transcribe-reliably-instead.md +6 -0
- package/corpus/templates/clips/changelog/2026-10-05-confirming-discard-recording-now-returns-you-to-your-library.md +6 -0
- package/corpus/templates/clips/changelog/2026-10-05-discarding-a-recording-now-removes-its-failed-upload-card-fr.md +6 -0
- package/corpus/templates/clips/changelog/2026-10-05-discarding-a-recovered-recording-no-longer-warns-that-it-s-o.md +6 -0
- package/corpus/templates/clips/changelog/2026-10-05-search-results-identify-recordings-in-trash-and-open-them-wi.md +6 -0
- package/corpus/templates/clips/package.json +1 -0
- package/corpus/templates/clips/server/lib/builder-cdn.ts +5 -0
- package/corpus/templates/clips/server/lib/builder-media-compression.ts +2 -2
- package/corpus/templates/clips/server/plugins/auth.ts +2 -1
- package/corpus/templates/clips/server/plugins/otel.ts +6 -0
- package/corpus/templates/clips/shared/transcript-failure.ts +5 -0
- package/corpus/templates/content/.agents/skills/document-editing/references/databases.md +8 -2
- package/corpus/templates/content/.agents/skills/security/SKILL.md +9 -2
- package/corpus/templates/content/.agents/skills/storing-data/SKILL.md +12 -7
- package/corpus/templates/content/CHANGELOG.md +41 -0
- package/corpus/templates/content/README.md +2 -2
- package/corpus/templates/content/actions/_database-navigation.ts +523 -344
- package/corpus/templates/content/actions/_database-row-mutation.ts +44 -10
- package/corpus/templates/content/actions/_database-setup-discovery.ts +9 -3
- package/corpus/templates/content/actions/_database-utils.ts +299 -271
- package/corpus/templates/content/actions/_property-utils.ts +293 -190
- package/corpus/templates/content/actions/_relation-values.ts +3 -1
- package/corpus/templates/content/actions/get-content-database.ts +1 -0
- package/corpus/templates/content/actions/query-content-database-items.ts +32 -5
- package/corpus/templates/content/actions/set-document-property.ts +17 -11
- package/corpus/templates/content/app/components/editor/DocumentBlockFields.tsx +21 -6
- package/corpus/templates/content/app/components/editor/DocumentEditor.tsx +127 -43
- package/corpus/templates/content/app/components/editor/DocumentEditorSkeleton.tsx +159 -50
- package/corpus/templates/content/app/components/editor/DocumentToolbar.tsx +12 -28
- package/corpus/templates/content/app/components/editor/PageDraftRecovery.tsx +6 -1
- package/corpus/templates/content/app/components/editor/TableHoverControls.tsx +139 -70
- package/corpus/templates/content/app/components/editor/VisualEditor.tsx +16 -1
- package/corpus/templates/content/app/components/editor/database/DatabaseView.tsx +180 -75
- package/corpus/templates/content/app/components/editor/database/DatabaseViewSkeleton.tsx +76 -0
- package/corpus/templates/content/app/components/editor/database/navigation-state.ts +60 -2
- package/corpus/templates/content/app/components/editor/database/sidebar.tsx +41 -42
- package/corpus/templates/content/app/components/editor/document-editor-layout.ts +18 -0
- package/corpus/templates/content/app/components/editor/extensions/NotionExtensions.tsx +11 -3
- package/corpus/templates/content/app/components/editor/suggestions/draft-session.ts +15 -7
- package/corpus/templates/content/app/components/editor/suggestions/model.ts +13 -0
- package/corpus/templates/content/app/components/editor/suggestions/read-only-blocks.ts +136 -0
- package/corpus/templates/content/app/components/editor/table-view.ts +73 -0
- package/corpus/templates/content/app/components/layout/ContentStartupShell.tsx +1 -1
- package/corpus/templates/content/app/components/layout/Layout.tsx +5 -1
- package/corpus/templates/content/app/components/sidebar/DocumentSidebar.tsx +57 -16
- package/corpus/templates/content/app/components/sidebar/MovePageDialog.tsx +4 -4
- package/corpus/templates/content/app/components/sidebar/SidebarNavigationRow.tsx +34 -10
- package/corpus/templates/content/app/global.css +16 -8
- package/corpus/templates/content/app/hooks/content-action-refresh.ts +14 -13
- package/corpus/templates/content/app/hooks/use-content-database.ts +72 -9
- package/corpus/templates/content/app/hooks/use-content-personal-navigation.ts +7 -3
- package/corpus/templates/content/app/hooks/use-documents.ts +27 -0
- package/corpus/templates/content/app/i18n-data.ts +3 -2
- package/corpus/templates/content/app/lib/content-landing.ts +3 -31
- package/corpus/templates/content/app/lib/files-navigation.ts +182 -10
- package/corpus/templates/content/app/lib/files-root-hint.ts +20 -32
- package/corpus/templates/content/app/lib/page-startup-hints.ts +108 -0
- package/corpus/templates/content/app/lib/private-link-redirect.ts +25 -0
- package/corpus/templates/content/app/lib/sidebar-layout-hint.ts +34 -1
- package/corpus/templates/content/app/lib/startup-timing.ts +2 -0
- package/corpus/templates/content/app/root.tsx +3 -1
- package/corpus/templates/content/app/routes/_app.home.tsx +10 -19
- package/corpus/templates/content/app/routes/p.$id.tsx +23 -5
- package/corpus/templates/content/changelog/2026-10-02-a-page-link-you-cant-open-now-says-so-instead-of-opening-an.md +6 -0
- package/corpus/templates/content/changelog/2026-10-02-after-a-refresh-the-files-sidebar-shows-the-open-page-s-fold.md +6 -0
- package/corpus/templates/content/changelog/2026-10-02-large-collections-open-faster.md +6 -0
- package/corpus/templates/content/changelog/2026-10-02-pages-stay-inside-the-window-at-every-width-long-inline-code.md +6 -0
- package/corpus/templates/content/changelog/2026-10-02-pages-with-open-comments-and-collections-with-sorted-or-filt.md +6 -0
- package/corpus/templates/content/changelog/2026-10-02-suggest-edits-works-on-pages-written-by-agents-including-pag.md +6 -0
- package/corpus/templates/content/changelog/2026-10-02-while-you-suggest-edits-tables-and-other-blocks-that-suggest.md +6 -0
- package/corpus/templates/content/changelog/2026-10-05-collection-view-context-now-survives-quick-page-changes-when.md +6 -0
- package/corpus/templates/content/changelog/2026-10-05-collection-views-keep-their-saved-rows-accurate-during-refre.md +6 -0
- package/corpus/templates/content/changelog/2026-10-05-exact-saved-view-links-keep-collection-retry-available.md +6 -0
- package/corpus/templates/content/changelog/2026-10-05-expanding-a-files-folder-now-shares-one-navigation-read-acro.md +6 -0
- package/corpus/templates/content/changelog/2026-10-05-files-navigation-retries-temporary-failures-consistently.md +6 -0
- package/corpus/templates/content/changelog/2026-10-05-shared-page-owners-keep-edit-and-manage-controls-in-files-an.md +6 -0
- package/corpus/templates/content/changelog/2026-10-05-tagging-an-ai-in-a-comment-works-again-instead-of-failing-ri.md +6 -0
- package/corpus/templates/content/changelog/2026-10-05-the-home-loading-shell-keeps-the-layout-of-your-last-opened-.md +6 -0
- package/corpus/templates/content/changelog/2026-10-05-typing-and-a-space-or-inserting-a-toggle-or-callout-no-longe.md +6 -0
- package/corpus/templates/content/docs/product/capabilities/content.workspace.session-restore.md +4 -1
- package/corpus/templates/content/e2e/playwright.config.ts +1 -1
- package/corpus/templates/content/package.json +2 -1
- package/corpus/templates/content/scripts/seed-perf-database.mjs +600 -0
- package/corpus/templates/content/scripts/trace-startup.mjs +25 -3
- package/corpus/templates/content/server/db/index.ts +1 -1
- package/corpus/templates/content/server/lib/suggested-edits.ts +23 -26
- package/corpus/templates/content/server/plugins/auth.ts +2 -1
- package/corpus/templates/content/server/plugins/otel.ts +6 -0
- package/corpus/templates/content/shared/api.ts +17 -0
- package/corpus/templates/content/shared/suggestion-diff.ts +124 -38
- package/corpus/templates/content/shared/suggestion-formatting.ts +134 -3
- package/corpus/templates/crm/.agents/skills/security/SKILL.md +9 -2
- package/corpus/templates/crm/.agents/skills/storing-data/SKILL.md +12 -7
- package/corpus/templates/crm/package.json +1 -0
- package/corpus/templates/crm/server/plugins/otel.ts +6 -0
- package/corpus/templates/design/.agents/skills/security/SKILL.md +9 -2
- package/corpus/templates/design/.agents/skills/storing-data/SKILL.md +12 -7
- package/corpus/templates/design/CHANGELOG.md +41 -0
- package/corpus/templates/design/README.md +2 -2
- package/corpus/templates/design/actions/list-designs.ts +11 -4
- package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +110 -32
- package/corpus/templates/design/app/components/design/multi-screen/cross-screen-drop.ts +28 -0
- package/corpus/templates/design/app/components/design/multi-screen/types.ts +1 -0
- package/corpus/templates/design/app/components/home/DesignHomeLibrary.tsx +79 -0
- package/corpus/templates/design/app/components/layout/Header.tsx +2 -4
- package/corpus/templates/design/app/components/ui/tabs.tsx +59 -1
- package/corpus/templates/design/app/global.css +1 -34
- package/corpus/templates/design/app/i18n/en-US.ts +4 -0
- package/corpus/templates/design/app/lib/design-filter.ts +4 -2
- package/corpus/templates/design/app/pages/Index.tsx +91 -59
- package/corpus/templates/design/app/pages/design-editor/commands/cross-screen-element-drop.ts +58 -17
- package/corpus/templates/design/changelog/2026-10-01-filter-designs-by-owner.md +6 -0
- package/corpus/templates/design/changelog/2026-10-03-layers-dropped-into-visible-canvas-space-beyond-a-screen-now.md +6 -0
- package/corpus/templates/design/changelog/2026-10-03-visible-screen-overflow-no-longer-routes-drops-to-the-canvas.md +6 -0
- package/corpus/templates/design/changelog/2026-10-05-the-recent-tab-shows-loading-placeholders-until-your-designs.md +6 -0
- package/corpus/templates/design/package.json +1 -0
- package/corpus/templates/design/server/plugins/auth.ts +2 -1
- package/corpus/templates/design/server/plugins/otel.ts +6 -0
- package/corpus/templates/dispatch/.agents/skills/security/SKILL.md +9 -2
- package/corpus/templates/dispatch/.agents/skills/storing-data/SKILL.md +12 -7
- package/corpus/templates/dispatch/README.md +2 -2
- package/corpus/templates/dispatch/package.json +1 -0
- package/corpus/templates/dispatch/server/plugins/otel.ts +6 -0
- package/corpus/templates/dispatch/server/plugins/setup-dispatch.ts +2 -1
- package/corpus/templates/factory/.agents/skills/security/SKILL.md +9 -2
- package/corpus/templates/factory/.agents/skills/storing-data/SKILL.md +12 -7
- package/corpus/templates/factory/package.json +1 -0
- package/corpus/templates/factory/server/plugins/otel.ts +6 -0
- package/corpus/templates/forms/.agents/skills/security/SKILL.md +9 -2
- package/corpus/templates/forms/.agents/skills/storing-data/SKILL.md +12 -7
- package/corpus/templates/forms/README.md +2 -2
- package/corpus/templates/forms/package.json +1 -0
- package/corpus/templates/forms/server/plugins/auth.ts +2 -1
- package/corpus/templates/forms/server/plugins/otel.ts +6 -0
- package/corpus/templates/mail/.agents/skills/security/SKILL.md +9 -2
- package/corpus/templates/mail/.agents/skills/storing-data/SKILL.md +12 -7
- package/corpus/templates/mail/CHANGELOG.md +6 -0
- package/corpus/templates/mail/README.md +2 -2
- package/corpus/templates/mail/package.json +1 -0
- package/corpus/templates/mail/server/plugins/auth.ts +2 -1
- package/corpus/templates/mail/server/plugins/otel.ts +6 -0
- package/corpus/templates/plan/.agents/skills/security/SKILL.md +9 -2
- package/corpus/templates/plan/.agents/skills/storing-data/SKILL.md +12 -7
- package/corpus/templates/plan/CHANGELOG.md +1 -0
- package/corpus/templates/plan/README.md +2 -2
- package/corpus/templates/plan/package.json +1 -0
- package/corpus/templates/plan/server/plugins/auth.ts +2 -1
- package/corpus/templates/plan/server/plugins/otel.ts +6 -0
- package/corpus/templates/slides/.agents/skills/security/SKILL.md +9 -2
- package/corpus/templates/slides/.agents/skills/storing-data/SKILL.md +12 -7
- package/corpus/templates/slides/CHANGELOG.md +23 -0
- package/corpus/templates/slides/README.md +2 -2
- package/corpus/templates/slides/actions/list-decks.ts +11 -4
- package/corpus/templates/slides/app/components/deck/DeckFilterMenu.tsx +44 -36
- package/corpus/templates/slides/app/components/home/SlidesHomeLibrary.tsx +79 -0
- package/corpus/templates/slides/app/components/layout/Header.tsx +2 -15
- package/corpus/templates/slides/app/components/ui/tabs.tsx +59 -0
- package/corpus/templates/slides/app/global.css +3 -54
- package/corpus/templates/slides/app/i18n/en-US.ts +4 -0
- package/corpus/templates/slides/app/lib/deck-filter.ts +5 -2
- package/corpus/templates/slides/app/pages/Index.tsx +102 -88
- package/corpus/templates/slides/changelog/2026-10-01-filter-decks-by-owner.md +6 -0
- package/corpus/templates/slides/changelog/2026-10-02-hide-recent-until-decks-available.md +6 -0
- package/corpus/templates/slides/changelog/2026-10-05-the-recent-tab-shows-loading-placeholders-until-your-decks-a.md +6 -0
- package/corpus/templates/slides/package.json +1 -0
- package/corpus/templates/slides/server/handlers/request-auth-context.ts +8 -0
- package/corpus/templates/slides/server/plugins/auth.ts +2 -1
- package/corpus/templates/slides/server/plugins/otel.ts +6 -0
- package/corpus/templates/tasks/.agents/skills/security/SKILL.md +9 -2
- package/corpus/templates/tasks/.agents/skills/storing-data/SKILL.md +12 -7
- 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 {
|
|
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
|
|
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 (
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
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
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
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.
|
|
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
|
|
545
|
-
|
|
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
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
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
|
-
|
|
721
|
+
siblings,
|
|
598
722
|
notRanked,
|
|
599
|
-
|
|
600
|
-
?
|
|
723
|
+
after && cursorRank === unrankedValue
|
|
724
|
+
? positionAfter(after)
|
|
601
725
|
: undefined,
|
|
602
726
|
),
|
|
603
727
|
)
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
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
|
-
|
|
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
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
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
|
-
.
|
|
664
|
-
|
|
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
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
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
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
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
|
-
|
|
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
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
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
|
}
|