@jantstack/adonis-authz 2.5.0-rc.1 → 2.5.0-rc.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,903 @@
1
+ var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
2
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
3
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
4
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
5
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
6
+ };
7
+ var __metadata = (this && this.__metadata) || function (k, v) {
8
+ if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
9
+ };
10
+ import { BaseCommand, flags } from '@adonisjs/core/ace';
11
+ import { systemClock } from '../src/clock.js';
12
+ /** Los DDL de la subida que se detectan pendientes uno a uno (cada uno es idempotente por inspección). */
13
+ export const UPGRADE_1X_PRE_CONVERT_DDL = ['collation-roles', 'collation-permissions', 'assignments', 'collation-denies'];
14
+ export const UPGRADE_1X_POST_CONVERT_DDL = ['catalog-version', 'roles-owner', 'permissions-assignable'];
15
+ /** Los pasos que CONVIERTEN caducidades: con `--force` exigen `--confirm-legacy-stopped` (C1). */
16
+ export const UPGRADE_1X_CONVERTING_STEPS = ['convert', 'fix-previous'];
17
+ /* ── La función PURA ───────────────────────────────────────────────────────── */
18
+ /** Los modos del marcador que este comando escribe. */
19
+ const MARKER_MODES = new Set(['upgrade', 'fix-previous', 'born']);
20
+ /** La zona que guarda el marcador `born`: 2.x siempre escribió UTC (columna `zone` NOT NULL). */
21
+ export const UPGRADE_1X_BORN_ZONE = '+00:00';
22
+ /** La letra de la negativa de born con rastro de 1.x (rotación 3 · cierre 5, B1/B1-bis). */
23
+ export const UPGRADE_1X_BORN_TRACE_TEXT = 'hay rastro de 1.x, born no aplica';
24
+ /**
25
+ * Los RASTROS de 1.x que el comando DETECTA en una base con `expires_at` ya `datetime(3)` y sin marcador (rotación 3 ·
26
+ * cierre 5, B1/B1-bis). Ninguna migración publicada de 2.x deja un paso de esquema pendiente (crean `authz_catalog_version`
27
+ * con sus dos filas, `owner_scope_key` con el unique de tres columnas, `assignable_at` y las columnas `utf8mb4_bin`), y el
28
+ * codec de 2.x no escribe fechas cero. Sin ninguno de los dos, una base nacida en 2.x y una subida entera con el bloque
29
+ * viejo tienen la misma forma: ahí `--declare-born-in-2` sigue siendo una DECLARACIÓN del operador.
30
+ */
31
+ export function upgrade1xLegacyTraces(state) {
32
+ const traces = [];
33
+ if (state.pendingDdl.length > 0)
34
+ traces.push(`DDL de 1.x pendiente (${state.pendingDdl.join(', ')})`);
35
+ if (state.unreadable > 0)
36
+ traces.push(`${state.unreadable} caducidad(es) ilegible(s) (fecha cero: el codec de 2.x no las escribe)`);
37
+ return traces;
38
+ }
39
+ /**
40
+ * La precondición de C1, en una frase: la usan la negativa, la línea del plan y la simulación. Un proceso 1.x vivo tras
41
+ * la conversión escribe la PARED de su zona en el `datetime(3)` que 2.x lee como UTC, y ya nada lo detecta.
42
+ */
43
+ export const UPGRADE_1X_LEGACY_STOPPED_TEXT = 'para TODOS los procesos 1.x antes de --force (y no hagas un rolling deploy 1.x → 2.x sobre MySQL): un proceso 1.x que ' +
44
+ 'siga vivo tras la conversión escribe su hora LOCAL en una columna que 2.x lee como UTC — al ESTE de UTC (Madrid) cada ' +
45
+ 'caducidad suya concede 1-2 h DESPUÉS de caducar (fail-open), al oeste 2.x la ve caducar antes — y este comando no lo ' +
46
+ 'puede detectar después: el marcador ya dice «hecho»';
47
+ const MARKER_STATES = new Set(['armed', 'done']);
48
+ /** El instante en el que `--zero-dates=expire` deja una caducidad ilegible: PASADO, nunca `NULL` (A2). */
49
+ export const UPGRADE_1X_EXPIRED_INSTANT = 86_400;
50
+ /** `--upgraded-at` exige ISO con zona explícita (`Z` o `±hh:mm`): sin ella JavaScript la leería en la hora local. */
51
+ export function parseUpgradedAt(value) {
52
+ if (value === undefined)
53
+ return null;
54
+ if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d{1,3})?)?(?:Z|[+-]\d{2}:\d{2})$/.test(value))
55
+ return null;
56
+ const ms = Date.parse(value);
57
+ return Number.isNaN(ms) ? null : ms;
58
+ }
59
+ /** El prefijo de UUIDv7 de un milisegundo (`xxxxxxxx-xxxx`): las filas insertadas antes ordenan por debajo, por bytes. */
60
+ export function v7Prefix(ms) {
61
+ const hex = Math.floor(ms).toString(16).padStart(12, '0');
62
+ return `${hex.slice(0, 8)}-${hex.slice(8)}`;
63
+ }
64
+ const STEP_TEXT = {
65
+ 'zero-dates': 'las caducidades ilegibles (fecha cero) pasan a un instante PASADO (--zero-dates=expire): caducadas, nunca NULL',
66
+ 'marker-table': 'crea la tabla del marcador authz_upgrade_1x (si no existe), ENGINE=InnoDB',
67
+ 'marker-arm': 'ARMA el marcador (fila expires_at, estado armed, con la zona) — solo porque expires_at sigue siendo timestamp',
68
+ 'collation-roles': 'authz_roles: slug y scope_type a utf8mb4_bin',
69
+ 'collation-permissions': 'authz_permissions: slug a utf8mb4_bin',
70
+ assignments: 'authz_assignments: identidad a varchar(64) utf8mb4_bin y expires_at a datetime(3), en la zona de sesión de 1.x (@@global.time_zone)',
71
+ 'collation-denies': 'authz_denies: identidad a varchar(64) utf8mb4_bin',
72
+ convert: 'CONVIERTE cada caducidad de la zona de 1.x a UTC y marca el paso hecho, en UNA transacción',
73
+ 'fix-previous': 'CORRIGE solo las filas de 1.x (UUIDv7 anterior a --upgraded-at) y solo hacia ANTES, y marca hecho, en UNA transacción',
74
+ born: 'escribe el marcador done/born (DECLARACIÓN del operador: la base nació en 2.x) — ninguna fila de datos se toca',
75
+ 'catalog-version': 'crea authz_catalog_version con las filas 1 y 2 (versión 0)',
76
+ 'roles-owner': 'authz_roles: owner_scope_key (default global) y el unique de tres columnas',
77
+ 'permissions-assignable': 'authz_permissions: assignable_at',
78
+ };
79
+ export function describeUpgrade1xStep(step) {
80
+ return STEP_TEXT[step];
81
+ }
82
+ /**
83
+ * Decide, sin I/O, qué hacer con el estado detectado. Tabla de verdad (la fija `command_flags_upgrade_1x.spec.ts`):
84
+ *
85
+ * | `expires_at` | marcador | otras entradas | estado | acción |
86
+ * |---|---|---|---|---|
87
+ * | (no MySQL) | — | — | `no-mysql` | noop, exit 0, cero sentencias |
88
+ * | falta una tabla de 1.x | — | — | `sin-tablas` | negativa |
89
+ * | `timestamp` | ninguno | sin `authz_catalog_version` | `1x-sin-subir` | subida completa; exige `--zone` |
90
+ * | `timestamp` | `armed` | sin `authz_catalog_version` | `a-medias` | reanuda con la zona del marcador |
91
+ * | `timestamp` | `done` | — | `incoherente` (B2: volcado de 1.x restaurado) | negativa |
92
+ * | `timestamp` | ninguno/`armed` | con `authz_catalog_version` | `incoherente` (2.x pudo escribir en la columna de 1.x) | negativa |
93
+ * | `datetime(3)` | ninguno | alguna caducidad o DDL pendiente | `receta-vieja` | negativa; con `--fix-previous-upgrade` corrige |
94
+ * | `datetime(3)` | ninguno | ninguna caducidad y nada pendiente | `hecho` | noop |
95
+ * | `datetime(3)` | `armed` | sin `authz_catalog_version` | `a-medias` | convierte y termina |
96
+ * | `datetime(3)` | `done` | DDL pendiente | `a-medias` | termina el DDL |
97
+ * | `datetime(3)` | `done` | nada pendiente | `hecho` | noop (otra `--zone` ⇒ negativa) |
98
+ * | otro tipo, marcador con otra forma, índices a medias | | | `incoherente` | negativa |
99
+ * | (cualquiera) | (cualquiera) | `authz_assignments` o el marcador no son InnoDB | `motor-no-transaccional` (C2) | negativa |
100
+ * | `datetime(3)` | ninguno | `--declare-born-in-2`, sin rastro de 1.x | `receta-vieja` / `hecho` | escribe el marcador `done`/`born`, cero datos (P1) |
101
+ * | `datetime(3)` | ninguno | `--declare-born-in-2` + DDL pendiente o fechas cero | `receta-vieja` | negativa: «hay rastro de 1.x, born no aplica» (B1) |
102
+ * | (cualquiera) | (cualquiera) | `--declare-born-in-2` + `--zero-dates`/`--confirm-legacy-stopped`/`--zone`/fix | | negativa (H4) |
103
+ *
104
+ * Y en todo estado que convierte: zona desconocida para MySQL ⇒ negativa ANTES de tocar nada; caducidades ilegibles ⇒
105
+ * negativa salvo `--zero-dates=expire`; con `--force`, sin `--confirm-legacy-stopped` ⇒ negativa (C1). Escribir exige
106
+ * `--force`; sin él es la simulación (que menciona la precondición).
107
+ */
108
+ export function upgrade1xPlan(state, flags) {
109
+ const lines = [];
110
+ const done = (key, action, extra = {}) => ({
111
+ state: key,
112
+ action,
113
+ write: extra.write ?? false,
114
+ exitCode: action === 'refuse' ? 1 : 0,
115
+ zone: extra.zone ?? null,
116
+ upgradedAtMs: extra.upgradedAtMs ?? null,
117
+ steps: extra.steps ?? [],
118
+ lines,
119
+ });
120
+ const head = (key, text) => lines.unshift({ level: 'info', message: `estado: ${key} · ${text}` });
121
+ const refuse = (key, message) => {
122
+ lines.push({ level: 'error', message });
123
+ lines.push({ level: 'error', message: 'No se ha escrito nada.' });
124
+ return done(key, 'refuse');
125
+ };
126
+ /* 1 · Fuera de MySQL: nada que hacer, nada que leer. */
127
+ if (state.dialect !== 'mysql' && state.dialect !== 'mysql2') {
128
+ head('no-mysql', `la conexión '${state.connection}' es ${state.dialect}: este comando solo sube MySQL. PostgreSQL sube con su receta ` +
129
+ 'SQL del README (1.x ya guardaba timestamptz: no hay nada que convertir) y SQLite no necesita nada. ' +
130
+ 'No se ha ejecutado ninguna sentencia.');
131
+ const ignored = [
132
+ flags.zone !== undefined ? `--zone=${flags.zone}` : null,
133
+ flags.force ? '--force' : null,
134
+ flags.dryRun ? '--dry-run' : null,
135
+ flags.fixPreviousUpgrade ? '--fix-previous-upgrade' : null,
136
+ flags.upgradedAt !== undefined ? `--upgraded-at=${flags.upgradedAt}` : null,
137
+ flags.zeroDates !== undefined ? `--zero-dates=${flags.zeroDates}` : null,
138
+ flags.confirmLegacyStopped ? '--confirm-legacy-stopped' : null,
139
+ flags.declareBornIn2 ? '--declare-born-in-2' : null,
140
+ ].filter((x) => x !== null);
141
+ if (ignored.length > 0)
142
+ lines.push({ level: 'info', message: `Banderas recibidas y sin efecto fuera de MySQL: ${ignored.join(', ')}.` });
143
+ return done('no-mysql', 'noop');
144
+ }
145
+ /* 2 · Clasificar. */
146
+ if (state.missingTables.length > 0) {
147
+ head('sin-tablas', `faltan ${state.missingTables.join(', ')} en la base de la conexión '${state.connection}': no hay un esquema de 1.x que subir.`);
148
+ return refuse('sin-tablas', 'Una instalación nueva usa la migración publicada (node ace configure), no este comando.');
149
+ }
150
+ /* C2 · antes de clasificar nada: sin InnoDB, «una transacción» no es todo o nada y una re-ejecución convertiría dos veces. */
151
+ const nonTransactional = state.nonTransactional ?? [];
152
+ if (nonTransactional.length > 0) {
153
+ head('motor-no-transaccional', `${nonTransactional.join(', ')}: la conversión y su marcador van en UNA transacción, y fuera de InnoDB un ROLLBACK no ` +
154
+ 'deshace nada — un corte entre las dos escrituras dejaría las caducidades convertidas con el marcador sin cerrar, y la ' +
155
+ 're-ejecución las convertiría OTRA vez (o al revés: marcador hecho sin conversión).');
156
+ return refuse('motor-no-transaccional', `Pasa esas tablas a InnoDB y repite: ${nonTransactional.map((t) => `ALTER TABLE ${t.split(' ')[0]} ENGINE=InnoDB;`).join(' ')} ` +
157
+ '(MyISAM ignora en silencio las claves foráneas: tras el ALTER, compara las de authz_assignments con las de la migración de 1.x).');
158
+ }
159
+ const odd = [...state.schemaOdd];
160
+ let marker = null;
161
+ if (state.marker === 'malformed') {
162
+ odd.push('la tabla authz_upgrade_1x existe con otra forma (no la escribió este comando)');
163
+ }
164
+ else if (state.marker !== null) {
165
+ const rows = state.marker;
166
+ if (rows.length > 1 || rows.some((r) => r.step !== 'expires_at' || !MARKER_STATES.has(r.state) || !MARKER_MODES.has(r.mode) || !r.zone)) {
167
+ odd.push(`el marcador authz_upgrade_1x tiene filas que este comando no escribe: ${JSON.stringify(rows)}`);
168
+ }
169
+ else if (rows.length === 1) {
170
+ marker = rows[0];
171
+ if (marker.mode === 'fix-previous' && marker.state !== 'done')
172
+ odd.push('el marcador de una corrección está a medias (la corrección es una sola transacción)');
173
+ if (marker.mode === 'born' && marker.state !== 'done')
174
+ odd.push('el marcador de una base declarada nacida en 2.x está a medias (se escribe ya hecho)');
175
+ }
176
+ }
177
+ const type = state.expiresType;
178
+ const isTimestamp = type === 'timestamp';
179
+ const isDatetime3 = type === 'datetime(3)';
180
+ if (!isTimestamp && !isDatetime3)
181
+ odd.push(`authz_assignments.expires_at es ${type ?? 'inexistente'} (ni el timestamp de 1.x ni el datetime(3) de 2.x)`);
182
+ if (isTimestamp && marker?.state === 'done') {
183
+ odd.push('el marcador dice que la conversión ya se hizo, pero expires_at vuelve a ser el TIMESTAMP de 1.x: ¿se restauró un volcado de ' +
184
+ '1.x de las tablas authz_* después de subir? Convertir con ese marcador dejaría la pared de 1.x sin convertir (B2)');
185
+ }
186
+ if ((isTimestamp || marker?.state === 'armed') && state.catalogVersionTable && marker?.state !== 'done') {
187
+ odd.push('authz_catalog_version existe pero la conversión no se ha hecho: un proceso 2.x puede haber escrito caducidades en UTC que ' +
188
+ 'la conversión movería (este comando crea esa tabla DESPUÉS de convertir)');
189
+ }
190
+ if (odd.length > 0) {
191
+ head('incoherente', `una combinación que ningún punto de corte de este comando produce: ${odd.join('; ')}.`);
192
+ return refuse('incoherente', 'El comando no adivina: revisa la base (y, si restauraste 1.x, borra authz_upgrade_1x para volver a subir desde cero) y repite.');
193
+ }
194
+ const traces = upgrade1xLegacyTraces(state);
195
+ let key;
196
+ if (isTimestamp)
197
+ key = marker === null ? '1x-sin-subir' : 'a-medias';
198
+ else if (marker === null)
199
+ key = state.expiring === 0 && state.pendingDdl.length === 0 ? 'hecho' : 'receta-vieja';
200
+ else if (marker.state === 'armed')
201
+ key = 'a-medias';
202
+ else
203
+ key = state.pendingDdl.length === 0 ? 'hecho' : 'a-medias';
204
+ const describe = {
205
+ '1x-sin-subir': () => `expires_at es el TIMESTAMP de 1.x y no hay marcador: subida completa (${state.expiring} caducidad(es) que convertir).`,
206
+ 'a-medias': () => `una ejecución anterior de este comando se cortó: marcador ${marker.state} (zona ${marker.zone}), expires_at ${type}` +
207
+ (state.pendingDdl.length ? `, DDL pendiente: ${state.pendingDdl.join(', ')}` : '') +
208
+ '. Se reanuda desde donde se quedó.',
209
+ 'receta-vieja': () => 'expires_at ya es datetime(3) y no hay marcador: esta base se subió de 1.x con el bloque SQL publicado de 2.4.0-alpha.1 a ' +
210
+ '2.5.0-rc.1 (que no convertía) — o nació en 2.x. ' +
211
+ (traces.length > 0
212
+ ? `Hay rastro de 1.x: ${traces.join('; ')} — no nació en 2.x. `
213
+ : 'Sin rastro de 1.x que el comando detecte (DDL de 1.x pendiente, caducidades ilegibles): con esa forma las dos son ' +
214
+ 'iguales y nacer en 2.x es algo que solo el operador puede declarar. ') +
215
+ `${state.expiring} fila(s) con caducidad.`,
216
+ hecho: () => marker === null
217
+ ? 'expires_at ya es datetime(3), no hay marcador y ninguna fila tiene caducidad: no hay nada que convertir.'
218
+ : marker.mode === 'born'
219
+ ? 'base DECLARADA nacida en 2.x (marcador born, --declare-born-in-2): no hay nada que convertir, y el esquema es el de 2.x.'
220
+ : `subida hecha: marcador ${marker.mode === 'fix-previous' ? 'de la corrección' : 'de la subida'} con la zona ${marker.zone}` +
221
+ (marker.upgradedAt ? ` y --upgraded-at=${marker.upgradedAt}` : '') +
222
+ ', y el esquema es el de 2.x.',
223
+ };
224
+ head(key, describe[key]());
225
+ /* 3 · Errores de uso (cualquier estado de MySQL). */
226
+ if (flags.force && flags.dryRun)
227
+ return refuse(key, '--force y --dry-run a la vez: elige uno (sin --force ya es la simulación).');
228
+ if (flags.zeroDates !== undefined && flags.zeroDates !== 'expire') {
229
+ return refuse(key, `--zero-dates=${flags.zeroDates}: el único valor es 'expire' (las caducidades ilegibles quedan en un instante PASADO).`);
230
+ }
231
+ if (flags.upgradedAt !== undefined && !flags.fixPreviousUpgrade) {
232
+ return refuse(key, '--upgraded-at solo tiene sentido con --fix-previous-upgrade.');
233
+ }
234
+ if (flags.declareBornIn2 && (flags.fixPreviousUpgrade || flags.zone !== undefined)) {
235
+ return refuse(key, '--declare-born-in-2 no convierte nada: va sin --zone y sin --fix-previous-upgrade (son las dos salidas OPUESTAS de ' +
236
+ 'receta-vieja; elige una).');
237
+ }
238
+ /* H4 (cierre 5) · con born, las banderas de la conversión no tienen efecto: se rechazan, no se tragan en silencio. */
239
+ if (flags.declareBornIn2) {
240
+ const inert = [
241
+ flags.zeroDates !== undefined ? `--zero-dates=${flags.zeroDates}` : null,
242
+ flags.confirmLegacyStopped ? '--confirm-legacy-stopped' : null,
243
+ flags.upgradedAt !== undefined ? `--upgraded-at=${flags.upgradedAt}` : null,
244
+ ].filter((x) => x !== null);
245
+ if (inert.length > 0) {
246
+ return refuse(key, `--declare-born-in-2 no convierte nada: ${inert.join(', ')} no tiene(n) efecto con ella (son banderas de la ` +
247
+ 'conversión). Quítala(s) y repite; born solo admite --force o --dry-run.');
248
+ }
249
+ }
250
+ /* P1 · la DECLARACIÓN del operador: solo sin rastro de 1.x (columna timestamp, un marcador, DDL pendiente, fechas cero). */
251
+ if (flags.declareBornIn2) {
252
+ if (isTimestamp) {
253
+ return refuse(key, '--declare-born-in-2: expires_at es todavía el TIMESTAMP de 1.x — esta base tiene el esquema de 1.x, no nació en 2.x. ' +
254
+ 'Súbela con --zone (y --confirm-legacy-stopped).');
255
+ }
256
+ if (marker !== null) {
257
+ return refuse(key, `--declare-born-in-2: ya hay un marcador (${marker.mode}, ${marker.state}): este comando ya trabajó sobre esta base, así ` +
258
+ 'que vino de 1.x o ya está declarada. No se declara encima.');
259
+ }
260
+ /* B1/B1-bis (cierre 5) · el comando SÍ ve 1.x: DDL de 1.x pendiente o fechas cero ⇒ la declaración es refutable. */
261
+ if (traces.length > 0) {
262
+ return refuse(key, `--declare-born-in-2: ${UPGRADE_1X_BORN_TRACE_TEXT} — ${traces.join('; ')}. Ninguna migración publicada de 2.x deja ` +
263
+ 'un paso de esquema pendiente ni escribe fechas cero, así que esta base vino de 1.x: su salida es la corrección ' +
264
+ '(--fix-previous-upgrade; sin --force, la simulación te dice qué movería).');
265
+ }
266
+ lines.push({
267
+ level: 'warning',
268
+ message: 'Declaras que esta base NACIÓ en 2.x: ninguna fila la escribió un proceso 1.x. El comando NO puede demostrarlo (no ve ' +
269
+ 'rastro de 1.x —ni DDL de 1.x pendiente ni fechas cero—, pero una base subida entera con el bloque viejo tiene la misma ' +
270
+ 'forma); si es falso, las caducidades de 1.x quedan desplazadas el offset ' +
271
+ 'de su zona para siempre (al este, fail-open) y este comando dirá «hecho». Ningún dato se toca.',
272
+ });
273
+ return planned(key, ['marker-table', 'born'], UPGRADE_1X_BORN_ZONE, null);
274
+ }
275
+ /* 4 · Por estado. */
276
+ if (key === 'hecho') {
277
+ if (marker?.mode === 'born' && flags.zone !== undefined && flags.zone !== marker.zone) {
278
+ return refuse(key, `pediste --zone=${flags.zone} y esta base está DECLARADA nacida en 2.x (marcador born): no hay nada de 1.x que convertir. ` +
279
+ 'Si la declaración era falsa, la corrección se aplica a mano a las filas de 1.x (README, «If the zone was wrong»).');
280
+ }
281
+ if (marker !== null && flags.zone !== undefined && flags.zone !== marker.zone) {
282
+ return refuse(key, `pediste --zone=${flags.zone} y la conversión ya se hizo con ${marker.zone}: este comando no convierte dos veces. Si la zona ` +
283
+ 'de entonces era errónea, la diferencia se aplica a mano a las filas de 1.x (README, «If the zone was wrong»).');
284
+ }
285
+ lines.push({ level: 'success', message: 'Nada que hacer.' });
286
+ return done(key, 'noop', { zone: marker?.zone ?? null });
287
+ }
288
+ const unreadableGuard = () => {
289
+ if (state.unreadable === 0 || flags.zeroDates === 'expire')
290
+ return null;
291
+ return refuse(key, `${state.unreadable} fila(s) de authz_assignments tienen una caducidad que MySQL no puede leer (fecha cero 0000-00-00 o un mes/día ` +
292
+ 'cero, lo que 1.x dejaba con un sql_mode no estricto al escribir más allá de 2038). En 1.x esas filas NO concedían; convertirlas ' +
293
+ 'daría NULL, que en 2.x es «sin caducidad» = PERMANENTE (A2). Repite con --zero-dates=expire para dejarlas caducadas (un ' +
294
+ 'instante pasado, nunca NULL), o corrígelas tú antes.');
295
+ };
296
+ const zoneGuard = (zone) => {
297
+ if (state.zones[zone] === true) {
298
+ if (/^[+-]\d{2}:\d{2}$/.test(zone) && zone !== '+00:00' && zone !== '-00:00') {
299
+ lines.push({
300
+ level: 'warning',
301
+ message: `--zone=${zone} es un offset FIJO: si la zona de los procesos 1.x tenía horario de verano, usa su nombre (Europe/Madrid) o las caducidades del otro lado del cambio quedan una hora mal.`,
302
+ });
303
+ }
304
+ return null;
305
+ }
306
+ return refuse(key, `MySQL no convierte la zona '${zone}' (CONVERT_TZ da NULL): o no existe, o es un nombre y el servidor no tiene cargadas las ` +
307
+ "tablas de zonas (mysql_tzinfo_to_sql). 'Z' se escribe '+00:00'. Nada se ha tocado: carga las tablas o usa la zona correcta.");
308
+ };
309
+ if (key === 'receta-vieja') {
310
+ if (!flags.fixPreviousUpgrade && traces.length > 0) {
311
+ return refuse(key, `Esta base vino de 1.x (${traces.join('; ')}): ${UPGRADE_1X_BORN_TRACE_TEXT}, y la conversión no se hace por defecto. ` +
312
+ 'Si tus procesos 1.x no serializaban las fechas en UTC, cada caducidad que escribió 1.x quedó desplazada el offset de ' +
313
+ 'su zona (al ESTE de UTC, Madrid +1/+2 h, concede ese tiempo DESPUÉS de caducar: fail-open; al oeste caduca antes) y la ' +
314
+ 'corrección es --fix-previous-upgrade --zone=<zona de los procesos 1.x> --upgraded-at=<ISO: el instante en que dejó de ' +
315
+ 'escribir el ÚLTIMO proceso 1.x; mejor tarde que pronto> --confirm-legacy-stopped --force (solo filas anteriores a ese ' +
316
+ 'instante, solo hacia ANTES; termina también el DDL pendiente).' +
317
+ (state.unreadable > 0 ? ' Con fechas cero, añade --zero-dates=expire.' : ''));
318
+ }
319
+ if (!flags.fixPreviousUpgrade) {
320
+ return refuse(key, 'El comando no ve rastro de 1.x (ni DDL de 1.x pendiente ni fechas cero) y no puede saber si esta base vino de 1.x, ' +
321
+ 'y por eso no convierte nada por defecto. Hay DOS salidas, y la ' +
322
+ 'elección es tuya: (1) si la base NACIÓ en 2.x (ningún proceso 1.x escribió nunca en ella): --declare-born-in-2 --force ' +
323
+ '— escribe el marcador y no toca ningún dato; es tu declaración. (2) si vino de 1.x y la subiste con el bloque viejo, ' +
324
+ 'y tus procesos 1.x no serializaban las fechas en UTC: cada caducidad que escribió 1.x quedó desplazada el offset de su ' +
325
+ 'zona (al ESTE de UTC, Madrid +1/+2 h, concede ese tiempo DESPUÉS de caducar: fail-open; al oeste caduca antes) y la ' +
326
+ 'corrección es --fix-previous-upgrade --zone=<zona de los procesos 1.x> --upgraded-at=<ISO: el instante en que dejó de ' +
327
+ 'escribir el ÚLTIMO proceso 1.x; mejor tarde que pronto> --confirm-legacy-stopped --force (solo filas anteriores a ese ' +
328
+ 'instante, solo hacia ANTES). La corrección sobre una base que nació en 2.x adelantaría sus caducidades (fail-closed ' +
329
+ 'masivo): no la uses «para ponerlo en verde».');
330
+ }
331
+ if (flags.zone === undefined)
332
+ return refuse(key, '--fix-previous-upgrade exige --zone (la zona de tus procesos 1.x; no hay default).');
333
+ if (flags.upgradedAt === undefined) {
334
+ return refuse(key, '--fix-previous-upgrade exige --upgraded-at=<ISO con Z u offset> (el instante en que dejó de escribir el último proceso 1.x; no hay default).');
335
+ }
336
+ const at = parseUpgradedAt(flags.upgradedAt);
337
+ if (at === null) {
338
+ return refuse(key, `--upgraded-at=${flags.upgradedAt} no es un instante ISO con zona explícita (p. ej. 2026-08-01T10:00:00Z).`);
339
+ }
340
+ if (at > state.nowMs) {
341
+ return refuse(key, `--upgraded-at=${flags.upgradedAt} está en el FUTURO: no es el instante en que dejó de escribir el último proceso 1.x.`);
342
+ }
343
+ if (state.oldestV7Ms === null) {
344
+ return refuse(key, 'ninguna fila de authz_assignments tiene un uuid v7: no se puede saber cuáles escribió 1.x. Nada que corregir con certeza.');
345
+ }
346
+ if (at <= state.oldestV7Ms) {
347
+ return refuse(key, `--upgraded-at=${flags.upgradedAt} es anterior (o igual) a la fila más antigua (${new Date(state.oldestV7Ms).toISOString()}): no ` +
348
+ 'corregiría ninguna y dejaría el marcador «hecho» (B1). Da el instante en que dejó de escribir el último proceso 1.x, mejor ' +
349
+ 'tarde que pronto.');
350
+ }
351
+ const guard = zoneGuard(flags.zone) ?? unreadableGuard();
352
+ if (guard)
353
+ return guard;
354
+ lines.push({
355
+ level: 'info',
356
+ message: `Corrección: ${state.fixBefore ?? '?'} fila(s) con caducidad insertadas antes de ${new Date(at).toISOString()} (de 1.x); ` +
357
+ `${state.fixCandidates ?? '?'} se adelantan de ${flags.zone} a UTC y las demás no se tocan` +
358
+ (state.fixCandidates === 0 ? ' (una zona al oeste de UTC no mueve nada: esas filas ya caducan antes, fail-closed).' : '.') +
359
+ ' Una fila de 1.x que 2.x reescribió después también se adelanta (caduca antes: fail-closed; re-concédela).',
360
+ });
361
+ if (state.nonV7 > 0) {
362
+ lines.push({ level: 'warning', message: `${state.nonV7} fila(s) sin uuid v7: se comparan por bytes y, si caen antes, solo se adelantan.` });
363
+ }
364
+ const steps = [
365
+ ...(state.unreadable > 0 ? ['zero-dates'] : []),
366
+ 'marker-table',
367
+ ...state.pendingDdl,
368
+ 'fix-previous',
369
+ ];
370
+ return planned(key, steps, flags.zone, at);
371
+ }
372
+ if (flags.fixPreviousUpgrade) {
373
+ return refuse(key, `--fix-previous-upgrade solo vale para una base subida con el bloque viejo (estado receta-vieja), y esta está en ${key}.`);
374
+ }
375
+ if (key === '1x-sin-subir') {
376
+ if (flags.zone === undefined) {
377
+ return refuse(key, '--zone es obligatoria (sin default): la zona en la que tus procesos 1.x serializaban las fechas — la opción timezone de su ' +
378
+ "conexión MySQL si la fijaron ('Z' es '+00:00'), si no el TZ del proceso Node. Con horario de verano, por nombre " +
379
+ "(Europe/Madrid). Un proceso en UTC: --zone=+00:00.");
380
+ }
381
+ const guard = zoneGuard(flags.zone) ?? unreadableGuard();
382
+ if (guard)
383
+ return guard;
384
+ const steps = [
385
+ ...(state.unreadable > 0 ? ['zero-dates'] : []),
386
+ 'marker-table',
387
+ 'marker-arm',
388
+ ...state.pendingDdl.filter((s) => UPGRADE_1X_PRE_CONVERT_DDL.includes(s)),
389
+ 'convert',
390
+ ...state.pendingDdl.filter((s) => UPGRADE_1X_POST_CONVERT_DDL.includes(s)),
391
+ ];
392
+ return planned(key, steps, flags.zone, null);
393
+ }
394
+ // a-medias
395
+ const zone = marker.zone;
396
+ if (flags.zone !== undefined && flags.zone !== zone) {
397
+ return refuse(key, `la ejecución cortada armó la conversión con --zone=${zone} y ahora pides ${flags.zone}: se reanuda SIEMPRE con la zona del ` +
398
+ 'marcador (o sin --zone). Si la de entonces era errónea, todavía no se ha convertido nada: borra la fila de authz_upgrade_1x ' +
399
+ 'SOLO si expires_at sigue siendo timestamp.');
400
+ }
401
+ if (marker.state === 'armed') {
402
+ const guard = zoneGuard(zone) ?? unreadableGuard();
403
+ if (guard)
404
+ return guard;
405
+ }
406
+ const steps = marker.state === 'armed'
407
+ ? [
408
+ ...(state.unreadable > 0 ? ['zero-dates'] : []),
409
+ ...state.pendingDdl.filter((s) => UPGRADE_1X_PRE_CONVERT_DDL.includes(s)),
410
+ 'convert',
411
+ ...state.pendingDdl.filter((s) => UPGRADE_1X_POST_CONVERT_DDL.includes(s)),
412
+ ]
413
+ : [...state.pendingDdl];
414
+ return planned(key, steps, zone, null);
415
+ function planned(key, steps, zone, upgradedAtMs) {
416
+ const converts = steps.some((s) => UPGRADE_1X_CONVERTING_STEPS.includes(s));
417
+ // C1 · la precondición, en el plan: antes de los pasos, para que se lea ANTES de decidir el --force.
418
+ if (converts) {
419
+ lines.push({
420
+ level: flags.confirmLegacyStopped ? 'info' : 'warning',
421
+ message: `Precondición (C1): ${UPGRADE_1X_LEGACY_STOPPED_TEXT}. ` +
422
+ (flags.confirmLegacyStopped
423
+ ? 'Confirmada con --confirm-legacy-stopped.'
424
+ : '--force exige --confirm-legacy-stopped, que es TU confirmación de que no queda ninguno.'),
425
+ });
426
+ }
427
+ steps.forEach((step, i) => lines.push({ level: 'info', message: `plan ${i + 1}/${steps.length} · ${step}: ${STEP_TEXT[step]}` }));
428
+ if (!flags.force) {
429
+ lines.push({ level: 'warning', message: 'Simulación (--dry-run por defecto): no se ha escrito nada. Repite con --force para ejecutarlo.' });
430
+ return done(key, 'run', { zone, upgradedAtMs, steps, write: false });
431
+ }
432
+ if (converts && !flags.confirmLegacyStopped) {
433
+ return refuse(key, `--force convierte caducidades y falta --confirm-legacy-stopped. Precondición: ${UPGRADE_1X_LEGACY_STOPPED_TEXT}. Para ` +
434
+ 'todos los procesos 1.x, comprueba que ninguno escribe, y repite la MISMA línea con --confirm-legacy-stopped.');
435
+ }
436
+ return done(key, 'run', { zone, upgradedAtMs, steps, write: true });
437
+ }
438
+ }
439
+ /** Columnas que la subida deja en `utf8mb4_bin` con este tipo (lo que produce el ALTER y la migración publicada). */
440
+ const BIN_COLUMNS = [
441
+ { table: 'authz_roles', column: 'slug', type: 'varchar(100)', step: 'collation-roles' },
442
+ { table: 'authz_roles', column: 'scope_type', type: 'varchar(20)', step: 'collation-roles' },
443
+ { table: 'authz_permissions', column: 'slug', type: 'varchar(100)', step: 'collation-permissions' },
444
+ { table: 'authz_assignments', column: 'holder_type', type: 'varchar(50)', step: 'assignments' },
445
+ { table: 'authz_assignments', column: 'holder_uuid', type: 'varchar(64)', step: 'assignments' },
446
+ { table: 'authz_assignments', column: 'scope_type', type: 'varchar(20)', step: 'assignments' },
447
+ { table: 'authz_assignments', column: 'scope_uuid', type: 'varchar(64)', step: 'assignments' },
448
+ { table: 'authz_denies', column: 'holder_type', type: 'varchar(50)', step: 'collation-denies' },
449
+ { table: 'authz_denies', column: 'holder_uuid', type: 'varchar(64)', step: 'collation-denies' },
450
+ { table: 'authz_denies', column: 'scope_type', type: 'varchar(20)', step: 'collation-denies' },
451
+ { table: 'authz_denies', column: 'scope_uuid', type: 'varchar(64)', step: 'collation-denies' },
452
+ ];
453
+ const LEGACY_TABLES = ['authz_roles', 'authz_permissions', 'authz_assignments', 'authz_denies'];
454
+ const MARKER_COLUMNS = ['armed_at', 'done_at', 'mode', 'state', 'step', 'upgraded_at', 'zone'];
455
+ /** «Ilegible» = presente y `CONVERT_TZ` (la función que convierte) da NULL: fecha cero, mes o día cero (medido en 8.4). */
456
+ const UNREADABLE = "expires_at IS NOT NULL AND CONVERT_TZ(expires_at, '+00:00', '+00:00') IS NULL";
457
+ const V7 = "LOWER(uuid) REGEXP '^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$'";
458
+ export async function readUpgrade1xState(q, connection, flags, nowMs) {
459
+ const columns = await q(`SELECT table_name AS t, column_name AS c, column_type AS ty, collation_name AS co
460
+ FROM information_schema.columns
461
+ WHERE table_schema = DATABASE()
462
+ AND table_name IN ('authz_roles', 'authz_permissions', 'authz_assignments', 'authz_denies', 'authz_catalog_version', 'authz_upgrade_1x')`);
463
+ const col = (table, column) => columns.find((r) => r.t === table && r.c === column);
464
+ const tables = new Set(columns.map((r) => r.t));
465
+ const missingTables = LEGACY_TABLES.filter((t) => !tables.has(t));
466
+ const base = {
467
+ connection,
468
+ dialect: 'mysql',
469
+ expiresType: null,
470
+ marker: null,
471
+ pendingDdl: [],
472
+ schemaOdd: [],
473
+ catalogVersionTable: tables.has('authz_catalog_version'),
474
+ expiring: 0,
475
+ unreadable: 0,
476
+ zones: {},
477
+ oldestV7Ms: null,
478
+ nonV7: 0,
479
+ fixBefore: null,
480
+ fixCandidates: null,
481
+ nowMs,
482
+ };
483
+ if (missingTables.length > 0)
484
+ return { ...base, missingTables };
485
+ // C2 · el motor de las dos tablas que la conversión escribe en UNA transacción (el marcador, solo si existe).
486
+ const engines = await q(`SELECT table_name AS t, engine AS e FROM information_schema.tables
487
+ WHERE table_schema = DATABASE() AND table_name IN ('authz_assignments', 'authz_upgrade_1x')`);
488
+ const nonTransactional = engines
489
+ .filter((r) => String(r.e ?? '').toLowerCase() !== 'innodb')
490
+ .map((r) => `${r.t} (${r.e ?? 'sin motor'})`)
491
+ .sort();
492
+ const pending = new Set();
493
+ for (const spec of BIN_COLUMNS) {
494
+ const c = col(spec.table, spec.column);
495
+ if (!c || c.ty !== spec.type || c.co !== 'utf8mb4_bin')
496
+ pending.add(spec.step);
497
+ }
498
+ const expiresType = col('authz_assignments', 'expires_at')?.ty ?? null;
499
+ if (expiresType !== 'datetime(3)')
500
+ pending.add('assignments');
501
+ const schemaOdd = [];
502
+ if (base.catalogVersionTable) {
503
+ const [{ n }] = await q('SELECT COUNT(*) AS n FROM authz_catalog_version WHERE id IN (1, 2)');
504
+ if (Number(n) < 2)
505
+ pending.add('catalog-version');
506
+ }
507
+ else {
508
+ pending.add('catalog-version');
509
+ }
510
+ const indexes = await q("SELECT DISTINCT index_name AS i FROM information_schema.statistics WHERE table_schema = DATABASE() AND table_name = 'authz_roles'");
511
+ const hasIndex = (name) => indexes.some((r) => r.i === name);
512
+ if (!col('authz_roles', 'owner_scope_key')) {
513
+ pending.add('roles-owner');
514
+ if (!hasIndex('authz_roles_slug_scope_uq'))
515
+ schemaOdd.push('authz_roles no tiene ni el unique de 1.x ni owner_scope_key');
516
+ }
517
+ else if (!hasIndex('authz_roles_slug_scope_owner_uq') || hasIndex('authz_roles_slug_scope_uq') || !hasIndex('authz_roles_owner_idx')) {
518
+ schemaOdd.push('authz_roles tiene owner_scope_key pero sus índices no son los de 2.x');
519
+ }
520
+ if (!col('authz_permissions', 'assignable_at'))
521
+ pending.add('permissions-assignable');
522
+ let marker = null;
523
+ if (tables.has('authz_upgrade_1x')) {
524
+ const have = columns.filter((r) => r.t === 'authz_upgrade_1x').map((r) => r.c).sort();
525
+ if (JSON.stringify(have) !== JSON.stringify(MARKER_COLUMNS)) {
526
+ marker = 'malformed';
527
+ }
528
+ else {
529
+ marker = (await q("SELECT step, state, mode, zone, DATE_FORMAT(upgraded_at, '%Y-%m-%dT%H:%i:%s.%fZ') AS up FROM authz_upgrade_1x ORDER BY step")).map((r) => ({ step: r.step, state: r.state, mode: r.mode, zone: r.zone, upgradedAt: r.up ? String(r.up).replace(/(\.\d{3})\d{3}Z$/, '$1Z') : null }));
530
+ }
531
+ }
532
+ const [{ expiring, unreadable, nonV7 }] = await q(`SELECT COALESCE(SUM(expires_at IS NOT NULL), 0) AS expiring,
533
+ COALESCE(SUM(${UNREADABLE}), 0) AS unreadable,
534
+ COALESCE(SUM(NOT (${V7})), 0) AS nonV7
535
+ FROM authz_assignments`);
536
+ const oldest = await q(`SELECT LOWER(uuid) AS u FROM authz_assignments WHERE ${V7} ORDER BY CAST(LOWER(uuid) AS BINARY) LIMIT 1`);
537
+ const oldestV7Ms = oldest.length ? Number.parseInt(String(oldest[0].u).replace('-', '').slice(0, 12), 16) : null;
538
+ const zones = {};
539
+ const markerZones = Array.isArray(marker) ? marker.map((m) => m.zone) : [];
540
+ for (const zone of [flags.zone, ...markerZones]) {
541
+ if (zone === undefined || zone === null || zone in zones)
542
+ continue;
543
+ const [{ v }] = await q("SELECT CONVERT_TZ('2030-01-01 00:00:00', ?, '+00:00') AS v", [zone]);
544
+ zones[zone] = v !== null;
545
+ }
546
+ let fixBefore = null;
547
+ let fixCandidates = null;
548
+ const at = parseUpgradedAt(flags.upgradedAt);
549
+ if (flags.fixPreviousUpgrade && flags.zone !== undefined && zones[flags.zone] && at !== null) {
550
+ const [r] = await q(`SELECT COALESCE(SUM(expires_at IS NOT NULL), 0) AS before1x,
551
+ COALESCE(SUM(expires_at IS NOT NULL AND CONVERT_TZ(expires_at, ?, '+00:00') < expires_at), 0) AS moved
552
+ FROM authz_assignments
553
+ WHERE CAST(LOWER(uuid) AS BINARY) < CAST(? AS BINARY)`, [flags.zone, v7Prefix(at)]);
554
+ fixBefore = Number(r.before1x);
555
+ fixCandidates = Number(r.moved);
556
+ }
557
+ return {
558
+ ...base,
559
+ missingTables,
560
+ nonTransactional,
561
+ expiresType,
562
+ marker,
563
+ pendingDdl: [...UPGRADE_1X_PRE_CONVERT_DDL, ...UPGRADE_1X_POST_CONVERT_DDL].filter((s) => pending.has(s)),
564
+ schemaOdd,
565
+ expiring: Number(expiring),
566
+ unreadable: Number(unreadable),
567
+ zones,
568
+ oldestV7Ms,
569
+ nonV7: Number(nonV7),
570
+ fixBefore,
571
+ fixCandidates,
572
+ };
573
+ }
574
+ /* ── Ejecución de un paso (MySQL, la misma sesión) ─────────────────────────── */
575
+ /** Las sentencias DDL de cada paso: las del bloque MySQL del README hasta 2.5.0-rc.1 (K14 las compara con la migración publicada). */
576
+ const DDL = {
577
+ 'collation-roles': [
578
+ `ALTER TABLE authz_roles
579
+ MODIFY slug varchar(100) COLLATE utf8mb4_bin NOT NULL,
580
+ MODIFY scope_type varchar(20) COLLATE utf8mb4_bin NOT NULL`,
581
+ ],
582
+ 'collation-permissions': ['ALTER TABLE authz_permissions MODIFY slug varchar(100) COLLATE utf8mb4_bin NOT NULL'],
583
+ assignments: [
584
+ `ALTER TABLE authz_assignments
585
+ MODIFY holder_type varchar(50) COLLATE utf8mb4_bin NOT NULL,
586
+ MODIFY holder_uuid varchar(64) COLLATE utf8mb4_bin NOT NULL,
587
+ MODIFY scope_type varchar(20) COLLATE utf8mb4_bin NOT NULL,
588
+ MODIFY scope_uuid varchar(64) COLLATE utf8mb4_bin NOT NULL,
589
+ MODIFY expires_at datetime(3) NULL`,
590
+ ],
591
+ 'collation-denies': [
592
+ `ALTER TABLE authz_denies
593
+ MODIFY holder_type varchar(50) COLLATE utf8mb4_bin NOT NULL,
594
+ MODIFY holder_uuid varchar(64) COLLATE utf8mb4_bin NOT NULL,
595
+ MODIFY scope_type varchar(20) COLLATE utf8mb4_bin NOT NULL,
596
+ MODIFY scope_uuid varchar(64) COLLATE utf8mb4_bin NOT NULL`,
597
+ ],
598
+ 'catalog-version': [
599
+ `CREATE TABLE IF NOT EXISTS authz_catalog_version (
600
+ id int NOT NULL PRIMARY KEY,
601
+ version bigint NOT NULL DEFAULT 0,
602
+ updated_at timestamp NOT NULL,
603
+ freeze_reason varchar(255) NULL,
604
+ freeze_holder varchar(120) NULL,
605
+ freeze_until_ms bigint NULL,
606
+ freeze_fence bigint NOT NULL DEFAULT 0
607
+ ) ENGINE=InnoDB`,
608
+ `INSERT INTO authz_catalog_version (id, version, updated_at)
609
+ VALUES (1, 0, CURRENT_TIMESTAMP), (2, 0, CURRENT_TIMESTAMP)
610
+ ON DUPLICATE KEY UPDATE id = id`,
611
+ ],
612
+ 'roles-owner': [
613
+ `ALTER TABLE authz_roles
614
+ ADD COLUMN owner_scope_key varchar(80) COLLATE utf8mb4_bin NOT NULL DEFAULT 'global',
615
+ DROP INDEX authz_roles_slug_scope_uq,
616
+ ADD UNIQUE INDEX authz_roles_slug_scope_owner_uq (slug, scope_type, owner_scope_key),
617
+ ADD INDEX authz_roles_owner_idx (owner_scope_key)`,
618
+ ],
619
+ 'permissions-assignable': ['ALTER TABLE authz_permissions ADD COLUMN assignable_at varchar(500) NULL'],
620
+ };
621
+ class StepRefused extends Error {
622
+ }
623
+ /** Una transacción de DML en la sesión: todo o nada (InnoDB; dentro no hay DDL, que haría commit implícito). */
624
+ async function inTransaction(q, body) {
625
+ await q('START TRANSACTION');
626
+ try {
627
+ await body();
628
+ await q('COMMIT');
629
+ }
630
+ catch (error) {
631
+ await q('ROLLBACK').catch(() => { });
632
+ throw error;
633
+ }
634
+ }
635
+ export async function runUpgrade1xStep(q, step, plan) {
636
+ const zone = plan.zone;
637
+ if (step in DDL) {
638
+ for (const sql of DDL[step])
639
+ await q(sql);
640
+ return 'hecho';
641
+ }
642
+ switch (step) {
643
+ case 'zero-dates': {
644
+ const r = await q(`UPDATE authz_assignments SET expires_at = FROM_UNIXTIME(?) WHERE ${UNREADABLE}`, [UPGRADE_1X_EXPIRED_INSTANT]);
645
+ return `${r?.affectedRows ?? '?'} fila(s) caducadas`;
646
+ }
647
+ case 'marker-table':
648
+ await q(`CREATE TABLE IF NOT EXISTS authz_upgrade_1x (
649
+ step varchar(40) COLLATE utf8mb4_bin NOT NULL PRIMARY KEY,
650
+ state varchar(10) NOT NULL,
651
+ mode varchar(20) NOT NULL,
652
+ zone varchar(64) NOT NULL,
653
+ upgraded_at datetime(3) NULL,
654
+ armed_at datetime(3) NOT NULL,
655
+ done_at datetime(3) NULL
656
+ ) ENGINE=InnoDB`);
657
+ return 'hecho';
658
+ case 'marker-arm': {
659
+ // Se re-comprueba en la sesión que la columna sigue siendo la de 1.x: armar sobre un datetime sería A1.
660
+ const [{ ty }] = await q("SELECT column_type AS ty FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'authz_assignments' AND column_name = 'expires_at'");
661
+ if (ty !== 'timestamp')
662
+ throw new StepRefused(`expires_at ya no es timestamp (${ty}): no se arma la conversión`);
663
+ await q("INSERT INTO authz_upgrade_1x (step, state, mode, zone, armed_at) VALUES ('expires_at', 'armed', 'upgrade', ?, UTC_TIMESTAMP(3))", [zone]);
664
+ return `armado con la zona ${zone}`;
665
+ }
666
+ case 'convert': {
667
+ let moved = 0;
668
+ await inTransaction(q, async () => {
669
+ const rows = await q("SELECT state, zone FROM authz_upgrade_1x WHERE step = 'expires_at' FOR UPDATE");
670
+ if (rows.length !== 1 || rows[0].state !== 'armed' || rows[0].zone !== zone) {
671
+ throw new StepRefused(`el marcador no está armado con ${zone} (${JSON.stringify(rows)}): no se convierte`);
672
+ }
673
+ const [{ ty }] = await q("SELECT column_type AS ty FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'authz_assignments' AND column_name = 'expires_at'");
674
+ if (ty !== 'datetime(3)')
675
+ throw new StepRefused(`expires_at es ${ty}, no datetime(3): no se convierte`);
676
+ const [{ n }] = await q("SELECT COUNT(*) AS n FROM authz_assignments WHERE expires_at IS NOT NULL AND CONVERT_TZ(expires_at, ?, '+00:00') IS NULL FOR UPDATE", [zone]);
677
+ if (Number(n) > 0)
678
+ throw new StepRefused(`${n} caducidad(es) que CONVERT_TZ no convierte: convertirlas daría NULL (permanente)`);
679
+ const r = (await q("UPDATE authz_assignments SET expires_at = CONVERT_TZ(expires_at, ?, '+00:00') WHERE expires_at IS NOT NULL AND CONVERT_TZ(expires_at, ?, '+00:00') IS NOT NULL", [zone, zone]));
680
+ moved = Number(r?.affectedRows ?? 0);
681
+ await q("UPDATE authz_upgrade_1x SET state = 'done', done_at = UTC_TIMESTAMP(3) WHERE step = 'expires_at' AND state = 'armed'");
682
+ });
683
+ return `${moved} caducidad(es) convertidas de ${zone} a UTC; marcador hecho`;
684
+ }
685
+ case 'born': {
686
+ // P1 · la declaración: un INSERT en el marcador y nada más. Re-comprueba en la sesión lo que el plan ya vio.
687
+ await inTransaction(q, async () => {
688
+ const rows = await q("SELECT state FROM authz_upgrade_1x WHERE step = 'expires_at' FOR UPDATE");
689
+ if (rows.length !== 0)
690
+ throw new StepRefused(`el marcador ya existe (${JSON.stringify(rows)}): no se declara encima`);
691
+ const [{ ty }] = await q("SELECT column_type AS ty FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'authz_assignments' AND column_name = 'expires_at'");
692
+ if (ty !== 'datetime(3)')
693
+ throw new StepRefused(`expires_at es ${ty}, no datetime(3): no es una base nacida en 2.x`);
694
+ await q(`INSERT INTO authz_upgrade_1x (step, state, mode, zone, armed_at, done_at)
695
+ VALUES ('expires_at', 'done', 'born', ?, UTC_TIMESTAMP(3), UTC_TIMESTAMP(3))`, [UPGRADE_1X_BORN_ZONE]);
696
+ });
697
+ return 'base declarada nacida en 2.x; ningún dato tocado';
698
+ }
699
+ case 'fix-previous': {
700
+ let moved = 0;
701
+ const prefix = v7Prefix(plan.upgradedAtMs);
702
+ await inTransaction(q, async () => {
703
+ const rows = await q("SELECT state FROM authz_upgrade_1x WHERE step = 'expires_at' FOR UPDATE");
704
+ if (rows.length !== 0)
705
+ throw new StepRefused(`el marcador ya existe (${JSON.stringify(rows)}): no se corrige dos veces`);
706
+ await q(`INSERT INTO authz_upgrade_1x (step, state, mode, zone, upgraded_at, armed_at, done_at)
707
+ VALUES ('expires_at', 'done', 'fix-previous', ?, ?, UTC_TIMESTAMP(3), UTC_TIMESTAMP(3))`, [zone, new Date(plan.upgradedAtMs).toISOString().slice(0, 23).replace('T', ' ')]);
708
+ const r = (await q(`UPDATE authz_assignments SET expires_at = CONVERT_TZ(expires_at, ?, '+00:00')
709
+ WHERE expires_at IS NOT NULL
710
+ AND CAST(LOWER(uuid) AS BINARY) < CAST(? AS BINARY)
711
+ AND CONVERT_TZ(expires_at, ?, '+00:00') IS NOT NULL
712
+ AND CONVERT_TZ(expires_at, ?, '+00:00') < expires_at`, [zone, prefix, zone, zone]));
713
+ moved = Number(r?.affectedRows ?? 0);
714
+ });
715
+ return `${moved} caducidad(es) de 1.x adelantadas de ${zone} a UTC; marcador hecho`;
716
+ }
717
+ }
718
+ throw new Error(`paso desconocido: ${step}`);
719
+ }
720
+ /* ── El comando ────────────────────────────────────────────────────────────── */
721
+ /**
722
+ * node ace authz:upgrade-1x --zone=Europe/Madrid # simulación: estado detectado + plan, cero escrituras
723
+ * node ace authz:upgrade-1x --zone=Europe/Madrid --confirm-legacy-stopped --force # sube (re-ejecutable tras un corte)
724
+ * node ace authz:upgrade-1x --fix-previous-upgrade --zone=Europe/Madrid --upgraded-at=2026-09-01T10:00:00Z --confirm-legacy-stopped --force
725
+ * node ace authz:upgrade-1x --declare-born-in-2 --force # base nacida en 2.x: marcador, cero datos
726
+ */
727
+ export default class AuthzUpgrade1x extends BaseCommand {
728
+ static commandName = 'authz:upgrade-1x';
729
+ static description = 'Upgrade a 1.x MySQL schema to 2.x (schema + expires_at converted to UTC); dry run unless --force, re-runnable after a cut';
730
+ static options = {
731
+ startApp: true,
732
+ };
733
+ /**
734
+ * Gancho de TEST (cortes): se llama tras cada paso confirmado; si lanza, el comando se comporta como un proceso
735
+ * cortado ahí (exit 1, lo hecho queda hecho). Nadie lo pone en producción.
736
+ */
737
+ static afterStep;
738
+ async run() {
739
+ // Perezosos (como los demás comandos): el loader de ace importa este módulo sin app; `sql_expiry` arrastra el `db`.
740
+ const { default: db } = await import('@adonisjs/lucid/services/db');
741
+ const { dialectOf, isMysqlDialect } = await import('../src/shared/sql_expiry.js');
742
+ const name = this.connection ?? db.primaryConnectionName;
743
+ const client = db.connection(name);
744
+ const flagsIn = {
745
+ zone: this.zone,
746
+ force: this.force === true,
747
+ dryRun: this.dryRun === true,
748
+ fixPreviousUpgrade: this.fixPreviousUpgrade === true,
749
+ upgradedAt: this.upgradedAt,
750
+ zeroDates: this.zeroDates,
751
+ confirmLegacyStopped: this.confirmLegacyStopped === true,
752
+ declareBornIn2: this.declareBornIn2 === true,
753
+ };
754
+ const print = (lines) => {
755
+ for (const { level, message } of lines)
756
+ this.logger[level](message);
757
+ };
758
+ if (!isMysqlDialect(client)) {
759
+ // Ni una sentencia: el estado es solo el dialecto.
760
+ const plan = upgrade1xPlan({ ...emptyState(name, nowMs()), dialect: dialectOf(client) }, flagsIn);
761
+ print(plan.lines);
762
+ this.exitCode = plan.exitCode;
763
+ return;
764
+ }
765
+ // UNA sesión para todo: `time_zone` y `sql_mode` son de sesión, y el cerrojo con nombre también.
766
+ const knex = client.getWriteClient();
767
+ const conn = await knex.client.acquireConnection();
768
+ const q = async (sql, bindings = []) => (await knex.raw(sql, bindings).connection(conn))[0];
769
+ let locked = false;
770
+ try {
771
+ // La zona de sesión que tenían las conexiones de 1.x (la del servidor) — el MODIFY de expires_at renderiza la pared
772
+ // con ella — y un sql_mode ESTRICTO: nada se trunca en silencio a la fecha cero (RR-3).
773
+ await q('SET time_zone = @@global.time_zone');
774
+ await q("SET SESSION sql_mode = 'STRICT_ALL_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION'");
775
+ if (flagsIn.force && !flagsIn.dryRun) {
776
+ const [{ l }] = await q("SELECT GET_LOCK(CONCAT('authz_upgrade_1x:', DATABASE()), 0) AS l");
777
+ locked = Number(l) === 1;
778
+ if (!locked) {
779
+ this.logger.error('estado: ocupado · otra ejecución de authz:upgrade-1x sobre esta base tiene el cerrojo. No se ha escrito nada.');
780
+ this.exitCode = 1;
781
+ return;
782
+ }
783
+ }
784
+ const state = await readUpgrade1xState(q, name, flagsIn, nowMs());
785
+ const plan = upgrade1xPlan(state, flagsIn);
786
+ print(plan.lines);
787
+ this.exitCode = plan.exitCode;
788
+ if (plan.action !== 'run' || !plan.write)
789
+ return;
790
+ for (const [i, step] of plan.steps.entries()) {
791
+ try {
792
+ const outcome = await runUpgrade1xStep(q, step, plan);
793
+ this.logger.success(`paso ${i + 1}/${plan.steps.length} · ${step}: ${outcome}`);
794
+ await AuthzUpgrade1x.afterStep?.(step);
795
+ }
796
+ catch (error) {
797
+ const message = error?.sqlMessage ?? error?.message ?? String(error);
798
+ this.logger.error(`corte en el paso ${i + 1}/${plan.steps.length} · ${step}: ${message}. Lo confirmado hasta aquí queda (en MySQL el DDL no se ` +
799
+ 'deshace; la conversión es una transacción: entera o nada). Arregla la causa y repite EL MISMO comando: reanuda desde aquí ' +
800
+ 'y nunca convierte dos veces.');
801
+ this.exitCode = 1;
802
+ return;
803
+ }
804
+ }
805
+ // Autocomprobación: lo que queda tiene que ser «hecho».
806
+ const after = upgrade1xPlan(await readUpgrade1xState(q, name, {}, nowMs()), {});
807
+ if (after.state !== 'hecho') {
808
+ this.logger.error(`tras los pasos la base no está «hecha» sino ${after.lines[0]?.message}. Repórtalo con esta línea.`);
809
+ this.exitCode = 1;
810
+ return;
811
+ }
812
+ this.logger.success(plan.steps.includes('born')
813
+ ? 'Base declarada nacida en 2.x: marcador authz_upgrade_1x escrito (born), ningún dato tocado. Re-ejecutar este comando ' +
814
+ 'dice «hecho». No lo borres.'
815
+ : `Subida 1.x → 2.x hecha (${plan.state === 'receta-vieja' ? 'corrección de la subida anterior' : 'esquema + caducidades'}, zona ` +
816
+ `${plan.zone}). El marcador authz_upgrade_1x lo recuerda: re-ejecutar este comando no convierte nada. No lo borres.`);
817
+ this.exitCode = 0;
818
+ }
819
+ finally {
820
+ if (locked)
821
+ await q("SELECT RELEASE_LOCK(CONCAT('authz_upgrade_1x:', DATABASE()))").catch(() => { });
822
+ // La conexión vuelve al pool como llegó (zona y sql_mode del servidor); si no se puede, se destruye.
823
+ try {
824
+ await q('SET time_zone = @@global.time_zone');
825
+ await q('SET SESSION sql_mode = @@global.sql_mode');
826
+ await knex.client.releaseConnection(conn);
827
+ }
828
+ catch {
829
+ await knex.client.destroyRawConnection?.(conn)?.catch?.(() => { });
830
+ }
831
+ }
832
+ }
833
+ }
834
+ __decorate([
835
+ flags.string({
836
+ description: "Zone your 1.x processes serialised dates in (their mysql2 'timezone' option, else the process TZ; by name if it has DST). Required, no default",
837
+ }),
838
+ __metadata("design:type", Object)
839
+ ], AuthzUpgrade1x.prototype, "zone", void 0);
840
+ __decorate([
841
+ flags.boolean({ description: 'Actually write (the default is a dry run that prints the detected state and the plan)' }),
842
+ __metadata("design:type", Object)
843
+ ], AuthzUpgrade1x.prototype, "force", void 0);
844
+ __decorate([
845
+ flags.boolean({ description: 'Print the detected state and the plan without writing (the default; refused together with --force)' }),
846
+ __metadata("design:type", Object)
847
+ ], AuthzUpgrade1x.prototype, "dryRun", void 0);
848
+ __decorate([
849
+ flags.boolean({
850
+ description: 'For a database upgraded with the MySQL block of 2.4.0-alpha.1 … 2.5.0-rc.1: correct only the 1.x rows, only earlier',
851
+ }),
852
+ __metadata("design:type", Object)
853
+ ], AuthzUpgrade1x.prototype, "fixPreviousUpgrade", void 0);
854
+ __decorate([
855
+ flags.string({
856
+ description: 'With --fix-previous-upgrade: when the LAST 1.x process stopped writing (ISO with Z or an offset; later rather than earlier)',
857
+ }),
858
+ __metadata("design:type", Object)
859
+ ], AuthzUpgrade1x.prototype, "upgradedAt", void 0);
860
+ __decorate([
861
+ flags.string({ description: "What to do with unreadable 1.x expiries (zero dates): only 'expire' (a past instant, never NULL)" }),
862
+ __metadata("design:type", Object)
863
+ ], AuthzUpgrade1x.prototype, "zeroDates", void 0);
864
+ __decorate([
865
+ flags.boolean({
866
+ description: 'Confirm that EVERY 1.x process is stopped (no rolling deploy 1.x -> 2.x on MySQL). Required with --force wherever expiries are converted',
867
+ }),
868
+ __metadata("design:type", Object)
869
+ ], AuthzUpgrade1x.prototype, "confirmLegacyStopped", void 0);
870
+ __decorate([
871
+ flags.boolean({
872
+ description: 'Declare that this database was born on 2.x (no 1.x process ever wrote it): writes the marker, touches no data. Refused where the command sees 1.x (pending 1.x DDL, zero dates). Your declaration, not a proof',
873
+ }),
874
+ __metadata("design:type", Object)
875
+ ], AuthzUpgrade1x.prototype, "declareBornIn2", void 0);
876
+ __decorate([
877
+ flags.string({ description: 'Lucid connection to upgrade (default: the primary one)' }),
878
+ __metadata("design:type", Object)
879
+ ], AuthzUpgrade1x.prototype, "connection", void 0);
880
+ function nowMs() {
881
+ return systemClock().getTime();
882
+ }
883
+ function emptyState(connection, now) {
884
+ return {
885
+ connection,
886
+ dialect: 'desconocido',
887
+ missingTables: [],
888
+ nonTransactional: [],
889
+ expiresType: null,
890
+ marker: null,
891
+ pendingDdl: [],
892
+ schemaOdd: [],
893
+ catalogVersionTable: false,
894
+ expiring: 0,
895
+ unreadable: 0,
896
+ zones: {},
897
+ oldestV7Ms: null,
898
+ nonV7: 0,
899
+ fixBefore: null,
900
+ fixCandidates: null,
901
+ nowMs: now,
902
+ };
903
+ }