@hiai-gg/docsmint 0.6.7 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. package/README.md +27 -4
  2. package/backend/src/lib/api-keys.ts +14 -6
  3. package/backend/src/lib/lifecycle-service.ts +397 -73
  4. package/dist/backend/index.js +13066 -10878
  5. package/dist/backend-account-runtime-cleanup.js +152 -42
  6. package/dist/backend-api-key-facade.js +207 -61
  7. package/dist/backend-pipeline-cancellation.js +144 -42
  8. package/dist/client.d.ts +12 -2
  9. package/dist/client.js +83 -30
  10. package/dist/frontend/{CategoryDialog-CbXBMBZB.js → CategoryDialog-C67_TXf0.js} +4 -4
  11. package/dist/frontend/{ConfirmDialog-DgMTQBq4.js → ConfirmDialog-_LlrA--f.js} +3 -3
  12. package/dist/frontend/{CreateSnapshotDialog-FUq-ZfBH.js → CreateSnapshotDialog-BmCRpOMv.js} +3 -3
  13. package/dist/frontend/{DatePicker-D6E-EEtZ.js → DatePicker-DsoepNdb.js} +8 -8
  14. package/dist/frontend/{DeleteDialog-8k6JYWCx.js → DeleteDialog-L1A3FhMM.js} +1 -1
  15. package/dist/frontend/{FolderNode-DAAn_T8_.js → FolderNode-CNwi0ySr.js} +1 -1
  16. package/dist/frontend/{HiAiEditor-DSVG3GNd.js → HiAiEditor-B1EDGsnA.js} +6 -6
  17. package/dist/frontend/{SettingsDialog-BDNgf5rv.js → SettingsDialog-AP42xOGg.js} +12 -12
  18. package/dist/frontend/ShareDialog-wGTXuXFE.js +281 -0
  19. package/dist/frontend/{Sidebar-DX08utdV.js → Sidebar-lKQT3dEf.js} +17 -16
  20. package/dist/frontend/{TagCreateDialog-Bb0-Vtzh.js → TagCreateDialog-su8BhLzS.js} +3 -3
  21. package/dist/frontend/api/attachments.js +1 -1
  22. package/dist/frontend/api/settings.js +1 -1
  23. package/dist/frontend/app-shell.js +2 -2
  24. package/dist/frontend/{attachments-B8AuDjno.js → attachments-B2aH3hXF.js} +10 -9
  25. package/dist/frontend/{auth-client-Cygmp1I2.js → auth-client-CnDZwPfp.js} +11 -11
  26. package/dist/frontend/components/category-dialog.js +1 -1
  27. package/dist/frontend/components/create-snapshot-dialog.js +1 -1
  28. package/dist/frontend/components/delete-dialog.js +1 -1
  29. package/dist/frontend/components/editor/compact-editor.js +1 -1
  30. package/dist/frontend/components/editor/document-editor.js +1 -1
  31. package/dist/frontend/components/folder-node.js +1 -1
  32. package/dist/frontend/components/save-as-dialog.js +3 -3
  33. package/dist/frontend/components/settings.js +1 -1
  34. package/dist/frontend/components/share-dialog.d.ts +2 -1
  35. package/dist/frontend/components/share-dialog.js +1 -1
  36. package/dist/frontend/components/sidebar.js +1 -1
  37. package/dist/frontend/components/tag-create-dialog.js +1 -1
  38. package/dist/frontend/components/version-history.js +2 -2
  39. package/dist/frontend/{confirm-dialog-CzmoZw3a.js → confirm-dialog-BG8y2Pum.js} +4 -4
  40. package/dist/frontend/dashboard.js +1008 -998
  41. package/dist/frontend/{db-ByrlYfaI.js → db-Bo1t_76T.js} +1 -1
  42. package/dist/frontend/{dialog-Ce79C9fd.js → dialog-C2iKObEu.js} +7 -7
  43. package/dist/frontend/{dropdown-menu-CJ_7V3N0.js → dropdown-menu-BXIl2E26.js} +12 -12
  44. package/dist/frontend/{floating-layer-anchor-S5RSefGN.js → floating-layer-anchor-BGIndLY-.js} +63 -63
  45. package/dist/frontend/{identity-DyWeNzu_.js → identity-Ynrglr5i.js} +1 -1
  46. package/dist/frontend/{input-BWlOML_m.js → input-eHFb2qiL.js} +2 -2
  47. package/dist/frontend/{label-C2HfJNQ2.js → label-_I1iiaNU.js} +2 -2
  48. package/dist/frontend/offline/identity.js +1 -1
  49. package/dist/frontend/search.js +2 -2
  50. package/dist/frontend/{select-value-JCAp2PmU.js → select-value-CtMyrvDJ.js} +18 -18
  51. package/dist/frontend/{settings-DSrUl4hW.js → settings-BR48ihB4.js} +6 -23
  52. package/dist/frontend/{textarea-DOAE-h42.js → textarea-2eslfANY.js} +2 -2
  53. package/dist/frontend/{utils-BPDRytUU.js → utils-CDBqTgoV.js} +1 -1
  54. package/dist/frontend-ssr/api/attachments.js +1 -1
  55. package/dist/frontend-ssr/api/settings.js +1 -1
  56. package/dist/frontend-ssr/app-shell.js +2 -2
  57. package/dist/frontend-ssr/assets/{CategoryDialog-QHEv_9N4.js → CategoryDialog-BGuHTb3K.js} +4 -4
  58. package/dist/frontend-ssr/assets/{ConfirmDialog-BhAelejz.js → ConfirmDialog-D1BDXXll.js} +3 -3
  59. package/dist/frontend-ssr/assets/{CreateSnapshotDialog-CI8vXXcN.js → CreateSnapshotDialog-BZ45odvJ.js} +3 -3
  60. package/dist/frontend-ssr/assets/{DatePicker-D84cJ-Eq.js → DatePicker-CPtl6W5E.js} +3 -3
  61. package/dist/frontend-ssr/assets/{DeleteDialog-DG9Lm38J.js → DeleteDialog-oHT_p_vG.js} +1 -1
  62. package/dist/frontend-ssr/assets/{FolderNode-kmilhDjO.js → FolderNode-D9kgJG9d.js} +1 -1
  63. package/dist/frontend-ssr/assets/{HiAiEditor-CUOJmKzT.js → HiAiEditor-DOd2MXjD.js} +5 -5
  64. package/dist/frontend-ssr/assets/{SettingsDialog-BjJIiVLc.js → SettingsDialog-CIVbMKmf.js} +12 -12
  65. package/dist/frontend-ssr/assets/{ShareDialog-C9C-XTm4.js → ShareDialog-D2UNUbF7.js} +19 -6
  66. package/dist/frontend-ssr/assets/{Sidebar-C2sUfGNk.js → Sidebar-Bb-k2R-Z.js} +13 -12
  67. package/dist/frontend-ssr/assets/{TagCreateDialog-DUPMitlL.js → TagCreateDialog-6Gk7L3D-.js} +3 -3
  68. package/dist/frontend-ssr/assets/{attachments-Cj3fQyUp.js → attachments-BdkOp8ha.js} +5 -4
  69. package/dist/frontend-ssr/assets/{auth-client-x26kqY8Z.js → auth-client-CojKIzqy.js} +13 -13
  70. package/dist/frontend-ssr/assets/{confirm-dialog-XuyaL8sr.js → confirm-dialog-B9MVS_bF.js} +4 -4
  71. package/dist/frontend-ssr/assets/{db-CnxeO66n.js → db-BhwJPEkn.js} +1 -1
  72. package/dist/frontend-ssr/assets/{dialog-BOV1u6zP.js → dialog-1SB5neow.js} +8 -8
  73. package/dist/frontend-ssr/assets/{dropdown-menu-Dn_vQq9b.js → dropdown-menu-DqoOkdqB.js} +12 -12
  74. package/dist/frontend-ssr/assets/{floating-layer-anchor-BIAu-252.js → floating-layer-anchor-BoiiIEiK.js} +71 -71
  75. package/dist/frontend-ssr/assets/{identity-B3uHoDtf.js → identity-DcMMBKWg.js} +1 -1
  76. package/dist/frontend-ssr/assets/{input-DezGsU3y.js → input-Df-QP3Km.js} +2 -2
  77. package/dist/frontend-ssr/assets/{label-Bj8-TfFN.js → label-CbUOUz6L.js} +2 -2
  78. package/dist/frontend-ssr/assets/{select-value-DBhbkNcc.js → select-value-CXEDWrZg.js} +18 -18
  79. package/dist/frontend-ssr/assets/{settings-Bk5Hk9Vq.js → settings-Mp8EuFSn.js} +3 -21
  80. package/dist/frontend-ssr/assets/{textarea-Dev-qSEc.js → textarea-CHh8NuSS.js} +2 -2
  81. package/dist/frontend-ssr/assets/{utils-DtlPPXpJ.js → utils-DtX0ta7y.js} +1 -1
  82. package/dist/frontend-ssr/components/category-dialog.js +1 -1
  83. package/dist/frontend-ssr/components/create-snapshot-dialog.js +1 -1
  84. package/dist/frontend-ssr/components/delete-dialog.js +1 -1
  85. package/dist/frontend-ssr/components/editor/compact-editor.js +1 -1
  86. package/dist/frontend-ssr/components/editor/document-editor.js +1 -1
  87. package/dist/frontend-ssr/components/folder-node.js +1 -1
  88. package/dist/frontend-ssr/components/save-as-dialog.js +3 -3
  89. package/dist/frontend-ssr/components/settings.js +1 -1
  90. package/dist/frontend-ssr/components/share-dialog.js +1 -1
  91. package/dist/frontend-ssr/components/sidebar.js +1 -1
  92. package/dist/frontend-ssr/components/tag-create-dialog.js +1 -1
  93. package/dist/frontend-ssr/components/version-history.js +2 -2
  94. package/dist/frontend-ssr/dashboard.js +31 -29
  95. package/dist/frontend-ssr/offline/identity.js +1 -1
  96. package/dist/frontend-ssr/search.js +2 -2
  97. package/dist/index.d.ts +5 -5
  98. package/dist/index.js +2 -2
  99. package/dist/lifecycle-persistent.d.ts +1 -1
  100. package/dist/lifecycle-runtime.js +52659 -338
  101. package/dist/mcp-cli.js +1056 -0
  102. package/dist/mcp-server.d.ts +39 -0
  103. package/dist/mcp-server.js +1053 -0
  104. package/dist/storage-quota.d.ts +5 -1
  105. package/dist/types.d.ts +7 -0
  106. package/dist/workspace.d.ts +11 -2
  107. package/dist/workspace.js +82 -10
  108. package/package.json +7 -13
  109. package/packages/cli/src/index.ts +1 -1
  110. package/packages/db/src/client.ts +56 -5
  111. package/packages/db/src/schema.ts +192 -1
  112. package/packages/db/src/with-tenant.ts +20 -2
  113. package/server.json +2 -2
  114. package/dist/frontend/ShareDialog-2Mty7hm4.js +0 -258
  115. package/packages/mcp-server/src/capabilities.ts +0 -189
  116. package/packages/mcp-server/src/client.ts +0 -225
  117. package/packages/mcp-server/src/index.ts +0 -8
  118. package/packages/mcp-server/src/server.ts +0 -87
  119. package/packages/mcp-server/src/tools/create-document.ts +0 -34
  120. package/packages/mcp-server/src/tools/create-folder.ts +0 -31
  121. package/packages/mcp-server/src/tools/create-snapshot.ts +0 -27
  122. package/packages/mcp-server/src/tools/export-document.ts +0 -22
  123. package/packages/mcp-server/src/tools/get-document.ts +0 -22
  124. package/packages/mcp-server/src/tools/list-documents.ts +0 -34
  125. package/packages/mcp-server/src/tools/list-folders.ts +0 -26
  126. package/packages/mcp-server/src/tools/search.ts +0 -39
  127. package/packages/mcp-server/src/tools/update-document.ts +0 -38
  128. package/packages/mcp-server/src/tools/version-history.ts +0 -27
  129. package/packages/mcp-server/src/types.ts +0 -126
