@hiai-gg/docsmint 0.3.2 → 0.3.4

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.
@@ -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
+ }