@cosmicdrift/kumiko-samples 0.363.0 → 0.364.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/packages/bundled-features/package.json +7 -1
- package/packages/bundled-features/src/audit/changes.json +6 -0
- package/packages/bundled-features/src/audit/handlers/list.query.ts +10 -5
- package/packages/bundled-features/src/auth-email-password/bootstrap.ts +29 -8
- package/packages/bundled-features/src/auth-email-password/changes.json +37 -0
- package/packages/bundled-features/src/auth-email-password/email-rate-limits.ts +16 -0
- package/packages/bundled-features/src/auth-email-password/handlers/change-password.write.ts +6 -2
- package/packages/bundled-features/src/auth-email-password/handlers/logout.write.ts +7 -2
- package/packages/bundled-features/src/auth-email-password/handlers/signup-request.write.ts +2 -1
- package/packages/bundled-features/src/auth-email-password/handlers/token-request-handler.ts +2 -1
- package/packages/bundled-features/src/auth-email-password/web/auth-gate.tsx +24 -5
- package/packages/bundled-features/src/billing-foundation/changes.json +36 -0
- package/packages/bundled-features/src/billing-foundation/consumer-protection/confirmation-mail.ts +57 -5
- package/packages/bundled-features/src/billing-foundation/consumer-protection/consent-gate.ts +22 -1
- package/packages/bundled-features/src/billing-foundation/consumer-protection/mail-notifications.ts +4 -1
- package/packages/bundled-features/src/billing-foundation/consumer-protection/register-termination.ts +11 -1
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-mail.ts +9 -3
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-receipt-limit.ts +41 -0
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-record.ts +10 -1
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-request.ts +35 -19
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-texts.ts +9 -0
- package/packages/bundled-features/src/billing-foundation/consumer-protection/withdrawal-period.ts +35 -0
- package/packages/bundled-features/src/billing-foundation/handlers/create-checkout-session.write.ts +11 -3
- package/packages/bundled-features/src/billing-foundation/handlers/process-event.write.ts +10 -4
- package/packages/bundled-features/src/billing-foundation/i18n.ts +3 -0
- package/packages/bundled-features/src/billing-foundation/subscription-tier-sync.ts +17 -5
- package/packages/bundled-features/src/billing-foundation/types.ts +7 -2
- package/packages/bundled-features/src/cap-counter/calendar-cap-usage-reservation.ts +2 -0
- package/packages/bundled-features/src/cap-counter/changes.json +6 -0
- package/packages/bundled-features/src/cap-counter/enforce-cap.ts +75 -5
- package/packages/bundled-features/src/cap-counter/index.ts +1 -0
- package/packages/bundled-features/src/cap-counter/with-cap-enforcement.ts +3 -0
- package/packages/bundled-features/src/channel-email/email-channel.ts +17 -4
- package/packages/bundled-features/src/config/changes.json +6 -0
- package/packages/bundled-features/src/config/handlers/set.write.ts +2 -2
- package/packages/bundled-features/src/config/resolver.ts +15 -12
- package/packages/bundled-features/src/config/write-helpers.ts +23 -0
- package/packages/bundled-features/src/crypto-shredding/changes.json +6 -0
- package/packages/bundled-features/src/crypto-shredding/handlers/forget-subject.write.ts +48 -27
- package/packages/bundled-features/src/custom-fields/changes.json +6 -0
- package/packages/bundled-features/src/custom-fields/handlers/update-tenant-field.write.ts +0 -1
- package/packages/bundled-features/src/delivery/attachment-ref.ts +21 -0
- package/packages/bundled-features/src/delivery/changes.json +18 -0
- package/packages/bundled-features/src/delivery/channel-context.ts +31 -8
- package/packages/bundled-features/src/delivery/index.ts +2 -0
- package/packages/bundled-features/src/delivery/jobs.ts +51 -14
- package/packages/bundled-features/src/delivery/types.ts +7 -1
- package/packages/bundled-features/src/file-derivatives/handlers/public-variant.query.ts +1 -1
- package/packages/bundled-features/src/file-foundation/changes.json +6 -0
- package/packages/bundled-features/src/file-foundation/read-file-ref-content.ts +6 -3
- package/packages/bundled-features/src/files-tenant-data/changes.json +6 -0
- package/packages/bundled-features/src/files-tenant-data/hooks.ts +4 -0
- package/packages/bundled-features/src/inbound-mail-foundation/changes.json +6 -0
- package/packages/bundled-features/src/inbound-mail-foundation/oauth-access-token.ts +13 -8
- package/packages/bundled-features/src/inbound-mail-foundation/watch-supervisor.ts +3 -1
- package/packages/bundled-features/src/jobs/changes.json +19 -0
- package/packages/bundled-features/src/jobs/db/queries/stale-run-sweep.ts +12 -4
- package/packages/bundled-features/src/jobs/feature.ts +7 -4
- package/packages/bundled-features/src/jobs/handlers/list.query.ts +3 -1
- package/packages/bundled-features/src/jobs/handlers/stale-run-sweep.job.ts +5 -2
- package/packages/bundled-features/src/jobs/job-run-logger.ts +52 -17
- package/packages/bundled-features/src/jobs/tenant-job-run-table.ts +7 -2
- package/packages/bundled-features/src/ledger/changes.json +7 -0
- package/packages/bundled-features/src/ledger/entity.ts +3 -1
- package/packages/bundled-features/src/renderer-foundation/types.ts +2 -0
- package/packages/bundled-features/src/renderer-pdf/changes.json +12 -0
- package/packages/bundled-features/src/renderer-pdf/render-table-pdf.ts +162 -82
- package/packages/bundled-features/src/secrets/changes.json +6 -0
- package/packages/bundled-features/src/secrets/feature.ts +4 -3
- package/packages/bundled-features/src/secrets/secrets-context.ts +23 -3
- package/packages/bundled-features/src/shared/changes.json +6 -0
- package/packages/bundled-features/src/shared/chunked-entity-migration.ts +4 -1
- package/packages/bundled-features/src/shared/mail-template-body.ts +17 -0
- package/packages/bundled-features/src/subscription-stripe/changes.json +6 -0
- package/packages/bundled-features/src/subscription-stripe/plugin-methods.ts +19 -11
- package/packages/bundled-features/src/subscription-stripe/verify-webhook.ts +3 -1
- package/packages/bundled-features/src/template-resolver/README.md +4 -2
- package/packages/bundled-features/src/template-resolver/api.ts +3 -2
- package/packages/bundled-features/src/template-resolver/changes.json +68 -0
- package/packages/bundled-features/src/template-resolver/compile-document-body.ts +77 -13
- package/packages/bundled-features/src/template-resolver/constants.ts +12 -2
- package/packages/bundled-features/src/template-resolver/document-schema.ts +26 -4
- package/packages/bundled-features/src/template-resolver/feature.ts +4 -1
- package/packages/bundled-features/src/template-resolver/handlers/copy-locale.write.ts +31 -13
- package/packages/bundled-features/src/template-resolver/handlers/diff-to-system.query.ts +5 -44
- package/packages/bundled-features/src/template-resolver/handlers/discard-draft.write.ts +52 -0
- package/packages/bundled-features/src/template-resolver/handlers/export-bundle.query.ts +56 -1
- package/packages/bundled-features/src/template-resolver/handlers/find-by-id.query.ts +6 -3
- package/packages/bundled-features/src/template-resolver/handlers/layout-source.query.ts +73 -30
- package/packages/bundled-features/src/template-resolver/handlers/list.query.ts +14 -11
- package/packages/bundled-features/src/template-resolver/handlers/override-diff.ts +49 -0
- package/packages/bundled-features/src/template-resolver/handlers/overview.query.ts +90 -17
- package/packages/bundled-features/src/template-resolver/handlers/resource-upload.write.ts +1 -1
- package/packages/bundled-features/src/template-resolver/handlers/send-test.write.ts +39 -10
- package/packages/bundled-features/src/template-resolver/handlers/template-gates.ts +14 -2
- package/packages/bundled-features/src/template-resolver/handlers/template-write.ts +54 -4
- package/packages/bundled-features/src/template-resolver/handlers/tenant-overrides.query.ts +96 -14
- package/packages/bundled-features/src/template-resolver/handlers/toggle-status.write.ts +51 -24
- package/packages/bundled-features/src/template-resolver/handlers/upsert-tenant.write.ts +1 -1
- package/packages/bundled-features/src/template-resolver/handlers/usage.query.ts +16 -2
- package/packages/bundled-features/src/template-resolver/index.ts +12 -1
- package/packages/bundled-features/src/template-resolver/layout-appearance.ts +40 -0
- package/packages/bundled-features/src/template-resolver/pdf-constants.ts +12 -0
- package/packages/bundled-features/src/template-resolver/qualified-names.ts +4 -0
- package/packages/bundled-features/src/template-resolver/seeding.ts +25 -4
- package/packages/bundled-features/src/template-resolver/store-template-resource.ts +2 -2
- package/packages/bundled-features/src/template-resolver/table.ts +13 -1
- package/packages/bundled-features/src/template-resolver/template-draft.ts +106 -0
- package/packages/bundled-features/src/template-resolver/template-meta.ts +87 -24
- package/packages/bundled-features/src/template-resolver/template-preview.ts +159 -56
- package/packages/bundled-features/src/template-resolver/template-resources.ts +44 -4
- package/packages/bundled-features/src/template-resolver/template-usage.ts +31 -11
- package/packages/bundled-features/src/template-resolver/template-validation.ts +1 -0
- package/packages/bundled-features/src/template-resolver/web/client-plugin.tsx +12 -8
- package/packages/bundled-features/src/template-resolver/web/document-editor-extensions.ts +63 -10
- package/packages/bundled-features/src/template-resolver/web/document-editor.tsx +12 -3
- package/packages/bundled-features/src/template-resolver/web/document-tiptap-editor.tsx +349 -134
- package/packages/bundled-features/src/template-resolver/web/document-toolbar-blocks.ts +11 -0
- package/packages/bundled-features/src/template-resolver/web/i18n.ts +25 -4
- package/packages/bundled-features/src/template-resolver/web/index.ts +7 -0
- package/packages/bundled-features/src/template-resolver/web/placeholder-line-editor.tsx +10 -3
- package/packages/bundled-features/src/tier-engine/changes.json +6 -0
- package/packages/bundled-features/src/tier-engine/feature.ts +11 -3
- package/packages/bundled-features/src/user/changes.json +6 -0
- package/packages/bundled-features/src/user/resolve-display-names.ts +8 -15
- package/packages/bundled-features/src/user/seeding.ts +19 -5
- package/packages/bundled-features/src/user-data-rights/changes.json +6 -0
- package/packages/bundled-features/src/user-profile/changes.json +1 -1
- package/packages/bundled-features/src/waitlist/options.ts +1 -1
- package/packages/bundled-features/src/workflow-runner/changes.json +6 -0
- package/packages/bundled-features/src/workflow-runner/event-trigger.ts +2 -1
- package/packages/bundled-features/src/workflow-runner/handlers/resume-run.write.ts +7 -1
- package/samples/apps/ui-walkthrough/package.json +9 -0
- package/samples/apps/ui-walkthrough/playwright.config.ts +40 -7
- package/samples/apps/ui-walkthrough/src/app/seed-templates.ts +87 -2
- package/samples/apps/ui-walkthrough/src/app/template-seed-data.ts +246 -30
- package/samples/apps/ui-walkthrough/src/features/billing/feature.ts +4 -0
- package/samples/apps/ui-walkthrough/src/run-config.ts +4 -0
- package/samples/apps/use-all-bundled/feature-manifest.json +5 -3
|
@@ -119,6 +119,19 @@ async function decryptPiiEncrypted(raw: string, qualifiedKey: string): Promise<s
|
|
|
119
119
|
return decryptPiiValueForSubject(kms, raw, kmsCtx, qualifiedKey);
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
+
// Plaintext form of a stored config_values.value for every read path (resolver,
|
|
123
|
+
// bootstrap idempotency check).
|
|
124
|
+
export async function decryptStoredValue(
|
|
125
|
+
raw: string,
|
|
126
|
+
keyDef: ConfigKeyDefinition,
|
|
127
|
+
cipher: EnvelopeCipher | undefined,
|
|
128
|
+
qualifiedKey: string,
|
|
129
|
+
): Promise<string> {
|
|
130
|
+
if (keyDef.encrypted) return decryptEncrypted(cipher, raw, qualifiedKey);
|
|
131
|
+
if (keyDef.piiEncrypted) return decryptPiiEncrypted(raw, qualifiedKey);
|
|
132
|
+
return raw;
|
|
133
|
+
}
|
|
134
|
+
|
|
122
135
|
// backing="secrets" keys store their value in the secrets store (flat per
|
|
123
136
|
// (tenant,key) at SYSTEM_TENANT_ID), not in config_values. Both read paths
|
|
124
137
|
// (getWithSource + buildCascade) route the system rung here. Missing reader =
|
|
@@ -230,12 +243,7 @@ async function buildCascade(
|
|
|
230
243
|
|
|
231
244
|
const row = await fetchRow(lookup.tenantId, lookup.userId);
|
|
232
245
|
if (row?.value !== null && row?.value !== undefined) {
|
|
233
|
-
|
|
234
|
-
if (keyDef.encrypted) {
|
|
235
|
-
raw = await decryptEncrypted(cipher, raw, qualifiedKey);
|
|
236
|
-
} else if (keyDef.piiEncrypted) {
|
|
237
|
-
raw = await decryptPiiEncrypted(raw, qualifiedKey);
|
|
238
|
-
}
|
|
246
|
+
const raw = await decryptStoredValue(row.value, keyDef, cipher, qualifiedKey);
|
|
239
247
|
if (activeIndex === -1) activeIndex = levels.length;
|
|
240
248
|
levels.push({
|
|
241
249
|
label: lookup.label,
|
|
@@ -409,12 +417,7 @@ export function createConfigResolver(options: ConfigResolverOptions = {}): Confi
|
|
|
409
417
|
for (const lookup of lookups) {
|
|
410
418
|
const row = await findRow(qualifiedKey, lookup.tenantId, lookup.userId, db);
|
|
411
419
|
if (row?.value !== null && row?.value !== undefined) {
|
|
412
|
-
|
|
413
|
-
if (keyDef.encrypted) {
|
|
414
|
-
raw = await decryptEncrypted(cipher, raw, qualifiedKey);
|
|
415
|
-
} else if (keyDef.piiEncrypted) {
|
|
416
|
-
raw = await decryptPiiEncrypted(raw, qualifiedKey);
|
|
417
|
-
}
|
|
420
|
+
const raw = await decryptStoredValue(row.value, keyDef, cipher, qualifiedKey);
|
|
418
421
|
return { value: deserializeValue(raw, keyDef.type), source: lookup.source };
|
|
419
422
|
}
|
|
420
423
|
}
|
|
@@ -28,8 +28,10 @@ import {
|
|
|
28
28
|
type WriteFailure,
|
|
29
29
|
writeFailure,
|
|
30
30
|
} from "@cosmicdrift/kumiko-framework/errors";
|
|
31
|
+
import type { EnvelopeCipher } from "@cosmicdrift/kumiko-framework/secrets";
|
|
31
32
|
import { assertUnreachable } from "@cosmicdrift/kumiko-framework/utils";
|
|
32
33
|
import { ConfigErrors } from "./constants.js";
|
|
34
|
+
import { decryptStoredValue } from "./resolver.js";
|
|
33
35
|
import { configValuesTable } from "./table.js";
|
|
34
36
|
|
|
35
37
|
export type ConfigRowLookup = {
|
|
@@ -62,6 +64,27 @@ export async function findConfigRow(
|
|
|
62
64
|
};
|
|
63
65
|
}
|
|
64
66
|
|
|
67
|
+
// Idempotency check for declarative writers: does the stored row already hold
|
|
68
|
+
// `serializedValue` (JSON of the plaintext)? Encrypted rows are decrypted
|
|
69
|
+
// first, since their ciphertext differs on every encrypt.
|
|
70
|
+
export async function configRowHoldsValue(
|
|
71
|
+
row: ConfigRowLookup | null,
|
|
72
|
+
keyDef: ConfigKeyDefinition | undefined,
|
|
73
|
+
cipher: EnvelopeCipher | undefined,
|
|
74
|
+
qualifiedKey: string,
|
|
75
|
+
serializedValue: string,
|
|
76
|
+
): Promise<boolean> {
|
|
77
|
+
if (row?.value === null || row?.value === undefined) return false;
|
|
78
|
+
if (!keyDef?.encrypted && !keyDef?.piiEncrypted) return row.value === serializedValue;
|
|
79
|
+
if (!keyDef) return false;
|
|
80
|
+
try {
|
|
81
|
+
return (await decryptStoredValue(row.value, keyDef, cipher, qualifiedKey)) === serializedValue;
|
|
82
|
+
} catch {
|
|
83
|
+
// Unreadable (rotated key, missing cipher/KMS): the caller rewrites the row, which is the repair.
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
65
88
|
// Three-stage pre-write gate that set + reset both need: resolve the key
|
|
66
89
|
// definition (404 when unknown), check the user's roles can write it (403),
|
|
67
90
|
// and map the target scope to the (tenantId | null, userId | null) pair the
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.364.0",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "forget-subject keeps the user status flip and PAT revocation when the sweep fails",
|
|
6
|
+
"detail": "The user is set to Deleted and its personal access tokens are revoked right after the key erase, in their own transaction on the outside-transaction db. A failing blind-index or search sweep still makes the handler throw so the cleanup can be retried, but it no longer rolls the login door back. Without an outside-transaction db the previous in-transaction savepoint path is used. A failure while closing the login door of an existing user now fails the forget (retry is safe) instead of passing silently; subjects without a user row are skipped."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.357.0",
|
|
4
10
|
"type": "improvement",
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import { requestContext } from "@cosmicdrift/kumiko-framework/api";
|
|
2
2
|
import { ROLES } from "@cosmicdrift/kumiko-framework/auth";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
fetchOne,
|
|
5
|
+
runInSavepointIfSupported,
|
|
6
|
+
runnerCanBeginTransaction,
|
|
7
|
+
transaction,
|
|
8
|
+
} from "@cosmicdrift/kumiko-framework/bun-db";
|
|
4
9
|
import {
|
|
5
10
|
computeBlindIndex,
|
|
6
11
|
configuredBlindIndexKey,
|
|
@@ -42,7 +47,7 @@ import * as z from "zod";
|
|
|
42
47
|
import { resolveRetentionPolicyForTenant } from "../../data-retention/index.js";
|
|
43
48
|
import { eraseNoteBodiesReachingSubject } from "../../notes-history-user-data/index.js";
|
|
44
49
|
import { revokeAllPatTokensForUser } from "../../personal-access-tokens/index.js";
|
|
45
|
-
import { USER_STATUS } from "../../user/index.js";
|
|
50
|
+
import { USER_STATUS, userTable } from "../../user/index.js";
|
|
46
51
|
import {
|
|
47
52
|
denyIfTargetOutsideAdminTenant,
|
|
48
53
|
isSystemAdminActor,
|
|
@@ -337,25 +342,33 @@ async function sweepDerivedSubjectData(
|
|
|
337
342
|
}
|
|
338
343
|
}
|
|
339
344
|
|
|
340
|
-
//
|
|
341
|
-
//
|
|
342
|
-
//
|
|
343
|
-
//
|
|
344
|
-
//
|
|
345
|
-
|
|
345
|
+
// Email subscribers and other non-user entities use user-style subject keys
|
|
346
|
+
// without a read_users row — the key erase is the GDPR-relevant part, so no row
|
|
347
|
+
// means nothing to close. For an existing row, failures propagate: the handler
|
|
348
|
+
// throws and the retry re-runs the (idempotent) erase plus the door close. With
|
|
349
|
+
// an outside-transaction runner that can open its own transaction, the door
|
|
350
|
+
// closes there and stays committed when the handler throws afterwards (sweep
|
|
351
|
+
// error). Otherwise a savepoint confines a failure to the handler transaction.
|
|
352
|
+
async function closeUserLoginDoor(
|
|
346
353
|
ctx: HandlerContext,
|
|
347
354
|
userId: string,
|
|
348
|
-
|
|
355
|
+
outsideRunner: DbRunner | undefined,
|
|
356
|
+
handlerTxRunner: DbRunner,
|
|
349
357
|
): Promise<void> {
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
358
|
+
const userRow = await fetchOne(handlerTxRunner, userTable, { id: userId });
|
|
359
|
+
// skip: no user row for this subject key (e.g. email subscribers); there is no door to close.
|
|
360
|
+
if (!userRow) return;
|
|
361
|
+
|
|
362
|
+
const closeDoor = async (runner: DbRunner): Promise<void> => {
|
|
363
|
+
await updateUserLifecycle(runner, userId, { status: USER_STATUS.Deleted });
|
|
364
|
+
if (ctx.registry.features.has("personal-access-tokens")) {
|
|
365
|
+
await revokeAllPatTokensForUser(runner, userId);
|
|
366
|
+
}
|
|
367
|
+
};
|
|
368
|
+
if (outsideRunner && runnerCanBeginTransaction(outsideRunner)) {
|
|
369
|
+
await transaction(outsideRunner, closeDoor);
|
|
370
|
+
} else {
|
|
371
|
+
await runInSavepointIfSupported(handlerTxRunner, closeDoor);
|
|
359
372
|
}
|
|
360
373
|
}
|
|
361
374
|
|
|
@@ -490,14 +503,6 @@ export const forgetSubjectWrite = defineWriteHandler({
|
|
|
490
503
|
eraseReason: event.payload.reason,
|
|
491
504
|
});
|
|
492
505
|
|
|
493
|
-
const cleanupError = await sweepDerivedSubjectData(
|
|
494
|
-
ctx,
|
|
495
|
-
subject,
|
|
496
|
-
subjectKey,
|
|
497
|
-
ctx.db.unsafeRaw(),
|
|
498
|
-
{ requestId, erasedBy: event.user.id },
|
|
499
|
-
);
|
|
500
|
-
|
|
501
506
|
// User subject: close the login door. DEK-erase makes the passwordHash
|
|
502
507
|
// ciphertext unreadable, but status + PATs are standalone credentials —
|
|
503
508
|
// the PAT resolver only checks revokedAt/expiresAt, NOT user.status
|
|
@@ -513,10 +518,26 @@ export const forgetSubjectWrite = defineWriteHandler({
|
|
|
513
518
|
// crash recovery is safe. User-feature guard: without the user feature
|
|
514
519
|
// read_users doesn't exist (crypto-only stack). Tenant subjects have no
|
|
515
520
|
// credentials.
|
|
521
|
+
// Runs before the sweep and in its own transaction: a failing sweep rolls the
|
|
522
|
+
// handler transaction back, and the handler tx holds no lock on this user's
|
|
523
|
+
// read_users row yet (only read gates ran), so there is no lock conflict.
|
|
516
524
|
if (raw.kind === "user" && ctx.registry.features.has("user")) {
|
|
517
|
-
await
|
|
525
|
+
await closeUserLoginDoor(
|
|
526
|
+
ctx,
|
|
527
|
+
raw.userId,
|
|
528
|
+
ctx.dbOutsideTransaction?.unsafeRaw(),
|
|
529
|
+
ctx.db.unsafeRaw(),
|
|
530
|
+
);
|
|
518
531
|
}
|
|
519
532
|
|
|
533
|
+
const cleanupError = await sweepDerivedSubjectData(
|
|
534
|
+
ctx,
|
|
535
|
+
subject,
|
|
536
|
+
subjectKey,
|
|
537
|
+
ctx.db.unsafeRaw(),
|
|
538
|
+
{ requestId, erasedBy: event.user.id },
|
|
539
|
+
);
|
|
540
|
+
|
|
520
541
|
await appendSubjectForgotten(
|
|
521
542
|
ctx,
|
|
522
543
|
event,
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.364.0",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "update-tenant-field no longer hides `type` from the handler schema",
|
|
6
|
+
"detail": "The `type` field is part of the documented payload; the 422 check against changing the stored type remains the protection."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.359.0",
|
|
4
10
|
"type": "improvement",
|
|
@@ -46,7 +46,6 @@ export function createUpdateTenantFieldHandler(
|
|
|
46
46
|
access: { roles: opts.roles ?? DEFAULT_FIELD_DEFINITION_WRITE_ROLES },
|
|
47
47
|
description:
|
|
48
48
|
"Replaces a tenant-owned custom-field definition with a complete new spec; serializedField.type may be omitted, in which case the stored type applies, and a different type is refused. Use it to relabel or re-constrain an existing field rather than to create or remove one.",
|
|
49
|
-
excludedFields: ["type"],
|
|
50
49
|
handler: async (event, ctx) => {
|
|
51
50
|
const payload = event.payload as UpdateFieldPayload; // @cast-boundary engine-payload
|
|
52
51
|
const tenantId = event.user.tenantId;
|
|
@@ -14,6 +14,27 @@ export const MAX_EMAIL_ATTACHMENTS = 20;
|
|
|
14
14
|
|
|
15
15
|
export const MAX_RENDERED_PDF_VARIABLES_BYTES = 64_000;
|
|
16
16
|
|
|
17
|
+
// Channel-data keys that steer delivery itself (envelope, headers, attachments);
|
|
18
|
+
// they are not document variables and must not be copied into attached PDF refs.
|
|
19
|
+
export const DELIVERY_ENVELOPE_DATA_KEYS = [
|
|
20
|
+
"attachments",
|
|
21
|
+
"from",
|
|
22
|
+
"fromName",
|
|
23
|
+
"replyTo",
|
|
24
|
+
"headers",
|
|
25
|
+
"unsubscribeUrl",
|
|
26
|
+
] as const;
|
|
27
|
+
|
|
28
|
+
const DELIVERY_ENVELOPE_DATA_KEY_SET: ReadonlySet<string> = new Set(DELIVERY_ENVELOPE_DATA_KEYS);
|
|
29
|
+
|
|
30
|
+
export function withoutDeliveryEnvelopeKeys(
|
|
31
|
+
variables: Readonly<Record<string, unknown>>,
|
|
32
|
+
): Readonly<Record<string, unknown>> {
|
|
33
|
+
return Object.fromEntries(
|
|
34
|
+
Object.entries(variables).filter(([key]) => !DELIVERY_ENVELOPE_DATA_KEY_SET.has(key)),
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
17
38
|
const MAX_ID_LENGTH = 64;
|
|
18
39
|
|
|
19
40
|
const fileRefIdSchema = z.string().min(1).max(MAX_ID_LENGTH);
|
|
@@ -1,4 +1,22 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.364.0",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "A failed final delivery attempt frees the idempotency key",
|
|
6
|
+
"detail": "A soft send failure and any error thrown on the final render or send attempt release the idempotency key, so the caller's retry is not dropped as a duplicate."
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"version": "0.364.0",
|
|
10
|
+
"type": "fix",
|
|
11
|
+
"title": "A mail without a readable inline image is sent without it",
|
|
12
|
+
"detail": "An inline image whose bytes could not be read (for example a system template logo while the job context has no provider resolver) failed the whole mail send. The attachment reader now logs a warning with the fileRef id and skips the image."
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"version": "0.364.0",
|
|
16
|
+
"type": "fix",
|
|
17
|
+
"title": "Delivery exports the envelope-key filter for mail variables",
|
|
18
|
+
"detail": "withoutDeliveryEnvelopeKeys strips reserved delivery keys from template variables."
|
|
19
|
+
},
|
|
2
20
|
{
|
|
3
21
|
"version": "0.363.0",
|
|
4
22
|
"type": "breaking",
|
|
@@ -3,8 +3,9 @@ import { createTenantDb, type DbConnection, type TenantDb } from "@cosmicdrift/k
|
|
|
3
3
|
import type { ConfigAccessor, Registry, TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
4
4
|
import { SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
|
|
5
5
|
import type { FileContext } from "@cosmicdrift/kumiko-framework/files";
|
|
6
|
+
import { createFallbackLogger } from "@cosmicdrift/kumiko-framework/logging";
|
|
6
7
|
import type { SecretsContext } from "@cosmicdrift/kumiko-framework/secrets";
|
|
7
|
-
import { readFileRefContent } from "../file-foundation/index.js";
|
|
8
|
+
import { type FileRefContent, readFileRefContent } from "../file-foundation/index.js";
|
|
8
9
|
import {
|
|
9
10
|
collectRendererPlugins,
|
|
10
11
|
createRendererFoundationApi,
|
|
@@ -40,7 +41,10 @@ export function createTemplateMailRenderer(
|
|
|
40
41
|
html: response.html,
|
|
41
42
|
text: response.text,
|
|
42
43
|
...(response.previewText !== undefined && { previewText: response.previewText }),
|
|
44
|
+
...(response.fromName !== undefined && { fromName: response.fromName }),
|
|
45
|
+
...(response.replyTo !== undefined && { replyTo: response.replyTo }),
|
|
43
46
|
...(response.attachments !== undefined && { attachments: response.attachments }),
|
|
47
|
+
...(response.layout !== undefined && { layout: response.layout }),
|
|
44
48
|
};
|
|
45
49
|
};
|
|
46
50
|
}
|
|
@@ -82,6 +86,22 @@ function createPdfRenderer(
|
|
|
82
86
|
};
|
|
83
87
|
}
|
|
84
88
|
|
|
89
|
+
// A decorative inline image (logo) that cannot be read must not fail the whole mail.
|
|
90
|
+
async function readInlineImageOrNull(
|
|
91
|
+
fileRefId: string,
|
|
92
|
+
read: () => Promise<FileRefContent | null>,
|
|
93
|
+
): Promise<FileRefContent | null> {
|
|
94
|
+
try {
|
|
95
|
+
return await read();
|
|
96
|
+
} catch (err) {
|
|
97
|
+
createFallbackLogger("delivery").warn("inline image could not be read, sending without it", {
|
|
98
|
+
fileRefId,
|
|
99
|
+
errorName: err instanceof Error ? err.name : typeof err,
|
|
100
|
+
});
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
85
105
|
// Inline images and file attachments the renderer took from a template may come from the system
|
|
86
106
|
// tenant (system template or tenant override of one). A file ref from notify data stays limited to
|
|
87
107
|
// the job's own tenant, since a caller picks it. Inline content that is not an image is dropped, so
|
|
@@ -96,13 +116,16 @@ export function createEmailAttachmentReader(
|
|
|
96
116
|
const renderPdf = createPdfRenderer(db, registry, tenantId, config, filesForTenant);
|
|
97
117
|
return async (ref: EmailAttachmentRef, origin: AttachmentOrigin = "message") => {
|
|
98
118
|
if (ref.kind === "rendered-pdf") return renderPdf(ref);
|
|
99
|
-
const
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
119
|
+
const readRef = () =>
|
|
120
|
+
readFileRefContent({
|
|
121
|
+
db,
|
|
122
|
+
filesForTenant,
|
|
123
|
+
fileRefId: ref.fileRefId,
|
|
124
|
+
allowedTenantIds:
|
|
125
|
+
ref.kind === "file" && origin === "message" ? [tenantId] : [tenantId, SYSTEM_TENANT_ID],
|
|
126
|
+
});
|
|
127
|
+
const found =
|
|
128
|
+
ref.kind === "inline" ? await readInlineImageOrNull(ref.fileRefId, readRef) : await readRef();
|
|
106
129
|
if (found === null) return null;
|
|
107
130
|
if (ref.kind === "inline" && !found.contentType.startsWith(INLINE_IMAGE_CONTENT_TYPE_PREFIX)) {
|
|
108
131
|
return null;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export { sanitizePdfAttachmentFileName } from "../shared/index.js";
|
|
2
2
|
export { hashUnsubscribeAddress } from "./address-opt-out.js";
|
|
3
3
|
export {
|
|
4
|
+
DELIVERY_ENVELOPE_DATA_KEYS,
|
|
4
5
|
type EmailAttachmentRef,
|
|
5
6
|
emailAttachmentRefSchema,
|
|
6
7
|
type FileAttachmentRef,
|
|
@@ -12,6 +13,7 @@ export {
|
|
|
12
13
|
type RenderedPdfAttachmentRef,
|
|
13
14
|
renderedPdfAttachmentRefSchema,
|
|
14
15
|
toAttachmentRef,
|
|
16
|
+
withoutDeliveryEnvelopeKeys,
|
|
15
17
|
} from "./attachment-ref.js";
|
|
16
18
|
export {
|
|
17
19
|
type AttemptLogRetentionOptions,
|
|
@@ -59,6 +59,8 @@ const sendJobPayloadSchema = renderJobPayloadSchema.extend({
|
|
|
59
59
|
html: z.string(),
|
|
60
60
|
subject: z.string(),
|
|
61
61
|
text: z.string().optional(),
|
|
62
|
+
fromName: z.string().optional(),
|
|
63
|
+
replyTo: z.string().optional(),
|
|
62
64
|
attachments: z.array(emailAttachmentRefSchema).max(MAX_EMAIL_ATTACHMENTS).optional(),
|
|
63
65
|
})
|
|
64
66
|
.optional(),
|
|
@@ -96,6 +98,8 @@ function toSendRendered(
|
|
|
96
98
|
html: rendered.html,
|
|
97
99
|
subject: rendered.subject,
|
|
98
100
|
...(rendered.text !== undefined && { text: rendered.text }),
|
|
101
|
+
...(rendered.fromName !== undefined && { fromName: rendered.fromName }),
|
|
102
|
+
...(rendered.replyTo !== undefined && { replyTo: rendered.replyTo }),
|
|
99
103
|
...(rendered.attachments !== undefined && {
|
|
100
104
|
attachments: rendered.attachments.map(toAttachmentRef),
|
|
101
105
|
}),
|
|
@@ -138,15 +142,11 @@ function entryFor(
|
|
|
138
142
|
};
|
|
139
143
|
}
|
|
140
144
|
|
|
141
|
-
//
|
|
142
|
-
//
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
p: RenderJobPayload,
|
|
147
|
-
): Promise<void> {
|
|
148
|
-
// skip: nothing to release before the last attempt or without an idempotency key
|
|
149
|
-
if (!ctx.finalAttempt || !p.idempotencyKey) return;
|
|
145
|
+
// Assumes ctx.redis is the handle the service claimed on; runProdApp wires the same
|
|
146
|
+
// instance into both.
|
|
147
|
+
async function releaseIdempotencyKey(ctx: JobContext, p: RenderJobPayload): Promise<void> {
|
|
148
|
+
// skip: no idempotency key was claimed for this notification
|
|
149
|
+
if (!p.idempotencyKey) return;
|
|
150
150
|
if (!ctx.redis) {
|
|
151
151
|
ctx.log.warn("delivery: idempotency key not released, job context has no redis", {
|
|
152
152
|
notificationType: p.notificationType,
|
|
@@ -162,6 +162,33 @@ async function releaseIdempotencyOnTerminalFailure(
|
|
|
162
162
|
}
|
|
163
163
|
}
|
|
164
164
|
|
|
165
|
+
// Only the final retry frees the key: earlier failures are retried by the runner and
|
|
166
|
+
// must still dedup.
|
|
167
|
+
async function releaseIdempotencyOnTerminalFailure(
|
|
168
|
+
ctx: JobContext,
|
|
169
|
+
p: RenderJobPayload,
|
|
170
|
+
): Promise<void> {
|
|
171
|
+
// skip: not the last attempt, the runner retries and the key must keep deduplicating
|
|
172
|
+
if (!ctx.finalAttempt) return;
|
|
173
|
+
await releaseIdempotencyKey(ctx, p);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Covers every throw after the payload parsed, not just the attempt-logged ones: a
|
|
177
|
+
// missing registry or unknown channel on the last attempt would otherwise hold the key
|
|
178
|
+
// until its TTL.
|
|
179
|
+
async function releasingKeyOnTerminalFailure<T>(
|
|
180
|
+
ctx: JobContext,
|
|
181
|
+
p: RenderJobPayload,
|
|
182
|
+
run: () => Promise<T>,
|
|
183
|
+
): Promise<T> {
|
|
184
|
+
try {
|
|
185
|
+
return await run();
|
|
186
|
+
} catch (err) {
|
|
187
|
+
await releaseIdempotencyOnTerminalFailure(ctx, p);
|
|
188
|
+
throw err;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
165
192
|
// The job runner stores err.message in the run row, and BullMQ keeps failedReason and
|
|
166
193
|
// the stack trace in Redis in plain text, so the rethrown error carries only the code.
|
|
167
194
|
// The redacted message goes to the log.
|
|
@@ -184,7 +211,6 @@ async function failAttempt(
|
|
|
184
211
|
p.deliveryAttemptId,
|
|
185
212
|
entryFor(p, "failed", code, p.address),
|
|
186
213
|
);
|
|
187
|
-
await releaseIdempotencyOnTerminalFailure(ctx, p);
|
|
188
214
|
throw new Error(code);
|
|
189
215
|
}
|
|
190
216
|
|
|
@@ -193,6 +219,10 @@ async function failAttempt(
|
|
|
193
219
|
// the send step is never reached, so a stuck render can't half-send.
|
|
194
220
|
export const deliveryRenderJob: JobHandlerFn = async (payload, ctx) => {
|
|
195
221
|
const p = renderJobPayloadSchema.parse(payload);
|
|
222
|
+
return releasingKeyOnTerminalFailure(ctx, p, () => renderStep(p, ctx));
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
async function renderStep(p: RenderJobPayload, ctx: JobContext): Promise<void> {
|
|
196
226
|
const tenantId = p.tenantId as TenantId; // @cast-boundary engine-payload — stream tenant
|
|
197
227
|
const { db, registry } = requireTenantScopedDeps(ctx, tenantId);
|
|
198
228
|
const channel = resolveChannel(registry, p.channelName);
|
|
@@ -203,7 +233,7 @@ export const deliveryRenderJob: JobHandlerFn = async (payload, ctx) => {
|
|
|
203
233
|
tenantId,
|
|
204
234
|
ctx.secrets,
|
|
205
235
|
ctx.config,
|
|
206
|
-
(fileTenantId) => fileContextForTenant(ctx, fileTenantId),
|
|
236
|
+
(fileTenantId) => fileContextForTenant(ctx, ctx.systemUser.tenantId, fileTenantId),
|
|
207
237
|
);
|
|
208
238
|
|
|
209
239
|
let rendered: RenderedMessage;
|
|
@@ -225,13 +255,17 @@ export const deliveryRenderJob: JobHandlerFn = async (payload, ctx) => {
|
|
|
225
255
|
} catch (err) {
|
|
226
256
|
return failAttempt(ctx, db, registry, p, "channel_error", err);
|
|
227
257
|
}
|
|
228
|
-
}
|
|
258
|
+
}
|
|
229
259
|
|
|
230
260
|
// Deliver via the channel using the rendered payload (if any). On failure:
|
|
231
261
|
// record the terminal failed event and rethrow so BullMQ retries the send with
|
|
232
262
|
// the same already-rendered HTML — no re-render needed.
|
|
233
263
|
export const deliverySendJob: JobHandlerFn = async (payload, ctx) => {
|
|
234
264
|
const p = sendJobPayloadSchema.parse(payload);
|
|
265
|
+
return releasingKeyOnTerminalFailure(ctx, p, () => sendStep(p, ctx));
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
async function sendStep(p: z.infer<typeof sendJobPayloadSchema>, ctx: JobContext): Promise<void> {
|
|
235
269
|
const tenantId = p.tenantId as TenantId; // @cast-boundary engine-payload — stream tenant
|
|
236
270
|
const { db, registry } = requireTenantScopedDeps(ctx, tenantId);
|
|
237
271
|
const channel = resolveChannel(registry, p.channelName);
|
|
@@ -242,7 +276,7 @@ export const deliverySendJob: JobHandlerFn = async (payload, ctx) => {
|
|
|
242
276
|
tenantId,
|
|
243
277
|
ctx.secrets,
|
|
244
278
|
ctx.config,
|
|
245
|
-
(fileTenantId) => fileContextForTenant(ctx, fileTenantId),
|
|
279
|
+
(fileTenantId) => fileContextForTenant(ctx, ctx.systemUser.tenantId, fileTenantId),
|
|
246
280
|
);
|
|
247
281
|
|
|
248
282
|
try {
|
|
@@ -259,7 +293,10 @@ export const deliverySendJob: JobHandlerFn = async (payload, ctx) => {
|
|
|
259
293
|
result.confirmed,
|
|
260
294
|
),
|
|
261
295
|
);
|
|
296
|
+
// A soft failure ends the job successfully, so no retry follows and the caller's
|
|
297
|
+
// retry must not be skipped as a duplicate.
|
|
298
|
+
if (result.status === "failed") await releaseIdempotencyKey(ctx, p);
|
|
262
299
|
} catch (err) {
|
|
263
300
|
return failAttempt(ctx, db, registry, p, "send_failed", err);
|
|
264
301
|
}
|
|
265
|
-
}
|
|
302
|
+
}
|
|
@@ -13,7 +13,7 @@ import type {
|
|
|
13
13
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
14
14
|
import type { FileProviderResolver } from "@cosmicdrift/kumiko-framework/files";
|
|
15
15
|
import type { SecretsContext } from "@cosmicdrift/kumiko-framework/secrets";
|
|
16
|
-
import type { MailPayload } from "../renderer-foundation/index.js";
|
|
16
|
+
import type { MailPayload, RenderedTemplateRef } from "../renderer-foundation/index.js";
|
|
17
17
|
import type { EmailAttachmentRef } from "./attachment-ref.js";
|
|
18
18
|
import { DELIVERY_CHANNEL_EXTENSION } from "./constants.js";
|
|
19
19
|
|
|
@@ -24,7 +24,11 @@ export type RenderedMail = {
|
|
|
24
24
|
readonly html: string;
|
|
25
25
|
readonly text: string;
|
|
26
26
|
readonly previewText?: string;
|
|
27
|
+
readonly fromName?: string;
|
|
28
|
+
readonly replyTo?: string;
|
|
27
29
|
readonly attachments?: readonly EmailAttachmentRef[];
|
|
30
|
+
/** The layout version the html was rendered through; the editor preview names it. */
|
|
31
|
+
readonly layout?: RenderedTemplateRef;
|
|
28
32
|
};
|
|
29
33
|
|
|
30
34
|
export type ChannelContext = {
|
|
@@ -71,6 +75,8 @@ export type RenderedMessage = {
|
|
|
71
75
|
readonly html: string;
|
|
72
76
|
readonly subject: string;
|
|
73
77
|
readonly text?: string;
|
|
78
|
+
readonly fromName?: string;
|
|
79
|
+
readonly replyTo?: string;
|
|
74
80
|
readonly attachments?: readonly EmailAttachmentRef[];
|
|
75
81
|
};
|
|
76
82
|
|
|
@@ -147,7 +147,7 @@ export const publicVariantQuery = defineQueryHandler({
|
|
|
147
147
|
const spec = resolveFieldVariant(ctx.registry, entityType, fieldName, query.payload.variant);
|
|
148
148
|
if (!spec) return null;
|
|
149
149
|
const result = await derivatives.variant(query.payload.fileRefId, spec, query.payload.variant);
|
|
150
|
-
const bytes = await files.
|
|
150
|
+
const bytes = await files.refFromFileRef(result).read();
|
|
151
151
|
|
|
152
152
|
// ponytail: fully buffered (no streaming), +33% over the wire from
|
|
153
153
|
// base64 — HttpRouteHandlerDeps only exposes {app, systemQuery} (no
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.364.0",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "readFileRefContent reads handed-over files; fileContextForTenant takes the context tenant",
|
|
6
|
+
"detail": "readFileRefContent, readTemplateResource and the public variant query read bytes through FileContext.refFromFileRef, so a fileRef moved by a tenant handover is readable again. fileContextForTenant gains a `contextTenantId` argument: without a provider resolver it returns undefined for any other tenant instead of the context's files, which are bound to a different key space."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.292.0",
|
|
4
10
|
"type": "improvement",
|
|
@@ -22,16 +22,19 @@ type FileRefRow = {
|
|
|
22
22
|
readonly size: number | bigint | string;
|
|
23
23
|
};
|
|
24
24
|
|
|
25
|
-
// Tenant-bound on purpose:
|
|
25
|
+
// Tenant-bound on purpose: without a resolver `ctx.files` only reaches the storage of the
|
|
26
|
+
// context's own tenant (`contextTenantId`), so a request for another tenant's files is
|
|
27
|
+
// undefined instead of silently reading through the wrong tenant's storage.
|
|
26
28
|
export function fileContextForTenant(
|
|
27
29
|
ctx: {
|
|
28
30
|
readonly _fileProviderResolver?: FileProviderResolver | undefined;
|
|
29
31
|
readonly files?: FileContext | undefined;
|
|
30
32
|
},
|
|
33
|
+
contextTenantId: string,
|
|
31
34
|
tenantId: string,
|
|
32
35
|
): FileContext | undefined {
|
|
33
36
|
const resolver = ctx._fileProviderResolver;
|
|
34
|
-
if (resolver === undefined) return ctx.files;
|
|
37
|
+
if (resolver === undefined) return tenantId === contextTenantId ? ctx.files : undefined;
|
|
35
38
|
return fileContextFromResolver(resolver, tenantId);
|
|
36
39
|
}
|
|
37
40
|
|
|
@@ -81,6 +84,6 @@ export async function readFileRefContent(args: {
|
|
|
81
84
|
if (files === undefined) {
|
|
82
85
|
throw new Error("readFileRefContent: no file storage is available for the file's tenant");
|
|
83
86
|
}
|
|
84
|
-
const content = await files.
|
|
87
|
+
const content = await files.refFromFileRef(row).read();
|
|
85
88
|
return { content, contentType: row.mimeType, tenantId: row.tenantId, size };
|
|
86
89
|
}
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.364.0",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "Tenant destroy hooks make progress even when the deadline already passed",
|
|
6
|
+
"detail": "The chunked migration takes a minBatches option and the file destroy hooks process one chunk per tick, so a slow list call no longer makes every run time out empty."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.354.0",
|
|
4
10
|
"type": "fix",
|
|
@@ -129,6 +129,7 @@ export const fileRefTenantDestroyHook: TenantDataDestroyHook = async (ctx) => {
|
|
|
129
129
|
},
|
|
130
130
|
maxFailures: 1,
|
|
131
131
|
deadlineAt: ctx.deadlineAt ?? Number.POSITIVE_INFINITY,
|
|
132
|
+
minBatches: 1,
|
|
132
133
|
onRowError: (_row, err) => {
|
|
133
134
|
firstFailure = err instanceof Error ? err.message : String(err);
|
|
134
135
|
},
|
|
@@ -222,6 +223,9 @@ export const fileRefStorageDestroyHook: StorageProviderDestroyTenantHook = async
|
|
|
222
223
|
},
|
|
223
224
|
maxFailures: 1,
|
|
224
225
|
deadlineAt: ctx.deadlineAt ?? Number.POSITIVE_INFINITY,
|
|
226
|
+
// list() and the survivor lookup above can use up the whole budget; one chunk per tick
|
|
227
|
+
// still deletes, so the stage never reports "no progress".
|
|
228
|
+
minBatches: 1,
|
|
225
229
|
onRowError: (_key, err) => {
|
|
226
230
|
firstFailure = err instanceof Error ? err.message : String(err);
|
|
227
231
|
},
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.364.0",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "Watch supervisor skips and logs accounts whose address does not decrypt",
|
|
6
|
+
"detail": "Previously a single such account made the whole account snapshot throw."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.340.0",
|
|
4
10
|
"type": "improvement",
|