package/README.md CHANGED
@@ -40,7 +40,27 @@ server.
40
40
  - **Own the full stack**: application data, vectors, graph, queue, and files run
41
41
  on infrastructure you control.
42
42
 
43
- ## What's new in DocsMint 0.6.7?
43
+ ## What's new in DocsMint 0.7.0?
44
+
45
+ - **Signed workspace assertions.** Trusted server-to-server hosts can restrict
46
+ an assertion to one category with independent `read`, `edit`, and `write`
47
+ permissions; an omitted scope remains workspace-wide and role-derived.
48
+ - **Consistent effective-category authorization.** Direct and folder-inherited
49
+ categories are enforced before counts, pagination, indexing, search, and
50
+ GraphRAG retrieval. Index status is read-only; refresh requires write.
51
+ - **Release-safe indexing.** Context-aware embedding generations and recovery
52
+ prevent stale metadata work from becoming active.
53
+
54
+ This OSS release adds no host RBAC, billing, invitations, or product UI.
55
+
56
+ DocsMint 0.7.0 includes the 0.6.8 reliability fixes:
57
+
58
+ - **Workspace-safe recovery.** Explicit operator reindexing carries the
59
+ document's workspace into every durable pipeline stage, including RLS reads.
60
+ - **Consistent operator authentication.** Both documented admin key headers
61
+ bypass interactive rate limits during maintenance operations.
62
+
63
+ DocsMint 0.6.8 includes the self-host reliability fixes introduced in 0.6.7:
44
64
 
