Skip to content

Commit b111f4f

Browse files
committed
Bump v1.7.62: fix binary data crashes, add V78 migration for missing AI columns
Fix UnicodeConversionError in integration plug when response body contains non-UTF8 binary data. Fix DB browser rendering of raw binary values. Add V78 migration to backfill AI module columns that were skipped by V41 conditional table_exists? checks.
1 parent 330f76c commit b111f4f

7 files changed

Lines changed: 176 additions & 33 deletions

File tree

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,10 @@
1+
## 1.7.62 - 2026-03-05
2+
- Fix UnicodeConversionError crash in integration plug when response body contains non-UTF8 binary data
3+
- Fix DB browser rendering of raw binary values (e.g. UUID bytes) in table and activity views
4+
- Add V78 migration: backfill missing AI module columns skipped by V41 conditional checks
5+
- Add `reasoning_enabled`, `reasoning_effort`, `reasoning_max_tokens`, `reasoning_exclude` to `phoenix_kit_ai_endpoints`
6+
- Add `prompt_uuid`, `prompt_name` to `phoenix_kit_ai_requests` with index and FK constraint
7+
18
## 1.7.61 - 2026-03-04
29
- Replace `plug_cowboy` with `bandit ~> 1.0` as HTTP adapter (Phoenix 1.8 default)
310
- Remove stale deps from lock: `cowboy`, `cowlib`, `cowboy_telemetry`, `plug_cowboy`, `combine`, `dns_cluster`, `phoenix_live_dashboard`, `poolboy`, `timex`, `tzdata`

lib/modules/db/web/activity.ex

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -223,8 +223,12 @@ defmodule PhoenixKit.Modules.DB.Web.Activity do
223223
def format_value(value) when is_map(value), do: Jason.encode!(value, pretty: true)
224224
def format_value(value) when is_list(value), do: inspect(value, pretty: true)
225225

226-
def format_value(value) when is_binary(value) and byte_size(value) > 200 do
227-
String.slice(value, 0, 200) <> "..."
226+
def format_value(value) when is_binary(value) do
227+
if String.valid?(value) do
228+
if byte_size(value) > 200, do: String.slice(value, 0, 200) <> "...", else: value
229+
else
230+
inspect(value)
231+
end
228232
end
229233

230234
def format_value(value), do: inspect(value)

lib/modules/db/web/show.ex

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,11 @@ defmodule PhoenixKit.Modules.DB.Web.Show do
158158

159159
def format_cell(value) when is_map(value), do: Jason.encode!(value)
160160
def format_cell(value) when is_list(value), do: inspect(value)
161-
def format_cell(value) when is_binary(value), do: value
161+
162+
def format_cell(value) when is_binary(value) do
163+
if String.valid?(value), do: value, else: inspect(value)
164+
end
165+
162166
def format_cell(value), do: to_string(value || "")
163167

164168
# Parse and validate page number - must be positive integer

lib/phoenix_kit/migrations/postgres.ex

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -621,7 +621,7 @@ defmodule PhoenixKit.Migrations.Postgres do
621621
use Ecto.Migration
622622

623623
@initial_version 1
624-
@current_version 77
624+
@current_version 78
625625
@default_prefix "public"
626626

