@hiai-gg/docsmint 0.3.4 → 0.3.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,13 +1,13 @@
1
1
  import {
2
2
  attachments,
3
3
  auditLog,
4
- db,
5
4
  documentEmbeddings,
6
5
  documents,
7
6
  lifecycleOperations,
8
7
  shareLinks,
9
8
  versions,
10
- } from "@hiai-docs/db";
9
+ } from "@hiai-docs/db/schema";
10
+ import type { TenantTransaction } from "@hiai-docs/db/with-tenant";
11
11
  import type {
12
12
  AssertPurgeAllowed,
13
13
  ExportUserDataContext,
@@ -15,24 +15,34 @@ import type {
15
15
  PurgeUserDataContext,
16
16
  PurgeUserDataResult,
17
17
  UserDataExportRecord,
18
+ UserDataLifecycle,
18
19
  } from "@hiai-docs/sdk";
19
20
  import { and, eq, inArray, lt, or, sql } from "drizzle-orm";
20
21
 
21
22
  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
23
 
34
24
  type PersistentOperation = typeof lifecycleOperations.$inferSelect;
35
25
 
26
+ /** A terminal host-fence denial. Never infer this from an Error message. */
27
+ export class LifecycleFenceRejectedError extends Error {
28
+ readonly code = "fence_rejected";
29
+
30
+ constructor(message = "Purge fence rejected") {
31
+ super(message);
32
+ this.name = "LifecycleFenceRejectedError";
33
+ }
34
+ }
35
+
36
+ /** The durable operation was reclaimed or expired during this worker's run. */
37
+ export class LifecycleLeaseLostError extends Error {
38
+ readonly code = "lease_lost";
39
+
40
+ constructor() {
41
+ super("Lifecycle operation lease was lost");
42
+ this.name = "LifecycleLeaseLostError";
43
+ }
44
+ }
45
+
36
46
  export type LifecycleRuntimeAdapters = Readonly<{
37
47
  /** Checks the host-owned fence immediately before the first mutation. */
38
48
  verifyPurgeFence: (
@@ -61,6 +71,18 @@ export type LifecycleRuntimeAdapters = Readonly<{
61
71
  ) => Promise<number>;
62
72
  }>;
63
73
 
74
+ /**
75
+ * The host supplies this executor so every persistent lifecycle query runs in
76
+ * a short transaction with transaction-local `app.current_user_id` GUCs.
77
+ * The lifecycle saga never imports or owns a process-global database client.
78
+ */
79
+ export type LifecycleScopedDatabaseExecutor = Readonly<{
80
+ withActorTransaction<T>(
81
+ actorUserId: string,
82
+ operation: (tx: TenantTransaction) => Promise<T>,
83
+ ): Promise<T>;
84
+ }>;
85
+
64
86
  export type PersistentLifecycleService = Readonly<{
65
87
  exportUserData(
66
88
  ctx: ExportUserDataContext,
@@ -71,6 +93,13 @@ export type PersistentLifecycleService = Readonly<{
71
93
  ): Promise<PurgeUserDataResult>;
72
94
  }>;
73
95
 
96
+ export type PersistentLifecycleRuntimeOptions = Readonly<{
97
+ runtime: LifecycleRuntimeAdapters;
98
+ database: LifecycleScopedDatabaseExecutor;
99
+ assertPurgeAllowed: AssertPurgeAllowed;
100
+ hostSteps?: readonly LifecycleHostStep[];
101
+ }>;
102
+
74
103
  function throwIfAborted(signal: AbortSignal | undefined): void {
75
104
  if (signal?.aborted)
76
105
  throw signal.reason ?? new DOMException("Aborted", "AbortError");
@@ -79,8 +108,8 @@ function throwIfAborted(signal: AbortSignal | undefined): void {
79
108
  function safeErrorCode(error: unknown): string {
80
109
  if (error instanceof DOMException && error.name === "AbortError")
81
110
  return "aborted";
82
- if (error instanceof Error && SAFE_ERROR_CODES.has(error.message))
83
- return error.message;
111
+ if (error instanceof LifecycleFenceRejectedError) return error.code;
112
+ if (error instanceof LifecycleLeaseLostError) return error.code;
84
113
  return "persistence_failed";
85
114
  }
86
115
 
@@ -88,6 +117,10 @@ function tokenHash(token: string): string {
88
117
  return new Bun.CryptoHasher("sha256").update(token).digest("hex");
89
118
  }
90
119
 
120
+ function subjectHash(actorUserId: string): string {
121
+ return new Bun.CryptoHasher("sha256").update(actorUserId).digest("hex");
122
+ }
123
+
91
124
  function checksumLine(
92
125
  hash: Bun.CryptoHasher,
93
126
  record: UserDataExportRecord,
@@ -110,12 +143,20 @@ function operationCounts(
110
143
  return counts(result?.deletedByDomain);
111
144
  }
112
145
 
146
+ /** Throw on a zero-row lease-fenced write; a stale worker must stop immediately. */
147
+ export function requireLeaseWrite<T extends { id: string }>(
148
+ rows: readonly T[],
149
+ ): void {
150
+ if (rows.length !== 1) throw new LifecycleLeaseLostError();
151
+ }
152
+
113
153
  /**
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.
154
+ * Durable OSS-owned lifecycle saga. It knows only OSS-owned data; workspace
155
+ * membership and final-owner policy remain in the injected host fence.
116
156
  */
117
157
  export function createPersistentLifecycleService(
118
158
  runtime: LifecycleRuntimeAdapters,
159
+ database: LifecycleScopedDatabaseExecutor,
119
160
  hostSteps: readonly LifecycleHostStep[] = [],
120
161
  ): PersistentLifecycleService {
121
162
  const orderedSteps = [...hostSteps].sort(
@@ -126,105 +167,119 @@ export function createPersistentLifecycleService(
126
167
  ) {
127
168
  throw new Error("Lifecycle host step IDs must be globally unique");
128
169
  }
170
+ const withActor = <T>(
171
+ actorUserId: string,
172
+ operation: (tx: TenantTransaction) => Promise<T>,
173
+ ) => database.withActorTransaction(actorUserId, operation);
129
174
 
130
175
  async function getOrCreateOperation(
131
176
  ctx: PurgeUserDataContext,
132
177
  ): 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
- ),
178
+ return withActor(ctx.actorUserId, async (tx) => {
179
+ await tx
180
+ .insert(lifecycleOperations)
181
+ .values({
182
+ actorUserId: ctx.actorUserId,
183
+ actorSubjectHash: subjectHash(ctx.actorUserId),
184
+ idempotencyKey: ctx.idempotencyKey,
185
+ operationKind: "purge",
186
+ status: "pending",
187
+ })
188
+ .onConflictDoNothing();
189
+ const operation = await tx.query.lifecycleOperations.findFirst({
190
+ where: and(
191
+ eq(lifecycleOperations.actorUserId, ctx.actorUserId),
192
+ eq(lifecycleOperations.idempotencyKey, ctx.idempotencyKey),
193
+ ),
194
+ });
195
+ if (operation?.operationKind !== "purge")
196
+ throw new Error("persistence_failed");
197
+ return operation;
147
198
  });
148
- if (!operation) throw new Error("persistence_failed");
149
- if (operation.operationKind !== "purge")
150
- throw new Error("persistence_failed");
151
- return operation;
152
199
  }
153
200
 
154
201
  async function acquireLease(
155
202
  operation: PersistentOperation,
203
+ actorUserId: string,
156
204
  owner: string,
157
205
  ): 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),
206
+ return withActor(actorUserId, async (tx) => {
207
+ const now = new Date();
208
+ const expiry = new Date(now.getTime() + LEASE_MS);
209
+ const updated = await tx
210
+ .update(lifecycleOperations)
211
+ .set({
212
+ status: "running",
213
+ leaseOwner: owner,
214
+ leaseExpiresAt: expiry,
215
+ attemptCount: sql`${lifecycleOperations.attemptCount} + 1`,
216
+ updatedAt: now,
217
+ })
218
+ .where(
219
+ and(
220
+ eq(lifecycleOperations.id, operation.id),
221
+ or(
222
+ eq(lifecycleOperations.status, "pending"),
223
+ eq(lifecycleOperations.status, "retryable"),
224
+ and(
225
+ eq(lifecycleOperations.status, "running"),
226
+ lt(lifecycleOperations.leaseExpiresAt, now),
227
+ ),
178
228
  ),
179
229
  ),
180
- ),
181
- )
182
- .returning({ id: lifecycleOperations.id });
183
- return updated.length === 1;
230
+ )
231
+ .returning({ id: lifecycleOperations.id });
232
+ return updated.length === 1;
233
+ });
184
234
  }
185
235
 
186
236
  async function persistStep(
237
+ actorUserId: string,
187
238
  operationId: string,
188
239
  leaseOwner: string,
189
240
  step: string,
190
241
  deletedByDomain: Record<string, number>,
191
242
  ): 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(
243
+ await withActor(actorUserId, async (tx) => {
244
+ const current = await tx.query.lifecycleOperations.findFirst({
245
+ where: and(
220
246
  eq(lifecycleOperations.id, operationId),
221
247
  eq(lifecycleOperations.leaseOwner, leaseOwner),
222
248
  ),
223
- );
249
+ });
250
+ if (
251
+ current?.status !== "running" ||
252
+ (current.leaseExpiresAt && current.leaseExpiresAt < new Date())
253
+ ) {
254
+ throw new LifecycleLeaseLostError();
255
+ }
256
+ const completed = Array.isArray(current.completedSteps)
257
+ ? current.completedSteps.filter(
258
+ (value): value is string => typeof value === "string",
259
+ )
260
+ : [];
261
+ if (!completed.includes(step)) completed.push(step);
262
+ const updated = await tx
263
+ .update(lifecycleOperations)
264
+ .set({
265
+ completedSteps: completed,
266
+ terminalResult: { deletedByDomain },
267
+ updatedAt: new Date(),
268
+ })
269
+ .where(
270
+ and(
271
+ eq(lifecycleOperations.id, operationId),
272
+ eq(lifecycleOperations.leaseOwner, leaseOwner),
273
+ ),
274
+ )
275
+ .returning({ id: lifecycleOperations.id });
276
+ requireLeaseWrite(updated);
277
+ });
224
278
  }
225
279
 
226
280
  async function runStep(
227
281
  operation: PersistentOperation,
282
+ actorUserId: string,
228
283
  leaseOwner: string,
229
284
  step: string,
230
285
  deletedByDomain: Record<string, number>,
@@ -236,7 +291,13 @@ export function createPersistentLifecycleService(
236
291
  if (completed.includes(step)) return;
237
292
  const count = await action();
238
293
  deletedByDomain[step] = count;
239
- await persistStep(operation.id, leaseOwner, step, deletedByDomain);
294
+ await persistStep(
295
+ actorUserId,
296
+ operation.id,
297
+ leaseOwner,
298
+ step,
299
+ deletedByDomain,
300
+ );
240
301
  operation.completedSteps = [...completed, step];
241
302
  }
242
303
 
@@ -256,10 +317,12 @@ export function createPersistentLifecycleService(
256
317
  checksumLine(hash, manifest);
257
318
  recordCount += 1;
258
319
  yield manifest;
259
- const ownedDocuments = await db
260
- .select()
261
- .from(documents)
262
- .where(eq(documents.ownerId, ctx.actorUserId));
320
+ const ownedDocuments = await withActor(ctx.actorUserId, (tx) =>
321
+ tx
322
+ .select()
323
+ .from(documents)
324
+ .where(eq(documents.ownerId, ctx.actorUserId)),
325
+ );
263
326
  for (const document of ownedDocuments) {
264
327
  throwIfAborted(ctx.signal);
265
328
  const record: UserDataExportRecord = {
@@ -282,17 +345,19 @@ export function createPersistentLifecycleService(
282
345
  recordCount += 1;
283
346
  yield record;
284
347
  }
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));
348
+ const ownedAttachments = await withActor(ctx.actorUserId, (tx) =>
349
+ tx
350
+ .select({
351
+ id: attachments.id,
352
+ workspaceId: attachments.workspaceId,
353
+ filename: attachments.filename,
354
+ mimeType: attachments.mimeType,
355
+ size: attachments.size,
356
+ })
357
+ .from(attachments)
358
+ .innerJoin(documents, eq(attachments.documentId, documents.id))
359
+ .where(eq(documents.ownerId, ctx.actorUserId)),
360
+ );
296
361
  for (const attachment of ownedAttachments) {
297
362
  throwIfAborted(ctx.signal);
298
363
  const record: UserDataExportRecord = {
@@ -325,39 +390,48 @@ export function createPersistentLifecycleService(
325
390
  async purgeUserData(ctx, gate) {
326
391
  throwIfAborted(ctx.signal);
327
392
  let operation = await getOrCreateOperation(ctx);
328
- if (operation.status === "completed") {
393
+ if (operation.status === "completed")
329
394
  return {
330
395
  status: "already_completed",
331
396
  operationId: operation.id,
332
397
  deletedByDomain: operationCounts(operation),
333
398
  };
334
- }
335
399
  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;
400
+ if (!(await acquireLease(operation, ctx.actorUserId, leaseOwner)))
401
+ throw new LifecycleLeaseLostError();
402
+ operation = await withActor(
403
+ ctx.actorUserId,
404
+ async (tx) =>
405
+ (await tx.query.lifecycleOperations.findFirst({
406
+ where: eq(lifecycleOperations.id, operation.id),
407
+ })) ?? operation,
408
+ );
342
409
  const deletedByDomain = operationCounts(operation);
343
410
  try {
344
411
  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
- );
412
+ await withActor(ctx.actorUserId, async (tx) => {
413
+ const updated = await tx
414
+ .update(lifecycleOperations)
415
+ .set({
416
+ fenceTokenHash: tokenHash(gate.fenceToken),
417
+ updatedAt: new Date(),
418
+ })
419
+ .where(
420
+ and(
421
+ eq(lifecycleOperations.id, operation.id),
422
+ eq(lifecycleOperations.leaseOwner, leaseOwner),
423
+ ),
424
+ )
425
+ .returning({ id: lifecycleOperations.id });
426
+ requireLeaseWrite(updated);
427
+ });
357
428
  await runtime.verifyPurgeFence(ctx, gate.fenceToken);
358
-
429
+ const dbDelete = <T extends { id: string }>(
430
+ action: (tx: TenantTransaction) => Promise<T[]>,
431
+ ) => withActor(ctx.actorUserId, action).then((rows) => rows.length);
359
432
  await runStep(
360
433
  operation,
434
+ ctx.actorUserId,
361
435
  leaseOwner,
362
436
  "cancel_account_jobs",
363
437
  deletedByDomain,
@@ -365,6 +439,7 @@ export function createPersistentLifecycleService(
365
439
  );
366
440
  await runStep(
367
441
  operation,
442
+ ctx.actorUserId,
368
443
  leaseOwner,
369
444
  "remove_collaboration_state",
370
445
  deletedByDomain,
@@ -372,70 +447,79 @@ export function createPersistentLifecycleService(
372
447
  );
373
448
  await runStep(
374
449
  operation,
450
+ ctx.actorUserId,
375
451
  leaseOwner,
376
452
  "remove_subject_created_shares",
377
453
  deletedByDomain,
378
- async () =>
379
- (
380
- await db
454
+ () =>
455
+ dbDelete((tx) =>
456
+ tx
381
457
  .delete(shareLinks)
382
458
  .where(eq(shareLinks.createdBy, ctx.actorUserId))
383
- .returning({ id: shareLinks.id })
384
- ).length,
459
+ .returning({ id: shareLinks.id }),
460
+ ),
461
+ );
462
+ const owned = await withActor(ctx.actorUserId, (tx) =>
463
+ tx
464
+ .select({ id: documents.id })
465
+ .from(documents)
466
+ .where(eq(documents.ownerId, ctx.actorUserId)),
385
467
  );
386
- const owned = await db
387
- .select({ id: documents.id })
388
- .from(documents)
389
- .where(eq(documents.ownerId, ctx.actorUserId));
390
468
  const documentIds = owned.map((row) => row.id);
391
469
  await runStep(
392
470
  operation,
471
+ ctx.actorUserId,
393
472
  leaseOwner,
394
473
  "remove_document_versions",
395
474
  deletedByDomain,
396
- async () =>
475
+ () =>
397
476
  documentIds.length
398
- ? (
399
- await db
477
+ ? dbDelete((tx) =>
478
+ tx
400
479
  .delete(versions)
401
480
  .where(inArray(versions.documentId, documentIds))
402
- .returning({ id: versions.id })
403
- ).length
404
- : 0,
481
+ .returning({ id: versions.id }),
482
+ )
483
+ : Promise.resolve(0),
405
484
  );
406
485
  await runStep(
407
486
  operation,
487
+ ctx.actorUserId,
408
488
  leaseOwner,
409
489
  "remove_chunks_and_embeddings",
410
490
  deletedByDomain,
411
- async () =>
491
+ () =>
412
492
  documentIds.length
413
- ? (
414
- await db
493
+ ? dbDelete((tx) =>
494
+ tx
415
495
  .delete(documentEmbeddings)
416
496
  .where(inArray(documentEmbeddings.documentId, documentIds))
417
- .returning({ id: documentEmbeddings.id })
418
- ).length
419
- : 0,
497
+ .returning({ id: documentEmbeddings.id }),
498
+ )
499
+ : Promise.resolve(0),
420
500
  );
421
501
  await runStep(
422
502
  operation,
503
+ ctx.actorUserId,
423
504
  leaseOwner,
424
505
  "remove_graph_state",
425
506
  deletedByDomain,
426
507
  () => runtime.removeGraphState(documentIds, ctx.signal),
427
508
  );
428
509
  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))
510
+ ? await withActor(ctx.actorUserId, (tx) =>
511
+ tx
512
+ .select({
513
+ id: attachments.id,
514
+ storageKey: attachments.storageKey,
515
+ })
516
+ .from(attachments)
517
+ .where(inArray(attachments.documentId, documentIds)),
518
+ )
436
519
  : [];
437
520
  await runStep(
438
521
  operation,
522
+ ctx.actorUserId,
439
523
  leaseOwner,
440
524
  "delete_attachment_objects",
441
525
  deletedByDomain,
@@ -447,13 +531,14 @@ export function createPersistentLifecycleService(
447
531
  );
448
532
  await runStep(
449
533
  operation,
534
+ ctx.actorUserId,
450
535
  leaseOwner,
451
536
  "remove_attachment_rows",
452
537
  deletedByDomain,
453
- async () =>
538
+ () =>
454
539
  objectRows.length
455
- ? (
456
- await db
540
+ ? dbDelete((tx) =>
541
+ tx
457
542
  .delete(attachments)
458
543
  .where(
459
544
  inArray(
@@ -461,114 +546,144 @@ export function createPersistentLifecycleService(
461
546
  objectRows.map((row) => row.id),
462
547
  ),
463
548
  )
464
- .returning({ id: attachments.id })
465
- ).length
466
- : 0,
549
+ .returning({ id: attachments.id }),
550
+ )
551
+ : Promise.resolve(0),
467
552
  );
468
553
  await runStep(
469
554
  operation,
555
+ ctx.actorUserId,
470
556
  leaseOwner,
471
557
  "remove_subject_documents",
472
558
  deletedByDomain,
473
- async () =>
474
- (
475
- await db
559
+ () =>
560
+ dbDelete((tx) =>
561
+ tx
476
562
  .delete(documents)
477
563
  .where(eq(documents.ownerId, ctx.actorUserId))
478
- .returning({ id: documents.id })
479
- ).length,
564
+ .returning({ id: documents.id }),
565
+ ),
480
566
  );
481
567
  await runStep(
482
568
  operation,
569
+ ctx.actorUserId,
483
570
  leaseOwner,
484
571
  "clear_redis_state",
485
572
  deletedByDomain,
486
573
  () => runtime.clearAccountRedisState(ctx.actorUserId, ctx.signal),
487
574
  );
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
- }
575
+ for (const step of orderedSteps)
576
+ if (step.purge)
577
+ await runStep(
578
+ operation,
579
+ ctx.actorUserId,
580
+ leaseOwner,
581
+ `host:${step.id}`,
582
+ deletedByDomain,
583
+ async () => (await step.purge?.(ctx))?.deletedCount ?? 0,
584
+ );
498
585
  await runStep(
499
586
  operation,
587
+ ctx.actorUserId,
500
588
  leaseOwner,
501
589
  "write_deletion_audit",
502
590
  deletedByDomain,
503
591
  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
- });
592
+ await withActor(ctx.actorUserId, (tx) =>
593
+ tx.insert(auditLog).values({
594
+ actorId: ctx.actorUserId,
595
+ action: "account_data_purged",
596
+ resourceType: "lifecycle_operation",
597
+ details: {
598
+ operationId: operation.id,
599
+ outcome: "completed",
600
+ deletedByDomain,
601
+ },
602
+ }),
603
+ );
514
604
  return 1;
515
605
  },
516
606
  );
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
- );
607
+ await withActor(ctx.actorUserId, async (tx) => {
608
+ const updated = await tx
609
+ .update(lifecycleOperations)
610
+ .set({
611
+ status: "completed",
612
+ terminalResult: { deletedByDomain },
613
+ completedAt: new Date(),
614
+ leaseOwner: null,
615
+ leaseExpiresAt: null,
616
+ updatedAt: new Date(),
617
+ })
618
+ .where(
619
+ and(
620
+ eq(lifecycleOperations.id, operation.id),
621
+ eq(lifecycleOperations.leaseOwner, leaseOwner),
622
+ ),
623
+ )
624
+ .returning({ id: lifecycleOperations.id });
625
+ requireLeaseWrite(updated);
626
+ });
533
627
  return {
534
628
  status: "completed",
535
629
  operationId: operation.id,
536
630
  deletedByDomain,
537
631
  };
538
632
  } catch (error) {
633
+ if (error instanceof LifecycleLeaseLostError) throw error;
539
634
  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
- );
635
+ await withActor(ctx.actorUserId, async (tx) => {
636
+ const updated = await tx
637
+ .update(lifecycleOperations)
638
+ .set({
639
+ status:
640
+ error instanceof LifecycleFenceRejectedError
641
+ ? "rejected"
642
+ : "retryable",
643
+ safeErrorCode: code,
644
+ leaseOwner: null,
645
+ leaseExpiresAt: null,
646
+ completedAt:
647
+ error instanceof LifecycleFenceRejectedError
648
+ ? new Date()
649
+ : null,
650
+ updatedAt: new Date(),
651
+ })
652
+ .where(
653
+ and(
654
+ eq(lifecycleOperations.id, operation.id),
655
+ eq(lifecycleOperations.leaseOwner, leaseOwner),
656
+ ),
657
+ )
658
+ .returning({ id: lifecycleOperations.id });
659
+ requireLeaseWrite(updated);
660
+ });
556
661
  throw error;
557
662
  }
558
663
  },
559
664
  };
560
665
  }
561
666
 
562
- /** Builds the public facade without allowing the OSS service to own membership policy. */
563
667
  export function bindPersistentLifecycle(
564
668
  service: PersistentLifecycleService,
565
669
  assertPurgeAllowed: AssertPurgeAllowed,
566
- ) {
670
+ ): UserDataLifecycle {
567
671
  return {
568
672
  exportUserData: service.exportUserData,
569
- async purgeUserData(ctx: PurgeUserDataContext) {
673
+ async purgeUserData(ctx) {
570
674
  const gate = await assertPurgeAllowed(ctx);
571
675
  return service.purgeUserData(ctx, gate);
572
676
  },
573
677
  };
574
678
  }
679
+
680
+ export function createPersistentLifecycleRuntime(
681
+ options: PersistentLifecycleRuntimeOptions,
682
+ ): UserDataLifecycle {
683
+ const service = createPersistentLifecycleService(
684
+ options.runtime,
685
+ options.database,
686
+ options.hostSteps ?? [],
687
+ );
688
+ return bindPersistentLifecycle(service, options.assertPurgeAllowed);
689
+ }