@aglyn/plugins-workflows 1.0.0-beta.217 → 1.0.0-beta.219

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 (52) hide show
  1. package/README.md +28 -4
  2. package/package.json +12 -12
  3. package/src/index.d.ts +1 -0
  4. package/src/index.js.map +1 -1
  5. package/src/lib/components/host-run-history-card.component.js +1 -1
  6. package/src/lib/components/host-run-history-card.component.js.map +1 -1
  7. package/src/lib/components/host-workflows-card.component.js +2 -1
  8. package/src/lib/components/host-workflows-card.component.js.map +1 -1
  9. package/src/lib/declarations.server.d.ts +6 -0
  10. package/src/lib/declarations.server.js +39 -0
  11. package/src/lib/declarations.server.js.map +1 -1
  12. package/src/lib/engine/run-event-actions.d.ts +2 -1
  13. package/src/lib/engine/run-event-actions.js +49 -15
  14. package/src/lib/engine/run-event-actions.js.map +1 -1
  15. package/src/lib/engine/run-event-workflows.js +2 -1
  16. package/src/lib/engine/run-event-workflows.js.map +1 -1
  17. package/src/lib/engine/workflow-steps.d.ts +1 -1
  18. package/src/lib/engine/workflow-steps.js +1 -1
  19. package/src/lib/engine/workflow-steps.js.map +1 -1
  20. package/src/lib/model/org-automations.js.map +1 -1
  21. package/src/lib/model/workflows-record-routes.d.ts +22 -0
  22. package/src/lib/model/workflows-record-routes.js +73 -0
  23. package/src/lib/model/workflows-record-routes.js.map +1 -0
  24. package/src/lib/model/workflows-search-sources.d.ts +20 -0
  25. package/src/lib/model/workflows-search-sources.js +35 -0
  26. package/src/lib/model/workflows-search-sources.js.map +1 -0
  27. package/src/lib/model/workflows.d.ts +90 -0
  28. package/src/lib/model/workflows.js +166 -0
  29. package/src/lib/model/workflows.js.map +1 -0
  30. package/src/lib/plan-entitlements.d.ts +36 -0
  31. package/src/lib/plan-entitlements.js +44 -0
  32. package/src/lib/plan-entitlements.js.map +1 -0
  33. package/src/lib/plugin.js +5 -1
  34. package/src/lib/plugin.js.map +1 -1
  35. package/src/lib/server/automation-record-index.d.ts +20 -0
  36. package/src/lib/server/automation-record-index.js +90 -0
  37. package/src/lib/server/automation-record-index.js.map +1 -0
  38. package/src/lib/server/workflow-dependents.d.ts +28 -0
  39. package/src/lib/server/workflow-dependents.js +61 -0
  40. package/src/lib/server/workflow-dependents.js.map +1 -0
  41. package/src/lib/server/workflow-variables.d.ts +20 -0
  42. package/src/lib/server/workflow-variables.js +75 -0
  43. package/src/lib/server/workflow-variables.js.map +1 -0
  44. package/src/lib/server.d.ts +1 -0
  45. package/src/lib/server.js +2 -1
  46. package/src/lib/server.js.map +1 -1
  47. package/src/lib/tenant-emails.d.ts +29 -0
  48. package/src/lib/tenant-emails.js +41 -0
  49. package/src/lib/tenant-emails.js.map +1 -0
  50. package/src/lib/usage-axes.d.ts +28 -0
  51. package/src/lib/usage-axes.js +61 -0
  52. package/src/lib/usage-axes.js.map +1 -0
@@ -16,6 +16,9 @@
16
16
  */ // The registry from its own module, not the runtime barrel: boot needs the
17
17
  // registry and nothing else, and the barrel reaches the data layer.
18
18
  import { registerHostEventListener } from "@aglyn/tenant-runtime/host-event-listeners";
19
+ import { registerVariableComputer } from "@aglyn/aglyn/plugin-manager/computed-variables";
20
+ import { registerPluginDependentsSource } from "@aglyn/aglyn/plugin-manager/plugin-dependents";
21
+ import { registerPluginRecordIndex } from "@aglyn/aglyn/plugin-manager/plugin-record-index";
19
22
  import { BUNDLE_ID } from "./constants/bundle-common.js";
