@@ -24,17 +24,59 @@ function legacyTwoKindCreateSql(): string {
2424 . replace ( / ' e x e c ' , \s * ' p l u g i n ' , \s * ' s y s t e m - a g e n t ' / , "'exec', 'plugin'" ) ;
2525}
2626
27- function seedRow ( db : DatabaseSync , kind : string ) : void {
28- db . exec ( `
27+ function withoutResolutionRefColumn ( sql : string ) : string {
28+ const resolutionRefStart = sql . indexOf ( "\n resolution_ref " ) ;
29+ const followingColumnStart = sql . indexOf ( "\n kind " , resolutionRefStart ) ;
30+ if ( resolutionRefStart < 0 || followingColumnStart < 0 ) {
31+ throw new Error ( "resolution_ref column not found" ) ;
32+ }
33+ return sql . slice ( 0 , resolutionRefStart ) + sql . slice ( followingColumnStart ) ;
34+ }
35+
36+ function createAlterAppendedLegacyTable ( db : DatabaseSync , strict : boolean ) : void {
37+ const createSql = withoutResolutionRefColumn (
38+ strict ? legacyTwoKindCreateSql ( ) . replace ( / \) ; $ / u, ") STRICT;" ) : legacyTwoKindCreateSql ( ) ,
39+ ) ;
40+ db . exec ( createSql ) ;
41+ db . exec ( "ALTER TABLE operator_approvals ADD COLUMN resolution_ref TEXT;" ) ;
42+ }
43+
44+ const RESOLUTION_REF = "ref0000000000000000000000000000000000000000" ;
45+
46+ function seedRow (
47+ db : DatabaseSync ,
48+ kind : string ,
49+ resolutionRef : string | null = RESOLUTION_REF ,
50+ approvalId = "a1" ,
51+ ) : void {
52+ db . prepare ( `
2953 INSERT INTO operator_approvals (
3054 approval_id, resolution_ref, kind, status, presentation_json,
3155 reviewer_device_ids_json, audience_session_keys_json, runtime_epoch,
3256 created_at_ms, expires_at_ms, updated_at_ms
3357 ) VALUES (
34- 'a1', 'ref0000000000000000000000000000000000000000', '${ kind } ',
35- 'pending', '{}', '[]', '[]', 1, 1, 1, 1
58+ ?, ?, ?, 'pending', '{}', '[]', '[]', 1, 1, 1, 1
3659 );
37- ` ) ;
60+ ` ) . run ( approvalId , resolutionRef , kind ) ;
61+ }
62+
63+ function expectAlterAppendedLegacyRepair ( strict : boolean ) : void {
64+ const db = new DatabaseSync ( ":memory:" ) ;
65+ createAlterAppendedLegacyTable ( db , strict ) ;
66+ seedRow ( db , "exec" ) ;
67+
68+ expect ( repairOperatorApprovalSchema ( db ) ) . toEqual ( [
69+ "Migrated shared state operator approvals → OpenClaw system changes" ,
70+ ] ) ;
71+
72+ expect ( ( ) => assertCanonicalOperatorApprovalKinds ( db , ":memory:" ) ) . not . toThrow ( ) ;
73+ expect (
74+ db . prepare ( "SELECT approval_id, kind, resolution_ref FROM operator_approvals" ) . all ( ) ,
75+ ) . toEqual ( [ { approval_id : "a1" , kind : "exec" , resolution_ref : RESOLUTION_REF } ] ) ;
76+ expect (
77+ db . prepare ( "SELECT strict FROM pragma_table_list WHERE name = 'operator_approvals'" ) . get ( ) ,
78+ ) . toEqual ( { strict : 1 } ) ;
79+ db . close ( ) ;
3880}
3981
4082describe ( "repairOperatorApprovalKinds" , ( ) => {
@@ -51,22 +93,59 @@ describe("repairOperatorApprovalKinds", () => {
5193 ] ) ;
5294
5395 expect ( ( ) => assertCanonicalOperatorApprovalKinds ( db , ":memory:" ) ) . not . toThrow ( ) ;
54- const rows = db . prepare ( "SELECT approval_id, kind FROM operator_approvals" ) . all ( ) ;
55- expect ( rows ) . toEqual ( [ { approval_id : "a1" , kind : "exec" } ] ) ;
96+ const rows = db
97+ . prepare ( "SELECT approval_id, kind, resolution_ref FROM operator_approvals" )
98+ . all ( ) ;
99+ expect ( rows ) . toEqual ( [ { approval_id : "a1" , kind : "exec" , resolution_ref : RESOLUTION_REF } ] ) ;
56100 expect (
57101 db . prepare ( "SELECT strict FROM pragma_table_list WHERE name = 'operator_approvals'" ) . get ( ) ,
58102 ) . toEqual ( { strict : 1 } ) ;
59103 db . close ( ) ;
60104 } ) ;
61105
106+ it ( "migrates the ALTER-appended non-STRICT legacy shape" , ( ) => {
107+ expectAlterAppendedLegacyRepair ( false ) ;
108+ } ) ;
109+
110+ it ( "migrates the ALTER-appended STRICT legacy shape" , ( ) => {
111+ expectAlterAppendedLegacyRepair ( true ) ;
112+ } ) ;
113+
114+ it ( "drops pre-ref rows that violate the canonical resolution_ref shape" , ( ) => {
115+ const db = new DatabaseSync ( ":memory:" ) ;
116+ createAlterAppendedLegacyTable ( db , false ) ;
117+ seedRow ( db , "exec" ) ;
118+ seedRow ( db , "exec" , null , "a2-null-ref" ) ;
119+ seedRow ( db , "plugin" , "short" , "a3-bad-ref" ) ;
120+ // Non-STRICT legacy tables can hold a BLOB in the TEXT column; it must be
121+ // filtered out or the STRICT destination insert aborts the whole repair.
122+ db . prepare ( `
123+ INSERT INTO operator_approvals (
124+ approval_id, resolution_ref, kind, status, presentation_json,
125+ reviewer_device_ids_json, audience_session_keys_json, runtime_epoch,
126+ created_at_ms, expires_at_ms, updated_at_ms
127+ ) VALUES ('a4-blob-ref', ?, 'exec', 'pending', '{}', '[]', '[]', 1, 1, 1, 1);
128+ ` ) . run ( Buffer . from ( "ref0000000000000000000000000000000000000000" ) ) ;
129+
130+ expect ( repairOperatorApprovalSchema ( db ) ) . toEqual ( [
131+ "Migrated shared state operator approvals → OpenClaw system changes" ,
132+ ] ) ;
133+
134+ expect ( ( ) => assertCanonicalOperatorApprovalKinds ( db , ":memory:" ) ) . not . toThrow ( ) ;
135+ expect ( db . prepare ( "SELECT approval_id, resolution_ref FROM operator_approvals" ) . all ( ) ) . toEqual ( [
136+ { approval_id : "a1" , resolution_ref : RESOLUTION_REF } ,
137+ ] ) ;
138+ db . close ( ) ;
139+ } ) ;
140+
62141 it ( "is a no-op when the schema is already canonical" , ( ) => {
63142 const db = new DatabaseSync ( ":memory:" ) ;
64143 db . exec ( canonicalOperatorApprovalCreateSql ( ) ) ;
65144 expect ( repairOperatorApprovalSchema ( db ) ) . toEqual ( [ ] ) ;
66145 db . close ( ) ;
67146 } ) ;
68147
69- it ( "refuses to replace a look-alike table with the same columns but different constraints " , ( ) => {
148+ it ( "refuses to replace an arbitrarily different table " , ( ) => {
70149 const db = new DatabaseSync ( ":memory:" ) ;
71150 // Same column names, but a different (non-canonical) kind constraint — the
72151 // fail-closed guard must not copy its rows under today's schema.
0 commit comments