@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.
- package/README.md +26 -23
- package/backend/src/lib/lifecycle-service.ts +574 -0
- package/{packages/sdk/dist → dist}/client.d.ts +16 -1
- package/{packages/sdk/dist → dist}/client.js +25 -1
- package/dist/frontend/DatePicker-BcMD49PV.js +275 -0
- package/dist/frontend/SettingsDialog-DiAyhzw0.js +4852 -0
- package/dist/frontend/ShareDialog-DaZf1raY.js +1317 -0
- package/dist/frontend/api/categories.d.ts +14 -0
- package/dist/frontend/api/categories.js +2 -0
- package/dist/frontend/api/documents.d.ts +16 -0
- package/dist/frontend/api/documents.js +2 -0
- package/dist/frontend/api/folders.d.ts +14 -0
- package/dist/frontend/api/folders.js +2 -0
- package/dist/frontend/api/settings.d.ts +10 -0
- package/dist/frontend/api/settings.js +2 -0
- package/dist/frontend/api/tags.d.ts +15 -0
- package/dist/frontend/api/tags.js +2 -0
- package/dist/frontend/categories-BE6ZpZl8.js +61 -0
- package/dist/frontend/client-B9hzI0W8.js +76 -0
- package/dist/frontend/client-C6QQGb0e.js +2766 -0
- package/dist/frontend/components/settings.d.ts +2 -0
- package/dist/frontend/components/settings.js +2 -0
- package/dist/frontend/components/sidebar.d.ts +2 -0
- package/dist/frontend/components/sidebar.js +4569 -0
- package/dist/frontend/context-DbnW5yib.js +31 -0
- package/dist/frontend/dashboard.d.ts +2 -0
- package/dist/frontend/dashboard.js +2152 -0
- package/dist/frontend/documents-DFVS9SsL.js +83 -0
- package/dist/frontend/extension.d.ts +35 -0
- package/dist/frontend/extension.js +10 -0
- package/dist/frontend/folders-CGe9diAG.js +91 -0
- package/dist/frontend/frontend.css +2 -0
- package/dist/frontend/i18n.d.ts +5 -0
- package/dist/frontend/i18n.js +8 -0
- package/dist/frontend/input-ieWIt8I7.js +257 -0
- package/dist/frontend/messages-CTGAsqFu.js +1106 -0
- package/dist/frontend/resolve-Bl6xmJl_.js +2068 -0
- package/dist/frontend/schemas-DwtKylCJ.js +3002 -0
- package/dist/frontend/search.d.ts +2 -0
- package/dist/frontend/search.js +1001 -0
- package/dist/frontend/settings-PCrOtied.js +1313 -0
- package/dist/frontend/shared-document.d.ts +16 -0
- package/dist/frontend/shared-document.js +193 -0
- package/dist/frontend/tag-C5xrg-bb.js +53 -0
- package/dist/frontend/tag-store.svelte-B3839FYq.js +6152 -0
- package/dist/frontend/tags-DVXTut0p.js +48 -0
- package/dist/frontend/theme.d.ts +4 -0
- package/dist/frontend/theme.js +8 -0
- package/dist/frontend/theme.svelte-ei0QxXa8.js +49 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +3 -0
- package/dist/lifecycle.d.ts +90 -0
- package/dist/lifecycle.js +99 -0
- package/{packages/sdk/dist → dist}/types.d.ts +5 -0
- package/dist/workspace.d.ts +21 -0
- package/dist/workspace.js +66 -0
- package/package.json +75 -35
- package/packages/cli/src/index.ts +1 -1
- package/packages/db/src/schema.ts +41 -0
- package/packages/mcp-server/src/index.ts +1 -1
- package/frontend/src/lib/components/editor/shared-document.ts +0 -237
- package/frontend/src/lib/extensions/context.ts +0 -60
- package/frontend/src/lib/extensions/doc-tabs.ts +0 -18
- package/frontend/src/lib/extensions/resolve.ts +0 -48
- package/frontend/src/lib/extensions/types.ts +0 -202
- package/frontend/src/lib/hosts/DocsmintSharedDocumentHost.svelte +0 -65
- package/frontend/src/lib/hosts/HiaiDocsDashboardHost.svelte +0 -1007
- package/frontend/src/lib/hosts/HiaiDocsExtensionProvider.svelte +0 -20
- package/frontend/src/lib/hosts/HiaiDocsSearchHost.svelte +0 -996
- package/frontend/src/lib/hosts/index.ts +0 -25
- package/frontend/src/lib/index.ts +0 -65
- package/frontend/src/lib/stores/doc-tab-registry.svelte.ts +0 -68
- package/packages/sdk/dist/index.d.ts +0 -3
- package/packages/sdk/dist/index.js +0 -1
- /package/{packages/sdk/dist → dist}/types.js +0 -0
package/README.md
CHANGED
|
@@ -1,12 +1,8 @@
|
|
|
1
|
-
#
|
|
1
|
+
# DocsMint
|
|
2
2
|
|
|
3
3
|
**A self-hosted, AI-native knowledge workspace for people, applications, and agents.**
|
|
4
4
|
|
|
5
|
-
|
|
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
|
[](LICENSE)
|
|
17
13
|
[](https://github.com/hiai-gg/docsmint/releases)
|
|
14
|
+
[](https://www.npmjs.com/package/@hiai-gg/docsmint)
|
|
15
|
+
[](https://hub.docker.com/r/vgalibov/docsmint)
|
|
18
16
|
[](https://github.com/hiai-gg/docsmint/stargazers)
|
|
19
17
|
[](https://github.com/hiai-gg/docsmint/actions/workflows/ci.yml)
|
|
20
18
|
[](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
|
|
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
|
|
54
|
-
|
|
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
|
|
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
|
|
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
|
|
148
|
+
## Use DocsMint from the terminal
|
|
151
149
|
|
|
152
150
|
The published package includes the CLI. It connects to an already running
|
|
153
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
310
|
+
| Project | Primary strength | Difference from DocsMint |
|
|
309
311
|
|---|---|---|
|
|
310
|
-
| [Outline](https://github.com/outline/outline) | Polished team wiki and collaboration |
|
|
311
|
-
| [Docmost](https://github.com/docmost/docmost) | Collaborative wiki and real-time editing |
|
|
312
|
-
| [AppFlowy](https://github.com/AppFlowy-IO/AppFlowy) | Broad local-first productivity workspace |
|
|
313
|
-
| [AnythingLLM](https://github.com/Mintplex-Labs/anything-llm) | Chat-oriented RAG over imported sources |
|
|
314
|
-
| [Danswer](https://github.com/danswer-ai/danswer) / Onyx | Enterprise search across external connectors |
|
|
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
|
-
|
|
348
|
+
DocsMint is released under the [Apache License 2.0](LICENSE).
|
|
347
349
|
|
|
348
|
-
|
|
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
|
+
}
|