20
23
  /**
21
24
  * The automation engine, as the runtime hears it.
@@ -55,11 +58,47 @@ import { BUNDLE_ID } from "./constants/bundle-common.js";
55
58
  * first of them arrives — which is what running this from both apps'
56
59
  * `instrumentation.ts` gives.
57
60
  *
61
+ * The indexes of the site's workflows, webhooks and actions, so another
62
+ * surface — an AI job — reads them without reaching for this plugin's
63
+ * collections; the workflows that call a function, which the "Used by"
64
+ * scan asks this plugin for; and the site variables a workflow computes,
65
+ * which every published page's compose asks for.
66
+ *
58
67
  * Also called from the plugin's own API register functions, so a process
59
68
  * whose boot did not run it still registers the listener the first time a
60
69
  * plugin door loads. Registering twice replaces in place.
61
70
  */ export function registerWorkflowsServerDeclarations() {
62
71
  registerHostEventListener(BUNDLE_ID, workflowsHostEventListener);
72
+ // The readers and the Admin SDK arrive with the first read, not with the boot.
73
+ registerPluginRecordIndex('workflow', lazyIndex('workflowRecordIndex'), {
74
+ pluginId: BUNDLE_ID
75
+ });
76
+ registerPluginRecordIndex('webhook', lazyIndex('webhookRecordIndex'), {
77
+ pluginId: BUNDLE_ID
78
+ });
79
+ registerPluginRecordIndex('action', lazyIndex('actionRecordIndex'), {
80
+ pluginId: BUNDLE_ID
81
+ });
82
+ registerPluginDependentsSource({
83
+ kinds: [
84
+ 'function'
85
+ ],
86
+ find: async (request)=>(await import("./server/workflow-dependents.js")).findFunctionDependents(request)
87
+ }, {
88
+ pluginId: BUNDLE_ID
89
+ });
90
+ registerVariableComputer({
91
+ prepare: async (hostId)=>(await import("./server/workflow-variables.js")).prepareWorkflowVariables(hostId)
92
+ }, {
93
+ pluginId: BUNDLE_ID
94
+ });
95
+ }
96
+ function lazyIndex(name) {
97
+ const load = async ()=>(await import("./server/automation-record-index.js"))[name];
98
+ return {
99
+ list: async (request)=>(await load()).list(request),
100
+ get: async (request)=>(await load()).get(request)
101
+ };
63
102
  }
64
103
 