45
65
  - **Reliable HTTPS sessions.** Every protected SvelteKit route accepts Better
46
66
  Auth's secure production cookie as well as the local-development cookie.
@@ -111,9 +131,9 @@ DocsMint 0.6.1 includes the mobile and PWA foundation introduced in 0.6.0:
111
131
  - **Stable document discovery.** The public API and TypeScript SDK provide
112
132
  deterministic cursor pagination and global sorting by title, category,
113
133
  folder, or update time, with canonical category and folder metadata.
114
- - **Clear public and restricted sharing.** Share creation exposes the resulting
115
- URL for both public links and restricted invitation workflows, with an
116
- explicit Copy action that works on narrow screens.
134
+ - **Safe standalone sharing.** Standalone DocsMint exposes public links with
135
+ expiry, optional passwords, Copy, and revoke controls. Host-managed consumers
136
+ retain their existing restricted invitation workflow.
117
137
  - **Knowledge built for agents.** REST, the typed SDK, CLI, and MCP server expose
118
138
  scoped document operations, while multilingual hybrid search and GraphRAG
119
139
  help people and AI agents retrieve connected knowledge instead of isolated
@@ -216,6 +236,9 @@ The canonical local ports are:
216
236
  See [Deployment](docs/DEPLOYMENT.md) for domains, TLS, provider tuning,
217
237
  backups, and production operation.
218
238
 
239
+ Embedding provider URLs, models, and credentials are deployment configuration.
240
+ They are never stored in browser settings or local storage.
241
+
219
242
  ## Use DocsMint from the terminal
220
243
 
221
244
  The published package includes the CLI. It connects to an already running
@@ -5,6 +5,7 @@ import {
5
5
  ZERO_UUID,
6
6
  } from "@hiai-docs/db/with-tenant";
