1import * as Effect from "effect/Effect";
2import * as SqlClient from "effect/sql/SqlClient";
3
4/**
5 * Canonicalize `modelSelection.options` / `defaultModelSelection.options` from
6 * the legacy object shape (`{ effort: "max", fastMode: true, ... }`) to the
7 * current array-of-selections shape (`[{ id: "effort", value: "max" }, ...]`).
8 *
9 * Migration 016 introduced `modelSelection` with `options` stored as a
10 * per-provider object. Later the schema was reshaped so that options are a
11 * generic `Array<{ id, value }>` of user-selected option entries. Stored rows
12 * from before the reshape still have the object shape and fail to decode.
13 *
14 * For each value in the legacy object:
15 * - string values are kept if non-empty after trim
16 * - boolean values are always kept (true | false)
17 * - any other value type (number, null, nested object/array) is dropped,
18 * matching the permissive client-side normalizer in composerDraftStore.
19 *
20 * Touched storage:
21 * - `projection_threads.model_selection_json.options`
22 * - `projection_projects.default_model_selection_json.options`
23 * - `orchestration_events.payload_json.$.modelSelection.options`
24 * (thread.created | thread.meta-updated | thread.turn-start-requested)
25 * - `orchestration_events.payload_json.$.defaultModelSelection.options`
26 * (project.created | project.meta-updated)
27 */
28export default Effect.gen(function* () {
29 const sql = yield* SqlClient.SqlClient;
30
31 yield* sql`
32 UPDATE projection_threads
33 SET model_selection_json = json_set(
34 model_selection_json,
35 '$.options',
36 (
37 SELECT json_group_array(
38 json_object(
39 'id', key,
40 'value',
41 CASE type
42 WHEN 'true' THEN json('true')
43 WHEN 'false' THEN json('false')
44 ELSE atom
45 END
46 )
47 )
48 FROM json_each(json_extract(model_selection_json, '$.options'))
49 WHERE (type = 'text' AND trim(coalesce(atom, '')) != '')
50 OR type IN ('true', 'false')
51 )
52 )
53 WHERE model_selection_json IS NOT NULL
54 AND json_type(model_selection_json, '$.options') = 'object'
55 `;
56
57 yield* sql`
58 UPDATE projection_projects
59 SET default_model_selection_json = json_set(
60 default_model_selection_json,
61 '$.options',
62 (
63 SELECT json_group_array(
64 json_object(
65 'id', key,
66 'value',
67 CASE type
68 WHEN 'true' THEN json('true')
69 WHEN 'false' THEN json('false')
70 ELSE atom
71 END
72 )
73 )
74 FROM json_each(json_extract(default_model_selection_json, '$.options'))
75 WHERE (type = 'text' AND trim(coalesce(atom, '')) != '')
76 OR type IN ('true', 'false')
77 )
78 )
79 WHERE default_model_selection_json IS NOT NULL
80 AND json_type(default_model_selection_json, '$.options') = 'object'
81 `;
82
83 yield* sql`
84 UPDATE orchestration_events
85 SET payload_json = json_set(
86 payload_json,
87 '$.modelSelection.options',
88 (
89 SELECT json_group_array(
90 json_object(
91 'id', key,
92 'value',
93 CASE type
94 WHEN 'true' THEN json('true')
95 WHEN 'false' THEN json('false')
96 ELSE atom
97 END
98 )
99 )
100 FROM json_each(json_extract(payload_json, '$.modelSelection.options'))
101 WHERE (type = 'text' AND trim(coalesce(atom, '')) != '')
102 OR type IN ('true', 'false')
103 )
104 )
105 WHERE event_type IN (
106 'thread.created',
107 'thread.meta-updated',
108 'thread.turn-start-requested'
109 )
110 AND json_type(payload_json, '$.modelSelection.options') = 'object'
111 `;
112
113 yield* sql`
114 UPDATE orchestration_events
115 SET payload_json = json_set(
116 payload_json,
117 '$.defaultModelSelection.options',
118 (
119 SELECT json_group_array(
120 json_object(
121 'id', key,
122 'value',
123 CASE type
124 WHEN 'true' THEN json('true')
125 WHEN 'false' THEN json('false')
126 ELSE atom
127 END
128 )
129 )
130 FROM json_each(json_extract(payload_json, '$.defaultModelSelection.options'))
131 WHERE (type = 'text' AND trim(coalesce(atom, '')) != '')
132 OR type IN ('true', 'false')
133 )
134 )
135 WHERE event_type IN ('project.created', 'project.meta-updated')
136 AND json_type(payload_json, '$.defaultModelSelection.options') = 'object'
137 `;
138});