65
104
  //# sourceMappingURL=declarations.server.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../libs/plugins/workflows/src/lib/declarations.server.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n// The registry from its own module, not the runtime barrel: boot needs the\n// registry and nothing else, and the barrel reaches the data layer.\nimport {\n type HostEventListener,\n registerHostEventListener,\n} from '@aglyn/tenant-runtime/host-event-listeners'\nimport { BUNDLE_ID } from './constants/bundle-common'\n\n/**\n * The automation engine, as the runtime hears it.\n *\n * Light by construction: the engine is imported when the first event\n * arrives, not when the process starts, so a process that never sees an\n * event pays for this object and nothing else.\n */\nexport const workflowsHostEventListener: HostEventListener = {\n async onEvent(hostId, event, payload, context) {\n const [{ runEventAutomations }, { withRunTriggerActor }] = await Promise.all([\n import('./engine/run-event-automations'),\n import('./engine/run-trigger-actor'),\n ])\n // Every run this event causes records who caused it (AGL-3376).\n return await withRunTriggerActor(context?.actor, () =>\n runEventAutomations(hostId, event, payload),\n )\n },\n async onDispatch(hostId, automationId, event, payload, context) {\n const [{ runSingleAction }, { withRunTriggerActor }] = await Promise.all([\n import('./engine/run-event-actions'),\n import('./engine/run-trigger-actor'),\n ])\n // A page-fired automation is always a visitor on the published page.\n return await withRunTriggerActor(context?.actor ?? { kind: 'visitor' }, () =>\n runSingleAction(hostId, automationId, event, payload),\n )\n },\n}\n\n/**\n * The plugin's SERVER declarations: what the server must know at boot,\n * before any door of the plugin has been called.\n *\n * The engine's host-event listener, because the doors that raise the events\n * mostly never load this plugin. A form submission and a page view are core\n * routes; a contact captured by a Stripe webhook is a core capture door. Each\n * calls `emitHostEvent`, and the listener has to be registered before the\n * first of them arrives — which is what running this from both apps'\n * `instrumentation.ts` gives.\n *\n * Also called from the plugin's own API register functions, so a process\n * whose boot did not run it still registers the listener the first time a\n * plugin door loads. Registering twice replaces in place.\n */\nexport function registerWorkflowsServerDeclarations(): void {\n registerHostEventListener(BUNDLE_ID, workflowsHostEventListener)\n}\n"],"names":["registerHostEventListener","BUNDLE_ID","workflowsHostEventListener","onEvent","hostId","event","payload","context","runEventAutomations","withRunTriggerActor","Promise","all","actor","onDispatch","automationId","runSingleAction","kind","registerWorkflowsServerDeclarations"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,2EAA2E;AAC3E,oEAAoE;AACpE,SAEEA,yBAAyB,QACpB,6CAA4C;AACnD,SAASC,SAAS,QAAQ,+BAA2B;AAErD;;;;;;CAMC,GACD,OAAO,MAAMC,6BAAgD;IAC3D,MAAMC,SAAQC,MAAM,EAAEC,KAAK,EAAEC,OAAO,EAAEC,OAAO;QAC3C,MAAM,CAAC,EAAEC,mBAAmB,EAAE,EAAE,EAAEC,mBAAmB,EAAE,CAAC,GAAG,MAAMC,QAAQC,GAAG,CAAC;YAC3E,MAAM,CAAC;YACP,MAAM,CAAC;SACR;QACD,gEAAgE;QAChE,OAAO,MAAMF,oBAAoBF,2BAAAA,QAASK,KAAK,EAAE,IAC/CJ,oBAAoBJ,QAAQC,OAAOC;IAEvC;IACA,MAAMO,YAAWT,MAAM,EAAEU,YAAY,EAAET,KAAK,EAAEC,OAAO,EAAEC,OAAO;;QAC5D,MAAM,CAAC,EAAEQ,eAAe,EAAE,EAAE,EAAEN,mBAAmB,EAAE,CAAC,GAAG,MAAMC,QAAQC,GAAG,CAAC;YACvE,MAAM,CAAC;YACP,MAAM,CAAC;SACR;QACD,qEAAqE;QACrE,OAAO,MAAMF,4BAAoBF,2BAAAA,QAASK,KAAK,mBAAI;YAAEI,MAAM;QAAU,GAAG,IACtED,gBAAgBX,QAAQU,cAAcT,OAAOC;IAEjD;AACF,EAAC;AAED;;;;;;;;;;;;;;CAcC,GACD,OAAO,SAASW;IACdjB,0BAA0BC,WAAWC;AACvC"}
