@agent-native/core 0.114.4 → 0.114.6

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 (95) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/core/CHANGELOG.md +16 -0
  3. package/corpus/core/package.json +1 -1
  4. package/corpus/core/src/client/resources/ResourceTree.tsx +11 -5
  5. package/corpus/core/src/client/resources/ResourcesPanel.tsx +141 -18
  6. package/corpus/templates/clips/.agents/skills/meetings/SKILL.md +11 -1
  7. package/corpus/templates/clips/AGENTS.md +14 -4
  8. package/corpus/templates/clips/actions/update-meeting.ts +13 -3
  9. package/corpus/templates/clips/actions/view-screen.ts +1 -0
  10. package/corpus/templates/clips/app/components/meetings/share-meeting-dialog.tsx +96 -4
  11. package/corpus/templates/clips/app/i18n/en-US.ts +11 -0
  12. package/corpus/templates/clips/app/lib/public-meeting.ts +85 -0
  13. package/corpus/templates/clips/app/routes/_app.meetings.$meetingId.tsx +11 -1
  14. package/corpus/templates/clips/app/routes/share.$shareId.tsx +6 -7
  15. package/corpus/templates/clips/app/routes/share.meeting.$meetingId.tsx +241 -135
  16. package/corpus/templates/clips/changelog/2026-07-20-meeting-share-links-can-include-the-full-transcript-with-an-.md +6 -0
  17. package/corpus/templates/clips/changelog/2026-07-20-opening-zoom-meeting-launches-native-app.md +6 -0
  18. package/corpus/templates/clips/changelog/2026-07-20-recording-retries-the-default-mac-microphone-when-a-saved-in.md +6 -0
  19. package/corpus/templates/clips/changelog/2026-07-20-shared-clips-now-tell-agents-to-wait-while-uploads-and-trans.md +6 -0
  20. package/corpus/templates/clips/desktop/src/app.tsx +7 -11
  21. package/corpus/templates/clips/desktop/src/lib/meeting-join-url.ts +1 -33
  22. package/corpus/templates/clips/desktop/src/lib/open-meeting-join-url.ts +22 -0
  23. package/corpus/templates/clips/desktop/src/lib/transcription-engine.ts +65 -2
  24. package/corpus/templates/clips/desktop/src/overlays/meeting-notification.tsx +2 -3
  25. package/corpus/templates/clips/server/db/schema.ts +3 -0
  26. package/corpus/templates/clips/server/lib/public-agent-context.ts +31 -21
  27. package/corpus/templates/clips/server/plugins/auth.ts +1 -0
  28. package/corpus/templates/clips/server/plugins/db.ts +6 -0
  29. package/corpus/templates/clips/server/routes/api/agent-transcript.json.get.ts +13 -3
  30. package/corpus/templates/clips/server/routes/api/public-meeting.get.ts +152 -0
  31. package/corpus/templates/clips/shared/agent-context.ts +60 -0
  32. package/corpus/templates/clips/shared/meeting-join-url.ts +31 -0
  33. package/corpus/templates/content/.agents/skills/document-editing/SKILL.md +9 -1
  34. package/corpus/templates/content/AGENTS.md +4 -1
  35. package/corpus/templates/content/actions/_content-spaces.ts +6 -1
  36. package/corpus/templates/content/actions/_database-row-batch.ts +15 -2
  37. package/corpus/templates/content/actions/_database-utils.ts +83 -15
  38. package/corpus/templates/content/actions/add-database-item.ts +3 -0
  39. package/corpus/templates/content/actions/connect-local-folder-source.ts +2 -1
  40. package/corpus/templates/content/actions/delete-content-database.ts +12 -9
  41. package/corpus/templates/content/actions/delete-database-items.ts +3 -2
  42. package/corpus/templates/content/actions/delete-document.ts +233 -6
  43. package/corpus/templates/content/actions/duplicate-database-item.ts +1 -0
  44. package/corpus/templates/content/actions/get-document.ts +4 -1
  45. package/corpus/templates/content/actions/list-content-databases.ts +1 -0
  46. package/corpus/templates/content/actions/list-documents.ts +1 -0
  47. package/corpus/templates/content/actions/list-trashed-content-databases.ts +8 -2
  48. package/corpus/templates/content/actions/list-trashed-documents.ts +50 -0
  49. package/corpus/templates/content/actions/permanently-delete-document.ts +28 -0
  50. package/corpus/templates/content/actions/pull-document.ts +15 -2
  51. package/corpus/templates/content/actions/restore-content-database.ts +48 -11
  52. package/corpus/templates/content/actions/restore-document.ts +28 -0
  53. package/corpus/templates/content/actions/search-documents.ts +2 -1
  54. package/corpus/templates/content/actions/view-screen.ts +2 -1
  55. package/corpus/templates/content/app/components/editor/database/DatabaseView.tsx +149 -55
  56. package/corpus/templates/content/app/components/editor/database/GalleryView.tsx +7 -2
  57. package/corpus/templates/content/app/components/editor/database/ListView.tsx +7 -2
  58. package/corpus/templates/content/app/components/editor/database/TimelineView.tsx +3 -0
  59. package/corpus/templates/content/app/components/editor/database/sidebar.tsx +26 -5
  60. package/corpus/templates/content/app/components/sidebar/DocumentSidebar.tsx +243 -163
  61. package/corpus/templates/content/app/components/sidebar/WorkspaceSourceMenu.tsx +154 -0
  62. package/corpus/templates/content/app/components/sidebar/select-content-space.ts +11 -0
  63. package/corpus/templates/content/app/hooks/use-content-database.ts +6 -0
  64. package/corpus/templates/content/app/hooks/use-documents.ts +57 -0
  65. package/corpus/templates/content/app/i18n-data.ts +83 -2
  66. package/corpus/templates/content/app/routes/_app.local-files.tsx +8 -1
  67. package/corpus/templates/content/changelog/2026-07-19-pages-now-move-to-a-reversible-trash-and-the-organization-pi.md +6 -0
  68. package/corpus/templates/content/changelog/2026-07-20-trash-actions-now-preserve-independent-archived-pages-enforc.md +6 -0
  69. package/corpus/templates/content/changelog/2026-07-20-workspace-toggles-now-stay-independently-open-or-closed-and-.md +6 -0
  70. package/corpus/templates/content/changelog/2026-07-20-workspaces-can-be-added-from-a-blank-workspace-or-a-connecte.md +6 -0
  71. package/corpus/templates/content/parity/matrix.md +1 -1
  72. package/corpus/templates/content/parity/matrix.ts +3 -0
  73. package/corpus/templates/content/server/db/schema.ts +2 -0
  74. package/corpus/templates/content/server/lib/public-documents.ts +8 -2
  75. package/corpus/templates/content/server/plugins/db.ts +52 -0
  76. package/corpus/templates/content/server/routes/api/document-agent-context.json.get.ts +2 -2
  77. package/corpus/templates/content/shared/api.ts +10 -0
  78. package/corpus/toolkit/CHANGELOG.md +6 -0
  79. package/corpus/toolkit/package.json +1 -1
  80. package/corpus/toolkit/src/ui/progress.tsx +3 -2
  81. package/dist/client/resources/ResourceTree.d.ts +3 -1
  82. package/dist/client/resources/ResourceTree.d.ts.map +1 -1
  83. package/dist/client/resources/ResourceTree.js +4 -4
  84. package/dist/client/resources/ResourceTree.js.map +1 -1
  85. package/dist/client/resources/ResourcesPanel.d.ts.map +1 -1
  86. package/dist/client/resources/ResourcesPanel.js +55 -6
  87. package/dist/client/resources/ResourcesPanel.js.map +1 -1
  88. package/dist/file-upload/actions/upload-image.d.ts +1 -1
  89. package/dist/notifications/routes.d.ts +3 -3
  90. package/dist/observability/routes.d.ts +5 -5
  91. package/dist/secrets/routes.d.ts +9 -9
  92. package/dist/server/transcribe-voice.d.ts +1 -1
  93. package/package.json +3 -3
  94. package/src/client/resources/ResourceTree.tsx +11 -5
  95. package/src/client/resources/ResourcesPanel.tsx +141 -18
