@@ -1042,6 +1042,20 @@ export interface LegacyUniqueReplacement {
10421042 column : string ;
10431043 legacyNames : string [ ] ;
10441044 replacement : ExpectedIndex ;
1045+ /**
1046+ * The EXACT physical key columns the superseded index must have, in key
1047+ * order — the shape the replacement relaxes away from.
1048+ *
1049+ * For a field-level unique this is `[column]`: the pre-#3696 single-column
1050+ * global index. For a DECLARED index respelled from the global spelling to
1051+ * `'organization'` (#8323) it is the index's listed columns, which may be
1052+ * several — `sys_user_preference`'s `(user_id, key)` is the case that put
1053+ * this here. Matching on the name alone is not enough (an unrelated index
1054+ * may collide with the generated spelling), and matching on a single leading
1055+ * column is not enough either: `(user_id, key)` and `(user_id, tenant)` share
1056+ * one, and only one of them is the index being replaced.
1057+ */
1058+ legacyColumns : string [ ] ;
10451059}
10461060
10471061/**
@@ -1092,6 +1106,7 @@ export function legacyUniqueReplacements(args: {
10921106 const columns = [ tenantField , name ] ;
10931107 out . push ( {
10941108 column : name ,
1109+ legacyColumns : [ name ] ,
10951110 legacyNames,
10961111 // NULL-safe organization key part (ADR-0120 D3). Still a pure
10971112 // relaxation to create from under the legacy GLOBAL single-column
@@ -1105,6 +1120,59 @@ export function legacyUniqueReplacements(args: {
11051120 } ,
11061121 } ) ;
11071122 }
1123+
1124+ // ── Declared indexes respelled from the global spelling to 'organization' ──
1125+ //
1126+ // #8323: the same retirement, one level up. A declared index's bare
1127+ // `unique: true` is the positional spelling of `'global'` — the listed
1128+ // columns VERBATIM — so respelling it `'organization'` changes the
1129+ // materialized shape from `(…listed)` to `(COALESCE(tenant,'__global__'),
1130+ // …listed)`, and with it the generated NAME. On a deployed database that
1131+ // reads as two unrelated findings: the composite is missing (create, safe)
1132+ // and the old global index is an orphan (drop, DESTRUCTIVE, opt-in). An
1133+ // operator who applies only the safe half keeps the global index — and the
1134+ // global index is the defect, so the migration would look applied while the
1135+ // cross-organization refusal it exists to remove is still enforced.
1136+ //
1137+ // Routing it through the SAME `replace_unique_index` op the field-level
1138+ // retirement uses states it as what it is: one pure relaxation, categorised
1139+ // `safe`, applied CREATE-before-DROP so uniqueness is never unenforced in
1140+ // between, and dropping the old index only once the replacement is confirmed
1141+ // present. Any two rows colliding on `(tenant, …listed)` already collided on
1142+ // `(…listed)`, so the create cannot fail on existing data and no data is lost.
1143+ for ( const idx of Array . isArray ( declaredIndexes ) ? declaredIndexes : [ ] ) {
1144+ if ( idx ?. unique !== 'organization' ) continue ;
1145+ // An EXPLICITLY NAMED index keeps its name across the respelling, so there
1146+ // is no second name to retire — same name, new definition, which is
1147+ // `recreate_index`'s job (drop-then-create under one name). Emitting a
1148+ // replacement here as well would propose dropping the very index the
1149+ // recreate is rebuilding.
1150+ if ( typeof idx ?. name === 'string' && idx . name . trim ( ) ) continue ;
1151+ const listed = Array . isArray ( idx ?. fields )
1152+ ? idx . fields . filter ( ( f ) : f is string => typeof f === 'string' && f . length > 0 )
1153+ : [ ] ;
1154+ if ( listed . length === 0 ) continue ;
1155+ // Every listed column must exist physically, or there is no index to match
1156+ // and nothing the replacement could be created from.
1157+ if ( ! listed . every ( ( c ) => physicalColumns . has ( c ) ) ) continue ;
1158+ const replacement = normalizeDeclaredIndex ( table , idx , tenantField ) ;
1159+ if ( ! replacement ) continue ;
1160+ const legacyName = buildIndexName ( table , listed , true ) ;
1161+ // The S6 hand-written composite already lists the tenant column, so
1162+ // `normalizeDeclaredIndex` prepends nothing and the "legacy" name IS the
1163+ // current name. Nothing was superseded; the D4 NULL-safe tightening path
1164+ // owns that transition.
1165+ if ( legacyName === replacement . name ) continue ;
1166+ // An index the CURRENT metadata declares is by definition not legacy
1167+ // (#3955) — the same guard the field-level arm applies.
1168+ if ( declaredNames . has ( legacyName ) ) continue ;
1169+ out . push ( {
1170+ column : listed [ 0 ] ,
1171+ legacyColumns : listed ,
1172+ legacyNames : [ legacyName ] ,
1173+ replacement,
1174+ } ) ;
1175+ }
11081176 return out ;
11091177}
11101178
@@ -1197,13 +1265,25 @@ export function diffManagedIndexes(args: {
11971265
11981266 // ── 1. Legacy platform-wide unique superseded by a tenant composite ──
11991267 for ( const l of legacy ) {
1200- // Only a *single-column unique on that very column* is the legacy shape.
1201- // Matching on the name alone would let an unrelated index that happens to
1202- // collide with the legacy spelling be dropped.
1268+ // Only a *plain unique on exactly those columns, in key order* is the
1269+ // legacy shape. Matching on the name alone would let an unrelated index
1270+ // that happens to collide with the legacy spelling be dropped.
1271+ //
1272+ // `legacyColumns` is `[column]` for the field-level retirement and the
1273+ // declared index's listed columns for the #8323 respelling — the same
1274+ // question either way, asked once. The plainness guards matter for the
1275+ // multi-column arm: an index carrying an expression key part, a NULL-safe
1276+ // organization part or a WHERE predicate is NOT the verbatim global shape
1277+ // being relaxed, whatever its column identities read as.
12031278 const present = l . legacyNames . filter ( ( n ) => {
12041279 const p = byName . get ( n ) ;
12051280 if ( ! p || p . primary || isRuntimeManagedIndex ( p , runtimeCreated , tenantField ) ) return false ;
1206- return p . unique && p . columns . length === 1 && p . columns [ 0 ] === l . column ;
1281+ if ( ! p . unique || p . partial === true ) return false ;
1282+ if ( ( p . expressions ?. length ?? 0 ) > 0 || ( p . nullSafeColumns ?. length ?? 0 ) > 0 ) return false ;
1283+ return (
1284+ p . columns . length === l . legacyColumns . length &&
1285+ p . columns . every ( ( c , i ) => c === l . legacyColumns [ i ] )
1286+ ) ;
12071287 } ) ;
12081288 if ( present . length === 0 ) continue ;
12091289 for ( const n of present ) explained . add ( n ) ;
@@ -1213,7 +1293,7 @@ export function diffManagedIndexes(args: {
12131293 table,
12141294 column : l . column ,
12151295 expected : indexSignature ( l . replacement . columns , true , l . replacement . nullSafeColumns ) ,
1216- actual : indexSignature ( [ l . column ] , true ) ,
1296+ actual : indexSignature ( l . legacyColumns , true ) ,
12171297 severity : 'warning' ,
12181298 category : 'safe' ,
12191299 op : {
@@ -1226,7 +1306,7 @@ export function diffManagedIndexes(args: {
12261306 ...( l . replacement . nullSafeColumns ? { nullSafeColumns : l . replacement . nullSafeColumns } : { } ) ,
12271307 } ,
12281308 message :
1229- `${ table } .${ l . column } : a legacy platform-wide UNIQUE index (${ present . join ( ', ' ) } ) still enforces ` +
1309+ `${ table } .${ l . legacyColumns . join ( '+' ) } : a legacy platform-wide UNIQUE index (${ present . join ( ', ' ) } ) still enforces ` +
12301310 `uniqueness across ALL tenants, but metadata scopes it per '${ l . replacement . columns [ 0 ] } ' — a second ` +
12311311 `tenant reusing the value is rejected on insert (#3696). Replacing it with ${ indexSignature ( l . replacement . columns , true , l . replacement . nullSafeColumns ) } ` +
12321312 `is a pure relaxation: run "os migrate apply".` ,
0 commit comments