From 225e48730ed591d0aaa703122be0691bf10f8694 Mon Sep 17 00:00:00 2001 From: Joe Clark Date: Wed, 26 Aug 2026 11:00:28 +0100 Subject: [PATCH 1/6] Add support for dataclips to be binary, rather than json encoded --- lib/lightning/runs/handlers.ex | 17 +++++++-- lib/lightning/setup_utils.ex | 32 ++++++++--------- test/lightning/runs_test.exs | 35 +++++++++++++++++-- .../channels/run_channel_test.exs | 18 +++++----- .../lightning_web/live/run_live/show_test.exs | 8 ++--- 5 files changed, 75 insertions(+), 35 deletions(-) diff --git a/lib/lightning/runs/handlers.ex b/lib/lightning/runs/handlers.ex index ebb4c526cd8..33e44a2911b 100644 --- a/lib/lightning/runs/handlers.ex +++ b/lib/lightning/runs/handlers.ex @@ -405,6 +405,11 @@ defmodule Lightning.Runs.Handlers do defmodule CompleteStep do @moduledoc """ Schema to validate the input attributes of a completed step. + + `output_dataclip` may arrive either as a JSON-encoded string (older + workers) or already decoded — a map, list, or scalar (newer workers). + See `maybe_decode_dataclip/1`. Map values are stored as-is; non-map + values are wrapped as `%{"value" => x}` before persistence. """ use Lightning.Schema import Ecto.Query @@ -415,7 +420,7 @@ defmodule Lightning.Runs.Handlers do embedded_schema do field :project_id, Ecto.UUID field :run_id, Ecto.UUID - field :output_dataclip, :string + field :output_dataclip, :any, virtual: true field :output_dataclip_id, Ecto.UUID field :reason, :string field :error_type, :string @@ -550,12 +555,20 @@ defmodule Lightning.Runs.Handlers do Dataclip.new(%{ id: dataclip_id, project_id: project_id, - body: output_dataclip |> Jason.decode!() |> ensure_map(), + body: output_dataclip |> maybe_decode_dataclip() |> ensure_map(), type: :step_result }) |> Repo.insert() end + # For back compat: older workers JSON-encode output_dataclip into a string before sending + # it (Lightning then decodes it); newer workers send the value already + # decoded. + defp maybe_decode_dataclip(value) when is_binary(value), + do: Jason.decode!(value) + + defp maybe_decode_dataclip(value), do: value + defp ensure_map(%{} = map), do: map defp ensure_map(value), do: %{"value" => value} end diff --git a/lib/lightning/setup_utils.ex b/lib/lightning/setup_utils.ex index 6c817c1b70b..28f3f3f42eb 100644 --- a/lib/lightning/setup_utils.ex +++ b/lib/lightning/setup_utils.ex @@ -508,7 +508,7 @@ defmodule Lightning.SetupUtils do [CLI] ✔ Done in 223ms! ✨ """), input_dataclip_id: dataclip.id, - output_dataclip: %{data: http_body, references: []} |> Jason.encode!() + output_dataclip: %{"data" => http_body, "references" => []} }, %{ job_id: send_to_openhim.id, @@ -532,7 +532,7 @@ defmodule Lightning.SetupUtils do [CLI] ✔ Writing output to /tmp/output-1686840746-126941-i2yb2g.json [CLI] ✔ Done in 223ms! ✨ """), - output_dataclip: %{data: http_body, references: []} |> Jason.encode!() + output_dataclip: %{"data" => http_body, "references" => []} }, %{ job_id: notify_upload_successful.id, @@ -556,7 +556,7 @@ defmodule Lightning.SetupUtils do [CLI] ✔ Writing output to /tmp/output-1686840747-126941-16ewhef.json [CLI] ✔ Done in 209ms! ✨ """), - output_dataclip: %{data: http_body, references: []} |> Jason.encode!() + output_dataclip: %{"data" => http_body, "references" => []} } ] @@ -733,20 +733,18 @@ defmodule Lightning.SetupUtils do [CMP] ℹ Added export * statement for @openfn/language-dhis2@latest """), input_dataclip_id: input_dataclip.id, - output_dataclip: - %{ - data: %{ - spreadsheetId: "wv5ftwhte", - tableRange: "A3:D3", - updates: %{ - updatedCells: 4 - } - }, - references: [ - %{} - ] - } - |> Jason.encode!() + output_dataclip: %{ + "data" => %{ + "spreadsheetId" => "wv5ftwhte", + "tableRange" => "A3:D3", + "updates" => %{ + "updatedCells" => 4 + } + }, + "references" => [ + %{} + ] + } }, %{ job_id: upload_to_google_sheet.id, diff --git a/test/lightning/runs_test.exs b/test/lightning/runs_test.exs index 3fac793b4a2..73fd572ed5d 100644 --- a/test/lightning/runs_test.exs +++ b/test/lightning/runs_test.exs @@ -401,6 +401,35 @@ defmodule Lightning.RunsTest do dataclip = insert(:dataclip) %{triggers: [trigger], jobs: [job]} = workflow = insert(:simple_workflow) + %{runs: [run]} = + work_order_for(trigger, workflow: workflow, dataclip: dataclip) + |> insert() + + step = + insert(:step, runs: [run], job: job, input_dataclip: dataclip) + + {:ok, step} = + Runs.complete_step(%{ + step_id: step.id, + reason: "success", + output_dataclip: %{"foo" => "bar"}, + output_dataclip_id: Ecto.UUID.generate(), + run_id: run.id, + project_id: workflow.project_id + }) + + step = + step + |> Repo.preload(output_dataclip: Invocation.Query.dataclip_with_body()) + + assert step.exit_reason == "success" + assert Jason.decode!(step.output_dataclip.body) == %{"foo" => "bar"} + end + + test "accepts a JSON-encoded string output_dataclip, for backward compatibility with older workers" do + dataclip = insert(:dataclip) + %{triggers: [trigger], jobs: [job]} = workflow = insert(:simple_workflow) + %{runs: [run]} = work_order_for(trigger, workflow: workflow, dataclip: dataclip) |> insert() @@ -446,7 +475,7 @@ defmodule Lightning.RunsTest do Runs.complete_step(%{ step_id: step.id, reason: "success", - output_dataclip: ~s({"deferred": "indexword"}), + output_dataclip: %{"deferred" => "indexword"}, output_dataclip_id: output_dataclip_id, run_id: run.id, project_id: workflow.project_id @@ -491,7 +520,7 @@ defmodule Lightning.RunsTest do %{ step_id: step.id, reason: "success", - output_dataclip: ~s({"foo": "bar"}), + output_dataclip: %{"foo" => "bar"}, output_dataclip_id: Ecto.UUID.generate(), run_id: run.id, project_id: workflow.project_id @@ -538,7 +567,7 @@ defmodule Lightning.RunsTest do Runs.complete_step(%{ step_id: Ecto.UUID.generate(), reason: "success", - output_dataclip: ~s({"foo": "bar"}), + output_dataclip: %{"foo" => "bar"}, output_dataclip_id: Ecto.UUID.generate(), run_id: run.id, project_id: workflow.project_id diff --git a/test/lightning_web/channels/run_channel_test.exs b/test/lightning_web/channels/run_channel_test.exs index 316fd759fad..36e68028151 100644 --- a/test/lightning_web/channels/run_channel_test.exs +++ b/test/lightning_web/channels/run_channel_test.exs @@ -976,7 +976,7 @@ defmodule LightningWeb.RunChannelTest do push(socket, "step:complete", %{ "step_id" => step.id, "output_dataclip_id" => Ecto.UUID.generate(), - "output_dataclip" => ~s({"foo": "bar"}), + "output_dataclip" => %{"foo" => "bar"}, "reason" => "normal", "timestamp" => to_string(timestamp) }) @@ -1001,7 +1001,7 @@ defmodule LightningWeb.RunChannelTest do push(socket, "step:complete", %{ "step_id" => step.id, "output_dataclip_id" => Ecto.UUID.generate(), - "output_dataclip" => ~s({"foo": "bar"}), + "output_dataclip" => %{"foo" => "bar"}, "reason" => "normal" }) @@ -1021,7 +1021,7 @@ defmodule LightningWeb.RunChannelTest do push(socket, "step:complete", %{ "step_id" => step.id, "output_dataclip_id" => Ecto.UUID.generate(), - "output_dataclip" => ~s({"foo": "bar"}), + "output_dataclip" => %{"foo" => "bar"}, "reason" => "fail" }) @@ -1043,7 +1043,7 @@ defmodule LightningWeb.RunChannelTest do push(socket, "step:complete", %{ "step_id" => step_id, "output_dataclip_id" => dataclip_id, - "output_dataclip" => ~s({"foo": "bar"}), + "output_dataclip" => %{"foo" => "bar"}, "reason" => "normal" }) @@ -1078,7 +1078,7 @@ defmodule LightningWeb.RunChannelTest do push(socket, "step:complete", %{ "step_id" => step_id, "output_dataclip_id" => dataclip_id, - "output_dataclip" => ~s({"foo": "bar"}), + "output_dataclip" => %{"foo" => "bar"}, "reason" => "normal" }) @@ -1105,7 +1105,7 @@ defmodule LightningWeb.RunChannelTest do ref = push(socket, "step:complete", %{ "step_id" => step_id, - "output_dataclip" => ~s({"foo": "bar"}), + "output_dataclip" => %{"foo" => "bar"}, "reason" => "normal" }) @@ -1124,7 +1124,7 @@ defmodule LightningWeb.RunChannelTest do push(socket, "step:complete", %{ "step_id" => foreign_step.id, "output_dataclip_id" => output_dataclip_id, - "output_dataclip" => ~s({"leaked": "data"}), + "output_dataclip" => %{"leaked" => "data"}, "reason" => "fail" }) @@ -2446,7 +2446,7 @@ defmodule LightningWeb.RunChannelTest do %{ "step_id" => step.id, "output_dataclip_id" => Ecto.UUID.generate(), - "output_dataclip" => ~s({"foo": "bar"}), + "output_dataclip" => %{"foo" => "bar"}, "reason" => "normal" } |> maybe_put("webhook_response", Keyword.get(opts, :webhook_response)) @@ -2956,7 +2956,7 @@ defmodule LightningWeb.RunChannelTest do Lightning.Runs.complete_step( %{ "step_id" => step.id, - "output_dataclip" => Jason.encode!(%{"foo" => "bar"}), + "output_dataclip" => %{"foo" => "bar"}, "output_dataclip_id" => Ecto.UUID.generate(), "reason" => "success", "finished_at" => DateTime.utc_now(), diff --git a/test/lightning_web/live/run_live/show_test.exs b/test/lightning_web/live/run_live/show_test.exs index 1d3527874c0..8d667043103 100644 --- a/test/lightning_web/live/run_live/show_test.exs +++ b/test/lightning_web/live/run_live/show_test.exs @@ -163,7 +163,7 @@ defmodule LightningWeb.RunLive.ShowTest do run_id: run_id, project_id: project.id, step_id: step.id, - output_dataclip: ~s({"y": 2}), + output_dataclip: %{"y" => 2}, output_dataclip_id: output_dataclip_id = Ecto.UUID.generate(), reason: "success" }) @@ -207,7 +207,7 @@ defmodule LightningWeb.RunLive.ShowTest do run_id: run_id, project_id: project.id, step_id: step_2.id, - output_dataclip: ~s({"z": 2}), + output_dataclip: %{"z" => 2}, output_dataclip_id: step_2_output_dataclip_id = Ecto.UUID.generate(), reason: "success" }) @@ -281,7 +281,7 @@ defmodule LightningWeb.RunLive.ShowTest do run_id: run_id, project_id: project.id, step_id: step.id, - output_dataclip: ~s({"result": 42}), + output_dataclip: %{"result" => 42}, output_dataclip_id: output_dataclip_id = Ecto.UUID.generate(), reason: "success" }) @@ -465,7 +465,7 @@ defmodule LightningWeb.RunLive.ShowTest do run_id: run_id, project_id: project.id, step_id: step.id, - output_dataclip: ~s({"y": 2}), + output_dataclip: %{"y" => 2}, output_dataclip_id: Ecto.UUID.generate(), reason: "success" }) From 1e2c0cf8d0750e6fc8736ea5c3d7da91c08262ea Mon Sep 17 00:00:00 2001 From: Joe Clark Date: Wed, 26 Aug 2026 11:22:19 +0100 Subject: [PATCH 2/6] changelog --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 672f70f5a30..b5bb42c9a0a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,9 @@ and this project adheres to button), closes any other open panel, deselects the current node, and drops any run-viewing context, landing on the bare canvas. [#4984](https://github.com/OpenFn/lightning/pull/4984) +- `step:complete` now accepts `output_dataclip` as a decoded value, not just a + JSON string, while staying compatible with workers that still send a string. + [#5098](https://github.com/OpenFn/lightning/pull/5098) ### Changed From 1d6d5233fbd45e6a23312f7e9fae79bc7e1f8529 Mon Sep 17 00:00:00 2001 From: Joe Clark Date: Thu, 27 Aug 2026 14:49:56 +0100 Subject: [PATCH 3/6] add test that ensure_map covers a scalar value --- test/lightning/runs_test.exs | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/test/lightning/runs_test.exs b/test/lightning/runs_test.exs index 73fd572ed5d..c5feee03f8b 100644 --- a/test/lightning/runs_test.exs +++ b/test/lightning/runs_test.exs @@ -455,6 +455,35 @@ defmodule Lightning.RunsTest do assert Jason.decode!(step.output_dataclip.body) == %{"foo" => "bar"} end + test "wraps a JSON-encoded scalar output_dataclip in %{\"value\" => x}" do + dataclip = insert(:dataclip) + %{triggers: [trigger], jobs: [job]} = workflow = insert(:simple_workflow) + + %{runs: [run]} = + work_order_for(trigger, workflow: workflow, dataclip: dataclip) + |> insert() + + step = + insert(:step, runs: [run], job: job, input_dataclip: dataclip) + + {:ok, step} = + Runs.complete_step(%{ + step_id: step.id, + reason: "success", + output_dataclip: ~s(42), + output_dataclip_id: Ecto.UUID.generate(), + run_id: run.id, + project_id: workflow.project_id + }) + + step = + step + |> Repo.preload(output_dataclip: Invocation.Query.dataclip_with_body()) + + assert step.exit_reason == "success" + assert Jason.decode!(step.output_dataclip.body) == %{"value" => 42} + end + # Regression for #4800: dataclip inserts no longer build the search_vector # synchronously (the AFTER INSERT trigger was dropped). Saving an output # dataclip via the handler must succeed and the row must be retrievable with From c4cef06eddb99daee68f2dedf0f5f45a06e9173a Mon Sep 17 00:00:00 2001 From: Joe Clark Date: Fri, 28 Aug 2026 09:47:55 +0100 Subject: [PATCH 4/6] add test to prove string types blow up --- test/lightning/runs_test.exs | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/test/lightning/runs_test.exs b/test/lightning/runs_test.exs index c5feee03f8b..c64ede3030b 100644 --- a/test/lightning/runs_test.exs +++ b/test/lightning/runs_test.exs @@ -484,6 +484,29 @@ defmodule Lightning.RunsTest do assert Jason.decode!(step.output_dataclip.body) == %{"value" => 42} end + test "raises when output_dataclip is a plain string that isn't valid JSON" do + dataclip = insert(:dataclip) + %{triggers: [trigger], jobs: [job]} = workflow = insert(:simple_workflow) + + %{runs: [run]} = + work_order_for(trigger, workflow: workflow, dataclip: dataclip) + |> insert() + + step = + insert(:step, runs: [run], job: job, input_dataclip: dataclip) + + assert_raise Jason.DecodeError, fn -> + Runs.complete_step(%{ + step_id: step.id, + reason: "success", + output_dataclip: "abc-123", + output_dataclip_id: Ecto.UUID.generate(), + run_id: run.id, + project_id: workflow.project_id + }) + end + end + # Regression for #4800: dataclip inserts no longer build the search_vector # synchronously (the AFTER INSERT trigger was dropped). Saving an output # dataclip via the handler must succeed and the row must be retrievable with From a80db84177a9de316bc2972250f6448674b350e8 Mon Sep 17 00:00:00 2001 From: Joe Clark Date: Fri, 28 Aug 2026 09:56:20 +0100 Subject: [PATCH 5/6] fix string state obejcts --- lib/lightning/runs/handlers.ex | 20 +++++++++++++++----- test/lightning/runs_test.exs | 12 +++++++++--- 2 files changed, 24 insertions(+), 8 deletions(-) diff --git a/lib/lightning/runs/handlers.ex b/lib/lightning/runs/handlers.ex index 33e44a2911b..ca3629f2aa1 100644 --- a/lib/lightning/runs/handlers.ex +++ b/lib/lightning/runs/handlers.ex @@ -561,11 +561,21 @@ defmodule Lightning.Runs.Handlers do |> Repo.insert() end - # For back compat: older workers JSON-encode output_dataclip into a string before sending - # it (Lightning then decodes it); newer workers send the value already - # decoded. - defp maybe_decode_dataclip(value) when is_binary(value), - do: Jason.decode!(value) + # For back compat: older workers JSON-encode output_dataclip into a string + # before sending it (Lightning then decodes it); newer workers send the + # value already decoded. A bare string is ambiguous either way — e.g. a + # job can legitimately return "24", "true", or "{}" as its literal state + # — so we try to parse it as JSON and, if that fails, fall back to the + # string as-is. This can misclassify a literal string that happens to + # look like JSON (a job returning the string "24" ends up stored as the + # number 24), but returning a bare string as step state is already an + # edge case we're comfortable accepting the ambiguity on for now. + defp maybe_decode_dataclip(value) when is_binary(value) do + case Jason.decode(value) do + {:ok, decoded} -> decoded + {:error, _} -> value + end + end defp maybe_decode_dataclip(value), do: value diff --git a/test/lightning/runs_test.exs b/test/lightning/runs_test.exs index c64ede3030b..6f3eb0daa74 100644 --- a/test/lightning/runs_test.exs +++ b/test/lightning/runs_test.exs @@ -484,7 +484,7 @@ defmodule Lightning.RunsTest do assert Jason.decode!(step.output_dataclip.body) == %{"value" => 42} end - test "raises when output_dataclip is a plain string that isn't valid JSON" do + test "wraps a plain string output_dataclip that isn't valid JSON in %{\"value\" => x}" do dataclip = insert(:dataclip) %{triggers: [trigger], jobs: [job]} = workflow = insert(:simple_workflow) @@ -495,7 +495,7 @@ defmodule Lightning.RunsTest do step = insert(:step, runs: [run], job: job, input_dataclip: dataclip) - assert_raise Jason.DecodeError, fn -> + {:ok, step} = Runs.complete_step(%{ step_id: step.id, reason: "success", @@ -504,7 +504,13 @@ defmodule Lightning.RunsTest do run_id: run.id, project_id: workflow.project_id }) - end + + step = + step + |> Repo.preload(output_dataclip: Invocation.Query.dataclip_with_body()) + + assert step.exit_reason == "success" + assert Jason.decode!(step.output_dataclip.body) == %{"value" => "abc-123"} end # Regression for #4800: dataclip inserts no longer build the search_vector From 23229127a409d95320d3bf02cf03e53f3ae1835d Mon Sep 17 00:00:00 2001 From: Joe Clark Date: Fri, 28 Aug 2026 10:23:17 +0100 Subject: [PATCH 6/6] remove maybe_decode_dataclip --- lib/lightning/runs/handlers.ex | 25 ++------------ test/lightning/runs_test.exs | 62 ++-------------------------------- 2 files changed, 5 insertions(+), 82 deletions(-) diff --git a/lib/lightning/runs/handlers.ex b/lib/lightning/runs/handlers.ex index ca3629f2aa1..712a64a0a03 100644 --- a/lib/lightning/runs/handlers.ex +++ b/lib/lightning/runs/handlers.ex @@ -406,9 +406,8 @@ defmodule Lightning.Runs.Handlers do @moduledoc """ Schema to validate the input attributes of a completed step. - `output_dataclip` may arrive either as a JSON-encoded string (older - workers) or already decoded — a map, list, or scalar (newer workers). - See `maybe_decode_dataclip/1`. Map values are stored as-is; non-map + `output_dataclip` arrives already decoded (a map, list, or scalar) rather + than as a JSON-encoded string. Map values are stored as-is; non-map values are wrapped as `%{"value" => x}` before persistence. """ use Lightning.Schema @@ -555,30 +554,12 @@ defmodule Lightning.Runs.Handlers do Dataclip.new(%{ id: dataclip_id, project_id: project_id, - body: output_dataclip |> maybe_decode_dataclip() |> ensure_map(), + body: output_dataclip |> ensure_map(), type: :step_result }) |> Repo.insert() end - # For back compat: older workers JSON-encode output_dataclip into a string - # before sending it (Lightning then decodes it); newer workers send the - # value already decoded. A bare string is ambiguous either way — e.g. a - # job can legitimately return "24", "true", or "{}" as its literal state - # — so we try to parse it as JSON and, if that fails, fall back to the - # string as-is. This can misclassify a literal string that happens to - # look like JSON (a job returning the string "24" ends up stored as the - # number 24), but returning a bare string as step state is already an - # edge case we're comfortable accepting the ambiguity on for now. - defp maybe_decode_dataclip(value) when is_binary(value) do - case Jason.decode(value) do - {:ok, decoded} -> decoded - {:error, _} -> value - end - end - - defp maybe_decode_dataclip(value), do: value - defp ensure_map(%{} = map), do: map defp ensure_map(value), do: %{"value" => value} end diff --git a/test/lightning/runs_test.exs b/test/lightning/runs_test.exs index 6f3eb0daa74..1ae745e675d 100644 --- a/test/lightning/runs_test.exs +++ b/test/lightning/runs_test.exs @@ -426,7 +426,7 @@ defmodule Lightning.RunsTest do assert Jason.decode!(step.output_dataclip.body) == %{"foo" => "bar"} end - test "accepts a JSON-encoded string output_dataclip, for backward compatibility with older workers" do + test "wraps a scalar output_dataclip in %{\"value\" => x}" do dataclip = insert(:dataclip) %{triggers: [trigger], jobs: [job]} = workflow = insert(:simple_workflow) @@ -441,36 +441,7 @@ defmodule Lightning.RunsTest do Runs.complete_step(%{ step_id: step.id, reason: "success", - output_dataclip: ~s({"foo": "bar"}), - output_dataclip_id: Ecto.UUID.generate(), - run_id: run.id, - project_id: workflow.project_id - }) - - step = - step - |> Repo.preload(output_dataclip: Invocation.Query.dataclip_with_body()) - - assert step.exit_reason == "success" - assert Jason.decode!(step.output_dataclip.body) == %{"foo" => "bar"} - end - - test "wraps a JSON-encoded scalar output_dataclip in %{\"value\" => x}" do - dataclip = insert(:dataclip) - %{triggers: [trigger], jobs: [job]} = workflow = insert(:simple_workflow) - - %{runs: [run]} = - work_order_for(trigger, workflow: workflow, dataclip: dataclip) - |> insert() - - step = - insert(:step, runs: [run], job: job, input_dataclip: dataclip) - - {:ok, step} = - Runs.complete_step(%{ - step_id: step.id, - reason: "success", - output_dataclip: ~s(42), + output_dataclip: 42, output_dataclip_id: Ecto.UUID.generate(), run_id: run.id, project_id: workflow.project_id @@ -484,35 +455,6 @@ defmodule Lightning.RunsTest do assert Jason.decode!(step.output_dataclip.body) == %{"value" => 42} end - test "wraps a plain string output_dataclip that isn't valid JSON in %{\"value\" => x}" do - dataclip = insert(:dataclip) - %{triggers: [trigger], jobs: [job]} = workflow = insert(:simple_workflow) - - %{runs: [run]} = - work_order_for(trigger, workflow: workflow, dataclip: dataclip) - |> insert() - - step = - insert(:step, runs: [run], job: job, input_dataclip: dataclip) - - {:ok, step} = - Runs.complete_step(%{ - step_id: step.id, - reason: "success", - output_dataclip: "abc-123", - output_dataclip_id: Ecto.UUID.generate(), - run_id: run.id, - project_id: workflow.project_id - }) - - step = - step - |> Repo.preload(output_dataclip: Invocation.Query.dataclip_with_body()) - - assert step.exit_reason == "success" - assert Jason.decode!(step.output_dataclip.body) == %{"value" => "abc-123"} - end - # Regression for #4800: dataclip inserts no longer build the search_vector # synchronously (the AFTER INSERT trigger was dropped). Saving an output # dataclip via the handler must succeed and the row must be retrievable with