@@ -22,7 +22,10 @@ import type {
22
22
  ContentDatabaseResponse,
23
23
  } from "../shared/api.js";
24
24
  import { favoriteDocumentIds } from "./_content-favorites.js";
25
- import { listContentOrganizationMemberships } from "./_content-space-access.js";
25
+ import {
26
+ listContentOrganizationMemberships,
27
+ normalizeContentSpaceEmail,
28
+ } from "./_content-space-access.js";
26
29
  import { getAllContentDatabaseSourceSnapshots } from "./_database-source-utils.js";
27
30
  import {
28
31
  applyFederatedOverlayValues,
@@ -81,6 +84,8 @@ export const contentDatabaseListDocumentSelection = {
81
84
  sourcePath: schema.documents.sourcePath,
82
85
  sourceRootPath: schema.documents.sourceRootPath,
83
86
  sourceUpdatedAt: schema.documents.sourceUpdatedAt,
87
+ trashedAt: schema.documents.trashedAt,
88
+ trashRootId: schema.documents.trashRootId,
84
89
  visibility: schema.documents.visibility,
85
90
  ownerEmail: schema.documents.ownerEmail,
86
91
  orgId: schema.documents.orgId,
@@ -300,9 +305,14 @@ export async function getContentDatabaseResponse(
300
305
 
301
306
  const { limit, offset } = normalizeContentDatabasePageOptions(options);
302
307
  const userEmail = getRequestUserEmail();
308
+ const normalizedUserEmail = userEmail
309
+ ? normalizeContentSpaceEmail(userEmail)
310
+ : null;
303
311
  const activeOrgId = getRequestOrgId();
304
312
  const authorizedOrgIds =
305
- database.systemRole === "favorites" && userEmail
313
+ (database.systemRole === "favorites" ||
314
+ database.systemRole === "workspaces") &&
315
+ userEmail
306
316
  ? [
307
317
  ...new Set([
308
318
  ...(await listContentOrganizationMemberships(userEmail)).map(
@@ -312,6 +322,45 @@ export async function getContentDatabaseResponse(
312
322
  ]),
313
323
  ]
314
324
  : [];
325
+ const workspacesVisibleDocumentIds =
326
+ database.systemRole === "workspaces" && normalizedUserEmail
327
+ ? (
328
+ await db
329
+ .select({
330
+ documentId: schema.contentSpaceCatalogItems.documentId,
331
+ ownerEmail: schema.contentSpaces.ownerEmail,
332
+ orgId: schema.contentSpaces.orgId,
333
+ })
334
+ .from(schema.contentSpaceCatalogItems)
335
+ .innerJoin(
336
+ schema.contentSpaces,
337
+ eq(
338
+ schema.contentSpaces.id,
339
+ schema.contentSpaceCatalogItems.spaceId,
340
+ ),
341
+ )
342
+ .where(
343
+ and(
344
+ eq(
345
+ schema.contentSpaceCatalogItems.catalogDatabaseId,
346
+ databaseId,
347
+ ),
348
+ eq(
349
+ schema.contentSpaceCatalogItems.ownerEmail,
350
+ normalizedUserEmail,
351
+ ),
352
+ isNull(schema.contentSpaces.archivedAt),
353
+ ),
354
+ )
355
+ )
356
+ .filter(
357
+ (row) =>
358
+ normalizeContentSpaceEmail(row.ownerEmail) ===
359
+ normalizedUserEmail ||
360
+ (!!row.orgId && authorizedOrgIds.includes(row.orgId)),
361
+ )
362
+ .map((row) => row.documentId)
363
+ : null;
315
364
  const favoritesVisibleDocumentIds =
316
365
  database.systemRole === "favorites" && userEmail
317
366
  ? (
@@ -331,6 +380,7 @@ export async function getContentDatabaseResponse(
331
380
  }),
332
381
  ),
333
382
  ),
383
+ isNull(schema.documents.trashedAt),
334
384
  documentDiscoveryFilter({
335
385
  userEmail,
336
386
  orgIds: authorizedOrgIds,
@@ -351,6 +401,11 @@ export async function getContentDatabaseResponse(
351
401
  : undefined;
352
402
  const visibleItemFilter = and(
353
403
  eq(schema.contentDatabaseItems.databaseId, databaseId),
404
+ sql`exists (
405
+ select 1 from ${schema.documents}
406
+ where ${schema.documents.id} = ${schema.contentDatabaseItems.documentId}
407
+ and ${schema.documents.trashedAt} is null
408
+ )`,
354
409
  organizationFilesItemFilter,
355
410
  favoritesVisibleDocumentIds
356
411
  ? favoritesVisibleDocumentIds.length > 0
@@ -360,6 +415,14 @@ export async function getContentDatabaseResponse(
360
415
  )
361
416
  : sql`1 = 0`
362
417
  : undefined,
418
+ workspacesVisibleDocumentIds
419
+ ? workspacesVisibleDocumentIds.length > 0
420
+ ? inArray(
421
+ schema.contentDatabaseItems.documentId,
422
+ workspacesVisibleDocumentIds,
423
+ )
424
+ : sql`1 = 0`
425
+ : undefined,
363
426
  );
364
427
  const [itemCount] = await db
365
428
  .select({ count: sql<number>`COUNT(*)` })
@@ -388,23 +451,28 @@ export async function getContentDatabaseResponse(
388
451
  schema.documents.id,
389
452
  items.map((item) => item.documentId),
390
453
  ),
454
+ isNull(schema.documents.trashedAt),
391
455
  database.systemRole === "favorites"
392
456
  ? favoritesVisibleDocumentIds?.length
393
457
  ? inArray(schema.documents.id, favoritesVisibleDocumentIds)
394
458
  : sql`1 = 0`
395
- : database.systemRole === "files" && database.orgId
396
- ? and(
397
- eq(schema.documents.orgId, database.orgId),
398
- or(
399
- eq(schema.documents.visibility, "org"),
400
- eq(schema.documents.visibility, "public"),
401
- ),
402
- or(
403
- eq(schema.documents.hideFromSearch, 0),
404
- isNull(schema.documents.hideFromSearch),
405
- ),
406
- )
407
- : eq(schema.documents.ownerEmail, database.ownerEmail),
459
+ : database.systemRole === "workspaces"
460
+ ? workspacesVisibleDocumentIds?.length
461
+ ? inArray(schema.documents.id, workspacesVisibleDocumentIds)
462
+ : sql`1 = 0`
463
+ : database.systemRole === "files" && database.orgId
464
+ ? and(
465
+ eq(schema.documents.orgId, database.orgId),
466
+ or(
467
+ eq(schema.documents.visibility, "org"),
468
+ eq(schema.documents.visibility, "public"),
469
+ ),
470
+ or(
471
+ eq(schema.documents.hideFromSearch, 0),
472
+ isNull(schema.documents.hideFromSearch),
473
+ ),
474
+ )
475
+ : eq(schema.documents.ownerEmail, database.ownerEmail),
408
476
  ),
409
477
  )
410
478
  : [];
@@ -41,6 +41,9 @@ export default defineAction({
41
41
  ),
42
42
  );
43
43
  if (!database) throw new Error(`Database "${databaseId}" not found`);
44
+ if (database.systemRole === "workspaces") {
45
+ throw new Error("Use create-content-space to add a workspace");
46
+ }
44
47
 
45
48
  const access = await assertAccess(
46
49
  "document",
@@ -51,6 +51,7 @@ export default defineAction({
51
51
  .optional()
52
52
  .default(false)
53
53
  .describe("Create a separate private Content space for this folder"),
54
+ propertyValues: z.record(z.string(), z.unknown()).optional(),
54
55
  truthPolicy: truthPolicySchema.describe(
55
56
  "Whether Content, the folder, or reviewed conflict resolution is authoritative",
56
57
  ),
@@ -130,7 +131,7 @@ export default defineAction({
130
131
  const provisioned = await provisionSourceBackedContentSpace(
131
132
  db,
132
133
  userEmail,
133
- { connectionId, name: label },
134
+ { connectionId, name: label, propertyValues: args.propertyValues },
134
135
  );
135
136
  targetSpaceId = provisioned.spaceId;
136
137
  targetDatabaseId = provisioned.filesDatabaseId;
@@ -1,10 +1,11 @@
1
1
  import { defineAction } from "@agent-native/core";
2
2
  import { writeAppState } from "@agent-native/core/application-state";
3
- import { eq } from "drizzle-orm";
3
+ import { assertAccess } from "@agent-native/core/sharing";
4
4
  import { z } from "zod";
5
5
 
6
- import { getDb, schema } from "../server/db/index.js";
6
+ import { getDb } from "../server/db/index.js";
7
7
  import { assertContentDatabaseLifecycleAccess } from "./_content-database-lifecycle.js";
8
+ import { trashDocumentSubtree } from "./delete-document.js";
8
9
 
9
10
  export default defineAction({
10
11
  description:
@@ -17,15 +18,17 @@ export default defineAction({
17
18
  if (database.systemRole) {
18
19
  throw new Error("System Content databases cannot be deleted");
19
20
  }
21
+ await assertAccess("document", database.documentId, "admin");
20
22
  const db = getDb();
21
23
  const deletedAt = database.deletedAt ?? new Date().toISOString();
22
-
23
- if (!database.deletedAt) {
24
- await db
25
- .update(schema.contentDatabases)
26
- .set({ deletedAt, updatedAt: deletedAt })
27
- .where(eq(schema.contentDatabases.id, databaseId));
28
- }
24
+ await db.transaction((tx) =>
25
+ trashDocumentSubtree(
26
+ tx as unknown as ReturnType<typeof getDb>,
27
+ database.documentId,
28
+ database.ownerEmail,
29
+ deletedAt,
30
+ ),
31
+ );
29
32
 
30
33
  await writeAppState("refresh-signal", { ts: Date.now() });
31
34
 
@@ -11,7 +11,7 @@ import {
11
11
  resolveDatabaseRowsForBatch,
12
12
  } from "./_database-row-batch.js";
13
13
  import { getContentDatabaseResponse } from "./_database-utils.js";
14
- import { deleteDocumentRecursive } from "./delete-document.js";
14
+ import { trashDocumentSubtree } from "./delete-document.js";
15
15
 
16
16
  export default defineAction({
17
17
  description:
@@ -57,10 +57,11 @@ export default defineAction({
57
57
 
58
58
  await db.transaction(async (tx) => {
59
59
  for (const row of rows) {
60
- await deleteDocumentRecursive(
60
+ await trashDocumentSubtree(
61
61
  tx as unknown as ReturnType<typeof getDb>,
62
62
  row.document.id,
63
63
  row.document.ownerEmail,
64
+ now,
64
65
  );
65
66
  }
66
67
  await renumberDatabaseRows(
@@ -1,12 +1,13 @@
1
1
  import { defineAction } from "@agent-native/core";
2
2
  import { writeAppState } from "@agent-native/core/application-state";
3
3
  import { assertAccess } from "@agent-native/core/sharing";
4
- import { and, eq, inArray } from "drizzle-orm";
4
+ import { and, eq, inArray, isNotNull, isNull, ne, or } from "drizzle-orm";
5
5
  import { z } from "zod";
6
6
 
7
7
  import { getDb, schema } from "../server/db/index.js";
8
8
  import { chunks } from "./_batch-utils.js";
9
9
  import { assertNotWorkspaceCatalogDocuments } from "./_content-space-catalog-guards.js";
10
+ import { renumberDatabaseRows } from "./_database-row-batch.js";
10
11
 
11
12
  const DELETE_BATCH_SIZE = 90;
12
13
 
@@ -155,6 +156,152 @@ async function collectDocumentSubtreeForDelete(
155
156
  };
156
157
  }
157
158
 
159
+ export async function trashDocumentSubtree(
160
+ db: ReturnType<typeof getDb>,
161
+ id: string,
162
+ ownerEmail: string,
163
+ trashedAt = new Date().toISOString(),
164
+ ): Promise<string[]> {
165
+ const { documentIds } = await collectDocumentSubtreeForDelete(
166
+ db,
167
+ id,
168
+ ownerEmail,
169
+ );
170
+ await assertNotWorkspaceCatalogDocuments(db, documentIds, "deleted");
171
+
172
+ const independentlyTrashedDatabaseDocumentIds = new Set<string>();
173
+ for (const batch of chunks(documentIds, DELETE_BATCH_SIZE)) {
174
+ for (const database of await db
175
+ .select({ documentId: schema.contentDatabases.documentId })
176
+ .from(schema.contentDatabases)
177
+ .where(
178
+ and(
179
+ inArray(schema.contentDatabases.documentId, batch),
180
+ eq(schema.contentDatabases.ownerEmail, ownerEmail),
181
+ isNotNull(schema.contentDatabases.deletedAt),
182
+ ),
183
+ )) {
184
+ independentlyTrashedDatabaseDocumentIds.add(database.documentId);
185
+ }
186
+ }
187
+
188
+ const activeDocumentIds: string[] = [];
189
+ for (const batch of chunks(documentIds, DELETE_BATCH_SIZE)) {
190
+ activeDocumentIds.push(
191
+ ...(
192
+ await db
193
+ .select({ id: schema.documents.id })
194
+ .from(schema.documents)
195
+ .where(
196
+ and(
197
+ inArray(schema.documents.id, batch),
198
+ eq(schema.documents.ownerEmail, ownerEmail),
199
+ isNull(schema.documents.trashedAt),
200
+ ),
201
+ )
202
+ )
203
+ .map((document) => document.id)
204
+ .filter(
205
+ (documentId) =>
206
+ !independentlyTrashedDatabaseDocumentIds.has(documentId),
207
+ ),
208
+ );
209
+ }
210
+
211
+ for (const batch of chunks(activeDocumentIds, DELETE_BATCH_SIZE)) {
212
+ await db
213
+ .update(schema.documents)
214
+ .set({ trashedAt, trashRootId: id, updatedAt: trashedAt })
215
+ .where(
216
+ and(
217
+ inArray(schema.documents.id, batch),
218
+ eq(schema.documents.ownerEmail, ownerEmail),
219
+ isNull(schema.documents.trashedAt),
220
+ ),
221
+ );
222
+ await db
223
+ .update(schema.contentDatabases)
224
+ .set({ deletedAt: trashedAt, updatedAt: trashedAt })
225
+ .where(
226
+ and(
227
+ inArray(schema.contentDatabases.documentId, batch),
228
+ eq(schema.contentDatabases.ownerEmail, ownerEmail),
229
+ isNull(schema.contentDatabases.deletedAt),
230
+ ),
231
+ );
232
+ }
233
+
234
+ return activeDocumentIds;
235
+ }
236
+
237
+ export async function restoreDocumentSubtree(
238
+ db: ReturnType<typeof getDb>,
239
+ rootId: string,
240
+ ownerEmail: string,
241
+ ): Promise<string[]> {
242
+ const documentIds = (
243
+ await db
244
+ .select({ id: schema.documents.id })
245
+ .from(schema.documents)
246
+ .where(
247
+ and(
248
+ eq(schema.documents.trashRootId, rootId),
249
+ eq(schema.documents.ownerEmail, ownerEmail),
250
+ ),
251
+ )
252
+ ).map((document) => document.id);
253
+ if (documentIds.length === 0) return [];
254
+
255
+ const now = new Date().toISOString();
256
+ for (const batch of chunks(documentIds, DELETE_BATCH_SIZE)) {
257
+ await db
258
+ .update(schema.documents)
259
+ .set({ trashedAt: null, trashRootId: null, updatedAt: now })
260
+ .where(
261
+ and(
262
+ inArray(schema.documents.id, batch),
263
+ eq(schema.documents.ownerEmail, ownerEmail),
264
+ eq(schema.documents.trashRootId, rootId),
265
+ ),
266
+ );
267
+ await db
268
+ .update(schema.contentDatabases)
269
+ .set({ deletedAt: null, updatedAt: now })
270
+ .where(
271
+ and(
272
+ inArray(schema.contentDatabases.documentId, batch),
273
+ eq(schema.contentDatabases.ownerEmail, ownerEmail),
274
+ ),
275
+ );
276
+ }
277
+
278
+ const databaseIds = [
279
+ ...new Set(
280
+ (
281
+ await db
282
+ .select({ databaseId: schema.contentDatabaseItems.databaseId })
283
+ .from(schema.contentDatabaseItems)
284
+ .where(inArray(schema.contentDatabaseItems.documentId, documentIds))
285
+ ).map((item) => item.databaseId),
286
+ ),
287
+ ];
288
+ for (const databaseId of databaseIds) {
289
+ const [database] = await db
290
+ .select()
291
+ .from(schema.contentDatabases)
292
+ .where(
293
+ and(
294
+ eq(schema.contentDatabases.id, databaseId),
295
+ isNull(schema.contentDatabases.deletedAt),
296
+ ),
297
+ )
298
+ .limit(1);
299
+ if (database) await renumberDatabaseRows(db, database, now);
300
+ }
301
+
302
+ return documentIds;
303
+ }
304
+
158
305
  async function deleteWhereIn<T>(
159
306
  items: T[],
160
307
  run: (batch: T[]) => Promise<unknown>,
@@ -171,6 +318,20 @@ export async function deleteDocumentRecursive(
171
318
  ): Promise<string[]> {
172
319
  const { documentIds, ownedDatabaseIds } =
173
320
  await collectDocumentSubtreeForDelete(db, id, ownerEmail);
321
+ return deleteCollectedDocuments(
322
+ db,
323
+ documentIds,
324
+ ownedDatabaseIds,
325
+ ownerEmail,
326
+ );
327
+ }
328
+
329
+ async function deleteCollectedDocuments(
330
+ db: ReturnType<typeof getDb>,
331
+ documentIds: string[],
332
+ ownedDatabaseIds: string[],
333
+ ownerEmail: string,
334
+ ): Promise<string[]> {
174
335
  await assertNotWorkspaceCatalogDocuments(db, documentIds, "deleted");
175
336
 
176
337
  const propertyDefinitionIds: string[] = [];
@@ -353,8 +514,72 @@ export async function deleteDocumentRecursive(
353
514
  return documentIds;
354
515
  }
355
516
 
517
+ export async function deleteTrashedDocumentSubtree(
518
+ db: ReturnType<typeof getDb>,
519
+ id: string,
520
+ ownerEmail: string,
521
+ ): Promise<string[]> {
522
+ const [root] = await db
523
+ .select({ id: schema.documents.id })
524
+ .from(schema.documents)
525
+ .where(
526
+ and(
527
+ eq(schema.documents.id, id),
528
+ eq(schema.documents.ownerEmail, ownerEmail),
529
+ eq(schema.documents.trashRootId, id),
530
+ isNotNull(schema.documents.trashedAt),
531
+ ),
532
+ )
533
+ .limit(1);
534
+ if (!root) {
535
+ throw new Error(
536
+ "Document must be in Trash and be a Trash root before permanent deletion",
537
+ );
538
+ }
539
+
540
+ const documentIds = (
541
+ await db
542
+ .select({ id: schema.documents.id })
543
+ .from(schema.documents)
544
+ .where(
545
+ and(
546
+ eq(schema.documents.ownerEmail, ownerEmail),
547
+ eq(schema.documents.trashRootId, id),
548
+ isNotNull(schema.documents.trashedAt),
549
+ ),
550
+ )
551
+ ).map((document) => document.id);
552
+ const ownedDatabaseIds = await selectOwnedDatabaseIds(
553
+ db,
554
+ documentIds,
555
+ ownerEmail,
556
+ ).then((rows) => rows.map((database) => database.id));
557
+
558
+ await db
559
+ .update(schema.documents)
560
+ .set({ parentId: null, updatedAt: new Date().toISOString() })
561
+ .where(
562
+ and(
563
+ eq(schema.documents.ownerEmail, ownerEmail),
564
+ inArray(schema.documents.parentId, documentIds),
565
+ or(
566
+ isNull(schema.documents.trashRootId),
567
+ ne(schema.documents.trashRootId, id),
568
+ ),
569
+ ),
570
+ );
571
+
572
+ return deleteCollectedDocuments(
573
+ db,
574
+ documentIds,
575
+ ownedDatabaseIds,
576
+ ownerEmail,
577
+ );
578
+ }
579
+
356
580
  export default defineAction({
357
- description: "Delete a document and all its children recursively.",
581
+ description:
582
+ "Move a document and all its children to Trash. Use permanently-delete-document to destroy an item already in Trash.",
358
583
  schema: z.object({
359
584
  id: z.string().optional().describe("Document ID (required)"),
360
585
  databaseDocumentId: z
@@ -408,10 +633,12 @@ export default defineAction({
408
633
  if (systemDatabase?.systemRole) {
409
634
  throw new Error("System Content database documents cannot be deleted");
410
635
  }
411
- const deleted = await deleteDocumentRecursive(
412
- db,
413
- id,
414
- existing.ownerEmail as string,
636
+ const deleted = await db.transaction((tx) =>
637
+ trashDocumentSubtree(
638
+ tx as unknown as ReturnType<typeof getDb>,
639
+ id,
640
+ existing.ownerEmail as string,
641
+ ),
415
642
  );
416
643
 
417
644
  await writeAppState("refresh-signal", { ts: Date.now() });
@@ -46,6 +46,7 @@ export default defineAction({
46
46
  ? eq(schema.contentDatabaseItems.id, itemId)
47
47
  : eq(schema.contentDatabaseItems.documentId, documentId!),
48
48
  isNull(schema.contentDatabases.deletedAt),
49
+ isNull(schema.documents.trashedAt),
49
50
  ),
50
51
  );
51
52
 
@@ -70,7 +70,10 @@ export default defineAction({
70
70
  statusCode: 404,
71
71
  });
72
72
  }
73
- if (await isSoftDeletedDatabaseDocument(args.id)) {
73
+ if (
74
+ access.resource.trashedAt ||
75
+ (await isSoftDeletedDatabaseDocument(args.id))
76
+ ) {
74
77
  throw Object.assign(new Error(`Document "${args.id}" not found`), {
75
78
  statusCode: 404,
76
79
  });
@@ -60,6 +60,7 @@ export default defineAction({
60
60
  .where(
61
61
  and(
62
62
  accessFilter(schema.documents, schema.documentShares),
63
+ isNull(schema.documents.trashedAt),
63
64
  documentDiscoveryFilter(),
64
65
  isNull(schema.contentDatabases.deletedAt),
65
66
  excludedDatabaseIds.size === 1
@@ -106,6 +106,7 @@ export default defineAction({
106
106
  accessFilter(schema.documents, schema.documentShares, context),
107
107
  ),
108
108
  ),
109
+ isNull(schema.documents.trashedAt),
109
110
  documentDiscoveryFilter({
110
111
  userEmail,
111
112
  orgIds: authorizedOrgIds,
@@ -34,6 +34,7 @@ export default defineAction({
34
34
  deletedAt: schema.contentDatabases.deletedAt,
35
35
  documentTitle: schema.documents.title,
36
36
  documentParentId: schema.documents.parentId,
37
+ documentTrashRootId: schema.documents.trashRootId,
37
38
  })
38
39
  .from(schema.contentDatabases)
39
40
  .innerJoin(
@@ -47,6 +48,10 @@ export default defineAction({
47
48
  .where(
48
49
  and(
49
50
  isNotNull(schema.contentDatabases.deletedAt),
51
+ or(
52
+ isNull(schema.documents.trashRootId),
53
+ eq(schema.documents.trashRootId, schema.documents.id),
54
+ ),
50
55
  or(
51
56
  and(
52
57
  isBlockOwned,
@@ -82,8 +87,9 @@ export default defineAction({
82
87
  ownerDocumentId: row.ownerDocumentId,
83
88
  deletedAt: row.deletedAt!,
84
89
  canPermanentlyDelete:
85
- row.ownerDocumentId === null ||
86
- row.documentParentId !== row.ownerDocumentId,
90
+ row.documentTrashRootId === row.documentId &&
91
+ (row.ownerDocumentId === null ||
92
+ row.documentParentId !== row.ownerDocumentId),
87
93
  })),
88
94
  };
89
95
  },
@@ -0,0 +1,50 @@
1
+ import { defineAction } from "@agent-native/core";
2
+ import { accessFilter } from "@agent-native/core/sharing";
3
+ import { and, desc, eq, isNotNull, isNull } from "drizzle-orm";
4
+ import { z } from "zod";
5
+
6
+ import { getDb, schema } from "../server/db/index.js";
7
+ import type { ListTrashedDocumentsResponse } from "../shared/api.js";
8
+
9
+ export default defineAction({
10
+ description:
11
+ "List trashed page roots the current user can manage. Titles are returned only after document access filtering.",
12
+ schema: z.object({}),
13
+ http: { method: "GET" },
14
+ readOnly: true,
15
+ run: async (): Promise<ListTrashedDocumentsResponse> => {
16
+ const rows = await getDb()
17
+ .select({
18
+ documentId: schema.documents.id,
19
+ title: schema.documents.title,
20
+ trashedAt: schema.documents.trashedAt,
21
+ })
22
+ .from(schema.documents)
23
+ .leftJoin(
24
+ schema.contentDatabases,
25
+ eq(schema.contentDatabases.documentId, schema.documents.id),
26
+ )
27
+ .where(
28
+ and(
29
+ isNotNull(schema.documents.trashedAt),
30
+ eq(schema.documents.trashRootId, schema.documents.id),
31
+ isNull(schema.contentDatabases.id),
32
+ accessFilter(
33
+ schema.documents,
34
+ schema.documentShares,
35
+ undefined,
36
+ "admin",
37
+ ),
38
+ ),
39
+ )
40
+ .orderBy(desc(schema.documents.trashedAt));
41
+
42
+ return {
43
+ documents: rows.map((row) => ({
44
+ documentId: row.documentId,
45
+ title: row.title.trim() || "Untitled",
46
+ trashedAt: row.trashedAt!,
47
+ })),
48
+ };
49
+ },
50
+ });
@@ -0,0 +1,28 @@
1
+ import { defineAction } from "@agent-native/core";
2
+ import { writeAppState } from "@agent-native/core/application-state";
3
+ import { assertAccess } from "@agent-native/core/sharing";
4
+ import { z } from "zod";
5
+
6
+ import { getDb } from "../server/db/index.js";
7
+ import { deleteTrashedDocumentSubtree } from "./delete-document.js";
8
+
9
+ export default defineAction({
10
+ description:
11
+ "Permanently delete a document subtree that is already in Trash. This cannot be undone.",
12
+ schema: z.object({
13
+ id: z.string().describe("Trashed root document ID"),
14
+ }),
15
+ run: async ({ id }) => {
16
+ const access = await assertAccess("document", id, "admin");
17
+ const db = getDb();
18
+ const deleted = await db.transaction((tx) =>
19
+ deleteTrashedDocumentSubtree(
20
+ tx as unknown as ReturnType<typeof getDb>,
21
+ id,
22
+ access.resource.ownerEmail as string,
23
+ ),
24
+ );
25
+ await writeAppState("refresh-signal", { ts: Date.now() });
26
+ return { success: true, deleted: deleted.length };
27
+ },
28
+ });