@cosmicdrift/kumiko-bundled-features 0.364.0 → 0.365.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.
Files changed (161) hide show
  1. package/dist/audit/feature.js +1 -1
  2. package/dist/audit/run-escape-hatch-retention.d.ts +1 -0
  3. package/dist/audit/run-escape-hatch-retention.js +39 -12
  4. package/dist/auth-email-password/bootstrap.d.ts +1 -3
  5. package/dist/auth-email-password/feature.js +2 -0
  6. package/dist/auth-email-password/handlers/invite-accept-with-login.write.js +2 -3
  7. package/dist/auth-email-password/handlers/invite-create.write.d.ts +2 -3
  8. package/dist/auth-email-password/handlers/invite-create.write.js +4 -1
  9. package/dist/auth-email-password/handlers/switch-tenant-mfa-gate.write.d.ts +1 -0
  10. package/dist/auth-email-password/handlers/switch-tenant-mfa-gate.write.js +1 -1
  11. package/dist/auth-email-password/magic-link-mail.js +6 -3
  12. package/dist/auth-email-password/mail-i18n.d.ts +3 -0
  13. package/dist/auth-email-password/mail-i18n.js +10 -0
  14. package/dist/billing-foundation/consumer-protection/termination-record.js +8 -1
  15. package/dist/billing-foundation/i18n.js +6 -0
  16. package/dist/billing-foundation/subscription-tier-sync.js +8 -2
  17. package/dist/cap-counter/book-cap-usage.js +8 -0
  18. package/dist/cap-counter/cap-reservation.js +7 -3
  19. package/dist/cap-counter/handlers/increment-rolling.write.d.ts +1 -0
  20. package/dist/cap-counter/handlers/increment-rolling.write.js +2 -0
  21. package/dist/cap-overview/handlers/tenant-caps-list.query.js +3 -1
  22. package/dist/cap-overview/handlers/tenant-options.query.js +3 -1
  23. package/dist/channel-discord/discord-channel.js +3 -2
  24. package/dist/channel-email/email-channel.js +24 -19
  25. package/dist/channel-email/smtp-transport.js +6 -1
  26. package/dist/channel-slack/slack-channel.js +3 -2
  27. package/dist/channel-teams/teams-channel.js +3 -2
  28. package/dist/crypto-shredding/handlers/forget-subject.write.js +2 -2
  29. package/dist/custom-fields/db/queries/retention.d.ts +1 -1
  30. package/dist/custom-fields/db/queries/retention.js +2 -1
  31. package/dist/data-retention/run-retention-cleanup.js +4 -5
  32. package/dist/delivery/attachment-ref.js +16 -1
  33. package/dist/delivery/delivery-service.js +71 -82
  34. package/dist/feature-toggles/toggle-runtime.d.ts +5 -3
  35. package/dist/feature-toggles/toggle-runtime.js +25 -12
  36. package/dist/files-tenant-data/index.d.ts +1 -0
  37. package/dist/files-tenant-data/index.js +2 -1
  38. package/dist/form-draft/db/queries/owned-file-refs.d.ts +1 -1
  39. package/dist/form-draft/db/queries/owned-file-refs.js +9 -2
  40. package/dist/form-draft/handlers/discard.write.js +1 -1
  41. package/dist/jobs/job-run-logger.js +5 -2
  42. package/dist/metrics/filters.d.ts +16 -1
  43. package/dist/metrics/filters.js +27 -11
  44. package/dist/personal-access-tokens/revoke-for-user.js +3 -7
  45. package/dist/secrets/handlers/delete.write.js +3 -1
  46. package/dist/secrets/write-gate.d.ts +3 -1
  47. package/dist/secrets/write-gate.js +6 -3
  48. package/dist/shared/index.d.ts +1 -0
  49. package/dist/shared/index.js +1 -0
  50. package/dist/shared/resource-image-formats.d.ts +23 -0
  51. package/dist/shared/resource-image-formats.js +21 -0
  52. package/dist/shared/sharp-input-limits.js +7 -8
  53. package/dist/step-dispatcher/webhook-runner.js +2 -2
  54. package/dist/template-resolver/compile-document-body.d.ts +3 -0
  55. package/dist/template-resolver/compile-document-body.js +63 -12
  56. package/dist/template-resolver/compile-steps-block.d.ts +10 -0
  57. package/dist/template-resolver/compile-steps-block.js +45 -0
  58. package/dist/template-resolver/constants.d.ts +4 -1
  59. package/dist/template-resolver/constants.js +10 -1
  60. package/dist/template-resolver/document-sanitizer.js +3 -0
  61. package/dist/template-resolver/document-schema.d.ts +2 -1
  62. package/dist/template-resolver/document-schema.js +6 -1
  63. package/dist/template-resolver/document-steps.d.ts +17 -0
  64. package/dist/template-resolver/document-steps.js +93 -0
  65. package/dist/template-resolver/feature.js +9 -0
  66. package/dist/template-resolver/handlers/bundle-import.d.ts +7 -1
  67. package/dist/template-resolver/handlers/bundle-import.js +63 -38
  68. package/dist/template-resolver/handlers/collection-set.write.js +4 -3
  69. package/dist/template-resolver/handlers/copy-locale.write.d.ts +1 -1
  70. package/dist/template-resolver/handlers/copy-locale.write.js +83 -55
  71. package/dist/template-resolver/handlers/diff-to-system.query.js +1 -0
  72. package/dist/template-resolver/handlers/import-bundle-system.write.d.ts +1 -1
  73. package/dist/template-resolver/handlers/import-bundle-system.write.js +16 -5
  74. package/dist/template-resolver/handlers/import-bundle.write.d.ts +1 -1
  75. package/dist/template-resolver/handlers/import-bundle.write.js +11 -1
  76. package/dist/template-resolver/handlers/overview.query.d.ts +3 -0
  77. package/dist/template-resolver/handlers/overview.query.js +9 -1
  78. package/dist/template-resolver/handlers/resource-upload.write.d.ts +2 -2
  79. package/dist/template-resolver/handlers/resource-upload.write.js +35 -5
  80. package/dist/template-resolver/handlers/validate.query.js +4 -1
  81. package/dist/template-resolver/index.d.ts +3 -3
  82. package/dist/template-resolver/index.js +3 -3
  83. package/dist/template-resolver/layout-appearance.d.ts +1 -1
  84. package/dist/template-resolver/layout-appearance.js +19 -9
  85. package/dist/template-resolver/mail-constants.d.ts +2 -0
  86. package/dist/template-resolver/mail-constants.js +3 -0
  87. package/dist/template-resolver/resource-image.d.ts +2 -9
  88. package/dist/template-resolver/resource-image.js +2 -11
  89. package/dist/template-resolver/seeding.js +2 -0
  90. package/dist/template-resolver/store-template-resource.d.ts +53 -9
  91. package/dist/template-resolver/store-template-resource.js +250 -97
  92. package/dist/template-resolver/template-bundle.js +4 -0
  93. package/dist/template-resolver/template-draft.d.ts +3 -0
  94. package/dist/template-resolver/template-draft.js +1 -1
  95. package/dist/template-resolver/template-meta.d.ts +5 -1
  96. package/dist/template-resolver/template-meta.js +7 -0
  97. package/dist/template-resolver/template-preview.js +1 -2
  98. package/dist/template-resolver/template-resources.d.ts +0 -5
  99. package/dist/template-resolver/template-resources.js +2 -34
  100. package/dist/template-resolver/template-validation.js +12 -9
  101. package/dist/template-resolver/web/document-editor-extensions.d.ts +1 -0
  102. package/dist/template-resolver/web/document-editor-extensions.js +26 -1
  103. package/dist/template-resolver/web/document-editor.js +5 -1
  104. package/dist/template-resolver/web/document-tiptap-editor.js +30 -7
  105. package/dist/template-resolver/web/document-toolbar-blocks.d.ts +5 -5
  106. package/dist/template-resolver/web/document-toolbar-blocks.js +2 -0
  107. package/dist/template-resolver/web/i18n.js +5 -0
  108. package/dist/template-resolver/web/index.d.ts +1 -0
  109. package/dist/template-resolver/web/index.js +1 -0
  110. package/dist/template-resolver/web/placeholder-line-editor.d.ts +4 -2
  111. package/dist/template-resolver/web/placeholder-line-editor.js +8 -6
  112. package/dist/tenant/handlers/update-member-roles.write.js +3 -6
  113. package/dist/tenant/seeding.js +23 -24
  114. package/dist/tenant-handover/move-entity-graph.js +14 -4
  115. package/dist/tenant-lifecycle/run-tenant-destroy.d.ts +1 -0
  116. package/dist/tenant-lifecycle/run-tenant-destroy.js +8 -1
  117. package/dist/tenant-lifecycle/stages.js +3 -2
  118. package/dist/tier-engine/constants.d.ts +2 -0
  119. package/dist/tier-engine/constants.js +4 -0
  120. package/dist/tier-engine/entity.d.ts +15 -14
  121. package/dist/tier-engine/entity.js +4 -3
  122. package/dist/tier-engine/handlers/set-tenant-tier.write.d.ts +5 -0
  123. package/dist/tier-engine/handlers/set-tenant-tier.write.js +7 -4
  124. package/dist/tier-engine/index.d.ts +1 -1
  125. package/dist/tier-engine/index.js +1 -1
  126. package/dist/tier-engine/tier-resolver.d.ts +13 -13
  127. package/dist/user/constants.d.ts +1 -0
  128. package/dist/user/invited-global-roles.d.ts +2 -4
  129. package/dist/user-data-rights/email-templates.js +0 -1
  130. package/dist/user-data-rights/i18n.js +5 -0
  131. package/dist/user-data-rights/lib/update-user-lifecycle.d.ts +2 -0
  132. package/dist/user-data-rights/lib/update-user-lifecycle.js +6 -2
  133. package/dist/user-data-rights/run-forget-cleanup.js +2 -2
  134. package/dist/waitlist/feature.js +1 -1
  135. package/dist/waitlist/handlers/invite.write.js +21 -3
  136. package/dist/waitlist/handlers/reject.write.js +3 -16
  137. package/dist/waitlist/handlers/submit.write.js +29 -7
  138. package/dist/waitlist/i18n.js +9 -0
  139. package/dist/waitlist/lib.d.ts +2 -1
  140. package/dist/waitlist/lib.js +15 -0
  141. package/dist/workflow-runner/feature.js +1 -1
  142. package/package.json +10 -10
  143. package/src/audit/changes.json +12 -0
  144. package/src/auth-email-password/changes.json +18 -0
  145. package/src/billing-foundation/changes.json +12 -0
  146. package/src/cap-counter/changes.json +13 -0
  147. package/src/cap-overview/changes.json +6 -0
  148. package/src/channel-email/changes.json +18 -0
  149. package/src/delivery/changes.json +12 -0
  150. package/src/document-ingest-foundation/changes.json +6 -0
  151. package/src/feature-toggles/changes.json +7 -0
  152. package/src/jobs/changes.json +12 -0
  153. package/src/metrics/changes.json +6 -0
  154. package/src/personal-access-tokens/changes.json +6 -0
  155. package/src/secrets/changes.json +6 -0
  156. package/src/template-resolver/changes.json +81 -0
  157. package/src/tenant/changes.json +6 -0
  158. package/src/tenant-lifecycle/changes.json +6 -0
  159. package/src/tier-engine/changes.json +12 -0
  160. package/src/user-data-rights/changes.json +6 -0
  161. package/src/waitlist/changes.json +18 -0
