@agent-native/core 0.176.5-nightly-20260903175350 → 0.176.5

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 (29) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/calendar/CHANGELOG.md +18 -0
  3. package/corpus/templates/clips/app/components/recorder/storage-setup-card.tsx +13 -7
  4. package/corpus/templates/content/CHANGELOG.md +1 -0
  5. package/corpus/templates/content/actions/_collection-export.ts +925 -0
  6. package/corpus/templates/content/actions/export-document.ts +79 -255
  7. package/corpus/templates/content/app/components/editor/DocumentToolbar.tsx +39 -38
  8. package/corpus/templates/content/app/components/editor/database/DatabaseExportDialog.tsx +444 -113
  9. package/corpus/templates/content/app/components/editor/database/DatabaseView.tsx +4 -0
  10. package/corpus/templates/content/app/i18n-data.ts +99 -0
  11. package/corpus/templates/content/docs/product/capabilities/content.portability.collection-export.md +139 -0
  12. package/corpus/templates/content/docs/product/encyclopedia.md +12 -8
  13. package/corpus/templates/content/docs/product/features/content.feature.take-the-whole-vault-with-you.md +5 -1
  14. package/corpus/templates/content/docs/product/roadmap.md +1 -1
  15. package/corpus/templates/content/shared/database-collection-export.ts +161 -0
  16. package/corpus/templates/content/shared/database-csv-export.ts +6 -3
  17. package/corpus/templates/design/CHANGELOG.md +1 -0
  18. package/corpus/templates/factory/CHANGELOG.md +20 -0
  19. package/corpus/templates/mail/CHANGELOG.md +12 -0
  20. package/corpus/templates/plan/CHANGELOG.md +6 -0
  21. package/corpus/templates/slides/CHANGELOG.md +9 -0
  22. package/dist/collab/awareness.d.ts +2 -2
  23. package/dist/collab/struct-routes.d.ts +1 -1
  24. package/dist/notifications/routes.d.ts +1 -1
  25. package/dist/observability/routes.d.ts +5 -5
  26. package/dist/progress/routes.d.ts +1 -1
  27. package/dist/secrets/routes.d.ts +9 -9
  28. package/dist/server/realtime-token.d.ts +1 -1
  29. package/package.json +3 -3
