@hiai-gg/docsmint 0.3.1 → 0.3.3

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 (75) hide show
  1. package/README.md +26 -23
  2. package/backend/src/lib/lifecycle-service.ts +574 -0
  3. package/{packages/sdk/dist → dist}/client.d.ts +16 -1
  4. package/{packages/sdk/dist → dist}/client.js +25 -1
  5. package/dist/frontend/DatePicker-BcMD49PV.js +275 -0
  6. package/dist/frontend/SettingsDialog-DiAyhzw0.js +4852 -0
  7. package/dist/frontend/ShareDialog-DaZf1raY.js +1317 -0
  8. package/dist/frontend/api/categories.d.ts +14 -0
  9. package/dist/frontend/api/categories.js +2 -0
  10. package/dist/frontend/api/documents.d.ts +16 -0
  11. package/dist/frontend/api/documents.js +2 -0
  12. package/dist/frontend/api/folders.d.ts +14 -0
  13. package/dist/frontend/api/folders.js +2 -0
  14. package/dist/frontend/api/settings.d.ts +10 -0
  15. package/dist/frontend/api/settings.js +2 -0
  16. package/dist/frontend/api/tags.d.ts +15 -0
  17. package/dist/frontend/api/tags.js +2 -0
  18. package/dist/frontend/categories-BE6ZpZl8.js +61 -0
  19. package/dist/frontend/client-B9hzI0W8.js +76 -0
  20. package/dist/frontend/client-C6QQGb0e.js +2766 -0
  21. package/dist/frontend/components/settings.d.ts +2 -0
  22. package/dist/frontend/components/settings.js +2 -0
  23. package/dist/frontend/components/sidebar.d.ts +2 -0
  24. package/dist/frontend/components/sidebar.js +4569 -0
  25. package/dist/frontend/context-DbnW5yib.js +31 -0
  26. package/dist/frontend/dashboard.d.ts +2 -0
  27. package/dist/frontend/dashboard.js +2152 -0
  28. package/dist/frontend/documents-DFVS9SsL.js +83 -0
  29. package/dist/frontend/extension.d.ts +35 -0
  30. package/dist/frontend/extension.js +10 -0
  31. package/dist/frontend/folders-CGe9diAG.js +91 -0
  32. package/dist/frontend/frontend.css +2 -0
  33. package/dist/frontend/i18n.d.ts +5 -0
  34. package/dist/frontend/i18n.js +8 -0
  35. package/dist/frontend/input-ieWIt8I7.js +257 -0
  36. package/dist/frontend/messages-CTGAsqFu.js +1106 -0
  37. package/dist/frontend/resolve-Bl6xmJl_.js +2068 -0
  38. package/dist/frontend/schemas-DwtKylCJ.js +3002 -0
  39. package/dist/frontend/search.d.ts +2 -0
  40. package/dist/frontend/search.js +1001 -0
  41. package/dist/frontend/settings-PCrOtied.js +1313 -0
  42. package/dist/frontend/shared-document.d.ts +16 -0
  43. package/dist/frontend/shared-document.js +193 -0
  44. package/dist/frontend/tag-C5xrg-bb.js +53 -0
  45. package/dist/frontend/tag-store.svelte-B3839FYq.js +6152 -0
  46. package/dist/frontend/tags-DVXTut0p.js +48 -0
  47. package/dist/frontend/theme.d.ts +4 -0
  48. package/dist/frontend/theme.js +8 -0
  49. package/dist/frontend/theme.svelte-ei0QxXa8.js +49 -0
  50. package/dist/index.d.ts +7 -0
  51. package/dist/index.js +3 -0
  52. package/dist/lifecycle.d.ts +90 -0
  53. package/dist/lifecycle.js +99 -0
  54. package/{packages/sdk/dist → dist}/types.d.ts +5 -0
  55. package/dist/workspace.d.ts +21 -0
  56. package/dist/workspace.js +66 -0
  57. package/package.json +75 -35
  58. package/packages/cli/src/index.ts +1 -1
  59. package/packages/db/src/schema.ts +41 -0
  60. package/packages/mcp-server/src/index.ts +1 -1
  61. package/frontend/src/lib/components/editor/shared-document.ts +0 -237
  62. package/frontend/src/lib/extensions/context.ts +0 -60
  63. package/frontend/src/lib/extensions/doc-tabs.ts +0 -18
  64. package/frontend/src/lib/extensions/resolve.ts +0 -48
  65. package/frontend/src/lib/extensions/types.ts +0 -202
  66. package/frontend/src/lib/hosts/DocsmintSharedDocumentHost.svelte +0 -65
  67. package/frontend/src/lib/hosts/HiaiDocsDashboardHost.svelte +0 -1007
  68. package/frontend/src/lib/hosts/HiaiDocsExtensionProvider.svelte +0 -20
  69. package/frontend/src/lib/hosts/HiaiDocsSearchHost.svelte +0 -996
  70. package/frontend/src/lib/hosts/index.ts +0 -25
  71. package/frontend/src/lib/index.ts +0 -65
  72. package/frontend/src/lib/stores/doc-tab-registry.svelte.ts +0 -68
  73. package/packages/sdk/dist/index.d.ts +0 -3
  74. package/packages/sdk/dist/index.js +0 -1
  75. /package/{packages/sdk/dist → dist}/types.js +0 -0
