fix(alarm): stop corrupt entries and unreadable payloads from wiping saved alarms
A single malformed alarm entry (bad/missing id, wrong type) used to discard the ENTIRE persisted list on next load, and a fully unparseable payload let the periodic recalculation silently overwrite it with an empty one -- both destroyed valid alarms with no user action. Adds a shared per-entry tolerant-parse helper (persistencia_tolerante.dart) that skips and logs only the bad entry; ServicioAlarmas now normalizes its cached raw after a partial load (no dirty-guard thrash) and sets a degraded-read flag after a total decode failure that suppresses automatic writes until a good read or an explicit user mutation restores authority.
This commit is contained in:
@@ -142,6 +142,14 @@ class AlarmaMusical {
|
||||
'actualizadaEn': actualizadaEn?.toIso8601String(),
|
||||
};
|
||||
|
||||
// persistence-resilience (D2): `id` stays a REQUIRED, un-defaulted cast
|
||||
// on purpose -- a missing/wrong-type id must throw, not fall back to a
|
||||
// fabricated value. Callers that read persisted collections (e.g.
|
||||
// ServicioAlarmas._parsear via persistencia_tolerante.dart) wrap each
|
||||
// fromJson call in a per-entry try: a thrown entry is skipped and
|
||||
// logged, never replacing this required field with a sentinel/fabricated
|
||||
// id ("skip-never-fabricate"). This boundary also tolerates any future
|
||||
// required-field break the same way, not just id.
|
||||
factory AlarmaMusical.fromJson(Map<String, dynamic> json) {
|
||||
return AlarmaMusical(
|
||||
id: json['id'] as String,
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// Shared per-entry tolerant-parse primitives (persistence-resilience D1).
|
||||
///
|
||||
/// Every subsystem that persists a collection (alarms, custom stations, EQ
|
||||
/// presets/device names) decodes its own top-level payload first — total
|
||||
/// top-level decode failure is a subsystem-specific policy (see each
|
||||
/// caller's own degraded-read handling) and is intentionally OUT of scope
|
||||
/// here. What IS shared is what happens once the container decoded fine but
|
||||
/// one entry inside it did not: that single entry must be skipped and
|
||||
/// logged, never allowed to take its valid siblings down with it, and never
|
||||
/// replaced by a fabricated identity.
|
||||
|
||||
/// Result of a tolerant, per-entry parse pass over a persisted collection.
|
||||
///
|
||||
/// [validas] holds whatever survived (a `List<T>` for
|
||||
/// [parseListaTolerante], a `Map<String, V>` for [parseMapaTolerante]).
|
||||
/// [saltadas] counts how many entries were skipped because they failed to
|
||||
/// parse — callers use this to detect a PARTIAL read (>0) versus a fully
|
||||
/// healthy one (0) without inspecting [validas] itself.
|
||||
typedef ResultadoTolerante<T> = ({T validas, int saltadas});
|
||||
|
||||
/// Parses each entry of the ALREADY-decoded [datos] independently via
|
||||
/// [parser]; an entry that is not a [Map], or whose [parser] throws (e.g. a
|
||||
/// missing/invalid identity field), is skipped and logged instead of
|
||||
/// aborting the whole parse. Order of survivors matches [datos]' order.
|
||||
///
|
||||
/// [datos] must already be the decoded top-level list (e.g. the result of
|
||||
/// `jsonDecode(raw) as Map<String, dynamic>` -> `map['alarmas'] as List?`).
|
||||
/// This helper never calls `jsonDecode` itself — total-failure policy for
|
||||
/// an unparseable top-level payload differs per subsystem and stays with
|
||||
/// the caller.
|
||||
ResultadoTolerante<List<T>> parseListaTolerante<T>(
|
||||
List<dynamic>? datos,
|
||||
T Function(Map<String, dynamic> entrada) parser, {
|
||||
required String subsistema,
|
||||
required String coleccion,
|
||||
}) {
|
||||
if (datos == null || datos.isEmpty) {
|
||||
return (validas: <T>[], saltadas: 0);
|
||||
}
|
||||
final validas = <T>[];
|
||||
var saltadas = 0;
|
||||
for (var indice = 0; indice < datos.length; indice++) {
|
||||
try {
|
||||
final entrada = datos[indice];
|
||||
if (entrada is! Map) {
|
||||
throw const FormatException('la entrada no es un mapa');
|
||||
}
|
||||
validas.add(parser(Map<String, dynamic>.from(entrada)));
|
||||
} catch (e) {
|
||||
saltadas++;
|
||||
registrarSaltoPersistencia(
|
||||
subsistema: subsistema,
|
||||
detalle: '$coleccion[$indice]',
|
||||
razon: e.toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
return (validas: validas, saltadas: saltadas);
|
||||
}
|
||||
|
||||
/// Parses each VALUE of the ALREADY-decoded [datos] independently via
|
||||
/// [parser]; a value whose [parser] throws is skipped and logged, and only
|
||||
/// its key is dropped from the result. Keys of surviving entries are
|
||||
/// preserved byte-for-byte (needed by callers whose keys carry meaning,
|
||||
/// e.g. EQ's colon-delimited `stationUuid:deviceId` matrix keys).
|
||||
///
|
||||
/// [datos] must already be the decoded top-level map (e.g. the result of
|
||||
/// `jsonDecode(raw) as Map<String, dynamic>?`). This helper never calls
|
||||
/// `jsonDecode` itself — see [parseListaTolerante] for the same rationale.
|
||||
ResultadoTolerante<Map<String, V>> parseMapaTolerante<V>(
|
||||
Map<String, dynamic>? datos,
|
||||
V Function(dynamic valor) parser, {
|
||||
required String subsistema,
|
||||
required String coleccion,
|
||||
}) {
|
||||
if (datos == null || datos.isEmpty) {
|
||||
return (validas: <String, V>{}, saltadas: 0);
|
||||
}
|
||||
final validas = <String, V>{};
|
||||
var saltadas = 0;
|
||||
for (final entrada in datos.entries) {
|
||||
try {
|
||||
validas[entrada.key] = parser(entrada.value);
|
||||
} catch (e) {
|
||||
saltadas++;
|
||||
registrarSaltoPersistencia(
|
||||
subsistema: subsistema,
|
||||
detalle: '$coleccion[${entrada.key}]',
|
||||
razon: e.toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
return (validas: validas, saltadas: saltadas);
|
||||
}
|
||||
|
||||
/// Logs a persistence skip or degraded read with enough context to
|
||||
/// diagnose it later: which subsystem, which entry/key/reason. Developer
|
||||
/// facing only — this MUST NOT surface as user-visible copy or gain an
|
||||
/// l10n key (persistence-resilience spec, "Diagnostics are developer-facing
|
||||
/// only").
|
||||
void registrarSaltoPersistencia({
|
||||
required String subsistema,
|
||||
required String detalle,
|
||||
required String razon,
|
||||
}) {
|
||||
debugPrint('[PluriWave][persistencia] $subsistema $detalle: $razon');
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../modelos/alarma_musical.dart';
|
||||
import '../modelos/emisora.dart';
|
||||
import 'persistencia_tolerante.dart';
|
||||
import 'servicio_programacion_alarmas.dart';
|
||||
|
||||
class ConfiguracionAlarmas {
|
||||
@@ -41,6 +42,14 @@ class ServicioAlarmas {
|
||||
String? _cacheRaw;
|
||||
Future<void> _cola = Future<void>.value();
|
||||
|
||||
// persistence-resilience (D4): set when the top-level payload could not
|
||||
// be decoded at all (vs. an individual entry inside it). While true, the
|
||||
// AUTOMATIC writer (recalcularTodas) must never reach disk -- only an
|
||||
// explicit user mutation (which always funnels through _guardar) may
|
||||
// overwrite a payload we could not read. Cleared by _guardar and by the
|
||||
// next successful (even if partial) read.
|
||||
bool _lecturaAlarmasDegradada = false;
|
||||
|
||||
Future<T> _enCola<T>(Future<T> Function() accion) {
|
||||
final resultado = _cola.then((_) => accion());
|
||||
_cola = resultado.then((_) {}, onError: (_) {});
|
||||
@@ -63,12 +72,29 @@ class ServicioAlarmas {
|
||||
final raw = prefs.getString(_keyConfig);
|
||||
final config = _parsear(raw);
|
||||
_cache = config;
|
||||
_cacheRaw = raw;
|
||||
return config;
|
||||
}
|
||||
|
||||
/// Parses the persisted [raw] payload with per-entry tolerance
|
||||
/// (persistence-resilience D1/D2) and updates [_cacheRaw] /
|
||||
/// [_lecturaAlarmasDegradada] as a side effect, since each outcome needs
|
||||
/// a DIFFERENT cached-raw value:
|
||||
/// - top-level decode failure (bad JSON, or any container-level field of
|
||||
/// the wrong shape) -> DEGRADED: empty config, [_cacheRaw] keeps the
|
||||
/// corrupt [raw] untouched, [_lecturaAlarmasDegradada] set so
|
||||
/// `recalcularTodas` never overwrites it (D4).
|
||||
/// - decodes, but one or more entries are malformed -> PARTIAL: only the
|
||||
/// survivors are kept, [_cacheRaw] is normalized to their own
|
||||
/// serialization -- NOT the corrupt raw (D3) -- so the automatic
|
||||
/// writer's dirty-guard compares against a coherent baseline instead of
|
||||
/// re-firing on every 60s tick.
|
||||
/// - fully healthy payload -> unchanged behavior; clears the flag if a
|
||||
/// previous read had set it (suppression lifts on next successful
|
||||
/// read).
|
||||
ConfiguracionAlarmas _parsear(String? raw) {
|
||||
if (raw == null || raw.trim().isEmpty) {
|
||||
_lecturaAlarmasDegradada = false;
|
||||
_cacheRaw = raw;
|
||||
return const ConfiguracionAlarmas(
|
||||
alarmas: [],
|
||||
vacaciones: [],
|
||||
@@ -77,30 +103,44 @@ class ServicioAlarmas {
|
||||
}
|
||||
try {
|
||||
final data = jsonDecode(raw) as Map<String, dynamic>;
|
||||
return ConfiguracionAlarmas(
|
||||
alarmas:
|
||||
(data['alarmas'] as List? ?? const [])
|
||||
.whereType<Map>()
|
||||
.map(
|
||||
(e) => AlarmaMusical.fromJson(Map<String, dynamic>.from(e)),
|
||||
)
|
||||
.toList(),
|
||||
vacaciones:
|
||||
(data['vacaciones'] as List? ?? const [])
|
||||
.whereType<Map>()
|
||||
.map(
|
||||
(e) => RangoVacaciones.fromJson(Map<String, dynamic>.from(e)),
|
||||
)
|
||||
.toList(),
|
||||
excepciones:
|
||||
(data['excepciones'] as List? ?? const [])
|
||||
.whereType<Map>()
|
||||
.map(
|
||||
(e) => ExcepcionAlarma.fromJson(Map<String, dynamic>.from(e)),
|
||||
)
|
||||
.toList(),
|
||||
final alarmas = parseListaTolerante<AlarmaMusical>(
|
||||
data['alarmas'] as List?,
|
||||
AlarmaMusical.fromJson,
|
||||
subsistema: 'alarmas',
|
||||
coleccion: 'alarmas',
|
||||
);
|
||||
final vacaciones = parseListaTolerante<RangoVacaciones>(
|
||||
data['vacaciones'] as List?,
|
||||
RangoVacaciones.fromJson,
|
||||
subsistema: 'alarmas',
|
||||
coleccion: 'vacaciones',
|
||||
);
|
||||
final excepciones = parseListaTolerante<ExcepcionAlarma>(
|
||||
data['excepciones'] as List?,
|
||||
ExcepcionAlarma.fromJson,
|
||||
subsistema: 'alarmas',
|
||||
coleccion: 'excepciones',
|
||||
);
|
||||
final config = ConfiguracionAlarmas(
|
||||
alarmas: alarmas.validas,
|
||||
vacaciones: vacaciones.validas,
|
||||
excepciones: excepciones.validas,
|
||||
);
|
||||
_lecturaAlarmasDegradada = false;
|
||||
final huboSaltos =
|
||||
alarmas.saltadas > 0 ||
|
||||
vacaciones.saltadas > 0 ||
|
||||
excepciones.saltadas > 0;
|
||||
_cacheRaw = huboSaltos ? _serializar(config) : raw;
|
||||
return config;
|
||||
} catch (e) {
|
||||
_lecturaAlarmasDegradada = true;
|
||||
_cacheRaw = raw;
|
||||
registrarSaltoPersistencia(
|
||||
subsistema: 'alarmas',
|
||||
detalle: _keyConfig,
|
||||
razon: e.toString(),
|
||||
);
|
||||
} catch (_) {
|
||||
return const ConfiguracionAlarmas(
|
||||
alarmas: [],
|
||||
vacaciones: [],
|
||||
@@ -200,6 +240,11 @@ class ServicioAlarmas {
|
||||
|
||||
Future<ConfiguracionAlarmas> recalcularTodas() => _enCola(() async {
|
||||
final config = await _configActual();
|
||||
// persistence-resilience (D4): a degraded top-level read must never
|
||||
// let this AUTOMATIC writer reach disk -- only an explicit user
|
||||
// mutation (guardarAlarma/eliminarAlarma/etc., via _guardar) may
|
||||
// persist over a payload we could not read.
|
||||
if (_lecturaAlarmasDegradada) return config;
|
||||
final alarmas = _recalcularLista(
|
||||
config.alarmas,
|
||||
config.vacaciones,
|
||||
@@ -427,6 +472,10 @@ class ServicioAlarmas {
|
||||
await prefs.setString(_keyConfig, serializado);
|
||||
_cache = config;
|
||||
_cacheRaw = serializado;
|
||||
// persistence-resilience (D4): every explicit mutation funnels through
|
||||
// here, so this is where write authority is restored after a degraded
|
||||
// read -- user intent wins over a suppressed automatic writer.
|
||||
_lecturaAlarmasDegradada = false;
|
||||
}
|
||||
|
||||
String _serializar(ConfiguracionAlarmas config) => jsonEncode({
|
||||
|
||||
Reference in New Issue
Block a user