@@ -0,0 +1,925 @@
1
+ import { fail } from "@agent-native/core/action";
2
+ import { getRequestUserEmail } from "@agent-native/core/server/request-context";
3
+ import { accessFilter } from "@agent-native/core/sharing";
4
+ import { and, asc, eq, inArray, isNull, or, sql } from "drizzle-orm";
5
+
6
+ import { getDb, schema } from "../server/db/index.js";
7
+ import type {
8
+ ContentDatabaseItem,
9
+ ContentDatabaseTableQuery,
10
+ DocumentProperty,
11
+ } from "../shared/api.js";
12
+ import type {
13
+ CollectionExportProjection,
14
+ CollectionExportRecord,
15
+ } from "../shared/database-collection-export.js";
16
+ import { databasePropertyValueText } from "../shared/database-csv-export.js";
17
+ import { applyContentDatabaseTableQuery } from "../shared/database-query.js";
18
+ import {
19
+ evaluatePropertyFormula,
20
+ formulaValueText,
21
+ isBlocksPropertyType,
22
+ isComputedPropertyType,
23
+ isPrimaryBlocksField,
24
+ parsePropertyValue,
25
+ resolveBlocksFieldValue,
26
+ type DocumentPropertyValue,
27
+ } from "../shared/properties.js";
28
+ import { chunks } from "./_batch-utils.js";
29
+ import { readPersonalDatabaseViewOverrides } from "./_content-database-personal-view.js";
30
+ import { listContentOrganizationMemberships } from "./_content-space-access.js";
31
+ import {
32
+ CONTENT_DATABASE_MAX_READ_LIMIT,
33
+ getDatabaseByDocumentId,
34
+ } from "./_database-utils.js";
35
+ import {
36
+ computedPropertyValue,
37
+ listPropertiesForDatabase,
38
+ parseDatabaseViewConfig,
39
+ } from "./_property-utils.js";
40
+
41
+ export interface CollectionExportRequest {
42
+ scope:
43
+ | { kind: "all_members" }
44
+ | {
45
+ kind: "current_view";
46
+ viewId: string;
47
+ query: ContentDatabaseTableQuery;
48
+ };
49
+ propertyIds: string[];
50
+ includePrimaryBody: boolean;
51
+ blockPropertyIds: string[];
52
+ }
53
+
54
+ type Candidate = {
55
+ item: {
56
+ id: string;
57
+ databaseId: string;
58
+ documentId: string;
59
+ position: number;
60
+ bodyHydrationStatus: string;
61
+ rowNumber: number;
62
+ };
63
+ document: {
64
+ id: string;
65
+ parentId: string | null;
66
+ title: string;
67
+ content?: string;
68
+ icon: string | null;
69
+ position: number;
70
+ isFavorite: number;
71
+ hideFromSearch: number;
72
+ ownerEmail: string;
73
+ createdAt: string;
74
+ updatedAt: string;
75
+ };
76
+ };
77
+
78
+ function accessClauses(userEmail: string, orgIds: readonly string[]) {
79
+ return [
80
+ accessFilter(
81
+ schema.documents,
82
+ schema.documentShares,
83
+ { userEmail },
84
+ "viewer",
85
+ { includePublic: true },
86
+ ),
87
+ ...orgIds.map((orgId) =>
88
+ accessFilter(
89
+ schema.documents,
90
+ schema.documentShares,
91
+ { userEmail, orgId },
92
+ "viewer",
93
+ { includePublic: true },
94
+ ),
95
+ ),
96
+ ];
97
+ }
98
+
99
+ async function loadCandidates(
100
+ databaseId: string,
101
+ includeContent: boolean,
102
+ clauses: ReturnType<typeof accessClauses>,
103
+ limit: number,
104
+ offset: number,
105
+ ): Promise<Candidate[]> {
106
+ const documentColumns = {
107
+ id: schema.documents.id,
108
+ parentId: schema.documents.parentId,
109
+ title: schema.documents.title,
110
+ icon: schema.documents.icon,
111
+ position: schema.documents.position,
112
+ isFavorite: schema.documents.isFavorite,
113
+ hideFromSearch: schema.documents.hideFromSearch,
114
+ ownerEmail: schema.documents.ownerEmail,
115
+ createdAt: schema.documents.createdAt,
116
+ updatedAt: schema.documents.updatedAt,
117
+ ...(includeContent ? { content: schema.documents.content } : {}),
118
+ };
119
+ const rankedItems = getDb()
120
+ .select({
121
+ id: schema.contentDatabaseItems.id,
122
+ databaseId: schema.contentDatabaseItems.databaseId,
123
+ documentId: schema.contentDatabaseItems.documentId,
124
+ position: schema.contentDatabaseItems.position,
125
+ bodyHydrationStatus: schema.contentDatabaseItems.bodyHydrationStatus,
126
+ createdAt: schema.contentDatabaseItems.createdAt,
127
+ rowNumber:
128
+ sql<number>`row_number() over (order by ${schema.contentDatabaseItems.position}, ${schema.contentDatabaseItems.createdAt}, ${schema.contentDatabaseItems.id})`.as(
129
+ "row_number",
130
+ ),
131
+ })
132
+ .from(schema.contentDatabaseItems)
133
+ .where(eq(schema.contentDatabaseItems.databaseId, databaseId))
134
+ .as("ranked_export_items");
135
+ return getDb()
136
+ .select({
137
+ item: {
138
+ id: rankedItems.id,
139
+ databaseId: rankedItems.databaseId,
140
+ documentId: rankedItems.documentId,
141
+ position: rankedItems.position,
142
+ bodyHydrationStatus: rankedItems.bodyHydrationStatus,
143
+ rowNumber: rankedItems.rowNumber,
144
+ },
145
+ document: documentColumns,
146
+ })
147
+ .from(rankedItems)
148
+ .innerJoin(
149
+ schema.documents,
150
+ eq(schema.documents.id, rankedItems.documentId),
151
+ )
152
+ .where(and(isNull(schema.documents.trashedAt), or(...clauses)))
153
+ .orderBy(
154
+ asc(rankedItems.position),
155
+ asc(rankedItems.createdAt),
156
+ asc(rankedItems.id),
157
+ )
158
+ .limit(limit)
159
+ .offset(offset) as Promise<Candidate[]>;
160
+ }
161
+
162
+ function assertUnique(ids: readonly string[], label: string) {
163
+ if (new Set(ids).size !== ids.length) {
164
+ fail(`${label} must be unique.`, {
165
+ errorCode: "invalid_collection_export_selection",
166
+ });
167
+ }
168
+ }
169
+
170
+ function assertValidQuery(
171
+ query: ContentDatabaseTableQuery,
172
+ propertyIds: ReadonlySet<string>,
173
+ ) {
174
+ for (const key of [...query.filters, ...query.sorts].map(({ key }) => key)) {
175
+ if (key !== "name" && !propertyIds.has(key)) {
176
+ fail(`Unknown database property "${key}" in export query.`, {
177
+ errorCode: "invalid_collection_export_query",
178
+ });
179
+ }
180
+ }
181
+ }
182
+
183
+ function queryPropertyIds(query: ContentDatabaseTableQuery) {
184
+ return [...query.filters, ...query.sorts]
185
+ .map(({ key }) => key)
186
+ .filter((key) => key !== "name");
187
+ }
188
+
189
+ function propertyNeedsPrimaryBody(
190
+ property: DocumentProperty,
191
+ propertyByName: ReadonlyMap<string, DocumentProperty>,
192
+ visiting = new Set<string>(),
193
+ ): boolean {
194
+ if (visiting.has(property.definition.id)) return false;
195
+ visiting.add(property.definition.id);
196
+ if (isBlocksPropertyType(property.definition.type)) {
197
+ return isPrimaryBlocksField(property.definition.options);
198
+ }
199
+ if (property.definition.type !== "formula") return false;
200
+ return formulaDependencyNames(property.definition.options.formula).some(
201
+ (name) => {
202
+ const dependency = propertyByName.get(name);
203
+ return (
204
+ !!dependency &&
205
+ propertyNeedsPrimaryBody(dependency, propertyByName, visiting)
206
+ );
207
+ },
208
+ );
209
+ }
210
+
211
+ function queryNeedsPrimaryBody(
212
+ query: ContentDatabaseTableQuery,
213
+ propertyById: ReadonlyMap<string, DocumentProperty>,
214
+ propertyByName: ReadonlyMap<string, DocumentProperty>,
215
+ ) {
216
+ if (query.search.trim()) {
217
+ return [...propertyById.values()].some((property) =>
218
+ propertyNeedsPrimaryBody(property, propertyByName),
219
+ );
220
+ }
221
+ return [...query.filters, ...query.sorts].some(({ key }) => {
222
+ const property = propertyById.get(key);
223
+ return !!property && propertyNeedsPrimaryBody(property, propertyByName);
224
+ });
225
+ }
226
+
227
+ function queryWithOnlyFilters(
228
+ filters: ContentDatabaseTableQuery["filters"],
229
+ filterMode: ContentDatabaseTableQuery["filterMode"],
230
+ ): ContentDatabaseTableQuery {
231
+ return { search: "", filters, sorts: [], filterMode };
232
+ }
233
+
234
+ function formulaDependencyNames(formula: string | undefined) {
235
+ return [...(formula ?? "").matchAll(/\{([^{}]+)\}/g)]
236
+ .map((match) => match[1]?.trim())
237
+ .filter((name): name is string => !!name);
238
+ }
239
+
240
+ function propertyKey(documentId: string, propertyId: string) {
241
+ return `${documentId}\0${propertyId}`;
242
+ }
243
+
244
+ async function loadStoredValues(
245
+ documentIds: readonly string[],
246
+ propertyIds: readonly string[],
247
+ ) {
248
+ const values = new Map<string, DocumentPropertyValue>();
249
+ for (const documentIdChunk of chunks([...documentIds], 180)) {
250
+ for (const propertyIdChunk of chunks([...propertyIds], 180)) {
251
+ const rows = await getDb()
252
+ .select({
253
+ documentId: schema.documentPropertyValues.documentId,
254
+ propertyId: schema.documentPropertyValues.propertyId,
255
+ valueJson: schema.documentPropertyValues.valueJson,
256
+ })
257
+ .from(schema.documentPropertyValues)
258
+ .where(
259
+ and(
260
+ inArray(schema.documentPropertyValues.documentId, documentIdChunk),
261
+ inArray(schema.documentPropertyValues.propertyId, propertyIdChunk),
262
+ ),
263
+ );
264
+ for (const row of rows) {
265
+ values.set(
266
+ propertyKey(row.documentId, row.propertyId),
267
+ parsePropertyValue(row.valueJson),
268
+ );
269
+ }
270
+ }
271
+ }
272
+ return values;
273
+ }
274
+
275
+ async function loadAdditionalBlocks(
276
+ documentIds: readonly string[],
277
+ propertyIds: readonly string[],
278
+ ) {
279
+ const values = new Map<string, string>();
280
+ for (const documentIdChunk of chunks([...documentIds], 180)) {
281
+ for (const propertyIdChunk of chunks([...propertyIds], 180)) {
282
+ const rows = await getDb()
283
+ .select({
284
+ documentId: schema.documentBlockFieldContents.documentId,
285
+ propertyId: schema.documentBlockFieldContents.propertyId,
286
+ content: schema.documentBlockFieldContents.content,
287
+ })
288
+ .from(schema.documentBlockFieldContents)
289
+ .where(
290
+ and(
291
+ inArray(
292
+ schema.documentBlockFieldContents.documentId,
293
+ documentIdChunk,
294
+ ),
295
+ inArray(
296
+ schema.documentBlockFieldContents.propertyId,
297
+ propertyIdChunk,
298
+ ),
299
+ ),
300
+ );
301
+ for (const row of rows) {
302
+ values.set(propertyKey(row.documentId, row.propertyId), row.content);
303
+ }
304
+ }
305
+ }
306
+ return values;
307
+ }
308
+
309
+ function aggregateRollup(
310
+ aggregation: string,
311
+ linkedIds: readonly string[],
312
+ values: readonly DocumentPropertyValue[],
313
+ ): DocumentPropertyValue {
314
+ if (aggregation === "count") return linkedIds.length;
315
+ const filled = values.filter(
316
+ (value) =>
317
+ value !== null && value !== undefined && formulaValueText(value) !== "",
318
+ );
319
+ if (aggregation === "count_values") return filled.length;
320
+ if (aggregation === "count_unique") {
321
+ return new Set(filled.map(formulaValueText)).size;
322
+ }
323
+ const numbers = filled
324
+ .map((value) => Number(formulaValueText(value)))
325
+ .filter(Number.isFinite);
326
+ if (numbers.length === 0) return null;
327
+ if (aggregation === "sum")
328
+ return numbers.reduce((sum, value) => sum + value, 0);
329
+ if (aggregation === "average") {
330
+ return numbers.reduce((sum, value) => sum + value, 0) / numbers.length;
331
+ }
332
+ if (aggregation === "min") return Math.min(...numbers);
333
+ if (aggregation === "max") return Math.max(...numbers);
334
+ return null;
335
+ }
336
+
337
+ export async function buildCollectionExportProjection(
338
+ documentId: string,
339
+ request: CollectionExportRequest,
340
+ ): Promise<CollectionExportProjection> {
341
+ const database = await getDatabaseByDocumentId(documentId);
342
+ if (!database) {
343
+ fail("Collection export requires a database document.", {
344
+ errorCode: "collection_export_requires_database",
345
+ });
346
+ }
347
+ const userEmail = getRequestUserEmail();
348
+ if (!userEmail)
349
+ fail("Not authenticated.", {
350
+ errorCode: "not_authenticated",
351
+ statusCode: 401,
352
+ });
353
+
354
+ assertUnique(request.propertyIds, "Collection export property IDs");
355
+ assertUnique(
356
+ request.blockPropertyIds,
357
+ "Collection export Blocks property IDs",
358
+ );
359
+ const allProperties = await listPropertiesForDatabase(database.id);
360
+ const propertyById = new Map(
361
+ allProperties.map((property) => [property.definition.id, property]),
362
+ );
363
+ const propertyByName = new Map(
364
+ allProperties.map((property) => [property.definition.name, property]),
365
+ );
366
+ const requestedProperties = request.propertyIds.map((id) => {
367
+ const property = propertyById.get(id);
368
+ if (!property) {
369
+ fail(`Unknown database property "${id}".`, {
370
+ errorCode: "invalid_collection_export_selection",
371
+ });
372
+ }
373
+ return property;
374
+ });
375
+ const selectedScalars = requestedProperties.filter(
376
+ (property) => !isBlocksPropertyType(property.definition.type),
377
+ );
378
+ const explicitlySelectedBlocks = request.blockPropertyIds.map((id) => {
379
+ const property = propertyById.get(id);
380
+ if (
381
+ !property ||
382
+ !isBlocksPropertyType(property.definition.type) ||
383
+ isPrimaryBlocksField(property.definition.options)
384
+ ) {
385
+ fail(`Unknown additional Blocks property "${id}".`, {
386
+ errorCode: "invalid_collection_export_selection",
387
+ });
388
+ }
389
+ return property;
390
+ });
391
+ const selectedBlocks = [
392
+ ...new Map(
393
+ [
394
+ ...requestedProperties.filter(
395
+ (property) =>
396
+ isBlocksPropertyType(property.definition.type) &&
397
+ !isPrimaryBlocksField(property.definition.options),
398
+ ),
399
+ ...explicitlySelectedBlocks,
400
+ ].map((property) => [property.definition.id, property]),
401
+ ).values(),
402
+ ];
403
+ const includePrimaryBody =
404
+ request.includePrimaryBody ||
405
+ requestedProperties.some(
406
+ (property) =>
407
+ isBlocksPropertyType(property.definition.type) &&
408
+ isPrimaryBlocksField(property.definition.options),
409
+ );
410
+ const primaryBlocks = allProperties.find(
411
+ (property) =>
412
+ isBlocksPropertyType(property.definition.type) &&
413
+ isPrimaryBlocksField(property.definition.options),
414
+ );
415
+
416
+ const savedQueries: ContentDatabaseTableQuery[] = [];
417
+ let personalQuery: ContentDatabaseTableQuery | null = null;
418
+ let transientQuery: ContentDatabaseTableQuery | null = null;
419
+ let effectiveSorts: ContentDatabaseTableQuery["sorts"] = [];
420
+ if (request.scope.kind === "current_view") {
421
+ const currentViewScope = request.scope;
422
+ const savedView = parseDatabaseViewConfig(
423
+ database.viewConfigJson,
424
+ ).views.find((view) => view.id === currentViewScope.viewId);
425
+ if (!savedView) {
426
+ fail(`Database view "${currentViewScope.viewId}" not found.`, {
427
+ errorCode: "collection_export_view_not_found",
428
+ statusCode: 404,
429
+ });
430
+ }
431
+ const knownPropertyIds = new Set(propertyById.keys());
432
+ transientQuery = currentViewScope.query;
433
+ assertValidQuery(transientQuery, knownPropertyIds);
434
+ const savedQuery = queryWithOnlyFilters(
435
+ savedView.filters,
436
+ savedView.filterMode ?? "and",
437
+ );
438
+ assertValidQuery(savedQuery, knownPropertyIds);
439
+ assertValidQuery(
440
+ { search: "", filters: [], sorts: savedView.sorts, filterMode: "and" },
441
+ knownPropertyIds,
442
+ );
443
+ effectiveSorts = savedView.sorts;
444
+ savedQueries.push(savedQuery);
445
+ const personal = await readPersonalDatabaseViewOverrides(
446
+ userEmail,
447
+ database.id,
448
+ );
449
+ const personalView = personal?.views.find(
450
+ (view) => view.id === savedView.id,
451
+ );
452
+ if (personalView) {
453
+ personalQuery = queryWithOnlyFilters(
454
+ personalView.filters,
455
+ personalView.filterMode,
456
+ );
457
+ assertValidQuery(personalQuery, knownPropertyIds);
458
+ assertValidQuery(
459
+ {
460
+ search: "",
461
+ filters: [],
462
+ sorts: personalView.sorts,
463
+ filterMode: "and",
464
+ },
465
+ knownPropertyIds,
466
+ );
467
+ effectiveSorts = personalView.sorts;
468
+ }
469
+ }
470
+
471
+ const requiredPropertyIds = new Set<string>([
472
+ ...selectedScalars.map((property) => property.definition.id),
473
+ ...selectedBlocks.map((property) => property.definition.id),
474
+ ...savedQueries.flatMap(queryPropertyIds),
475
+ ...(personalQuery ? queryPropertyIds(personalQuery) : []),
476
+ ...(transientQuery
477
+ ? queryPropertyIds({ ...transientQuery, sorts: [] })
478
+ : []),
479
+ ...effectiveSorts.map(({ key }) => key).filter((key) => key !== "name"),
480
+ ]);
481
+ if (transientQuery?.search.trim()) {
482
+ for (const property of allProperties) {
483
+ requiredPropertyIds.add(property.definition.id);
484
+ }
485
+ }
486
+ const addDependencies = (
487
+ propertyId: string,
488
+ visiting = new Set<string>(),
489
+ ) => {
490
+ if (visiting.has(propertyId)) return;
491
+ visiting.add(propertyId);
492
+ const property = propertyById.get(propertyId);
493
+ if (!property) return;
494
+ if (property.definition.type === "formula") {
495
+ for (const name of formulaDependencyNames(
496
+ property.definition.options.formula,
497
+ )) {
498
+ const dependency = propertyByName.get(name);
499
+ if (!dependency) continue;
500
+ requiredPropertyIds.add(dependency.definition.id);
501
+ addDependencies(dependency.definition.id, visiting);
502
+ }
503
+ }
504
+ if (property.definition.type === "rollup") {
505
+ const relationId = property.definition.options.rollup?.relationPropertyId;
506
+ if (relationId) {
507
+ requiredPropertyIds.add(relationId);
508
+ addDependencies(relationId, visiting);
509
+ }
510
+ }
511
+ };
512
+ for (const propertyId of [...requiredPropertyIds])
513
+ addDependencies(propertyId);
514
+
515
+ const requiredProperties = allProperties.filter((property) =>
516
+ requiredPropertyIds.has(property.definition.id),
517
+ );
518
+ const requiredBlocks = requiredProperties.filter((property) =>
519
+ isBlocksPropertyType(property.definition.type),
520
+ );
521
+ const includePrimaryContent =
522
+ includePrimaryBody ||
523
+ requiredBlocks.some((property) =>
524
+ isPrimaryBlocksField(property.definition.options),
525
+ );
526
+ const memberships = await listContentOrganizationMemberships(userEmail);
527
+ const clauses = accessClauses(
528
+ userEmail,
529
+ memberships.map((membership) => membership.orgId),
530
+ );
531
+ const hydrateCandidates = async (
532
+ candidates: Candidate[],
533
+ ): Promise<ContentDatabaseItem[]> => {
534
+ const documentIds = candidates.map(({ document }) => document.id);
535
+ const storedPropertyIds = requiredProperties
536
+ .filter(
537
+ (property) =>
538
+ !isBlocksPropertyType(property.definition.type) &&
539
+ !isComputedPropertyType(property.definition.type),
540
+ )
541
+ .map((property) => property.definition.id);
542
+ const storedValues = await loadStoredValues(documentIds, storedPropertyIds);
543
+ const additionalBlockIds = requiredBlocks
544
+ .filter((property) => !isPrimaryBlocksField(property.definition.options))
545
+ .map((property) => property.definition.id);
546
+ const additionalBlocks = await loadAdditionalBlocks(
547
+ documentIds,
548
+ additionalBlockIds,
549
+ );
550
+ const rowNumberByDocumentId = new Map(
551
+ candidates.map(({ document, item }) => [document.id, item.rowNumber]),
552
+ );
553
+
554
+ const rollupValues = new Map<string, DocumentPropertyValue>();
555
+ const requiredRollups = requiredProperties.filter(
556
+ (property) => property.definition.type === "rollup",
557
+ );
558
+ const requiredRelations = requiredProperties.filter(
559
+ (property) => property.definition.type === "relation",
560
+ );
561
+ const linkedIds = new Set<string>();
562
+ for (const relation of requiredRelations) {
563
+ for (const documentId of documentIds) {
564
+ const relationValue = storedValues.get(
565
+ propertyKey(documentId, relation.definition.id),
566
+ );
567
+ const ids = Array.isArray(relationValue)
568
+ ? relationValue.filter(
569
+ (value): value is string => typeof value === "string",
570
+ )
571
+ : typeof relationValue === "string" && relationValue
572
+ ? [relationValue]
573
+ : [];
574
+ for (const id of ids) linkedIds.add(id);
575
+ }
576
+ }
577
+ const linkedDocuments = new Map<string, Candidate["document"]>();
578
+ if (linkedIds.size) {
579
+ for (const idChunk of chunks([...linkedIds], 180)) {
580
+ const rows = await getDb()
581
+ .select({
582
+ id: schema.documents.id,
583
+ parentId: schema.documents.parentId,
584
+ title: schema.documents.title,
585
+ icon: schema.documents.icon,
586
+ position: schema.documents.position,
587
+ isFavorite: schema.documents.isFavorite,
588
+ hideFromSearch: schema.documents.hideFromSearch,
589
+ ownerEmail: schema.documents.ownerEmail,
590
+ createdAt: schema.documents.createdAt,
591
+ updatedAt: schema.documents.updatedAt,
592
+ })
593
+ .from(schema.documents)
594
+ .where(
595
+ and(
596
+ inArray(schema.documents.id, idChunk),
597
+ isNull(schema.documents.trashedAt),
598
+ or(...clauses),
599
+ ),
600
+ );
601
+ for (const row of rows) linkedDocuments.set(row.id, row);
602
+ }
603
+ }
604
+ for (const relation of requiredRelations) {
605
+ for (const documentId of documentIds) {
606
+ const key = propertyKey(documentId, relation.definition.id);
607
+ const relationValue = storedValues.get(key);
608
+ if (Array.isArray(relationValue)) {
609
+ storedValues.set(
610
+ key,
611
+ relationValue.filter(
612
+ (id): id is string =>
613
+ typeof id === "string" && linkedDocuments.has(id),
614
+ ),
615
+ );
616
+ } else if (typeof relationValue === "string") {
617
+ storedValues.set(
618
+ key,
619
+ linkedDocuments.has(relationValue) ? relationValue : null,
620
+ );
621
+ }
622
+ }
623
+ }
624
+ const rollupTargetIds = [
625
+ ...new Set(
626
+ requiredRollups
627
+ .map(
628
+ (property) => property.definition.options.rollup?.targetPropertyId,
629
+ )
630
+ .filter((id): id is string => !!id),
631
+ ),
632
+ ];
633
+ for (const rollup of requiredRollups) {
634
+ const config = rollup.definition.options.rollup;
635
+ if (!config?.targetPropertyId || config.aggregation === "count") continue;
636
+ const target = propertyById.get(config.targetPropertyId);
637
+ if (
638
+ target &&
639
+ (isComputedPropertyType(target.definition.type) ||
640
+ isBlocksPropertyType(target.definition.type))
641
+ ) {
642
+ fail(
643
+ `Rollup "${rollup.definition.name}" targets a computed or Blocks property that this bounded export cannot hydrate safely.`,
644
+ {
645
+ errorCode: "collection_export_rollup_target_unsupported",
646
+ statusCode: 422,
647
+ },
648
+ );
649
+ }
650
+ }
651
+ const linkedStoredValues = await loadStoredValues(
652
+ [...linkedDocuments.keys()],
653
+ rollupTargetIds,
654
+ );
655
+ for (const rollup of requiredRollups) {
656
+ const config = rollup.definition.options.rollup;
657
+ if (!config?.relationPropertyId) continue;
658
+ for (const documentId of documentIds) {
659
+ const relationValue = storedValues.get(
660
+ propertyKey(documentId, config.relationPropertyId),
661
+ );
662
+ const ids = (
663
+ Array.isArray(relationValue)
664
+ ? relationValue
665
+ : typeof relationValue === "string"
666
+ ? [relationValue]
667
+ : []
668
+ ).filter(
669
+ (id): id is string =>
670
+ typeof id === "string" && linkedDocuments.has(id),
671
+ );
672
+ const values = config.targetPropertyId
673
+ ? ids.map(
674
+ (id) =>
675
+ linkedStoredValues.get(
676
+ propertyKey(id, config.targetPropertyId!),
677
+ ) ?? null,
678
+ )
679
+ : [];
680
+ rollupValues.set(
681
+ propertyKey(documentId, rollup.definition.id),
682
+ aggregateRollup(config.aggregation ?? "count", ids, values),
683
+ );
684
+ }
685
+ }
686
+
687
+ const propertiesByDocumentId = new Map<string, DocumentProperty[]>();
688
+ for (const { document } of candidates) {
689
+ const memo = new Map<string, DocumentPropertyValue>();
690
+ const evaluating = new Set<string>();
691
+ const valueFor = (property: DocumentProperty): DocumentPropertyValue => {
692
+ const id = property.definition.id;
693
+ if (memo.has(id)) return memo.get(id) ?? null;
694
+ if (evaluating.has(id)) return null;
695
+ evaluating.add(id);
696
+ let value: DocumentPropertyValue;
697
+ if (isBlocksPropertyType(property.definition.type)) {
698
+ value = resolveBlocksFieldValue({
699
+ options: property.definition.options,
700
+ documentBody: document.content,
701
+ blockFieldContent: additionalBlocks.get(
702
+ propertyKey(document.id, id),
703
+ ),
704
+ });
705
+ } else if (property.definition.type === "formula") {
706
+ const names = formulaDependencyNames(
707
+ property.definition.options.formula,
708
+ );
709
+ const valuesByName = Object.fromEntries(
710
+ names.map((name) => {
711
+ const dependency = propertyByName.get(name);
712
+ return [name, dependency ? valueFor(dependency) : null];
713
+ }),
714
+ );
715
+ value = evaluatePropertyFormula(
716
+ property.definition.options.formula,
717
+ valuesByName,
718
+ );
719
+ } else if (property.definition.type === "rollup") {
720
+ value = rollupValues.get(propertyKey(document.id, id)) ?? null;
721
+ } else if (isComputedPropertyType(property.definition.type)) {
722
+ value = computedPropertyValue(
723
+ property.definition.type,
724
+ document as never,
725
+ {
726
+ databaseRowNumber: rowNumberByDocumentId.get(document.id),
727
+ },
728
+ );
729
+ } else {
730
+ value = storedValues.get(propertyKey(document.id, id)) ?? null;
731
+ }
732
+ evaluating.delete(id);
733
+ memo.set(id, value);
734
+ return value;
735
+ };
736
+ propertiesByDocumentId.set(
737
+ document.id,
738
+ requiredProperties.map((property) => ({
739
+ ...property,
740
+ value: valueFor(property),
741
+ })),
742
+ );
743
+ }
744
+
745
+ return candidates.map(({ item, document }) => ({
746
+ id: item.id,
747
+ databaseId: item.databaseId,
748
+ document: {
749
+ id: document.id,
750
+ parentId: document.parentId,
751
+ title: document.title,
752
+ content: document.content ?? "",
753
+ icon: document.icon,
754
+ position: document.position,
755
+ isFavorite: document.isFavorite === 1,
756
+ hideFromSearch: document.hideFromSearch === 1,
757
+ createdAt: document.createdAt,
758
+ updatedAt: document.updatedAt,
759
+ },
760
+ position: item.position,
761
+ properties: propertiesByDocumentId.get(document.id) ?? [],
762
+ }));
763
+ };
764
+ const queryFilterStages = [
765
+ ...savedQueries,
766
+ ...(personalQuery ? [personalQuery] : []),
767
+ ...(transientQuery ? [{ ...transientQuery, sorts: [] }] : []),
768
+ ];
769
+ const assertBodiesReady = (
770
+ items: ContentDatabaseItem[],
771
+ itemCandidates: Candidate[],
772
+ ) => {
773
+ const remainingIds = new Set(items.map((item) => item.document.id));
774
+ const unreadable = itemCandidates.find(
775
+ ({ item }) =>
776
+ remainingIds.has(item.documentId) &&
777
+ item.bodyHydrationStatus !== "hydrated",
778
+ );
779
+ if (unreadable?.item.bodyHydrationStatus === "unavailable") {
780
+ fail(
781
+ `Database item "${unreadable.item.documentId}" body is unavailable for export.`,
782
+ {
783
+ errorCode: "collection_export_body_unavailable",
784
+ statusCode: 422,
785
+ },
786
+ );
787
+ }
788
+ if (unreadable) {
789
+ fail(
790
+ `Database item "${unreadable.item.documentId}" is not ready for export.`,
791
+ {
792
+ errorCode: "collection_export_body_not_ready",
793
+ statusCode: 409,
794
+ },
795
+ );
796
+ }
797
+ };
798
+
799
+ const pageSize =
800
+ request.scope.kind === "all_members"
801
+ ? CONTENT_DATABASE_MAX_READ_LIMIT + 1
802
+ : 500;
803
+ const candidates: Candidate[] = [];
804
+ let queryItems: ContentDatabaseItem[] = [];
805
+ for (let offset = 0; ; offset += pageSize) {
806
+ const pageCandidates = await loadCandidates(
807
+ database.id,
808
+ includePrimaryContent,
809
+ clauses,
810
+ pageSize,
811
+ offset,
812
+ );
813
+ let pageItems = await hydrateCandidates(pageCandidates);
814
+ for (const query of queryFilterStages) {
815
+ if (queryNeedsPrimaryBody(query, propertyById, propertyByName)) {
816
+ assertBodiesReady(pageItems, pageCandidates);
817
+ }
818
+ pageItems = applyContentDatabaseTableQuery(
819
+ pageItems,
820
+ requiredProperties,
821
+ { ...query, sorts: [] },
822
+ );
823
+ }
824
+ const remainingIds = new Set(pageItems.map((item) => item.document.id));
825
+ candidates.push(
826
+ ...pageCandidates.filter(({ document }) => remainingIds.has(document.id)),
827
+ );
828
+ queryItems.push(...pageItems);
829
+ if (queryItems.length > CONTENT_DATABASE_MAX_READ_LIMIT) {
830
+ fail(
831
+ `Collection export supports up to ${CONTENT_DATABASE_MAX_READ_LIMIT} authorized records. Narrow the current View and try again.`,
832
+ { errorCode: "collection_export_limit_exceeded", statusCode: 413 },
833
+ );
834
+ }
835
+ if (
836
+ request.scope.kind === "all_members" ||
837
+ pageCandidates.length < pageSize
838
+ ) {
839
+ break;
840
+ }
841
+ }
842
+
843
+ if (request.scope.kind === "current_view" && effectiveSorts.length > 0) {
844
+ const sortQuery: ContentDatabaseTableQuery = {
845
+ search: "",
846
+ filters: [],
847
+ sorts: effectiveSorts,
848
+ filterMode: "and",
849
+ };
850
+ if (queryNeedsPrimaryBody(sortQuery, propertyById, propertyByName)) {
851
+ assertBodiesReady(queryItems, candidates);
852
+ }
853
+ queryItems = applyContentDatabaseTableQuery(
854
+ queryItems,
855
+ requiredProperties,
856
+ sortQuery,
857
+ );
858
+ }
859
+ if (
860
+ includePrimaryBody ||
861
+ selectedScalars.some((property) =>
862
+ propertyNeedsPrimaryBody(property, propertyByName),
863
+ )
864
+ ) {
865
+ assertBodiesReady(queryItems, candidates);
866
+ }
867
+
868
+ const bodyFields = [
869
+ ...(includePrimaryBody
870
+ ? [
871
+ {
872
+ id: "primary-body",
873
+ name: primaryBlocks?.definition.name ?? "Content",
874
+ },
875
+ ]
876
+ : []),
877
+ ...selectedBlocks.map((property) => ({
878
+ id: property.definition.id,
879
+ name: property.definition.name,
880
+ })),
881
+ ];
882
+ const records: CollectionExportRecord[] = queryItems.map((item) => {
883
+ const values = new Map(
884
+ item.properties.map((property) => [property.definition.id, property]),
885
+ );
886
+ return {
887
+ id: item.document.id,
888
+ title: item.document.title || "Untitled",
889
+ scalarValues: new Map(
890
+ selectedScalars.map((property) => [
891
+ property.definition.id,
892
+ databasePropertyValueText(
893
+ property,
894
+ values.get(property.definition.id)?.value,
895
+ ),
896
+ ]),
897
+ ),
898
+ bodyValues: new Map([
899
+ ...(includePrimaryBody
900
+ ? [["primary-body", item.document.content] as const]
901
+ : []),
902
+ ...selectedBlocks.map(
903
+ (property) =>
904
+ [
905
+ property.definition.id,
906
+ typeof values.get(property.definition.id)?.value === "string"
907
+ ? (values.get(property.definition.id)?.value as string)
908
+ : "",
909
+ ] as const,
910
+ ),
911
+ ]),
912
+ };
913
+ });
914
+ return {
915
+ id: database.documentId,
916
+ title: database.title,
917
+ updatedAt: database.updatedAt,
918
+ scalarFields: selectedScalars.map((property) => ({
919
+ id: property.definition.id,
920
+ name: property.definition.name,
921
+ })),
922
+ bodyFields,
923
+ records,
924
+ };
925
+ }