7
7
  import { and, desc, eq } from "drizzle-orm";
8
+ import { isAccountPurgeFencedError } from "./account-purge-fence";
8
9
  import { decryptApiKey, encryptApiKey } from "./api-key-encryption";
9
10
 
10
11
  const API_KEY_ADMIN_TENANT = adminTenantContext(ZERO_UUID);
@@ -228,12 +229,19 @@ export async function validateApiKey(key: string): Promise<{
228
229
  if (!scopes) return null;
229
230
 
230
231
  // Update last_used_at
231
- await withTenant(API_KEY_ADMIN_TENANT, (tx) =>
232
- tx
233
- .update(apiKeys)
234
- .set({ lastUsedAt: new Date() })
235
- .where(eq(apiKeys.id, row.id)),
236
- );
232
+ try {
233
+ await withTenant(API_KEY_ADMIN_TENANT, (tx) =>
234
+ tx
235
+ .update(apiKeys)
236
+ .set({ lastUsedAt: new Date() })
237
+ .where(eq(apiKeys.id, row.id)),
238
+ );
239
+ } catch (error) {
240
+ // Preserve the authenticated principal so guarded routes return the
241
+ // stable public fence error. The key row itself remains frozen until
242
+ // lifecycle cleanup deletes it.
243
+ if (!isAccountPurgeFencedError(error)) throw error;
244
+ }
237
245
 
238
246
  return {
239
247
  id: row.id,
@@ -1,5 +1,7 @@
1
1
  import {
2
2
  accounts,
3
+ apiKeys,
4
+ attachmentStorageCleanupOutbox,
3
5
  attachments,
4
6
  auditLog,
5
7
  categories,
@@ -7,6 +9,7 @@ import {
7
9
  documents,
8
10
  folders,
9
11
  lifecycleOperations,
12
+ pendingAttachmentUploads,
10
13
  sessions,
11
14
  shareLinks,
12
15
  tags,
@@ -23,9 +26,21 @@ import type {
23
26
  UserDataExportRecord,
24
27
  UserDataLifecycle,
25
28
  } from "@hiai-docs/sdk";
26
- import { and, eq, inArray, lt, or, sql } from "drizzle-orm";
29
+ import { and, asc, eq, inArray, isNull, lt, or, sql } from "drizzle-orm";
30
+ import {
31
+ drainAttachmentStorageCleanupOutbox,
32
+ stageAttachmentStorageCleanup,
33
+ } from "./attachment-storage-cleanup";
34
+ import {
35
+ type PendingAttachmentUploadRow,
36
+ stagePendingAttachmentCleanup,
37
+ } from "./attachment-upload-cleanup";
38
+ import { acquireDocumentPipelineLocks } from "./document-pipeline-serialization";
39
+ import type { RuntimeAttachmentQuotaAdmission } from "./runtime-options";
40
+ import { getDocsMintRuntimeOptions } from "./runtime-options";
27
41
 
28
42
  const LEASE_MS = 60_000;
43
+ export const LIFECYCLE_CLEANUP_PAGE_SIZE = 100;
29
44
 
30
45
  type PersistentOperation = typeof lifecycleOperations.$inferSelect;
31
46
 
@@ -49,6 +64,16 @@ export class LifecycleLeaseLostError extends Error {
49
64
  }
50
65
  }
51
66
 
67
+ /** An issued direct-upload URL remains usable until its fixed expiry. */
68
+ export class LifecyclePendingAttachmentUploadsError extends Error {
69
+ readonly code = "pending_attachment_uploads";
70
+
71
+ constructor() {
72
+ super("Pending attachment upload URLs have not expired");
73
+ this.name = "LifecyclePendingAttachmentUploadsError";
74
+ }
75
+ }
76
+
52
77
  export type LifecycleRuntimeAdapters = Readonly<{
53
78
  /** Checks the host-owned fence immediately before the first mutation. */
54
79
  verifyPurgeFence: (
@@ -75,6 +100,8 @@ export type LifecycleRuntimeAdapters = Readonly<{
75
100
  documentIds: readonly string[],
76
101
  signal?: AbortSignal,
77
102
  ) => Promise<number>;
103
+ /** Generic OSS quota saga used while settling workspace attachment cleanup. */
104
+ attachmentStorageQuotaAdmission?: RuntimeAttachmentQuotaAdmission;
78
105
  }>;
79
106
 
80
107
  /**
@@ -116,6 +143,8 @@ function safeErrorCode(error: unknown): string {
116
143
  return "aborted";
117
144
  if (error instanceof LifecycleFenceRejectedError) return error.code;
118
145
  if (error instanceof LifecycleLeaseLostError) return error.code;
146
+ if (error instanceof LifecyclePendingAttachmentUploadsError)
147
+ return error.code;
119
148
  return "persistence_failed";
120
149
  }
121
150
 
@@ -509,7 +538,17 @@ export function createPersistentLifecycleService(
509
538
  const deletedByDomain = operationCounts(operation);
510
539
  try {
511
540
  throwIfAborted(ctx.signal);
541
+ await runtime.verifyPurgeFence(ctx, gate.fenceToken);
512
542
  await withActor(ctx.actorUserId, async (tx) => {
543
+ await tx.execute(
544
+ sql`SELECT public.acquire_account_purge_fence_lock(${ctx.actorUserId}::uuid)`,
545
+ );
546
+ const [actor] = await tx
547
+ .select({ id: users.id })
548
+ .from(users)
549
+ .where(eq(users.id, ctx.actorUserId))
550
+ .limit(1);
551
+ if (!actor) throw new LifecycleLeaseLostError();
513
552
  const updated = await tx
514
553
  .update(lifecycleOperations)
515
554
  .set({
@@ -525,10 +564,21 @@ export function createPersistentLifecycleService(
525
564
  .returning({ id: lifecycleOperations.id });
526
565
  requireLeaseWrite(updated);
527
566
  });
528
- await runtime.verifyPurgeFence(ctx, gate.fenceToken);
567
+ const withCleanupActor = <T>(
568
+ operationAction: (tx: TenantTransaction) => Promise<T>,
569
+ ) =>
570
+ withActor(ctx.actorUserId, async (tx) => {
571
+ await tx.execute(
572
+ sql`SELECT set_config('app.lifecycle_operation_id', ${operation.id}, true)`,
573
+ );
574
+ await tx.execute(
575
+ sql`SELECT set_config('app.lifecycle_lease_owner', ${leaseOwner}, true)`,
576
+ );
577
+ return operationAction(tx);
578
+ });
529
579
  const dbDelete = <T extends { id: string }>(
530
580
  action: (tx: TenantTransaction) => Promise<T[]>,
531
- ) => withActor(ctx.actorUserId, action).then((rows) => rows.length);
581
+ ) => withCleanupActor(action).then((rows) => rows.length);
532
582
  await runStep(
533
583
  operation,
534
584
  ctx.actorUserId,
@@ -537,6 +587,126 @@ export function createPersistentLifecycleService(
537
587
  deletedByDomain,
538
588
  () => runtime.cancelAccountJobs(ctx.actorUserId, ctx.signal),
539
589
  );
590
+ await runStep(
591
+ operation,
592
+ ctx.actorUserId,
593
+ leaseOwner,
594
+ "settle_pending_attachment_uploads",
595
+ deletedByDomain,
596
+ async () => {
597
+ let staged = 0;
598
+ for (;;) {
599
+ const page = await withCleanupActor(async (tx) => {
600
+ const pending = await tx
601
+ .select({
602
+ id: pendingAttachmentUploads.id,
603
+ documentId: pendingAttachmentUploads.documentId,
604
+ ownerUserId: sql<string>`public.lifecycle_child_document_owner(
605
+ ${pendingAttachmentUploads.documentId},
606
+ ${ctx.actorUserId}::uuid
607
+ )`,
608
+ actorUserId: pendingAttachmentUploads.actorUserId,
609
+ workspaceId: pendingAttachmentUploads.workspaceId,
610
+ storageKey: pendingAttachmentUploads.storageKey,
611
+ tokenHash: pendingAttachmentUploads.tokenHash,
612
+ filename: pendingAttachmentUploads.filename,
613
+ mimeType: pendingAttachmentUploads.mimeType,
614
+ declaredSize: pendingAttachmentUploads.declaredSize,
615
+ quotaReservationId:
616
+ pendingAttachmentUploads.quotaReservationId,
617
+ quotaOperationKey:
618
+ pendingAttachmentUploads.quotaOperationKey,
619
+ quotaState: pendingAttachmentUploads.quotaState,
620
+ actualSize: pendingAttachmentUploads.actualSize,
621
+ urlIssuedAt: pendingAttachmentUploads.urlIssuedAt,
622
+ expiresAt: pendingAttachmentUploads.expiresAt,
623
+ leaseOwner: pendingAttachmentUploads.leaseOwner,
624
+ })
625
+ .from(pendingAttachmentUploads)
626
+ .where(
627
+ eq(pendingAttachmentUploads.actorUserId, ctx.actorUserId),
628
+ )
629
+ .orderBy(asc(pendingAttachmentUploads.id))
630
+ .limit(LIFECYCLE_CLEANUP_PAGE_SIZE);
631
+ for (const row of pending) {
632
+ await stagePendingAttachmentCleanup(
633
+ tx,
634
+ row as PendingAttachmentUploadRow,
635
+ ctx.actorUserId,
636
+ );
637
+ }
638
+ return pending.length;
639
+ });
640
+ staged += page;
641
+ if (page === 0) break;
642
+ }
643
+ const leftover = await withCleanupActor((tx) =>
644
+ tx
645
+ .select({ id: attachmentStorageCleanupOutbox.id })
646
+ .from(attachmentStorageCleanupOutbox)
647
+ .where(
648
+ or(
649
+ eq(
650
+ attachmentStorageCleanupOutbox.actorUserId,
651
+ ctx.actorUserId,
652
+ ),
653
+ eq(
654
+ attachmentStorageCleanupOutbox.ownerUserId,
655
+ ctx.actorUserId,
656
+ ),
657
+ eq(
658
+ attachmentStorageCleanupOutbox.requestedByUserId,
659
+ ctx.actorUserId,
660
+ ),
661
+ ),
662
+ )
663
+ .limit(1),
664
+ );
665
+ if (staged > 0 || leftover.length > 0) {
666
+ for (let page = 0; page < 10_000; page += 1) {
667
+ const result = await drainAttachmentStorageCleanupOutbox({
668
+ deleteObjects: runtime.deleteObjects,
669
+ actorUserId: ctx.actorUserId,
670
+ quotaAdmission:
671
+ runtime.attachmentStorageQuotaAdmission ??
672
+ getDocsMintRuntimeOptions()
673
+ ?.attachmentStorageQuotaAdmission,
674
+ signal: ctx.signal,
675
+ pageSize: LIFECYCLE_CLEANUP_PAGE_SIZE,
676
+ maxPages: 1,
677
+ });
678
+ if (result.claimed === 0) break;
679
+ if (result.failed > 0 || result.deferred > 0)
680
+ throw new LifecyclePendingAttachmentUploadsError();
681
+ }
682
+ }
683
+ const retained = await withCleanupActor((tx) =>
684
+ tx
685
+ .select({ id: attachmentStorageCleanupOutbox.id })
686
+ .from(attachmentStorageCleanupOutbox)
687
+ .where(
688
+ or(
689
+ eq(
690
+ attachmentStorageCleanupOutbox.actorUserId,
691
+ ctx.actorUserId,
692
+ ),
693
+ eq(
694
+ attachmentStorageCleanupOutbox.ownerUserId,
695
+ ctx.actorUserId,
696
+ ),
697
+ eq(
698
+ attachmentStorageCleanupOutbox.requestedByUserId,
699
+ ctx.actorUserId,
700
+ ),
701
+ ),
702
+ )
703
+ .limit(1),
704
+ );
705
+ if (retained.length > 0)
706
+ throw new LifecyclePendingAttachmentUploadsError();
707
+ return staged;
708
+ },
709
+ );
540
710
  await runStep(
541
711
  operation,
542
712
  ctx.actorUserId,
@@ -563,7 +733,12 @@ export function createPersistentLifecycleService(
563
733
  tx
564
734
  .select({ id: documents.id })
565
735
  .from(documents)
566
- .where(eq(documents.ownerId, ctx.actorUserId)),
736
+ .where(
737
+ and(
738
+ eq(documents.ownerId, ctx.actorUserId),
739
+ isNull(documents.workspaceId),
740
+ ),
741
+ ),
567
742
  );
568
743
  const documentIds = owned.map((row) => row.id);
569
744
  await runStep(
@@ -573,14 +748,19 @@ export function createPersistentLifecycleService(
573
748
  "remove_document_versions",
574
749
  deletedByDomain,
575
750
  () =>
576
- documentIds.length
577
- ? dbDelete((tx) =>
578
- tx
579
- .delete(versions)
580
- .where(inArray(versions.documentId, documentIds))
581
- .returning({ id: versions.id }),
751
+ dbDelete((tx) =>
752
+ tx
753
+ .delete(versions)
754
+ .where(
755
+ documentIds.length
756
+ ? or(
757
+ eq(versions.createdBy, ctx.actorUserId),
758
+ inArray(versions.documentId, documentIds),
759
+ )
760
+ : eq(versions.createdBy, ctx.actorUserId),
582
761
  )
583
- : Promise.resolve(0),
762
+ .returning({ id: versions.id }),
763
+ ),
584
764
  );
585
765
  await runStep(
586
766
  operation,
@@ -606,29 +786,183 @@ export function createPersistentLifecycleService(
606
786
  deletedByDomain,
607
787
  () => runtime.removeGraphState(documentIds, ctx.signal),
608
788
  );
609
- const objectRows = documentIds.length
610
- ? await withActor(ctx.actorUserId, (tx) =>
611
- tx
612
- .select({
613
- id: attachments.id,
614
- storageKey: attachments.storageKey,
615
- })
616
- .from(attachments)
617
- .where(inArray(attachments.documentId, documentIds)),
618
- )
619
- : [];
620
789
  await runStep(
621
790
  operation,
622
791
  ctx.actorUserId,
623
792
  leaseOwner,
624
793
  "delete_attachment_objects",
625
794
  deletedByDomain,
795
+ async () => {
796
+ let removed = 0;
797
+ for (;;) {
798
+ const page = await withCleanupActor(async (tx) => {
799
+ const objectRows = await tx
800
+ .select({
801
+ id: attachments.id,
802
+ documentId: attachments.documentId,
803
+ storageKey: attachments.storageKey,
804
+ size: attachments.size,
805
+ uploadedBy: attachments.uploadedBy,
806
+ workspaceId: attachments.workspaceId,
807
+ ownerUserId: sql<string>`public.lifecycle_child_document_owner(
808
+ ${attachments.documentId},
809
+ ${ctx.actorUserId}::uuid
810
+ )`,
811
+ })
812
+ .from(attachments)
813
+ .where(
814
+ documentIds.length
815
+ ? or(
816
+ eq(attachments.uploadedBy, ctx.actorUserId),
817
+ inArray(attachments.documentId, documentIds),
818
+ )
819
+ : eq(attachments.uploadedBy, ctx.actorUserId),
820
+ )
821
+ .orderBy(asc(attachments.id))
822
+ .limit(LIFECYCLE_CLEANUP_PAGE_SIZE);
823
+ if (objectRows.length === 0) return 0;
824
+ for (const row of objectRows) {
825
+ await stageAttachmentStorageCleanup(tx, {
826
+ sourceKind: "attachment",
827
+ sourceId: row.id,
828
+ storageKey: row.storageKey,
829
+ documentId: row.documentId,
830
+ actorUserId: row.uploadedBy,
831
+ ownerUserId: row.ownerUserId,
832
+ requestedByUserId: ctx.actorUserId,
833
+ workspaceId: row.workspaceId,
834
+ size: row.size,
835
+ quotaOperationKey: `attachment:${row.documentId}:${row.storageKey}`,
836
+ quotaReleaseKind: row.workspaceId ? "committed" : "none",
837
+ });
838
+ }
839
+ const deleted = await tx
840
+ .delete(attachments)
841
+ .where(
842
+ inArray(
843
+ attachments.id,
844
+ objectRows.map((row) => row.id),
845
+ ),
846
+ )
847
+ .returning({ id: attachments.id });
848
+ return deleted.length;
849
+ });
850
+ removed += page;
851
+ if (page === 0) break;
852
+ }
853
+ return removed;
854
+ },
855
+ );
856
+ await runStep(
857
+ operation,
858
+ ctx.actorUserId,
859
+ leaseOwner,
860
+ "release_attachment_quota",
861
+ deletedByDomain,
862
+ async () => {
863
+ let deleted = 0;
864
+ for (let page = 0; page < 10_000; page += 1) {
865
+ const result = await drainAttachmentStorageCleanupOutbox({
866
+ deleteObjects: runtime.deleteObjects,
867
+ actorUserId: ctx.actorUserId,
868
+ quotaAdmission:
869
+ runtime.attachmentStorageQuotaAdmission ??
870
+ getDocsMintRuntimeOptions()?.attachmentStorageQuotaAdmission,
871
+ signal: ctx.signal,
872
+ pageSize: LIFECYCLE_CLEANUP_PAGE_SIZE,
873
+ maxPages: 1,
874
+ });
875
+ deleted += result.deleted;
876
+ if (result.claimed === 0) break;
877
+ if (result.failed > 0 || result.deferred > 0)
878
+ throw new LifecyclePendingAttachmentUploadsError();
879
+ }
880
+ const retained = await withCleanupActor((tx) =>
881
+ tx
882
+ .select({ id: attachmentStorageCleanupOutbox.id })
883
+ .from(attachmentStorageCleanupOutbox)
884
+ .where(
885
+ or(
886
+ eq(
887
+ attachmentStorageCleanupOutbox.actorUserId,
888
+ ctx.actorUserId,
889
+ ),
890
+ eq(
891
+ attachmentStorageCleanupOutbox.ownerUserId,
892
+ ctx.actorUserId,
893
+ ),
894
+ eq(
895
+ attachmentStorageCleanupOutbox.requestedByUserId,
896
+ ctx.actorUserId,
897
+ ),
898
+ ),
899
+ )
900
+ .limit(1),
901
+ );
902
+ if (retained.length > 0)
903
+ throw new LifecyclePendingAttachmentUploadsError();
904
+ return deleted;
905
+ },
906
+ );
907
+ await runStep(
908
+ operation,
909
+ ctx.actorUserId,
910
+ leaseOwner,
911
+ "remove_attachment_rows",
912
+ deletedByDomain,
913
+ () => Promise.resolve(0),
914
+ );
915
+ await runStep(
916
+ operation,
917
+ ctx.actorUserId,
918
+ leaseOwner,
919
+ "clear_redis_state",
920
+ deletedByDomain,
921
+ () => runtime.clearAccountRedisState(ctx.actorUserId, ctx.signal),
922
+ );
923
+ for (const step of orderedSteps)
924
+ if (step.purge)
925
+ await runStep(
926
+ operation,
927
+ ctx.actorUserId,
928
+ leaseOwner,
929
+ `host:${step.id}`,
930
+ deletedByDomain,
931
+ async () => (await step.purge?.(ctx))?.deletedCount ?? 0,
932
+ );
933
+ await runStep(
934
+ operation,
935
+ ctx.actorUserId,
936
+ leaseOwner,
937
+ "remove_subject_documents",
938
+ deletedByDomain,
626
939
  () =>
627
- runtime.deleteObjects(
628
- objectRows.map((row) => row.storageKey),
629
- ctx.signal,
630
- ),
940
+ dbDelete(async (tx) => {
941
+ const currentDocuments = await tx
942
+ .select({ id: documents.id })
943
+ .from(documents)
944
+ .where(
945
+ and(
946
+ eq(documents.ownerId, ctx.actorUserId),
947
+ isNull(documents.workspaceId),
948
+ ),
949
+ );
950
+ const currentDocumentIds = currentDocuments.map(({ id }) => id);
951
+ await acquireDocumentPipelineLocks(tx, currentDocumentIds);
952
+ if (currentDocumentIds.length === 0) return [];
953
+ return tx
954
+ .delete(documents)
955
+ .where(
956
+ and(
957
+ eq(documents.ownerId, ctx.actorUserId),
958
+ isNull(documents.workspaceId),
959
+ ),
960
+ )
961
+ .returning({ id: documents.id });
962
+ }),
631
963
  );
964
+ // Parent metadata uses ON DELETE SET NULL. Delete documents first so
965
+ // those referential actions do not become fenced document UPDATEs.
632
966
  await runStep(
633
967
  operation,
634
968
  ctx.actorUserId,
@@ -639,7 +973,12 @@ export function createPersistentLifecycleService(
639
973
  dbDelete((tx) =>
640
974
  tx
641
975
  .delete(folders)
642
- .where(eq(folders.ownerId, ctx.actorUserId))
976
+ .where(
977
+ and(
978
+ eq(folders.ownerId, ctx.actorUserId),
979
+ isNull(folders.workspaceId),
980
+ ),
981
+ )
643
982
  .returning({ id: folders.id }),
644
983
  ),
645
984
  );
@@ -653,7 +992,12 @@ export function createPersistentLifecycleService(
653
992
  dbDelete((tx) =>
654
993
  tx
655
994
  .delete(tags)
656
- .where(eq(tags.ownerId, ctx.actorUserId))
995
+ .where(
996
+ and(
997
+ eq(tags.ownerId, ctx.actorUserId),
998
+ isNull(tags.workspaceId),
999
+ ),
1000
+ )
657
1001
  .returning({ id: tags.id }),
658
1002
  ),
659
1003
  );
@@ -667,7 +1011,12 @@ export function createPersistentLifecycleService(
667
1011
  dbDelete((tx) =>
668
1012
  tx
669
1013
  .delete(categories)
670
- .where(eq(categories.ownerId, ctx.actorUserId))
1014
+ .where(
1015
+ and(
1016
+ eq(categories.ownerId, ctx.actorUserId),
1017
+ isNull(categories.workspaceId),
1018
+ ),
1019
+ )
671
1020
  .returning({ id: categories.id }),
672
1021
  ),
673
1022
  );
@@ -675,55 +1024,16 @@ export function createPersistentLifecycleService(
675
1024
  operation,
676
1025
  ctx.actorUserId,
677
1026
  leaseOwner,
678
- "remove_attachment_rows",
679
- deletedByDomain,
680
- () =>
681
- objectRows.length
682
- ? dbDelete((tx) =>
683
- tx
684
- .delete(attachments)
685
- .where(
686
- inArray(
687
- attachments.id,
688
- objectRows.map((row) => row.id),
689
- ),
690
- )
691
- .returning({ id: attachments.id }),
692
- )
693
- : Promise.resolve(0),
694
- );
695
- await runStep(
696
- operation,
697
- ctx.actorUserId,
698
- leaseOwner,
699
- "remove_subject_documents",
1027
+ "remove_api_keys",
700
1028
  deletedByDomain,
701
1029
  () =>
702
1030
  dbDelete((tx) =>
703
1031
  tx
704
- .delete(documents)
705
- .where(eq(documents.ownerId, ctx.actorUserId))
706
- .returning({ id: documents.id }),
1032
+ .delete(apiKeys)
1033
+ .where(eq(apiKeys.ownerId, ctx.actorUserId))
1034
+ .returning({ id: apiKeys.id }),
707
1035
  ),
708
1036
  );
709
- await runStep(
710
- operation,
711
- ctx.actorUserId,
712
- leaseOwner,
713
- "clear_redis_state",
714
- deletedByDomain,
715
- () => runtime.clearAccountRedisState(ctx.actorUserId, ctx.signal),
716
- );
717
- for (const step of orderedSteps)
718
- if (step.purge)
719
- await runStep(
720
- operation,
721
- ctx.actorUserId,
722
- leaseOwner,
723
- `host:${step.id}`,
724
- deletedByDomain,
725
- async () => (await step.purge?.(ctx))?.deletedCount ?? 0,
726
- );
727
1037
  await runStep(
728
1038
  operation,
729
1039
  ctx.actorUserId,
@@ -752,6 +1062,20 @@ export function createPersistentLifecycleService(
752
1062
  .returning({ id: accounts.id }),
753
1063
  ),
754
1064
  );
1065
+ await runStep(
1066
+ operation,
1067
+ ctx.actorUserId,
1068
+ leaseOwner,
1069
+ "remove_subject_audit",
1070
+ deletedByDomain,
1071
+ () =>
1072
+ dbDelete((tx) =>
1073
+ tx
1074
+ .delete(auditLog)
1075
+ .where(eq(auditLog.actorId, ctx.actorUserId))
1076
+ .returning({ id: auditLog.id }),
1077
+ ),
1078
+ );
755
1079
  await runStep(
756
1080
  operation,
757
1081
  ctx.actorUserId,
@@ -780,7 +1104,7 @@ export function createPersistentLifecycleService(
780
1104
  "write_deletion_audit",
781
1105
  deletedByDomain,
782
1106
  async () => {
783
- await withActor(ctx.actorUserId, (tx) =>
1107
+ await withCleanupActor((tx) =>
784
1108
  tx.insert(auditLog).values({
785
1109
  actorId: ctx.actorUserId,
786
1110
  action: "account_data_purged",