@@ -76,15 +76,13 @@ function listItemFields(variables, listName) {
76
76
  function isKnownName(name, scope, itemFields) {
77
77
  const { variables } = scope;
78
78
  const root = rootName(name);
79
- if (Object.hasOwn(itemFields, name) || Object.hasOwn(itemFields, root)) {
79
+ // Declared variables and item fields are scalars: a dotted path under one never renders a value.
80
+ if (Object.hasOwn(itemFields, name))
80
81
  return { known: true, used: "" };
81
- }
82
82
  if (scope.systemPlaceholders.has(root))
83
83
  return { known: true, used: "" };
84
84
  if (Object.hasOwn(variables, name))
85
85
  return { known: true, used: name };
86
- if (Object.hasOwn(variables, root))
87
- return { known: true, used: root };
88
86
  return { known: false, used: "" };
89
87
  }
90
88
  function recordName(name, itemFields, scope) {
@@ -97,6 +95,10 @@ function recordName(name, itemFields, scope) {
97
95
  function enclosingEachName(element) {
98
96
  return element.closest("tr[data-each]")?.getAttribute("data-each") ?? null;
99
97
  }
98
+ const NAME_ATTRIBUTE_BY_TAG = {
99
+ span: "data-variable",
100
+ div: "data-steps",
101
+ };
100
102
  function scanBodyPlaceholders(body, scope) {
101
103
  withParsedDocument(body, (root) => {
102
104
  for (const element of Array.from(root.querySelectorAll("tr[data-each]"))) {
@@ -107,11 +109,12 @@ function scanBodyPlaceholders(body, scope) {
107
109
  else
108
110
  scope.unknown.add(listName);
109
111
  }
110
- for (const element of Array.from(root.querySelectorAll("span[data-variable], a[href], img[data-resource]"))) {
112
+ for (const element of Array.from(root.querySelectorAll("span[data-variable], div[data-steps], a[href], img[data-resource]"))) {
111
113
  const itemFields = listItemFields(scope.variables, enclosingEachName(element));
112
114
  const tag = element.tagName.toLowerCase();
113
- if (tag === "span") {
114
- recordName(element.getAttribute("data-variable") ?? "", itemFields, scope);
115
+ const nameAttribute = NAME_ATTRIBUTE_BY_TAG[tag];
116
+ if (nameAttribute !== undefined) {
117
+ recordName(element.getAttribute(nameAttribute) ?? "", itemFields, scope);
115
118
  }
116
119
  else if (tag === "a") {
117
120
  const href = element.getAttribute("href") ?? "";
@@ -127,7 +130,7 @@ function scanBodyPlaceholders(body, scope) {
127
130
  });
128
131
  }
129
132
  const HANDLEBARS_EXPRESSION = /\{\{([^{}]*)\}\}/g;
130
- // Meta text supports only plain `{{name}}` / `{{name.field}}`; every other
133
+ // Meta text supports only plain `{{name}}` references; every other
131
134
  // Handlebars construct is reported as unknown so it can never reach the compiler.
132
135
  function scanMetaPlaceholders(text, scope) {
133
136
  const leftover = text.replace(HANDLEBARS_EXPRESSION, (_match, inner) => {
@@ -144,7 +147,7 @@ function scanMetaPlaceholders(text, scope) {
144
147
  function metaTexts(meta) {
145
148
  if (meta === null || !("subject" in meta))
146
149
  return [];
147
- return [meta.subject, ...(meta.previewText === undefined ? [] : [meta.previewText])];
150
+ return [meta.subject, meta.previewText, meta.headerChip, meta.tone, meta.footerNote].filter((text) => text !== undefined);
148
151
  }
149
152
  export function validateTemplatePlaceholders(input) {
150
153
  const scope = {
@@ -6,6 +6,7 @@ export type DocumentEditorLabels = {
6
6
  readonly image: string;
7
7
  readonly pageBreak: string;
8
8
  readonly button?: string;
9
+ readonly steps?: string;
9
10
  /** Canvas block label of an image, e.g. "Image · inline". */
10
11
  readonly imageBlock?: string;
11
12
  readonly variableLabels?: Readonly<Record<string, string>>;
@@ -1,6 +1,7 @@
1
1
  import { ListKeymap } from "@tiptap/extension-list";
2
2
  import { Dropcursor, Gapcursor, Placeholder, UndoRedo } from "@tiptap/extensions";
3
- import { DocumentButton, DocumentPageBreak, DocumentResource, DocumentVariable, documentExtensions, } from "../document-schema.js";
3
+ import { DocumentButton, DocumentPageBreak, DocumentResource, DocumentSteps, DocumentVariable, documentExtensions, } from "../document-schema.js";
4
+ import { readDocumentStepsAttribute } from "../document-steps.js";
4
5
  const DOCUMENT_RESOURCE_SIZE_TOKEN = "{size}";
5
6
  export const DOCUMENT_BLOCK_ATTRIBUTE = "data-document-block";
6
7
  function createElementWithClass(tag, className, text) {
@@ -110,6 +111,28 @@ function createPageBreakView(pageBreakLabel) {
110
111
  return { dom };
111
112
  };
112
113
  }
114
+ // A static sketch of the row: which stage is reached is only known at render time.
115
+ function createStepsView(labels) {
116
+ return ({ node }) => {
117
+ const dom = createElementWithClass("div", "relative my-3 rounded border border-dashed border-input px-3 pb-2 pt-3");
118
+ dom.setAttribute(DOCUMENT_BLOCK_ATTRIBUTE, "");
119
+ dom.contentEditable = "false";
120
+ const variable = String(node.attrs["variable"]);
121
+ const variableLabels = labels.variableLabels ?? {};
122
+ const variableLabel = Object.hasOwn(variableLabels, variable)
123
+ ? variableLabels[variable]
124
+ : undefined;
125
+ dom.append(createBlockLabel(`${labels.steps ?? "Steps"} · ${variableLabel ?? variable}`));
126
+ const row = createElementWithClass("div", "flex gap-1.5");
127
+ for (const step of readDocumentStepsAttribute(node.attrs["steps"])) {
128
+ const cell = createElementWithClass("div", "min-w-0 flex-1");
129
+ cell.append(createElementWithClass("div", "h-1 rounded-full bg-primary/40"), createElementWithClass("div", "truncate pt-1.5 text-xs text-muted-foreground", step.label));
130
+ row.append(cell);
131
+ }
132
+ dom.append(row);
133
+ return { dom };
134
+ };
135
+ }
113
136
  // Replaced by name, not appended: the schema must keep exactly one node per
114
137
  // name, and only the editor view differs from the shared document schema.
115
138
  export function createDocumentEditorExtensions(labels) {
@@ -125,6 +148,8 @@ export function createDocumentEditorExtensions(labels) {
125
148
  return labels.blockLabels
126
149
  ? DocumentButton.extend({ addNodeView: () => createButtonView(labels.button ?? "") })
127
150
  : extension;
151
+ case DocumentSteps.name:
152
+ return DocumentSteps.extend({ addNodeView: () => createStepsView(labels) });
128
153
  case DocumentPageBreak.name:
129
154
  return DocumentPageBreak.extend({
130
155
  addNodeView: () => createPageBreakView(labels.pageBreak),
@@ -13,5 +13,9 @@ export const DocumentBlocksContext = createContext(undefined);
13
13
  export function DocumentContentEditor(props) {
14
14
  const contextBlocks = useContext(DocumentBlocksContext);
15
15
  const editorProps = props.blocks === undefined ? { ...props, blocks: contextBlocks } : props;
16
- return (_jsx(Suspense, { fallback: _jsx(TextareaContentEditor, { id: props.id, value: props.value, onChange: props.onChange, variables: props.variables, readOnly: props.readOnly }), children: _jsx(LazyDocumentTiptapEditor, { ...editorProps }) }));
16
+ return (_jsx(Suspense
17
+ // Read-only without chips: a textarea would insert {{name}} text and plaintext into a document body.
18
+ , {
19
+ // Read-only without chips: a textarea would insert {{name}} text and plaintext into a document body.
20
+ fallback: _jsx(TextareaContentEditor, { id: props.id, value: props.value, onChange: props.onChange, variables: [], readOnly: true }), children: _jsx(LazyDocumentTiptapEditor, { ...editorProps }) }));
17
21
  }
@@ -10,10 +10,11 @@ import { cn } from "@cosmicdrift/kumiko-renderer-web";
10
10
  import { isOneOf } from "@cosmicdrift/kumiko-types/type-guards";
11
11
  import { EditorContent, useEditor, useEditorState } from "@tiptap/react";
12
12
  import { BubbleMenu } from "@tiptap/react/menus";
13
- import { AlignCenter, AlignJustify, AlignLeft, AlignRight, Bold as BoldIcon, Braces, ChevronDown, Image as ImageIcon, Italic as ItalicIcon, Link as LinkIcon, List, ListOrdered, Minus, MousePointerClick, Repeat, Scissors, Table as TableIcon, Underline as UnderlineIcon, } from "lucide-react";
13
+ import { AlignCenter, AlignJustify, AlignLeft, AlignRight, Bold as BoldIcon, Braces, ChevronDown, Image as ImageIcon, Italic as ItalicIcon, Link as LinkIcon, List, ListChecks, ListOrdered, Minus, MousePointerClick, Repeat, Scissors, Table as TableIcon, Underline as UnderlineIcon, } from "lucide-react";
14
14
  import { Fragment, useEffect, useMemo, useState } from "react";
15
15
  import { createPortal } from "react-dom";
16
16
  import { DOCUMENT_TEXT_ALIGNMENTS, isAllowedDocumentLinkHref, isDocumentPlaceholderHref, isDocumentReferenceName, isDocumentResourceKey, } from "../document-schema.js";
17
+ import { parseDocumentStepsInput } from "../document-steps.js";
17
18
  import { createDocumentEditorExtensions, DOCUMENT_BLOCK_ATTRIBUTE, } from "./document-editor-extensions.js";
18
19
  import { MAIL_DOCUMENT_BLOCKS, } from "./document-toolbar-blocks.js";
19
20
  import { createEditorHandle } from "./editor-handle.js";
@@ -33,6 +34,7 @@ const BLOCK_PANEL = {
33
34
  button: "button",
34
35
  repeat: "repeat",
35
36
  align: "align",
37
+ steps: "steps",
36
38
  };
37
39
  const ALIGNMENT_ICONS = {
38
40
  left: _jsx(AlignLeft, { size: 16 }),
@@ -185,6 +187,7 @@ function InsertPanel({ editor, editorId, kind, variables, onClose }) {
185
187
  const [resource, setResource] = useState("");
186
188
  const [alt, setAlt] = useState("");
187
189
  const [buttonText, setButtonText] = useState(() => t("template-resolver.documentEditor.buttonDefaultText"));
190
+ const [stepsText, setStepsText] = useState(() => t("template-resolver.documentEditor.stepsDefault"));
188
191
  const [chosenName, setChosenName] = useState("");
189
192
  const [error, setError] = useState(null);
190
193
  const referenceNames = variables.filter(isDocumentReferenceName);
@@ -231,7 +234,15 @@ function InsertPanel({ editor, editorId, kind, variables, onClose }) {
231
234
  }
232
235
  insertAndClose({ type: "resource", attrs: { resource, alt: alt || null } });
233
236
  };
234
- const needsVariables = kind === "button" || kind === "repeat" || kind === "placeholder";
237
+ const submitSteps = () => {
238
+ const steps = parseDocumentStepsInput(stepsText);
239
+ if (steps === null) {
240
+ setError(t("template-resolver.documentEditor.invalidSteps"));
241
+ return;
242
+ }
243
+ insertAndClose({ type: "steps", attrs: { variable: selectedName, steps } });
244
+ };
245
+ const needsVariables = kind === "button" || kind === "repeat" || kind === "placeholder" || kind === "steps";
235
246
  const noVariables = needsVariables && selectable.length === 0;
236
247
  const idPrefix = `${editorId}-panel`;
237
248
  return (_jsxs("div", { className: "flex flex-col gap-2 border-b border-input bg-muted/40 p-2", children: [noVariables && (_jsx("p", { className: "text-sm text-muted-foreground", children: t("template-resolver.documentEditor.noVariables") })), kind === "link" && (_jsx(Field, { id: `${idPrefix}-href`, label: t("template-resolver.documentEditor.linkHref"), children: _jsx(Input, { kind: "text", id: `${idPrefix}-href`, name: "href", value: href, onChange: setHref, placeholder: t("template-resolver.documentEditor.linkHrefPlaceholder") }) })), kind === "placeholder" && !noVariables && (_jsx("div", { className: "flex flex-wrap gap-1", children: _jsx(VariableChips, { variables: selectable, onInsert: (name) => insertAndClose({ type: "variable", attrs: { name } }) }) })), kind === "align" && (_jsx("div", { className: "flex flex-wrap gap-1", children: DOCUMENT_TEXT_ALIGNMENTS.map((alignment) => (_jsxs(Button, { type: "button", variant: "secondary", size: "sm", onClick: () => {
@@ -242,7 +253,7 @@ function InsertPanel({ editor, editorId, kind, variables, onClose }) {
242
253
  .updateAttributes("heading", { textAlign: alignment })
243
254
  .run();
244
255
  onClose();
245
- }, children: [ALIGNMENT_ICONS[alignment], t(ALIGNMENT_LABEL_KEYS[alignment])] }, alignment))) })), kind === "image" && (_jsxs(_Fragment, { children: [_jsx(Field, { id: `${idPrefix}-resource`, label: t("template-resolver.documentEditor.resourceKey"), children: _jsx(Input, { kind: "text", id: `${idPrefix}-resource`, name: "resource", value: resource, onChange: setResource }) }), _jsx(Field, { id: `${idPrefix}-alt`, label: t("template-resolver.documentEditor.imageAlt"), children: _jsx(Input, { kind: "text", id: `${idPrefix}-alt`, name: "alt", value: alt, onChange: setAlt }) })] })), kind === "button" && !noVariables && (_jsxs(_Fragment, { children: [_jsx(NameSelect, { id: `${idPrefix}-target`, label: t("template-resolver.documentEditor.buttonTarget"), names: selectable, value: selectedName, onChange: setChosenName }), _jsx(Field, { id: `${idPrefix}-text`, label: t("template-resolver.documentEditor.buttonText"), children: _jsx(Input, { kind: "text", id: `${idPrefix}-text`, name: "text", value: buttonText, onChange: setButtonText }) })] })), kind === "repeat" && !noVariables && (_jsx(NameSelect, { id: `${idPrefix}-list`, label: t("template-resolver.documentEditor.repeatList"), names: selectable, value: selectedName, onChange: setChosenName })), error !== null && (_jsx("p", { role: "alert", className: "text-sm text-destructive", children: error })), _jsx("div", { className: "flex flex-wrap gap-2", children: _jsx(PanelActions, { onCancel: onClose, ...(kind === "link" && { onInsert: submitLink }), ...(kind === "link" &&
256
+ }, children: [ALIGNMENT_ICONS[alignment], t(ALIGNMENT_LABEL_KEYS[alignment])] }, alignment))) })), kind === "image" && (_jsxs(_Fragment, { children: [_jsx(Field, { id: `${idPrefix}-resource`, label: t("template-resolver.documentEditor.resourceKey"), children: _jsx(Input, { kind: "text", id: `${idPrefix}-resource`, name: "resource", value: resource, onChange: setResource }) }), _jsx(Field, { id: `${idPrefix}-alt`, label: t("template-resolver.documentEditor.imageAlt"), children: _jsx(Input, { kind: "text", id: `${idPrefix}-alt`, name: "alt", value: alt, onChange: setAlt }) })] })), kind === "button" && !noVariables && (_jsxs(_Fragment, { children: [_jsx(NameSelect, { id: `${idPrefix}-target`, label: t("template-resolver.documentEditor.buttonTarget"), names: selectable, value: selectedName, onChange: setChosenName }), _jsx(Field, { id: `${idPrefix}-text`, label: t("template-resolver.documentEditor.buttonText"), children: _jsx(Input, { kind: "text", id: `${idPrefix}-text`, name: "text", value: buttonText, onChange: setButtonText }) })] })), kind === "repeat" && !noVariables && (_jsx(NameSelect, { id: `${idPrefix}-list`, label: t("template-resolver.documentEditor.repeatList"), names: selectable, value: selectedName, onChange: setChosenName })), kind === "steps" && !noVariables && (_jsxs(_Fragment, { children: [_jsx(NameSelect, { id: `${idPrefix}-steps-variable`, label: t("template-resolver.documentEditor.stepsVariable"), names: selectable, value: selectedName, onChange: setChosenName }), _jsx(Field, { id: `${idPrefix}-steps-list`, label: t("template-resolver.documentEditor.stepsList"), children: _jsx(Input, { kind: "text", id: `${idPrefix}-steps-list`, name: "steps", value: stepsText, onChange: setStepsText }) })] })), error !== null && (_jsx("p", { role: "alert", className: "text-sm text-destructive", children: error })), _jsx("div", { className: "flex flex-wrap gap-2", children: _jsx(PanelActions, { onCancel: onClose, ...(kind === "link" && { onInsert: submitLink }), ...(kind === "link" &&
246
257
  editor.isActive("link") && {
247
258
  onRemoveLink: () => {
248
259
  editor.chain().focus().extendMarkRange("link").unsetLink().run();
@@ -256,7 +267,7 @@ function InsertPanel({ editor, editorId, kind, variables, onClose }) {
256
267
  content: buttonText ? [{ type: "text", text: buttonText }] : [],
257
268
  }),
258
269
  }), ...(kind === "repeat" &&
259
- !noVariables && { onInsert: () => insertAndClose(repeatTableContent(selectedName)) }) }) })] }));
270
+ !noVariables && { onInsert: () => insertAndClose(repeatTableContent(selectedName)) }), ...(kind === "steps" && !noVariables && { onInsert: submitSteps }) }) })] }));
260
271
  }
261
272
  export default function DocumentTiptapEditor({ id, value, onChange, variables, readOnly, blocks = MAIL_DOCUMENT_BLOCKS, variableLabels = NO_LABELS, systemVariables = NO_NAMES, resourcePreviews = NO_PREVIEWS, chrome = "default", toolbar = "full", renderPaper, toolbarHost, onEditorReady, }) {
262
273
  const t = useTranslation();
@@ -272,6 +283,7 @@ export default function DocumentTiptapEditor({ id, value, onChange, variables, r
272
283
  const imageBlockLabel = t("template-resolver.documentEditor.imageBlock");
273
284
  const pageBreakLabel = t("template-resolver.documentEditor.pageBreak");
274
285
  const buttonLabel = t("template-resolver.documentEditor.button");
286
+ const stepsLabel = t("template-resolver.documentEditor.steps");
275
287
  const stableLabels = useStableByKey(variableLabels, JSON.stringify(variableLabels));
276
288
  const stableSystem = useStableByKey(systemVariables, systemVariables.join("\n"));
277
289
  const stablePreviews = useStableByKey(resourcePreviews, previewsKey(resourcePreviews));
@@ -280,6 +292,7 @@ export default function DocumentTiptapEditor({ id, value, onChange, variables, r
280
292
  image: imageLabel,
281
293
  pageBreak: pageBreakLabel,
282
294
  button: buttonLabel,
295
+ steps: stepsLabel,
283
296
  imageBlock: imageBlockLabel,
284
297
  variableLabels: stableLabels,
285
298
  systemVariables: stableSystem,
@@ -291,6 +304,7 @@ export default function DocumentTiptapEditor({ id, value, onChange, variables, r
291
304
  imageBlockLabel,
292
305
  pageBreakLabel,
293
306
  buttonLabel,
307
+ stepsLabel,
294
308
  stableLabels,
295
309
  stableSystem,
296
310
  stablePreviews,
@@ -308,6 +322,7 @@ export default function DocumentTiptapEditor({ id, value, onChange, variables, r
308
322
  immediatelyRender: false,
309
323
  editorProps: { attributes: { id } },
310
324
  onCreate: ({ editor: created }) => onEditorReady?.(createEditorHandle(created)),
325
+ onDestroy: () => onEditorReady?.(null),
311
326
  onUpdate: ({ editor: e }) => onChange(e.getHTML()),
312
327
  }, [extensions]);
313
328
  // @tiptap/react v3 does not re-render on selection-only transactions, so
@@ -344,18 +359,25 @@ export default function DocumentTiptapEditor({ id, value, onChange, variables, r
344
359
  });
345
360
  // The entry loads asynchronously, so `value` arrives after useEditor read
346
361
  // its initial content; the guard keeps in-flight typing from being clobbered.
362
+ // When `extensions` change, useEditor's own effect destroys this render's
363
+ // editor earlier in the same commit (a late validate result in the layout
364
+ // editor does that); reading it then throws and unmounts the whole app. The
365
+ // replacement editor re-renders and syncs `value` itself.
347
366
  // kumiko-lint-ignore no-raw-hooks DOM integration: syncs the controlled value into the imperative tiptap editor
348
367
  useEffect(() => {
349
- if (editor && value !== editor.getHTML()) {
368
+ if (editor && !editor.isDestroyed && value !== editor.getHTML()) {
350
369
  editor.commands.setContent(value, { emitUpdate: false });
351
370
  }
352
371
  }, [value, editor]);
353
372
  // kumiko-lint-ignore no-raw-hooks DOM integration: tiptap exposes editability only imperatively via setEditable
354
373
  useEffect(() => {
355
- editor?.setEditable(!readOnly);
374
+ // setEditable emits "update" by default, which would report the mount as an edit.
375
+ if (editor && !editor.isDestroyed)
376
+ editor.setEditable(!readOnly, false);
356
377
  }, [readOnly, editor]);
378
+ // Read-only without chips: a textarea would insert {{name}} text and plaintext into a document body.
357
379
  if (!editor)
358
- return (_jsx(TextareaContentEditor, { id: id, value: value, onChange: onChange, variables: variables, readOnly: readOnly }));
380
+ return (_jsx(TextareaContentEditor, { id: id, value: value, onChange: onChange, variables: [], readOnly: true }));
359
381
  const togglePanel = (kind) => setOpenPanel((current) => (current === kind ? null : kind));
360
382
  const setBlockStyle = (style) => {
361
383
  if (!isParagraphStyle(style))
@@ -391,6 +413,7 @@ export default function DocumentTiptapEditor({ id, value, onChange, variables, r
391
413
  repeat: { label: t("template-resolver.documentEditor.repeat"), icon: _jsx(Repeat, { size: 16 }) },
392
414
  pageBreak: { label: pageBreakLabel, icon: _jsx(Scissors, { size: 16 }) },
393
415
  align: { label: t("template-resolver.documentEditor.align"), icon: _jsx(AlignLeft, { size: 16 }) },
416
+ steps: { label: stepsLabel, icon: _jsx(ListChecks, { size: 16 }) },
394
417
  };
395
418
  const repeatTable = activeState?.repeatTable ?? null;
396
419
  const repeatListLabel = repeatTable !== null && repeatTable.lists.length === 1
@@ -1,6 +1,6 @@
1
1
  import type { ContentEditorProps } from "@cosmicdrift/kumiko-renderer";
2
2
  import type { ReactNode } from "react";
3
- export type DocumentToolbarBlock = "image" | "table" | "button" | "divider" | "repeat" | "pageBreak" | "align";
3
+ export type DocumentToolbarBlock = "image" | "table" | "button" | "divider" | "repeat" | "pageBreak" | "align" | "steps";
4
4
  /** Imperative access for an outside panel (placeholder list, resource list). */
5
5
  export type DocumentEditorHandle = {
6
6
  readonly insertPlaceholder: (name: string) => void;
@@ -32,10 +32,10 @@ export type DocumentEditorProps = ContentEditorProps & {
32
32
  /** Canvas only: renders the toolbar (and its insert panel) into this element instead of above the editor;
33
33
  * null renders none. Lets several region editors share one toolbar that follows the focused one. */
34
34
  readonly toolbarHost?: HTMLElement | null;
35
- /** Called with the handle once the editor exists and with null when it is destroyed. */
36
- readonly onEditorReady?: (handle: DocumentEditorHandle) => void;
35
+ /** Called with the handle once the editor exists and with null when it is destroyed (unmount or rebuild), so a held handle never outlives its editor. */
36
+ readonly onEditorReady?: (handle: DocumentEditorHandle | null) => void;
37
37
  };
38
- export declare const MAIL_DOCUMENT_BLOCKS: readonly ["image", "table", "button", "divider", "repeat"];
39
- export declare const PDF_DOCUMENT_BLOCKS: readonly ["align", "image", "table", "divider", "repeat", "pageBreak"];
38
+ export declare const MAIL_DOCUMENT_BLOCKS: readonly ["image", "table", "button", "divider", "repeat", "steps"];
39
+ export declare const PDF_DOCUMENT_BLOCKS: readonly ["align", "image", "table", "divider", "repeat", "steps", "pageBreak"];
40
40
  /** Block set for a template-resource kind; every kind except "document-pdf" gets the mail set. */
41
41
  export declare function documentBlocksForKind(kind: string | undefined): readonly DocumentToolbarBlock[];
@@ -4,6 +4,7 @@ export const MAIL_DOCUMENT_BLOCKS = [
4
4
  "button",
5
5
  "divider",
6
6
  "repeat",
7
+ "steps",
7
8
  ];
8
9
  export const PDF_DOCUMENT_BLOCKS = [
9
10
  "align",
@@ -11,6 +12,7 @@ export const PDF_DOCUMENT_BLOCKS = [
11
12
  "table",
12
13
  "divider",
13
14
  "repeat",
15
+ "steps",
14
16
  "pageBreak",
15
17
  ];
16
18
  /** Block set for a template-resource kind; every kind except "document-pdf" gets the mail set. */
@@ -39,6 +39,11 @@ export const defaultTranslations = {
39
39
  "template-resolver.documentEditor.removeLink": "Remove link",
40
40
  "template-resolver.documentEditor.repeat": "Repeat",
41
41
  "template-resolver.documentEditor.repeatList": "List to repeat",
42
+ "template-resolver.documentEditor.steps": "Steps",
43
+ "template-resolver.documentEditor.stepsVariable": "Current step from",
44
+ "template-resolver.documentEditor.stepsList": "Steps, separated by commas (value=label)",
45
+ "template-resolver.documentEditor.stepsDefault": "reported=Reported, investigating=Investigating, resolved=Resolved",
46
+ "template-resolver.documentEditor.invalidSteps": "Enter 1 to 8 different steps; values use letters, digits, - and _.",
42
47
  "template-resolver.documentEditor.resourceKey": "Resource key",
43
48
  "template-resolver.documentEditor.table": "Table",
44
49
  "template-resolver.editor.titleLabel": "Title",
@@ -1,3 +1,4 @@
1
+ export { MAIL_TONES, type MailTone } from "../mail-constants.js";
1
2
  export { PDF_DEFAULT_MARGIN_MM, PDF_FOOTER_MARGIN_MM, PDF_HEADER_MARGIN_MM, PDF_ORIENTATIONS, PDF_PAGE_FORMATS, } from "../pdf-constants.js";
2
3
  export { TemplateResolverErrors, TemplateResolverHandlers, TemplateResolverQueries, } from "../qualified-names.js";
3
4
  export { textBlocksClient } from "./client-plugin.js";
@@ -4,6 +4,7 @@
4
4
  // — the server side (defineFeature) lives in
5
5
  // `@cosmicdrift/kumiko-bundled-features/template-resolver` and has no React or
6
6
  // DOM deps.
7
+ export { MAIL_TONES } from "../mail-constants.js";
7
8
  export { PDF_DEFAULT_MARGIN_MM, PDF_FOOTER_MARGIN_MM, PDF_HEADER_MARGIN_MM, PDF_ORIENTATIONS, PDF_PAGE_FORMATS, } from "../pdf-constants.js";
8
9
  export { TemplateResolverErrors, TemplateResolverHandlers, TemplateResolverQueries, } from "../qualified-names.js";
9
10
  export { textBlocksClient } from "./client-plugin.js";
@@ -11,7 +11,9 @@ export type PlaceholderLineEditorProps = {
11
11
  readonly readOnly?: boolean;
12
12
  /** Drops the input frame so the line sits flat inside a label/value row. */
13
13
  readonly flat?: boolean;
14
- readonly onEditorReady?: (handle: DocumentEditorHandle) => void;
14
+ /** Hint shown while the line is empty. */
15
+ readonly placeholder?: string;
16
+ readonly onEditorReady?: (handle: DocumentEditorHandle | null) => void;
15
17
  readonly "aria-label": string;
16
18
  };
17
- export default function PlaceholderLineEditor({ id, value, onChange, variableLabels, systemVariables, readOnly, flat, onEditorReady, "aria-label": ariaLabel, }: PlaceholderLineEditorProps): ReactNode;
19
+ export default function PlaceholderLineEditor({ id, value, onChange, variableLabels, systemVariables, readOnly, flat, placeholder, onEditorReady, "aria-label": ariaLabel, }: PlaceholderLineEditorProps): ReactNode;
@@ -6,7 +6,7 @@ import { jsx as _jsx } from "react/jsx-runtime";
6
6
  // as chips while editing. Lives in the same lazy chunk as the document editor.
7
7
  import { cn } from "@cosmicdrift/kumiko-renderer-web";
8
8
  import { Extension, Node } from "@tiptap/core";
9
- import { UndoRedo } from "@tiptap/extensions";
9
+ import { Placeholder, UndoRedo } from "@tiptap/extensions";
10
10
  import { EditorContent, useEditor } from "@tiptap/react";
11
11
  import StarterKit from "@tiptap/starter-kit";
12
12
  import { useMemo, useState } from "react";
@@ -25,7 +25,7 @@ const BlockLineBreaks = Extension.create({
25
25
  });
26
26
  const NO_LABELS = {};
27
27
  const NO_NAMES = [];
28
- function LineEditorBody({ id, initialValue, onChange, variableLabels, systemVariables, readOnly, flat, onEditorReady, ariaLabel, }) {
28
+ function LineEditorBody({ id, initialValue, onChange, variableLabels, systemVariables, readOnly, flat, placeholder, onEditorReady, ariaLabel, }) {
29
29
  const extensions = useMemo(() => [
30
30
  LineDocument,
31
31
  StarterKit.configure({
@@ -55,7 +55,8 @@ function LineEditorBody({ id, initialValue, onChange, variableLabels, systemVari
55
55
  }),
56
56
  UndoRedo,
57
57
  BlockLineBreaks,
58
- ], [variableLabels, systemVariables, flat]);
58
+ ...(placeholder === undefined ? [] : [Placeholder.configure({ placeholder })]),
59
+ ], [variableLabels, systemVariables, flat, placeholder]);
59
60
  const editor = useEditor({
60
61
  extensions,
61
62
  content: {
@@ -71,11 +72,12 @@ function LineEditorBody({ id, initialValue, onChange, variableLabels, systemVari
71
72
  transformPastedHTML: (html) => singleLine(html.replace(/<[^>]*>/g, " ")),
72
73
  },
73
74
  onCreate: ({ editor: created }) => onEditorReady?.(createEditorHandle(created)),
75
+ onDestroy: () => onEditorReady?.(null),
74
76
  onUpdate: ({ editor: e }) => onChange(contentToLine(e.getJSON().content?.[0]?.content)),
75
77
  });
76
- return (_jsx(EditorContent, { editor: editor, className: cn("flex items-center bg-transparent text-base md:text-sm", flat ? "min-h-10" : "min-h-9 rounded-md border border-input px-3", "[&_.ProseMirror]:w-full [&_.ProseMirror]:whitespace-nowrap [&_.ProseMirror]:outline-none", "[&_p]:m-0", readOnly && "opacity-60") }));
78
+ return (_jsx(EditorContent, { editor: editor, className: cn("flex items-center bg-transparent text-base md:text-sm", flat ? "min-h-10" : "min-h-9 rounded-md border border-input px-3", "[&_.ProseMirror]:w-full [&_.ProseMirror]:whitespace-nowrap [&_.ProseMirror]:outline-none", "[&_p]:m-0", "[&_.is-editor-empty]:before:pointer-events-none [&_.is-editor-empty]:before:float-left [&_.is-editor-empty]:before:h-0 [&_.is-editor-empty]:before:text-muted-foreground [&_.is-editor-empty]:before:content-[attr(data-placeholder)]", readOnly && "opacity-60") }));
77
79
  }
78
- export default function PlaceholderLineEditor({ id, value, onChange, variableLabels = NO_LABELS, systemVariables = NO_NAMES, readOnly = false, flat = false, onEditorReady, "aria-label": ariaLabel, }) {
80
+ export default function PlaceholderLineEditor({ id, value, onChange, variableLabels = NO_LABELS, systemVariables = NO_NAMES, readOnly = false, flat = false, placeholder, onEditorReady, "aria-label": ariaLabel, }) {
79
81
  const stableLabels = useStableByKey(variableLabels, JSON.stringify(variableLabels));
80
82
  const stableSystem = useStableByKey(systemVariables, systemVariables.join("\n"));
81
83
  // The text is the source of truth. Typing reports it through onChange and
@@ -90,5 +92,5 @@ export default function PlaceholderLineEditor({ id, value, onChange, variableLab
90
92
  return (_jsx(LineEditorBody, { id: id, initialValue: value, onChange: (next) => {
91
93
  setEmitted(next);
92
94
  onChange(next);
93
- }, variableLabels: stableLabels, systemVariables: stableSystem, readOnly: readOnly, flat: flat, onEditorReady: onEditorReady, ariaLabel: ariaLabel }, `${epoch}-${readOnly}-${JSON.stringify(stableLabels)}`));
95
+ }, variableLabels: stableLabels, systemVariables: stableSystem, readOnly: readOnly, flat: flat, placeholder: placeholder, onEditorReady: onEditorReady, ariaLabel: ariaLabel }, `${epoch}-${readOnly}-${JSON.stringify(stableLabels)}-${stableSystem.join("\n")}`));
94
96
  }
@@ -21,9 +21,6 @@ function resolveTargetTenantId(isSystem, payloadTenantId, sessionTenantId) {
21
21
  return sessionTenantId;
22
22
  return parseTenantId(payloadTenantId) ?? undefined;
23
23
  }
24
- function foreignStreamTenant(user, targetTenantId) {
25
- return targetTenantId === user.tenantId ? undefined : targetTenantId;
26
- }
27
24
  // Editing your OWN roles keeps the current session: roles are re-derived per
28
25
  // request, so the new roles apply at once without a re-login. Other sessions
29
26
  // of that user and any foreign edit still revoke all.
@@ -123,13 +120,13 @@ export const updateMemberRolesWrite = defineWriteHandler({
123
120
  await ctx.writeAs(createSystemUser(targetTenantId), REVOKE_ALL_SESSIONS_QN, revokeSessionsPayload(event.payload.userId, targetTenantId, event.user));
124
121
  }
125
122
  // The membership stream lives on the membership's tenant (where seedTenantMembership/
126
- // invite-accept wrote it); a cross-tenant operator addresses it without becoming a member.
127
- // Same-tenant writes run on a tenant-mode db, which refuses a stream override.
123
+ // invite-accept wrote it); streamTenantId addresses it with the operator as actor, also
124
+ // without membership there.
128
125
  const result = await executor.update({
129
126
  id: row["id"], // @cast-boundary db-row
130
127
  version: row["version"], // @cast-boundary db-row
131
128
  changes: { roles: JSON.stringify(nextRoles) },
132
- }, event.user, db, { streamTenantId: foreignStreamTenant(event.user, targetTenantId) });
129
+ }, event.user, db, { streamTenantId: targetTenantId });
133
130
  return withResponseData(result, {
134
131
  ...event.payload,
135
132
  roles: nextRoles,
@@ -42,6 +42,26 @@ const tenantExecutor = createEventStoreExecutor(tenantTable, tenantEntity, {
42
42
  const executor = createEventStoreExecutor(tenantMembershipsTable, tenantMembershipEntity, {
43
43
  entityName: "tenant-membership",
44
44
  });
45
+ function buildSeedHookNotify(context, scopedUser, targetTenantId, notifyTiming) {
46
+ const notifyFactory = context._notifyFactory;
47
+ if (!notifyFactory)
48
+ return context.notify;
49
+ const notifyAfterCommit = (context._notifyAfterCommitFactory ?? notifyFactory)(scopedUser, targetTenantId);
50
+ if (notifyTiming === "inline")
51
+ return notifyAfterCommit;
52
+ // Still inside the caller's write tx: delivery DB work must not wait for an app-pool slot.
53
+ const notifyInsideTransaction = (context._notifyInTransactionFactory ?? notifyFactory)(scopedUser, targetTenantId);
54
+ const scheduleAfterCommit = context.scheduleAfterCommit;
55
+ if (!scheduleAfterCommit)
56
+ return notifyInsideTransaction;
57
+ return createDeferringNotify({
58
+ registry: context.registry,
59
+ tenantId: targetTenantId,
60
+ notifyAfterCommit,
61
+ notifyNow: notifyInsideTransaction,
62
+ scheduleAfterCommit,
63
+ });
64
+ }
45
65
  // Shared by seedTenant/seedTenantMembership/auth-email-password's
46
66
  // seedUserWithPassword: fires the same entity-scoped postSave hooks the
47
67
  // real dispatcher would (registry.getEntityPostSaveHooks by entityName),
@@ -61,26 +81,6 @@ const executor = createEventStoreExecutor(tenantMembershipsTable, tenantMembersh
61
81
  // (HandlerContext from a live write), afterCommit hooks are queued onto the
62
82
  // dispatcher's sink and flush post-commit. Without a sink (fixture / plain
63
83
  // Connection) they still fire immediately — there is no outer TX to wait for.
64
- //
65
- function buildSeedHookNotify(context, scopedUser, targetTenantId, notifyTiming) {
66
- const notifyFactory = context._notifyFactory;
67
- if (!notifyFactory)
68
- return context.notify;
69
- if (notifyTiming === "inline")
70
- return notifyFactory(scopedUser, targetTenantId);
71
- // Still inside the caller's write tx: delivery DB work must not wait for an app-pool slot.
72
- const notifyInsideTransaction = (context._notifyInTransactionFactory ?? notifyFactory)(scopedUser, targetTenantId);
73
- const scheduleAfterCommit = context.scheduleAfterCommit;
74
- if (!scheduleAfterCommit)
75
- return notifyInsideTransaction;
76
- return createDeferringNotify({
77
- registry: context.registry,
78
- tenantId: targetTenantId,
79
- notifyAfterCommit: notifyFactory(scopedUser, targetTenantId),
80
- notifyNow: notifyInsideTransaction,
81
- scheduleAfterCommit,
82
- });
83
- }
84
84
  // `runner` comes from the caller, which already holds a declared DbRunner —
85
85
  // a TenantDb exposes no runner back out to feature code.
86
86
  function scopeSeedHookContext(context, targetTenantId, runner,
@@ -132,11 +132,10 @@ target) {
132
132
  const scopedContext = target === undefined
133
133
  ? hooks.context
134
134
  : scopeSeedHookContext(hooks.context, target.tenantId, target.db, "deferUntilCommit");
135
- const afterCommitContext = target === undefined
136
- ? hooks.context
137
- : scopeSeedHookContext(hooks.context, target.tenantId, target.db, "inline");
138
135
  await lifecycle.runPostSave(pseudoType, entityData, scopedContext, HookPhases.inTransaction);
139
- const runAfterCommit = () => lifecycle.runPostSave(pseudoType, entityData, afterCommitContext, HookPhases.afterCommit);
136
+ const runAfterCommit = () => lifecycle.runPostSave(pseudoType, entityData, target === undefined
137
+ ? hooks.context
138
+ : scopeSeedHookContext(hooks.context, target.tenantId, target.db, "inline"), HookPhases.afterCommit);
140
139
  if (scopedContext.scheduleAfterCommit) {
141
140
  scopedContext.scheduleAfterCommit(runAfterCommit);
142
141
  }
@@ -21,7 +21,7 @@
21
21
  // child entity, kumiko-framework#3035's named-error requirement), the WHOLE
22
22
  // transaction — root row included — rolls back, so a caller can safely
23
23
  // retry the same token after fixing the app's `transferable` declarations.
24
- import { entityTableFromRegistry, executeRawQuery, extractTableName, physicalColumnName, } from "@cosmicdrift/kumiko-framework/db";
24
+ import { entityTableFromRegistry, executeRawQuery, extractTableName, physicalColumnName, toPostgresArrayLiteral, } from "@cosmicdrift/kumiko-framework/db";
25
25
  import { MAX_TRANSFER_DEPTH } from "@cosmicdrift/kumiko-framework/engine";
26
26
  import { UnprocessableError } from "@cosmicdrift/kumiko-framework/errors";
27
27
  import { transferAggregateStreams } from "@cosmicdrift/kumiko-framework/event-store";
@@ -47,7 +47,12 @@ async function moveFileRefs(args) {
47
47
  if (args.entityIds.length === 0)
48
48
  return 0;
49
49
  const rows = await executeRawQuery(args.db, `UPDATE ${FILE_REFS_TABLE} SET tenant_id = $1 ` +
50
- `WHERE tenant_id = $2 AND entity_type = $3 AND entity_id = ANY($4) RETURNING id`, [args.destinationTenantId, args.sourceTenantId, args.entityType, args.entityIds]);
50
+ `WHERE tenant_id = $2 AND entity_type = $3 AND entity_id = ANY($4) RETURNING id`, [
51
+ args.destinationTenantId,
52
+ args.sourceTenantId,
53
+ args.entityType,
54
+ toPostgresArrayLiteral(args.entityIds),
55
+ ]);
51
56
  const movedIds = rows.map((row) => row.id);
52
57
  if (movedIds.length === 0)
53
58
  return 0;
@@ -78,7 +83,12 @@ async function moveFileRefs(args) {
78
83
  async function moveChildRows(args) {
79
84
  const rows = await executeRawQuery(args.db, `UPDATE "${args.tableName}" SET "${args.tenantCol}" = $1 ` +
80
85
  `WHERE "${args.typeCol}" = $2 AND "${args.idCol}" = ANY($3) AND "${args.tenantCol}" = $4 ` +
81
- `RETURNING "${args.pkCol}" AS id`, [args.destinationTenantId, args.parentEntityType, args.parentRowIds, args.sourceTenantId]);
86
+ `RETURNING "${args.pkCol}" AS id`, [
87
+ args.destinationTenantId,
88
+ args.parentEntityType,
89
+ toPostgresArrayLiteral(args.parentRowIds),
90
+ args.sourceTenantId,
91
+ ]);
82
92
  return rows.map((row) => row.id);
83
93
  }
84
94
  // A `reference` field names its target entity in the schema, so unlike a
@@ -87,7 +97,7 @@ async function moveChildRows(args) {
87
97
  async function moveReferencedChildRows(args) {
88
98
  const rows = await executeRawQuery(args.db, `UPDATE "${args.tableName}" SET "${args.tenantCol}" = $1 ` +
89
99
  `WHERE "${args.refCol}" = ANY($2) AND "${args.tenantCol}" = $3 ` +
90
- `RETURNING "${args.pkCol}" AS id`, [args.destinationTenantId, args.parentRowIds, args.sourceTenantId]);
100
+ `RETURNING "${args.pkCol}" AS id`, [args.destinationTenantId, toPostgresArrayLiteral(args.parentRowIds), args.sourceTenantId]);
91
101
  return rows.map((row) => row.id);
92
102
  }
93
103
  // Resolves one edge against the ids its parent type just moved, and reports
@@ -20,6 +20,7 @@ export declare function runNextDestructionStage(args: {
20
20
  readonly escapeHatchAuditSink?: EscapeHatchAuditSink;
21
21
  readonly escapeHatchAuditLog?: Logger;
22
22
  readonly actor?: string;
23
+ readonly deadlineAt?: number;
23
24
  }): Promise<StageTickResult>;
24
25
  export declare function hasTenantDestructionWork(reads: Pick<TenantDb, "count">, now: Temporal.Instant): Promise<boolean>;
25
26
  /** Cron entry: start destruction for tenants past grace, then advance stages. */
@@ -164,7 +164,7 @@ export async function runNextDestructionStage(args) {
164
164
  escapeHatchAuditSink: args.escapeHatchAuditSink,
165
165
  escapeHatchAuditLog: args.escapeHatchAuditLog,
166
166
  actor: args.actor,
167
- deadlineAt: Date.now() + DESTRUCTION_STAGE_TICK_BUDGET_MS,
167
+ deadlineAt: args.deadlineAt ?? Date.now() + DESTRUCTION_STAGE_TICK_BUDGET_MS,
168
168
  };
169
169
  let version = lastEventVersion(events);
170
170
  if (!isStageContinuation(events, next.name)) {
@@ -249,9 +249,16 @@ export async function runTenantDestructionSweep(args) {
249
249
  }
250
250
  let advanced = 0;
251
251
  const destroying = await selectMany(args.db, tenantTable, destroyingWhere);
252
+ // One budget per sweep tick, split evenly so a large tenant cannot eat the
253
+ // window and starve the ones behind it. Each slice starts when its tenant
254
+ // starts: anchoring to a shared tick deadline would hand tenants after an
255
+ // overrun a zero slice, and a no-progress stage counts as a failed attempt.
256
+ const sliceMs = DESTRUCTION_STAGE_TICK_BUDGET_MS / Math.max(1, destroying.length);
252
257
  for (const row of destroying) {
258
+ const tenantDeadlineAt = Date.now() + sliceMs;
253
259
  try {
254
260
  const result = await runNextDestructionStage({
261
+ deadlineAt: tenantDeadlineAt,
255
262
  db: args.db,
256
263
  registry: args.registry,
257
264
  tenantId: row.id,
@@ -13,8 +13,9 @@ const tenantCrud = createEventStoreExecutor(tenantTable, tenantEntity, { entityN
13
13
  const tenantMembershipCrud = createEventStoreExecutor(tenantMembershipsTable, tenantMembershipEntity, {
14
14
  entityName: "tenant-membership",
15
15
  });
16
- // Every hook still runs each tick (they are idempotent), so a later hook is not
17
- // starved by an earlier one that keeps reporting done:false.
16
+ // Every hook runs each tick (they are idempotent) but all share one ctx.deadlineAt: an
17
+ // earlier hook that keeps reporting done:false can use up the budget, so later chunking
18
+ // hooks time out with nothing processed until it finishes.
18
19
  function newOutcomeAccumulator() {
19
20
  let allDone = true;
20
21
  let processed = 0;
@@ -9,6 +9,8 @@ export declare const TierAssignmentSources: {
9
9
  readonly billing: "billing";
10
10
  readonly default: "default";
11
11
  };
12
+ export type TierAssignmentSource = (typeof TierAssignmentSources)[keyof typeof TierAssignmentSources];
13
+ export declare function isTierAssignmentSource(value: unknown): value is TierAssignmentSource;
12
14
  export declare const TierEngineQueries: {
13
15
  readonly list: "tier-engine:query:tier-assignment:list";
14
16
  readonly getActiveTier: "tier-engine:query:get-active-tier";
@@ -19,6 +19,10 @@ export const TierAssignmentSources = {
19
19
  billing: "billing",
20
20
  default: "default",
21
21
  };
22
+ const TIER_ASSIGNMENT_SOURCE_VALUES = Object.values(TierAssignmentSources);
23
+ export function isTierAssignmentSource(value) {
24
+ return typeof value === "string" && TIER_ASSIGNMENT_SOURCE_VALUES.includes(value);
25
+ }
22
26
  // Qualified query handler names.
23
27
  export const TierEngineQueries = {
24
28
  list: "tier-engine:query:tier-assignment:list",