package/README.md CHANGED
@@ -1,12 +1,8 @@
1
- # HiAi-Docs / DocsMint
1
+ # DocsMint
2
2
 
3
3
  **A self-hosted, AI-native knowledge workspace for people, applications, and agents.**
4
4
 
5
- HiAi-Docs is the open-source module and repository. **DocsMint** is the current
6
- product branding used by the standalone installable web application. Typed
7
- frontend hosts and extension contracts support self-hosted customization.
8
-
9
- HiAi-Docs stores documents in a structured JSON editor model first. Markdown is
5
+ DocsMint stores documents in a structured JSON editor model first. Markdown is
10
6
  the convenient second format for editing, importing, and exporting content.
11
7
  Automatic chunking, 1024-dimensional embeddings, multilingual hybrid search,
12
8
  and GraphRAG make the same knowledge base useful to people, applications, and
@@ -15,6 +11,8 @@ server.
15
11
 
16
12
  [![Apache-2.0 License](https://img.shields.io/badge/License-Apache--2.0-green.svg)](LICENSE)
17
13
  [![Release](https://img.shields.io/github/v/release/hiai-gg/docsmint?sort=semver)](https://github.com/hiai-gg/docsmint/releases)
14
+ [![npm](https://img.shields.io/npm/v/@hiai-gg/docsmint?logo=npm)](https://www.npmjs.com/package/@hiai-gg/docsmint)
15
+ [![Docker Pulls](https://img.shields.io/docker/pulls/vgalibov/docsmint?logo=docker)](https://hub.docker.com/r/vgalibov/docsmint)
18
16
  [![Stars](https://img.shields.io/github/stars/hiai-gg/docsmint)](https://github.com/hiai-gg/docsmint/stargazers)
19
17
  [![CI](https://github.com/hiai-gg/docsmint/actions/workflows/ci.yml/badge.svg)](https://github.com/hiai-gg/docsmint/actions/workflows/ci.yml)
20
18
  [![Bun](https://img.shields.io/badge/Runtime-Bun_1.3-black?logo=bun&logoColor=white)](https://bun.sh)
@@ -27,7 +25,7 @@ server.
27
25
 
28
26
  <img width="1920" height="974" alt="DocsMint installable document workspace" src="https://github.com/user-attachments/assets/94701d01-a361-4ca1-b16d-de2a0c64d684" />
29
27
 
30
- ## Why HiAi-Docs?
28
+ ## Why DocsMint?
31
29
 
32
30
  - **Write naturally** in a rich visual editor or raw Markdown.
33
31
  - **Find meaning, not only keywords** with exact, lexical, fuzzy, vector,
@@ -50,8 +48,8 @@ server.
50
48
  never silently replays mutations.
51
49
  - **Explicit local drafts** with local autosave, review/apply, optimistic
52
50
  concurrency (`expectedUpdatedAt`), and actionable conflict handling.
53
- - **Composable host integration** with typed dashboard/search hosts and
54
- extension slots. Standalone HiAi-Docs remains owner-scoped.
51
+ - **Composable self-hosted UI** with typed dashboard/search hosts and extension
52
+ slots.
55
53
  - **Mobile-first editor polish** including a single responsive sidebar, safe
56
54
  PWA updates, raw Markdown auto-height, and accessible narrow-screen controls.
57
55
 
@@ -64,12 +62,12 @@ For self-hosted customization, see [PWA hosting](docs/PWA_HOSTING.md) and
64
62
 
65
63
  ## Fastest installation: give this prompt to your agent
66
64
 
67
- If you are installing HiAi-Docs through an AI coding agent, use this path first.
65
+ If you are installing DocsMint through an AI coding agent, use this path first.
68
66
  It keeps the setup to Docker plus one provider choice and avoids unnecessary
69
67
  source-code changes.
70
68
 
71
69
  ```text
72
- Install HiAi-Docs from https://github.com/HiAi-gg/docsmint.
70
+ Install DocsMint from https://github.com/HiAi-gg/docsmint.
73
71
  Verify Docker and Docker Compose v2, clone the repository, and run
74
72
  `bash scripts/quickstart.sh`. Do not print or commit .env. Ask me to enter only
75
73
  an OpenRouter key or select Ollama, then run quickstart again. Verify
@@ -147,10 +145,14 @@ The canonical local ports are:
147
145
  See [Deployment](docs/DEPLOYMENT.md) for domains, TLS, provider tuning,
148
146
  backups, and production operation.
149
147
 
150
- ## Use HiAi-Docs from the terminal
148
+ ## Use DocsMint from the terminal
151
149
 
152
150
  The published package includes the CLI. It connects to an already running
153
- HiAi-Docs server; installing it does not deploy the server.
151
+ DocsMint server; installing it does not deploy the server.
152
+
153
+ ```bash
154
+ npm install @hiai-gg/docsmint
155
+ ```
154
156
 
155
157
  ```bash
156
158
  bunx --package @hiai-gg/docsmint docsmint init \
@@ -169,7 +171,7 @@ command and configuration precedence.
169
171
 
170
172
  ## Connect an MCP client
171
173
 
172
- HiAi-Docs exposes document search, reading, creation, updates, folders,
174
+ DocsMint exposes document search, reading, creation, updates, folders,
173
175
  snapshots, history, and export as MCP tools.
174
176
 
175
177
  ```json
@@ -302,16 +304,16 @@ For pipeline internals and tuning, see [Architecture](docs/ARCHITECTURE.md) and
302
304
 
303
305
  ## Comparison
304
306
 
305
- HiAi-Docs overlaps with several excellent open-source knowledge tools, but its
307
+ DocsMint overlaps with several excellent open-source knowledge tools, but its
306
308
  focus is a compact knowledge runtime shared equally by humans and agents.
307
309
 
308
- | Project | Primary strength | Difference from HiAi-Docs |
310
+ | Project | Primary strength | Difference from DocsMint |
309
311
  |---|---|---|
310
- | [Outline](https://github.com/outline/outline) | Polished team wiki and collaboration | HiAi-Docs emphasizes built-in retrieval, GraphRAG, scoped agent access, CLI, and MCP |
311
- | [Docmost](https://github.com/docmost/docmost) | Collaborative wiki and real-time editing | HiAi-Docs centers automatic embeddings and agent-facing integration surfaces |
312
- | [AppFlowy](https://github.com/AppFlowy-IO/AppFlowy) | Broad local-first productivity workspace | HiAi-Docs is narrower: a self-hosted document and retrieval service |
313
- | [AnythingLLM](https://github.com/Mintplex-Labs/anything-llm) | Chat-oriented RAG over imported sources | HiAi-Docs starts with the editable knowledge base and exposes it to many clients |
314
- | [Danswer](https://github.com/danswer-ai/danswer) / Onyx | Enterprise search across external connectors | HiAi-Docs owns and edits its native corpus rather than primarily indexing other systems |
312
+ | [Outline](https://github.com/outline/outline) | Polished team wiki and collaboration | DocsMint emphasizes built-in retrieval, GraphRAG, scoped agent access, CLI, and MCP |
313
+ | [Docmost](https://github.com/docmost/docmost) | Collaborative wiki and real-time editing | DocsMint centers automatic embeddings and agent-facing integration surfaces |
314
+ | [AppFlowy](https://github.com/AppFlowy-IO/AppFlowy) | Broad local-first productivity workspace | DocsMint is narrower: a self-hosted document and retrieval service |
315
+ | [AnythingLLM](https://github.com/Mintplex-Labs/anything-llm) | Chat-oriented RAG over imported sources | DocsMint starts with the editable knowledge base and exposes it to many clients |
316
+ | [Danswer](https://github.com/danswer-ai/danswer) / Onyx | Enterprise search across external connectors | DocsMint owns and edits its native corpus rather than primarily indexing other systems |
315
317
 
316
318
  This is a product-positioning summary, not a claim that every listed project
317
319
  lacks a feature. Check each project's current documentation when choosing a
@@ -343,6 +345,7 @@ report vulnerabilities through [SECURITY.md](SECURITY.md), not a public issue.
343
345
 
344
346
  ## License
345
347
 
346
- HiAi-Docs / DocsMint is released under the [Apache License 2.0](LICENSE).
348
+ DocsMint is released under the [Apache License 2.0](LICENSE).
347
349
 
348
- Part of the [HiAi](https://hiai.gg) open-source ecosystem.
350
+ Built as an independent open-source project in the
351
+ [HiAi](https://github.com/HiAi-gg) ecosystem.
@@ -0,0 +1,574 @@
1
+ import {
2
+ attachments,
3
+ auditLog,
4
+ db,
5
+ documentEmbeddings,
6
+ documents,
7
+ lifecycleOperations,
8
+ shareLinks,
9
+ versions,
10
+ } from "@hiai-docs/db";
11
+ import type {
12
+ AssertPurgeAllowed,
13
+ ExportUserDataContext,
14
+ LifecycleHostStep,
15
+ PurgeUserDataContext,
16
+ PurgeUserDataResult,
17
+ UserDataExportRecord,
18
+ } from "@hiai-docs/sdk";
19
+ import { and, eq, inArray, lt, or, sql } from "drizzle-orm";
20
+
21
+ const LEASE_MS = 60_000;
22
+ const SAFE_ERROR_CODES = new Set([
23
+ "aborted",
24
+ "fence_rejected",
25
+ "lease_lost",
26
+ "object_storage_failed",
27
+ "queue_failed",
28
+ "redis_failed",
29
+ "graph_failed",
30
+ "host_step_failed",
31
+ "persistence_failed",
32
+ ]);
33
+
34
+ type PersistentOperation = typeof lifecycleOperations.$inferSelect;
35
+
36
+ export type LifecycleRuntimeAdapters = Readonly<{
37
+ /** Checks the host-owned fence immediately before the first mutation. */
38
+ verifyPurgeFence: (
39
+ ctx: PurgeUserDataContext,
40
+ fenceToken: string,
41
+ ) => Promise<void>;
42
+ deleteObjects: (
43
+ keys: readonly string[],
44
+ signal?: AbortSignal,
45
+ ) => Promise<number>;
46
+ cancelAccountJobs: (
47
+ actorUserId: string,
48
+ signal?: AbortSignal,
49
+ ) => Promise<number>;
50
+ clearAccountRedisState: (
51
+ actorUserId: string,
52
+ signal?: AbortSignal,
53
+ ) => Promise<number>;
54
+ removeCollaborationState: (
55
+ actorUserId: string,
56
+ signal?: AbortSignal,
57
+ ) => Promise<number>;
58
+ removeGraphState: (
59
+ documentIds: readonly string[],
60
+ signal?: AbortSignal,
61
+ ) => Promise<number>;
62
+ }>;
63
+
64
+ export type PersistentLifecycleService = Readonly<{
65
+ exportUserData(
66
+ ctx: ExportUserDataContext,
67
+ ): AsyncIterable<UserDataExportRecord>;
68
+ purgeUserData(
69
+ ctx: PurgeUserDataContext,
70
+ gate: Readonly<{ fenceToken: string }>,
71
+ ): Promise<PurgeUserDataResult>;
72
+ }>;
73
+
74
+ function throwIfAborted(signal: AbortSignal | undefined): void {
75
+ if (signal?.aborted)
76
+ throw signal.reason ?? new DOMException("Aborted", "AbortError");
77
+ }
78
+
79
+ function safeErrorCode(error: unknown): string {
80
+ if (error instanceof DOMException && error.name === "AbortError")
81
+ return "aborted";
82
+ if (error instanceof Error && SAFE_ERROR_CODES.has(error.message))
83
+ return error.message;
84
+ return "persistence_failed";
85
+ }
86
+
87
+ function tokenHash(token: string): string {
88
+ return new Bun.CryptoHasher("sha256").update(token).digest("hex");
89
+ }
90
+
91
+ function checksumLine(
92
+ hash: Bun.CryptoHasher,
93
+ record: UserDataExportRecord,
94
+ ): void {
95
+ hash.update(`${JSON.stringify(record)}\n`, "utf8");
96
+ }
97
+
98
+ function counts(value: unknown): Record<string, number> {
99
+ return typeof value === "object" && value !== null
100
+ ? (value as Record<string, number>)
101
+ : {};
102
+ }
103
+
104
+ function operationCounts(
105
+ operation: PersistentOperation,
106
+ ): Record<string, number> {
107
+ const result = operation.terminalResult as {
108
+ deletedByDomain?: unknown;
109
+ } | null;
110
+ return counts(result?.deletedByDomain);
111
+ }
112
+
113
+ /**
114
+ * Durable OSS-owned lifecycle saga. It does not know workspace membership or
115
+ * billing policy: the caller supplies a host fence and optional host steps.
116
+ */
117
+ export function createPersistentLifecycleService(
118
+ runtime: LifecycleRuntimeAdapters,
119
+ hostSteps: readonly LifecycleHostStep[] = [],
120
+ ): PersistentLifecycleService {
121
+ const orderedSteps = [...hostSteps].sort(
122
+ (a, b) => a.order - b.order || a.id.localeCompare(b.id),
123
+ );
124
+ if (
125
+ new Set(orderedSteps.map((step) => step.id)).size !== orderedSteps.length
126
+ ) {
127
+ throw new Error("Lifecycle host step IDs must be globally unique");
128
+ }
129
+
130
+ async function getOrCreateOperation(
131
+ ctx: PurgeUserDataContext,
132
+ ): Promise<PersistentOperation> {
133
+ await db
134
+ .insert(lifecycleOperations)
135
+ .values({
136
+ actorUserId: ctx.actorUserId,
137
+ idempotencyKey: ctx.idempotencyKey,
138
+ operationKind: "purge",
139
+ status: "pending",
140
+ })
141
+ .onConflictDoNothing();
142
+ const operation = await db.query.lifecycleOperations.findFirst({
143
+ where: and(
144
+ eq(lifecycleOperations.actorUserId, ctx.actorUserId),
145
+ eq(lifecycleOperations.idempotencyKey, ctx.idempotencyKey),
146
+ ),
147
+ });
148
+ if (!operation) throw new Error("persistence_failed");
149
+ if (operation.operationKind !== "purge")
150
+ throw new Error("persistence_failed");
151
+ return operation;
152
+ }
153
+
154
+ async function acquireLease(
155
+ operation: PersistentOperation,
156
+ owner: string,
157
+ ): Promise<boolean> {
158
+ const now = new Date();
159
+ const expiry = new Date(now.getTime() + LEASE_MS);
160
+ const updated = await db
161
+ .update(lifecycleOperations)
162
+ .set({
163
+ status: "running",
164
+ leaseOwner: owner,
165
+ leaseExpiresAt: expiry,
166
+ attemptCount: sql`${lifecycleOperations.attemptCount} + 1`,
167
+ updatedAt: now,
168
+ })
169
+ .where(
170
+ and(
171
+ eq(lifecycleOperations.id, operation.id),
172
+ or(
173
+ eq(lifecycleOperations.status, "pending"),
174
+ eq(lifecycleOperations.status, "retryable"),
175
+ and(
176
+ eq(lifecycleOperations.status, "running"),
177
+ lt(lifecycleOperations.leaseExpiresAt, now),
178
+ ),
179
+ ),
180
+ ),
181
+ )
182
+ .returning({ id: lifecycleOperations.id });
183
+ return updated.length === 1;
184
+ }
185
+
186
+ async function persistStep(
187
+ operationId: string,
188
+ leaseOwner: string,
189
+ step: string,
190
+ deletedByDomain: Record<string, number>,
191
+ ): Promise<void> {
192
+ const current = await db.query.lifecycleOperations.findFirst({
193
+ where: and(
194
+ eq(lifecycleOperations.id, operationId),
195
+ eq(lifecycleOperations.leaseOwner, leaseOwner),
196
+ ),
197
+ });
198
+ if (!current) throw new Error("lease_lost");
199
+ if (
200
+ current.status !== "running" ||
201
+ (current.leaseExpiresAt && current.leaseExpiresAt < new Date())
202
+ ) {
203
+ throw new Error("lease_lost");
204
+ }
205
+ const completed = Array.isArray(current.completedSteps)
206
+ ? current.completedSteps.filter(
207
+ (value): value is string => typeof value === "string",
208
+ )
209
+ : [];
210
+ if (!completed.includes(step)) completed.push(step);
211
+ await db
212
+ .update(lifecycleOperations)
213
+ .set({
214
+ completedSteps: completed,
215
+ terminalResult: { deletedByDomain },
216
+ updatedAt: new Date(),
217
+ })
218
+ .where(
219
+ and(
220
+ eq(lifecycleOperations.id, operationId),
221
+ eq(lifecycleOperations.leaseOwner, leaseOwner),
222
+ ),
223
+ );
224
+ }
225
+
226
+ async function runStep(
227
+ operation: PersistentOperation,
228
+ leaseOwner: string,
229
+ step: string,
230
+ deletedByDomain: Record<string, number>,
231
+ action: () => Promise<number>,
232
+ ): Promise<void> {
233
+ const completed = Array.isArray(operation.completedSteps)
234
+ ? operation.completedSteps
235
+ : [];
236
+ if (completed.includes(step)) return;
237
+ const count = await action();
238
+ deletedByDomain[step] = count;
239
+ await persistStep(operation.id, leaseOwner, step, deletedByDomain);
240
+ operation.completedSteps = [...completed, step];
241
+ }
242
+
243
+ return {
244
+ async *exportUserData(ctx) {
245
+ throwIfAborted(ctx.signal);
246
+ const exportId = crypto.randomUUID();
247
+ const hash = new Bun.CryptoHasher("sha256");
248
+ let recordCount = 0;
249
+ const manifest: UserDataExportRecord = {
250
+ type: "manifest",
251
+ schemaVersion: 1,
252
+ exportId,
253
+ actorUserId: ctx.actorUserId,
254
+ generatedAt: new Date().toISOString(),
255
+ };
256
+ checksumLine(hash, manifest);
257
+ recordCount += 1;
258
+ yield manifest;
259
+ const ownedDocuments = await db
260
+ .select()
261
+ .from(documents)
262
+ .where(eq(documents.ownerId, ctx.actorUserId));
263
+ for (const document of ownedDocuments) {
264
+ throwIfAborted(ctx.signal);
265
+ const record: UserDataExportRecord = {
266
+ type: "data",
267
+ domain: "documents",
268
+ resourceType: "document",
269
+ resourceId: document.id,
270
+ workspaceId: document.workspaceId,
271
+ payload: {
272
+ title: document.title,
273
+ content: document.content,
274
+ contentJson: document.contentJson,
275
+ metadata: document.metadata,
276
+ visibility: document.visibility,
277
+ createdAt: document.createdAt,
278
+ updatedAt: document.updatedAt,
279
+ },
280
+ };
281
+ checksumLine(hash, record);
282
+ recordCount += 1;
283
+ yield record;
284
+ }
285
+ const ownedAttachments = await db
286
+ .select({
287
+ id: attachments.id,
288
+ workspaceId: attachments.workspaceId,
289
+ filename: attachments.filename,
290
+ mimeType: attachments.mimeType,
291
+ size: attachments.size,
292
+ })
293
+ .from(attachments)
294
+ .innerJoin(documents, eq(attachments.documentId, documents.id))
295
+ .where(eq(documents.ownerId, ctx.actorUserId));
296
+ for (const attachment of ownedAttachments) {
297
+ throwIfAborted(ctx.signal);
298
+ const record: UserDataExportRecord = {
299
+ type: "attachment",
300
+ attachmentId: attachment.id,
301
+ workspaceId: attachment.workspaceId,
302
+ filename: attachment.filename,
303
+ contentType: attachment.mimeType,
304
+ size: attachment.size,
305
+ sha256: null,
306
+ };
307
+ checksumLine(hash, record);
308
+ recordCount += 1;
309
+ yield record;
310
+ }
311
+ for (const step of orderedSteps) {
312
+ if (!step.export) continue;
313
+ for await (const record of step.export(ctx)) {
314
+ throwIfAborted(ctx.signal);
315
+ if (record.type === "manifest" || record.type === "complete")
316
+ throw new Error("Host export step emitted a reserved record type");
317
+ checksumLine(hash, record);
318
+ recordCount += 1;
319
+ yield record;
320
+ }
321
+ }
322
+ yield { type: "complete", recordCount, checksum: hash.digest("hex") };
323
+ },
324
+
325
+ async purgeUserData(ctx, gate) {
326
+ throwIfAborted(ctx.signal);
327
+ let operation = await getOrCreateOperation(ctx);
328
+ if (operation.status === "completed") {
329
+ return {
330
+ status: "already_completed",
331
+ operationId: operation.id,
332
+ deletedByDomain: operationCounts(operation),
333
+ };
334
+ }
335
+ const leaseOwner = crypto.randomUUID();
336
+ if (!(await acquireLease(operation, leaseOwner)))
337
+ throw new Error("lease_lost");
338
+ operation =
339
+ (await db.query.lifecycleOperations.findFirst({
340
+ where: eq(lifecycleOperations.id, operation.id),
341
+ })) ?? operation;
342
+ const deletedByDomain = operationCounts(operation);
343
+ try {
344
+ throwIfAborted(ctx.signal);
345
+ await db
346
+ .update(lifecycleOperations)
347
+ .set({
348
+ fenceTokenHash: tokenHash(gate.fenceToken),
349
+ updatedAt: new Date(),
350
+ })
351
+ .where(
352
+ and(
353
+ eq(lifecycleOperations.id, operation.id),
354
+ eq(lifecycleOperations.leaseOwner, leaseOwner),
355
+ ),
356
+ );
357
+ await runtime.verifyPurgeFence(ctx, gate.fenceToken);
358
+
359
+ await runStep(
360
+ operation,
361
+ leaseOwner,
362
+ "cancel_account_jobs",
363
+ deletedByDomain,
364
+ () => runtime.cancelAccountJobs(ctx.actorUserId, ctx.signal),
365
+ );
366
+ await runStep(
367
+ operation,
368
+ leaseOwner,
369
+ "remove_collaboration_state",
370
+ deletedByDomain,
371
+ () => runtime.removeCollaborationState(ctx.actorUserId, ctx.signal),
372
+ );
373
+ await runStep(
374
+ operation,
375
+ leaseOwner,
376
+ "remove_subject_created_shares",
377
+ deletedByDomain,
378
+ async () =>
379
+ (
380
+ await db
381
+ .delete(shareLinks)
382
+ .where(eq(shareLinks.createdBy, ctx.actorUserId))
383
+ .returning({ id: shareLinks.id })
384
+ ).length,
385
+ );
386
+ const owned = await db
387
+ .select({ id: documents.id })
388
+ .from(documents)
389
+ .where(eq(documents.ownerId, ctx.actorUserId));
390
+ const documentIds = owned.map((row) => row.id);
391
+ await runStep(
392
+ operation,
393
+ leaseOwner,
394
+ "remove_document_versions",
395
+ deletedByDomain,
396
+ async () =>
397
+ documentIds.length
398
+ ? (
399
+ await db
400
+ .delete(versions)
401
+ .where(inArray(versions.documentId, documentIds))
402
+ .returning({ id: versions.id })
403
+ ).length
404
+ : 0,
405
+ );
406
+ await runStep(
407
+ operation,
408
+ leaseOwner,
409
+ "remove_chunks_and_embeddings",
410
+ deletedByDomain,
411
+ async () =>
412
+ documentIds.length
413
+ ? (
414
+ await db
415
+ .delete(documentEmbeddings)
416
+ .where(inArray(documentEmbeddings.documentId, documentIds))
417
+ .returning({ id: documentEmbeddings.id })
418
+ ).length
419
+ : 0,
420
+ );
421
+ await runStep(
422
+ operation,
423
+ leaseOwner,
424
+ "remove_graph_state",
425
+ deletedByDomain,
426
+ () => runtime.removeGraphState(documentIds, ctx.signal),
427
+ );
428
+ const objectRows = documentIds.length
429
+ ? await db
430
+ .select({
431
+ id: attachments.id,
432
+ storageKey: attachments.storageKey,
433
+ })
434
+ .from(attachments)
435
+ .where(inArray(attachments.documentId, documentIds))
436
+ : [];
437
+ await runStep(
438
+ operation,
439
+ leaseOwner,
440
+ "delete_attachment_objects",
441
+ deletedByDomain,
442
+ () =>
443
+ runtime.deleteObjects(
444
+ objectRows.map((row) => row.storageKey),
445
+ ctx.signal,
446
+ ),
447
+ );
448
+ await runStep(
449
+ operation,
450
+ leaseOwner,
451
+ "remove_attachment_rows",
452
+ deletedByDomain,
453
+ async () =>
454
+ objectRows.length
455
+ ? (
456
+ await db
457
+ .delete(attachments)
458
+ .where(
459
+ inArray(
460
+ attachments.id,
461
+ objectRows.map((row) => row.id),
462
+ ),
463
+ )
464
+ .returning({ id: attachments.id })
465
+ ).length
466
+ : 0,
467
+ );
468
+ await runStep(
469
+ operation,
470
+ leaseOwner,
471
+ "remove_subject_documents",
472
+ deletedByDomain,
473
+ async () =>
474
+ (
475
+ await db
476
+ .delete(documents)
477
+ .where(eq(documents.ownerId, ctx.actorUserId))
478
+ .returning({ id: documents.id })
479
+ ).length,
480
+ );
481
+ await runStep(
482
+ operation,
483
+ leaseOwner,
484
+ "clear_redis_state",
485
+ deletedByDomain,
486
+ () => runtime.clearAccountRedisState(ctx.actorUserId, ctx.signal),
487
+ );
488
+ for (const step of orderedSteps) {
489
+ if (!step.purge) continue;
490
+ await runStep(
491
+ operation,
492
+ leaseOwner,
493
+ `host:${step.id}`,
494
+ deletedByDomain,
495
+ async () => (await step.purge?.(ctx))?.deletedCount ?? 0,
496
+ );
497
+ }
498
+ await runStep(
499
+ operation,
500
+ leaseOwner,
501
+ "write_deletion_audit",
502
+ deletedByDomain,
503
+ async () => {
504
+ await db.insert(auditLog).values({
505
+ actorId: ctx.actorUserId,
506
+ action: "account_data_purged",
507
+ resourceType: "lifecycle_operation",
508
+ details: {
509
+ operationId: operation.id,
510
+ outcome: "completed",
511
+ deletedByDomain,
512
+ },
513
+ });
514
+ return 1;
515
+ },
516
+ );
517
+ await db
518
+ .update(lifecycleOperations)
519
+ .set({
520
+ status: "completed",
521
+ terminalResult: { deletedByDomain },
522
+ completedAt: new Date(),
523
+ leaseOwner: null,
524
+ leaseExpiresAt: null,
525
+ updatedAt: new Date(),
526
+ })
527
+ .where(
528
+ and(
529
+ eq(lifecycleOperations.id, operation.id),
530
+ eq(lifecycleOperations.leaseOwner, leaseOwner),
531
+ ),
532
+ );
533
+ return {
534
+ status: "completed",
535
+ operationId: operation.id,
536
+ deletedByDomain,
537
+ };
538
+ } catch (error) {
539
+ const code = safeErrorCode(error);
540
+ await db
541
+ .update(lifecycleOperations)
542
+ .set({
543
+ status: code === "fence_rejected" ? "rejected" : "retryable",
544
+ safeErrorCode: code,
545
+ leaseOwner: null,
546
+ leaseExpiresAt: null,
547
+ completedAt: code === "fence_rejected" ? new Date() : null,
548
+ updatedAt: new Date(),
549
+ })
550
+ .where(
551
+ and(
552
+ eq(lifecycleOperations.id, operation.id),
553
+ eq(lifecycleOperations.leaseOwner, leaseOwner),
554
+ ),
555
+ );
556
+ throw error;
557
+ }
558
+ },
559
+ };
560
+ }
561
+
562
+ /** Builds the public facade without allowing the OSS service to own membership policy. */
563
+ export function bindPersistentLifecycle(
564
+ service: PersistentLifecycleService,
565
+ assertPurgeAllowed: AssertPurgeAllowed,
566
+ ) {
567
+ return {
568
+ exportUserData: service.exportUserData,
569
+ async purgeUserData(ctx: PurgeUserDataContext) {
570
+ const gate = await assertPurgeAllowed(ctx);
571
+ return service.purgeUserData(ctx, gate);
572
+ },
573
+ };
574
+ }