627627
@doc false
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
defmodule PhoenixKit.Migrations.Postgres.V78 do
2+
@moduledoc """
3+
V78: Add missing columns to AI tables from V41.
4+
5+
V41 conditionally added columns to AI tables (reasoning columns on endpoints,
6+
prompt tracking on requests), but only if the tables existed at the time.
7+
If the AI module was enabled after V41 ran, these columns were never created.
8+
9+
This migration ensures they exist. All operations are idempotent.
10+
"""
11+
12+
use Ecto.Migration
13+
14+
def up(opts) do
15+
prefix = Map.get(opts, :prefix, "public")
16+
17+
# Reasoning columns on endpoints (from V41)
18+
if table_exists?(:phoenix_kit_ai_endpoints, prefix) do
19+
alter table(:phoenix_kit_ai_endpoints, prefix: prefix) do
20+
add_if_not_exists :reasoning_enabled, :boolean
21+
add_if_not_exists :reasoning_effort, :string, size: 20
22+
add_if_not_exists :reasoning_max_tokens, :integer
23+
add_if_not_exists :reasoning_exclude, :boolean
24+
end
25+
end
26+
27+
# Prompt tracking columns on requests (from V41)
28+
if table_exists?(:phoenix_kit_ai_requests, prefix) do
29+
alter table(:phoenix_kit_ai_requests, prefix: prefix) do
30+
add_if_not_exists :prompt_uuid, :uuid
31+
add_if_not_exists :prompt_name, :string, size: 255
32+
end
33+
34+
create_if_not_exists index(:phoenix_kit_ai_requests, [:prompt_uuid],
35+
name: :phoenix_kit_ai_requests_prompt_uuid_idx,
36+
prefix: prefix
37+
)
38+
39+
if table_exists?(:phoenix_kit_ai_prompts, prefix) do
40+
execute """
41+
DO $$
42+
BEGIN
43+
IF NOT EXISTS (
44+
SELECT 1 FROM pg_constraint
45+
WHERE conname = 'phoenix_kit_ai_requests_prompt_uuid_fkey'
46+
AND conrelid = '#{prefix_str(prefix)}phoenix_kit_ai_requests'::regclass
47+
) THEN
48+
ALTER TABLE #{prefix_str(prefix)}phoenix_kit_ai_requests
49+
ADD CONSTRAINT phoenix_kit_ai_requests_prompt_uuid_fkey
50+
FOREIGN KEY (prompt_uuid)
51+
REFERENCES #{prefix_str(prefix)}phoenix_kit_ai_prompts(uuid)
52+
ON DELETE SET NULL;
53+
END IF;
54+
END $$;
55+
"""
56+
end
57+
end
58+
59+
execute "COMMENT ON TABLE #{prefix_str(prefix)}phoenix_kit IS '78'"
60+
end
61+
62+
def down(opts) do
63+
prefix = Map.get(opts, :prefix, "public")
64+
65+
if table_exists?(:phoenix_kit_ai_endpoints, prefix) do
66+
alter table(:phoenix_kit_ai_endpoints, prefix: prefix) do
67+
remove_if_exists :reasoning_enabled, :boolean
68+
remove_if_exists :reasoning_effort, :string
69+
remove_if_exists :reasoning_max_tokens, :integer
70+
remove_if_exists :reasoning_exclude, :boolean
71+
end
72+
end
73+
74+
if table_exists?(:phoenix_kit_ai_requests, prefix) do
75+
execute """
76+
DO $$
77+
BEGIN
78+
IF EXISTS (
79+
SELECT 1 FROM pg_constraint
80+
WHERE conname = 'phoenix_kit_ai_requests_prompt_uuid_fkey'
81+
AND conrelid = '#{prefix_str(prefix)}phoenix_kit_ai_requests'::regclass
82+
) THEN
83+
ALTER TABLE #{prefix_str(prefix)}phoenix_kit_ai_requests
84+
DROP CONSTRAINT phoenix_kit_ai_requests_prompt_uuid_fkey;
85+
END IF;
86+
END $$;
87+
"""
88+
89+
drop_if_exists index(:phoenix_kit_ai_requests, [:prompt_uuid],
90+
name: :phoenix_kit_ai_requests_prompt_uuid_idx,
91+
prefix: prefix
92+
)
93+
94+
alter table(:phoenix_kit_ai_requests, prefix: prefix) do
95+
remove_if_exists :prompt_uuid, :uuid
96+
remove_if_exists :prompt_name, :string
97+
end
98+
end
99+
100+
execute "COMMENT ON TABLE #{prefix_str(prefix)}phoenix_kit IS '77'"
101+
end
102+
103+
defp table_exists?(table_name, prefix) do
104+
query = """
105+
SELECT EXISTS (
106+
SELECT FROM information_schema.tables
107+
WHERE table_schema = '#{prefix}'
108+
AND table_name = '#{table_name}'
109+
)
110+
"""
111+
112+
%{rows: [[exists]]} = PhoenixKit.RepoHelper.repo().query!(query)
113+
exists
114+
end
115+
116+
defp prefix_str("public"), do: "public."
117+
defp prefix_str(prefix), do: "#{prefix}."
118+
end

lib/phoenix_kit_web/plugs/integration.ex

Lines changed: 38 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -91,34 +91,44 @@ defmodule PhoenixKitWeb.Plugs.Integration do
9191
# Inject the script right after <head> tag so it runs before any other scripts
9292
# Uses simple string replacement instead of regex for better performance
9393
defp inject_script_into_head(conn) do
94-
body = to_string(conn.resp_body)
95-
96-
# Try simple replacements first (faster than regex)
97-
cond do
98-
String.contains?(body, "<head>") ->
99-
new_body =
100-
String.replace(body, "<head>", "<head>" <> @websocket_fix_script, global: false)
101-
102-
%{conn | resp_body: new_body}
103-
104-
String.contains?(body, "<HEAD>") ->
105-
new_body =
106-
String.replace(body, "<HEAD>", "<HEAD>" <> @websocket_fix_script, global: false)
107-
108-
%{conn | resp_body: new_body}
109-
110-
true ->
111-
# No simple <head> tag found, try regex for <head ...> with attributes
112-
case Regex.run(~r/<head[^>]*>/i, body, return: :index) do
113-
[{start, length}] ->
114-
insert_pos = start + length
115-
{before, after_head} = String.split_at(body, insert_pos)
116-
new_body = before <> @websocket_fix_script <> after_head
117-
%{conn | resp_body: new_body}
118-
119-
_ ->
120-
conn
121-
end
94+
body =
95+
try do
96+
IO.iodata_to_binary(conn.resp_body)
97+
rescue
98+
_ -> nil
99+
end
100+
101+
# Skip if body couldn't be converted or isn't valid UTF-8
102+
if is_nil(body) or not String.valid?(body) do
103+
conn
104+
else
105+
# Try simple replacements first (faster than regex)
106+
cond do
107+
String.contains?(body, "<head>") ->
108+
new_body =
109+
String.replace(body, "<head>", "<head>" <> @websocket_fix_script, global: false)
110+
111+
%{conn | resp_body: new_body}
112+
113+
String.contains?(body, "<HEAD>") ->
114+
new_body =
115+
String.replace(body, "<HEAD>", "<HEAD>" <> @websocket_fix_script, global: false)
116+
117+
%{conn | resp_body: new_body}
118+
119+
true ->
120+
# No simple <head> tag found, try regex for <head ...> with attributes
121+
case Regex.run(~r/<head[^>]*>/i, body, return: :index) do
122+
[{start, length}] ->
123+
insert_pos = start + length
124+
{before, after_head} = String.split_at(body, insert_pos)
125+
new_body = before <> @websocket_fix_script <> after_head
126+
%{conn | resp_body: new_body}
127+
128+
_ ->
129+
conn
130+
end
131+
end
122132
end
123133
end
124134
end

mix.exs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
defmodule PhoenixKit.MixProject do
22
use Mix.Project
33

4-
@version "1.7.61"
4+
@version "1.7.62"
55
@description "PhoenixKit is a starter kit for building modern web applications with Elixir and Phoenix"
66
@source_url "https://github.com/BeamLabEU/phoenix_kit"
77

0 commit comments

Comments
 (0)