1
+ {"version":3,"sources":["../../../../../../libs/plugins/workflows/src/lib/declarations.server.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n// The registry from its own module, not the runtime barrel: boot needs the\n// registry and nothing else, and the barrel reaches the data layer.\nimport {\n type HostEventListener,\n registerHostEventListener,\n} from '@aglyn/tenant-runtime/host-event-listeners'\nimport { registerVariableComputer } from '@aglyn/aglyn/plugin-manager/computed-variables'\nimport { registerPluginDependentsSource } from '@aglyn/aglyn/plugin-manager/plugin-dependents'\nimport {\n registerPluginRecordIndex,\n type PluginRecordIndex,\n} from '@aglyn/aglyn/plugin-manager/plugin-record-index'\nimport { BUNDLE_ID } from './constants/bundle-common'\n\n/**\n * The automation engine, as the runtime hears it.\n *\n * Light by construction: the engine is imported when the first event\n * arrives, not when the process starts, so a process that never sees an\n * event pays for this object and nothing else.\n */\nexport const workflowsHostEventListener: HostEventListener = {\n async onEvent(hostId, event, payload, context) {\n const [{ runEventAutomations }, { withRunTriggerActor }] = await Promise.all([\n import('./engine/run-event-automations'),\n import('./engine/run-trigger-actor'),\n ])\n // Every run this event causes records who caused it (AGL-3376).\n return await withRunTriggerActor(context?.actor, () =>\n runEventAutomations(hostId, event, payload),\n )\n },\n async onDispatch(hostId, automationId, event, payload, context) {\n const [{ runSingleAction }, { withRunTriggerActor }] = await Promise.all([\n import('./engine/run-event-actions'),\n import('./engine/run-trigger-actor'),\n ])\n // A page-fired automation is always a visitor on the published page.\n return await withRunTriggerActor(context?.actor ?? { kind: 'visitor' }, () =>\n runSingleAction(hostId, automationId, event, payload),\n )\n },\n}\n\n/**\n * The plugin's SERVER declarations: what the server must know at boot,\n * before any door of the plugin has been called.\n *\n * The engine's host-event listener, because the doors that raise the events\n * mostly never load this plugin. A form submission and a page view are core\n * routes; a contact captured by a Stripe webhook is a core capture door. Each\n * calls `emitHostEvent`, and the listener has to be registered before the\n * first of them arrives — which is what running this from both apps'\n * `instrumentation.ts` gives.\n *\n * The indexes of the site's workflows, webhooks and actions, so another\n * surface — an AI job — reads them without reaching for this plugin's\n * collections; the workflows that call a function, which the \"Used by\"\n * scan asks this plugin for; and the site variables a workflow computes,\n * which every published page's compose asks for.\n *\n * Also called from the plugin's own API register functions, so a process\n * whose boot did not run it still registers the listener the first time a\n * plugin door loads. Registering twice replaces in place.\n */\nexport function registerWorkflowsServerDeclarations(): void {\n registerHostEventListener(BUNDLE_ID, workflowsHostEventListener)\n // The readers and the Admin SDK arrive with the first read, not with the boot.\n registerPluginRecordIndex('workflow', lazyIndex('workflowRecordIndex'), { pluginId: BUNDLE_ID })\n registerPluginRecordIndex('webhook', lazyIndex('webhookRecordIndex'), { pluginId: BUNDLE_ID })\n registerPluginRecordIndex('action', lazyIndex('actionRecordIndex'), { pluginId: BUNDLE_ID })\n registerPluginDependentsSource(\n {\n kinds: ['function'],\n find: async (request) =>\n (await import('./server/workflow-dependents')).findFunctionDependents(request),\n },\n { pluginId: BUNDLE_ID },\n )\n registerVariableComputer(\n {\n prepare: async (hostId) =>\n (await import('./server/workflow-variables')).prepareWorkflowVariables(hostId),\n },\n { pluginId: BUNDLE_ID },\n )\n}\n\ntype IndexName = 'workflowRecordIndex' | 'webhookRecordIndex' | 'actionRecordIndex'\n\nfunction lazyIndex(name: IndexName): PluginRecordIndex {\n const load = async () => (await import('./server/automation-record-index'))[name]\n return {\n list: async (request) => (await load()).list(request),\n get: async (request) => (await load()).get(request),\n }\n}\n"],"names":["registerHostEventListener","registerVariableComputer","registerPluginDependentsSource","registerPluginRecordIndex","BUNDLE_ID","workflowsHostEventListener","onEvent","hostId","event","payload","context","runEventAutomations","withRunTriggerActor","Promise","all","actor","onDispatch","automationId","runSingleAction","kind","registerWorkflowsServerDeclarations","lazyIndex","pluginId","kinds","find","request","findFunctionDependents","prepare","prepareWorkflowVariables","name","load","list","get"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,2EAA2E;AAC3E,oEAAoE;AACpE,SAEEA,yBAAyB,QACpB,6CAA4C;AACnD,SAASC,wBAAwB,QAAQ,iDAAgD;AACzF,SAASC,8BAA8B,QAAQ,gDAA+C;AAC9F,SACEC,yBAAyB,QAEpB,kDAAiD;AACxD,SAASC,SAAS,QAAQ,+BAA2B;AAErD;;;;;;CAMC,GACD,OAAO,MAAMC,6BAAgD;IAC3D,MAAMC,SAAQC,MAAM,EAAEC,KAAK,EAAEC,OAAO,EAAEC,OAAO;QAC3C,MAAM,CAAC,EAAEC,mBAAmB,EAAE,EAAE,EAAEC,mBAAmB,EAAE,CAAC,GAAG,MAAMC,QAAQC,GAAG,CAAC;YAC3E,MAAM,CAAC;YACP,MAAM,CAAC;SACR;QACD,gEAAgE;QAChE,OAAO,MAAMF,oBAAoBF,2BAAAA,QAASK,KAAK,EAAE,IAC/CJ,oBAAoBJ,QAAQC,OAAOC;IAEvC;IACA,MAAMO,YAAWT,MAAM,EAAEU,YAAY,EAAET,KAAK,EAAEC,OAAO,EAAEC,OAAO;;QAC5D,MAAM,CAAC,EAAEQ,eAAe,EAAE,EAAE,EAAEN,mBAAmB,EAAE,CAAC,GAAG,MAAMC,QAAQC,GAAG,CAAC;YACvE,MAAM,CAAC;YACP,MAAM,CAAC;SACR;QACD,qEAAqE;QACrE,OAAO,MAAMF,4BAAoBF,2BAAAA,QAASK,KAAK,mBAAI;YAAEI,MAAM;QAAU,GAAG,IACtED,gBAAgBX,QAAQU,cAAcT,OAAOC;IAEjD;AACF,EAAC;AAED;;;;;;;;;;;;;;;;;;;;CAoBC,GACD,OAAO,SAASW;IACdpB,0BAA0BI,WAAWC;IACrC,+EAA+E;IAC/EF,0BAA0B,YAAYkB,UAAU,wBAAwB;QAAEC,UAAUlB;IAAU;IAC9FD,0BAA0B,WAAWkB,UAAU,uBAAuB;QAAEC,UAAUlB;IAAU;IAC5FD,0BAA0B,UAAUkB,UAAU,sBAAsB;QAAEC,UAAUlB;IAAU;IAC1FF,+BACE;QACEqB,OAAO;YAAC;SAAW;QACnBC,MAAM,OAAOC,UACX,AAAC,CAAA,MAAM,MAAM,CAAC,kCAA8B,EAAGC,sBAAsB,CAACD;IAC1E,GACA;QAAEH,UAAUlB;IAAU;IAExBH,yBACE;QACE0B,SAAS,OAAOpB,SACd,AAAC,CAAA,MAAM,MAAM,CAAC,iCAA6B,EAAGqB,wBAAwB,CAACrB;IAC3E,GACA;QAAEe,UAAUlB;IAAU;AAE1B;AAIA,SAASiB,UAAUQ,IAAe;IAChC,MAAMC,OAAO,UAAY,AAAC,CAAA,MAAM,MAAM,CAAC,sCAAkC,CAAE,CAACD,KAAK;IACjF,OAAO;QACLE,MAAM,OAAON,UAAY,AAAC,CAAA,MAAMK,MAAK,EAAGC,IAAI,CAACN;QAC7CO,KAAK,OAAOP,UAAY,AAAC,CAAA,MAAMK,MAAK,EAAGE,GAAG,CAACP;IAC7C;AACF"}
@@ -14,7 +14,8 @@
14
14
  * See the License for the specific language governing permissions and
15
15
  * limitations under the License.
16
16
  */
17
- import { type HostAction, type HostActionAlert, type HostActionStep, type HostFunction, type HostVariable, type HostWorkflow, type PluginJobHostGate } from '@aglyn/aglyn/server';
17
+ import { type HostAction, type HostActionAlert, type HostActionStep, type HostFunction, type HostVariable, type PluginJobHostGate } from '@aglyn/aglyn/server';
18
+ import { type HostWorkflow } from '../model/workflows';
18
19
  import { type FlowEnrollment, type FlowSweepCursor, type FlowSweepResult } from './flow-enrollments';
19
20
  import type { HostEventPayload } from '@aglyn/tenant-runtime/host-event-listeners';
20
21
  import { type AutomationWorkflow, type WorkflowStep } from './workflow-steps';
@@ -14,7 +14,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
14
14
  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
15
  * See the License for the specific language governing permissions and
16
16
  * limitations under the License.
17
- */ import { ACTION_MAX_EVENT_DEPTH, ACTION_MAX_STEPS, checkEntitlement, consentGroupForHost, planLabelGrantingFeature, checkQuota, WEBHOOK_URL_PATTERN, evaluateExpression, evaluateStepGuard, evaluateTriggerConditions, FLOW_TIMED_OUT_FIELD, flowEmailTopicId, hostPublicOrigin, isClientActionStep, isCrmActionStep, isFlowSuspendingStep, buildDatasetRecordValues, datasetDisplayName, contactCampaignFieldPath, datasetIntegrityFields, datasetIntegrityUpdate, describeStepOutcome, effectiveDatasetModel, normalizeTriggerConditions, resolveOrgEntitlements, runWorkflow, WORKFLOW_MAX_STEPS } from "@aglyn/aglyn/server";
17
+ */ import { ACTION_MAX_EVENT_DEPTH, ACTION_MAX_STEPS, checkEntitlement, consentGroupForHost, planLabelGrantingFeature, checkQuota, WEBHOOK_URL_PATTERN, evaluateExpression, evaluateStepGuard, evaluateTriggerConditions, FLOW_TIMED_OUT_FIELD, flowEmailTopicId, hostPublicOrigin, isClientActionStep, isCrmActionStep, isFlowSuspendingStep, datasetDisplayName, describeDatasetRecordErrors, contactCampaignFieldPath, datasetIntegrityFields, datasetIntegrityUpdate, describeStepOutcome, effectiveDatasetModel, ensureDeclaredCustomFieldTypes, normalizeTriggerConditions, prepareDatasetRecordWrite, resolveOrgEntitlements } from "@aglyn/aglyn/server";
18
18
  import { isDeferrableSendResult, isEmailConfigured, sendEmail, sendFailureReason } from "@aglyn/shared-util-email";
19
19
  import { dataStorageRefusal, enrollListMember, firebaseAdmin, flowEmailRefusal, getOrgForHost, hostSendingIdentity, meterHostEmail, notifyHostManagers, consentGroupForSite, orgDataCollectionForHost, orgDataQueryForHost, resolveOrgIdForHost } from "@aglyn/tenant-data-admin";
20
20
  import { announceDatasetRecordChange } from "@aglyn/tenant-data-admin/server/dataset-live-pages";
@@ -30,6 +30,7 @@ import { renderSiteTextEmail } from "@aglyn/tenant-data-admin/server/host-email-
30
30
  import { createHmac } from "crypto";
31
31
  import { FieldValue } from "firebase-admin/firestore";
32
32
  import { runSummaryFields } from "../model/run-history.js";
33
+ import { runWorkflow, WORKFLOW_MAX_STEPS } from "../model/workflows.js";
33
34
  import { eventRunSuspension } from "./site-suspension.js";
34
35
  import { advanceFlowEnrollment, claimFlowEnrollment, deferFlowEnrollment, endFlowEnrollment, enrollInFlow, findFlowEnrollmentsAwaiting, sweepDueFlowEnrollments } from "./flow-enrollments.js";
35
36
  import { logCrmEmailActivity, prepareCrmEmailActivity, runCrmActionStep } from "./crm-action-steps.js";
@@ -384,7 +385,11 @@ async function datasetAppendRefusal(env, datasetRef) {
384
385
  model: datasetDoc.get('model'),
385
386
  fields: Array.isArray(datasetDoc.get('fields')) ? datasetDoc.get('fields') : []
386
387
  };
387
- const values = buildDatasetRecordValues(appendDataset, payload);
388
+ const appendModel = effectiveDatasetModel(appendDataset);
389
+ // A plugin's field validator runs only once its plugin registered it.
390
+ await ensureDeclaredCustomFieldTypes(appendModel);
391
+ const write = prepareDatasetRecordWrite(appendDataset, payload);
392
+ const values = write.values;
388
393
  // Same name precedence `findDatasetByName` resolves in.
389
394
  const appendLabel = (datasetDisplayName({
390
395
  displayName: datasetDoc.get('displayName'),
@@ -394,14 +399,23 @@ async function datasetAppendRefusal(env, datasetRef) {
394
399
  // to write. An error rather than a quiet success: a run history that
395
400
  // says `saved to Leads` while nothing saves is how a mismatched field
396
401
  // name goes unnoticed.
397
- if (!Object.keys(values).length) {
402
+ if (!write.matched.length) {
398
403
  return failed(`no event field matches a field in dataset "${appendLabel || step.datasetId}"`);
399
404
  }
405
+ // Held to the model like every other record write (AGL-2773): a value
406
+ // its field cannot hold refuses the whole record, and the run says
407
+ // which field and why instead of `saved to Leads`.
408
+ if (Object.keys(write.errors).length) {
409
+ return failed(`record failed validation for dataset "${appendLabel || step.datasetId}": ${describeDatasetRecordErrors(write.errors)}`);
410
+ }
411
+ if (!Object.keys(values).length) {
412
+ return failed(`every event field matching dataset "${appendLabel || step.datasetId}" is empty`);
413
+ }
400
414
  const refusal = await datasetAppendRefusal(env, datasetDoc.ref);
401
415
  if (refusal) return failed(refusal);
402
416
  await datasetDoc.ref.collection('records').add(_extends({
403
417
  values
404
- }, datasetIntegrityFields(effectiveDatasetModel(appendDataset), values), {
418
+ }, datasetIntegrityFields(appendModel, values), {
405
419
  createdAt: FieldValue.serverTimestamp()
406
420
  }));
407
421
  await announceDatasetStepWrite(env, datasetDoc.id);
@@ -421,15 +435,22 @@ async function datasetAppendRefusal(env, datasetRef) {
421
435
  fields: Array.isArray(datasetDoc.get('fields')) ? datasetDoc.get('fields') : []
422
436
  };
423
437
  const updateModel = effectiveDatasetModel(updateDataset);
424
- const values = buildDatasetRecordValues(updateDataset, payload);
438
+ await ensureDeclaredCustomFieldTypes(updateModel);
439
+ const updateLabel = String(datasetDisplayName({
440
+ displayName: datasetDoc.get('displayName'),
441
+ name: datasetDoc.get('name')
442
+ }) || step.datasetName || step.datasetId || '').slice(0, 60);
443
+ // Checked before the lookup, as an append: the fields this write
444
+ // supplies are held to the model whichever leg runs below, and a
445
+ // refusal costs no read.
446
+ const incoming = prepareDatasetRecordWrite(updateDataset, payload);
425
447
  // Nothing to merge or append — an error, for the reason the append
426
448
  // branch above gives.
427
- if (!Object.keys(values).length) {
428
- const updateLabel = datasetDisplayName({
429
- displayName: datasetDoc.get('displayName'),
430
- name: datasetDoc.get('name')
431
- }) || step.datasetName || step.datasetId;
432
- return failed(`no event field matches a field in dataset "${String(updateLabel != null ? updateLabel : '').slice(0, 60)}"`);
449
+ if (!incoming.matched.length) {
450
+ return failed(`no event field matches a field in dataset "${updateLabel}"`);
451
+ }
452
+ if (!Object.keys(incoming.values).length) {
453
+ return failed(`every event field matching dataset "${updateLabel}" is empty`);
433
454
  }
434
455
  const email = String((_payload_email = payload.email) != null ? _payload_email : '').trim();
435
456
  // `records.values` is exempt from indexing, so this lookup is served
@@ -439,7 +460,16 @@ async function datasetAppendRefusal(env, datasetRef) {
439
460
  const existing = email ? await datasetDoc.ref.collection('records').where('values.email', '==', email).limit(1).get() : null;
440
461
  if (existing && !existing.empty) {
441
462
  var _existing_docs__get;
442
- const merged = _extends({}, (_existing_docs__get = existing.docs[0].get('values')) != null ? _existing_docs__get : {}, values);
463
+ // A merge holds only the fields this write sent to the model: a row
464
+ // stored as text before AGL-2773 is not refused for a legacy value
465
+ // this run never touched.
466
+ const write = prepareDatasetRecordWrite(updateDataset, payload, {
467
+ existing: (_existing_docs__get = existing.docs[0].get('values')) != null ? _existing_docs__get : {}
468
+ });
469
+ if (Object.keys(write.errors).length) {
470
+ return failed(`record failed validation for dataset "${updateLabel}": ${describeDatasetRecordErrors(write.errors)}`);
471
+ }
472
+ const merged = write.values;
443
473
  await existing.docs[0].ref.set(_extends({
444
474
  values: merged
445
475
  }, datasetIntegrityUpdate(updateModel, merged, FieldValue.delete()), {
@@ -460,12 +490,16 @@ async function datasetAppendRefusal(env, datasetRef) {
460
490
  } else {
461
491
  // The APPEND leg of update-or-append, and the only one of the two
462
492
  // that adds a row — the merge above rewrites a record that already
463
- // counts against the band.
493
+ // counts against the band. A new row is held to the whole model,
494
+ // required fields included, like any other append.
495
+ if (Object.keys(incoming.errors).length) {
496
+ return failed(`record failed validation for dataset "${updateLabel}": ${describeDatasetRecordErrors(incoming.errors)}`);
497
+ }
464
498
  const refusal = await datasetAppendRefusal(env, datasetDoc.ref);
465
499
  if (refusal) return failed(refusal);
466
500
  await datasetDoc.ref.collection('records').add(_extends({
467
- values
468
- }, datasetIntegrityFields(updateModel, values), {
501
+ values: incoming.values
502
+ }, datasetIntegrityFields(updateModel, incoming.values), {
469
503
  createdAt: FieldValue.serverTimestamp()
470
504
  }));
471
505
  }