@@ -25,37 +25,86 @@ No Makefile/linter config present, no test suite — use plain `go` toolchain co
...
@@ -25,37 +25,86 @@ No Makefile/linter config present, no test suite — use plain `go` toolchain co
1.`sync_state` table is auto-bootstrapped at startup (`Pool.BootstrapSchema`, seeds `id=1` students row) — no manual migration step required, though the SQL files under `migrations/` are kept for reference/manual runs.
1.`sync_state` table is auto-bootstrapped at startup (`Pool.BootstrapSchema`, seeds `id=1` students row) — no manual migration step required, though the SQL files under `migrations/` are kept for reference/manual runs.
2. Mongo `students` collection must exist with a `$jsonSchema` validator requiring `student_id` int and `enrollment_id` int. A `deleted_students` collection archives students removed at the source — no schema setup needed for it.
2. Mongo `students` collection must exist with a `$jsonSchema` validator requiring `student_id` int and `enrollment_id` int. A `deleted_students` collection archives students removed at the source — no schema setup needed for it.
3. Required env vars: `PG_DSN`, `MONGO_URI`, `MONGO_DB`. Optional: `SYNC_INTERVAL` (default `5m`), `PORT` (default `8080`). Per-pipeline PG function name (or raw `PGQuery`) / Mongo collection / deleted-collection / id field / `sync_state` id are **not** env vars — they're declared in `internal/config/pipelines.go` (`Pipelines []PipelineDef`). Add a new sync pipeline by adding an entry there; `cmd/server/main.go` loops over `config.Pipelines` to wire reader/upserter/mover/state/service/scheduler for each.
3. Required env vars: `PG_DSN`, `MONGO_URI`, `MONGO_DB`. Optional: `SYNC_INTERVAL` (default `5m`), `PORT` (default `8080`). Per-pipeline PG function name (or raw `PGQuery`) / Mongo collection / deleted-collection / id field / `sync_state` id are **not** env vars — they're declared in `internal/config/pipelines.go` (`Pipelines []PipelineDef`). Add a new sync pipeline by adding an entry there; `cmd/server/main.go` loops over `config.Pipelines` to wire reader/upserter/mover/state/service/scheduler for each.
4. Per-pipeline schedule (interval vs. fixed daily time) IS configurable via env vars, unlike the other pipeline settings above: `SYNC_<NAME>_MODE` (`interval` default, or `daily`), `SYNC_<NAME>_INTERVAL` (falls back to `SYNC_INTERVAL`), `SYNC_<NAME>_TIME` (`HH:MM`, required when `MODE=daily`). `<NAME>` is the pipeline name uppercased (`SYNC_STUDENTS_MODE`, `SYNC_PAYMENT_PLANS_MODE`, `SYNC_PARENTS_MODE`). Built by `config.ScheduleFor` into an `internal/sync.Schedule` (`IntervalSchedule` or `DailySchedule`), consumed by `sync.NewScheduler`. Example — run every pipeline once a day at 1am (current `.env`):
```
SYNC_STUDENTS_MODE=daily
SYNC_STUDENTS_TIME=01:00
SYNC_PAYMENT_PLANS_MODE=daily
SYNC_PAYMENT_PLANS_TIME=01:00
SYNC_PARENTS_MODE=daily
SYNC_PARENTS_TIME=01:00
```
When adding a new pipeline module, add its matching `SYNC_<NAME>_MODE`/`_TIME` (or `_INTERVAL`) block to `.env` too — schedule vars are per-module and not auto-derived.
4.`go run ./cmd/server`
4.`go run ./cmd/server`
## Endpoints
## Endpoints
-`GET /health` — checks Postgres and Mongo connectivity.
All endpoints prefixed with `/api/v1`.
-`POST /sync/students/trigger` / `GET /sync/students/status` — students pipeline: run now / last run time, rows synced (created/updated/deleted breakdown), last error if any.
-`GET /api/v1/health` — checks Postgres and Mongo connectivity.
-`POST /api/v1/sync/students/trigger` / `GET /api/v1/sync/students/status` — students pipeline: run now / last run time, rows synced (created/updated/deleted breakdown), last error if any.
Students: `internal/config.studentsQuery`, a raw SQL query (not a function) joining `matricula.ma_estudiante`/`persona.pe_persona`/`matricula.ma_matricula`/`caja.ca_plan_de_pago`, grouped per student, `payment_plans`/`payment_plans_current_year` built via `JSON_AGG(...) FILTER (...)` split on `periodo_academico_id = 14`. Read directly by `db.StudentsQueryReader` (`pgx` row scan, no JSON envelope) — one Mongo doc per row, upserted by `_id = student_id`, requires numeric `enrollment_id`.
Students: `internal/config.studentsQuery`, a raw SQL query (not a function) joining `matricula.ma_estudiante`/`persona.pe_persona`/`matricula.ma_matricula`/`caja.ca_plan_de_pago`, grouped per student, `payment_plans`/`payment_plans_current_year` built via `JSON_AGG(...) FILTER (...)` split on `periodo_academico_id = 14`. Read directly by `db.StudentsQueryReader` (`pgx` row scan, no JSON envelope) — one Mongo doc per row, requires numeric `enrollment_id`.
Payment plans: `internal/config.paymentPlansQuery`, one document per plan of payment. Read by `db.PaymentPlansQueryReader`.
Parents: `internal/config.parentsQuery`, joins `persona.pe_persona` with an aggregated subquery on `matricula.ma_estudiante_apoderado` (one row per parent/guardian, `students` embedded via `JSON_AGG(json_build_object('student_id', ...))`). Read by `db.ParentsQueryReader` into `entity.ParentRecord` — collection `parents`, archive `deleted_parents`, `IDField` = `parent_id`, `StateID` = 3.
Users: `internal/users.Query`, one row per `matricula.ma_usuario`-equivalent login (`user_id`, `user_login`, `user_password`, `user_creation_date`, `parent_id`, `user_status`). Read by `users.QueryReader` into `users.Record` — collection `users`, archive `deleted_users`, `IDField` = `user_id`, `StateID` = 4.
## Mongo `_id` rule
**`_id` nunca es el id de negocio.** Todas las colecciones dejan que Mongo genere su propio ObjectID; el id de negocio (`student_id`, `payment_plan_id`, etc.) vive como campo normal del documento — el mismo declarado en `PipelineDef.IDField` (`internal/config/pipelines.go`). `db.CollectionUpserter` y `db.DeletedMover` reciben ese `idField` y filtran/listan/archivan por él, no por `_id`. Al archivar en la colección `deleted_*`, se descarta el `_id` original para que se genere uno nuevo. Cualquier pipeline nuevo debe seguir esta misma regla — no volver a usar `_id = <id de negocio>`. Requiere índice único sobre `idField` en cada colección Mongo (no lo impone el código).
`updated_at` is stamped by this service (Postgres does not supply it).
`updated_at` is stamped by this service (Postgres does not supply it).
## Sync behavior
## Sync behavior
Each cycle processes rows concurrently via a bounded worker pool (`defaultConcurrency` = 10, `errgroup`-based) so a full migration finishes faster than a sequential loop:
Each cycle has two phases:
1.**Hash + diff (CPU, concurrent)**: a bounded worker pool (`defaultConcurrency` = 10, `errgroup`-based) computes a SHA-256 `row_hash` of each Postgres row's JSON-marshaled doc (before `updated_at`/`row_hash` are stamped) and compares it against the `row_hash` already stored in Mongo for that id (fetched up front via `MongoIDLister.ListIDsAndHashes`, one query for the whole collection). Rows whose hash matches are skipped entirely — no write — and counted as `Unchanged`. Only rows that are new or changed get `updated_at`/`row_hash` stamped and are kept for phase 2.
2.**Bulk write (I/O, one round trip)**: all changed rows are sent in a single `mongo.BulkWrite` (unordered) via `MongoUpserter.BulkUpsert`, which upserts by business `idField`. `res.UpsertedCount` gives `Created`; the rest of the batch is `Updated`.
-**New record**: id not yet in Mongo → inserted, counted as `Created`.
-**New record**: id not yet in Mongo → inserted, counted as `Created`.
-**Existing record**: id already in Mongo → fields overwritten, counted as `Updated`.
-**Existing record, changed**: hash differs from stored `row_hash` → fields overwritten, counted as `Updated`.
-**Removed record**: id present in Mongo but no longer returned by the Postgres source → moved (not just deleted) into the archive collection (`deleted_students`) with a `deleted_at` stamp, counted as `Deleted`.
-**Existing record, unchanged**: hash matches stored `row_hash` → no write at all, counted as `Unchanged`.
-**Removed record**: id present in Mongo (from `ListIDsAndHashes`) but no longer returned by the Postgres source → moved (not just deleted) into the archive collection (`deleted_students`) with a `deleted_at` stamp, counted as `Deleted`.
`SyncResult` carries `Created`/`Updated`/`Unchanged`/`Deleted` separately; `/sync/<module>/status` surfaces all four. Persisted `sync_state` only advances if the entire cycle (hash/upsert phase + delete reconciliation) succeeds; any failure short-circuits and is surfaced via `/sync/<module>/status` as `last_error`.
Every entity implements `SetRowHash(h string)` (alongside `SetUpdatedAt`) to get the skip-unchanged optimization — a `RowHash string `bson:"row_hash,omitempty" json:"-"`` field, stamped by the service, never read back from Postgres.
## Estructura del proyecto (por colección/módulo)
Cada pipeline/colección (`students`, `payment_plans`, `parents`, `users`) es un paquete Go autocontenido bajo `internal/<módulo>` (`internal/students`, `internal/paymentplans`, `internal/parents`, `internal/users`), con tres archivos:
- `entity.go` — struct del documento Mongo (implementa `SetUpdatedAt` y `SetRowHash`).
- `query.go` — `const Query`, el SQL crudo de origen del módulo.
Para agregar un módulo nuevo (ej. uno futuro cualquiera, siguiendo el mismo patrón que `users`):
1. Crear `internal/<módulo>/{entity.go,query.go,reader.go}` siguiendo el mismo patrón.
2. Agregar una entrada en `internal/config.Pipelines` (`PipelineDef`) referenciando `<módulo>.Query`, colección Mongo, colección de archivo, `IDField`, `StateID`.
3. En `cmd/server/main.go`, sumar un `case p.Name == "<módulo>":` que arme `<módulo>.NewQueryReader(...)`.
4. Endpoints `trigger`/`status` del módulo en `internal/api` (`router.go`/`handlers.go`) siguiendo el mismo patrón que los existentes.
5. Opcional: agregar `SYNC_<MÓDULO>_MODE`/`_TIME` (o `_INTERVAL`) en `.env` si el módulo necesita un horario propio distinto de `SYNC_INTERVAL` — ver sección "Sync scheduling" arriba.
Persisted `sync_state` only advances if the entire cycle (upserts + delete reconciliation) succeeds; any failure short-circuits and is surfaced via `/sync/students/status`.
`internal/jsonutil` trae el helper genérico `DecodeList[T]` para decodificar columnas `JSON_AGG` (usado actualmente por `parents`). Capas transversales (`internal/sync`: `Service`, `Scheduler`, `StateStore`; `internal/db`: `Pool`, `CollectionUpserter`, `DeletedMover`, y los readers legacy `FunctionReader`/`GenericFunctionReader`) son genéricas y se reutilizan entre módulos vía interfaces pequeñas — no se duplican por módulo. Un módulo nuevo = nuevo paquete + nueva entrada en `Pipelines` + nuevo case en `main.go` + nuevos endpoints; no nuevas env vars.
## Architecture
## Architecture
Layering, dependency direction is `api` / `sync` → small interfaces, with concrete Postgres/Mongo clients living in `internal/db` and injected from `cmd/server/main.go`:
Layering, dependency direction is `api` / `sync` → small interfaces, with concrete Postgres/Mongo clients living in `internal/db` and injected from `cmd/server/main.go`:
- `internal/config` — `config.go`: env var loading/validation (`config.Load()`, only `PGDSN`/`MongoURI`/`MongoDB`/`SyncInterval`/`Port`), fails fast on missing required vars. `pipelines.go`: `Pipelines []PipelineDef` — static Go-defined list of sync pipelines (PG function or raw `PGQuery`, Mongo collection, deleted collection, id field, `sync_state` id, `RequireEnrollmentID` flag). Adding a pipeline means adding an entry here, not new env vars. Also holds `studentsQuery`, the raw SQL for the students pipeline.
- `internal/config` — `config.go`: env var loading/validation (`config.Load()`, only `PGDSN`/`MongoURI`/`MongoDB`/`SyncInterval`/`Port`), fails fast on missing required vars. `pipelines.go`: `Pipelines []PipelineDef` — static Go-defined list of sync pipelines (PG function or raw `PGQuery`, Mongo collection, deleted collection, id field, `sync_state` id, `RequireEnrollmentID` flag). Adding a pipeline means adding an entry here, not new env vars. Also holds `studentsQuery`, the raw SQL for the students pipeline.
-`internal/db` — `NewPostgresPool`, `NewMongoClient`, `StudentsQueryReader` (students-specific: runs `PGQuery` directly via `pgx` row scan, no JSON envelope, requires `student_id`+`enrollment_id`), `FunctionReader` (legacy: calls a PG function returning a JSON envelope, unwraps into `[]sync.SyncRow`, requires `student_id`+`enrollment_id` — kept for pipelines not yet migrated to raw queries), `GenericFunctionReader` (generic: same envelope unwrap, validates only a configurable id field), `CollectionUpserter` (Mongo upsert by int id, also implements `ListIDs` for delete reconciliation), `DeletedMover` (moves a doc from a source collection into an archive collection, stamping `deleted_at`).
- `internal/db` — `NewPostgresPool`, `NewMongoClient`, `StudentsQueryReader` (students-specific: runs `PGQuery` directly via `pgx` row scan, no JSON envelope, requires `student_id`+`enrollment_id`), `FunctionReader` (legacy: calls a PG function returning a JSON envelope, unwraps into `[]sync.SyncRow`, requires `student_id`+`enrollment_id` — kept for pipelines not yet migrated to raw queries), `GenericFunctionReader` (generic: same envelope unwrap, validates only a configurable id field), `CollectionUpserter` (Mongo upsert by business `idField`, never `_id` — see Mongo `_id` rule above — `BulkUpsert` writes a whole batch of changed rows in one `mongo.BulkWrite`, and `ListIDsAndHashes` returns every existing id plus its stored `row_hash` in one query, used both for delete reconciliation and to skip unchanged rows), `DeletedMover` (moves a doc from a source collection into an archive collection by `idField`, stamping `deleted_at`, dropping the old `_id`).
- `internal/sync` — core domain, framework-free:
- `internal/sync` — core domain, framework-free:
-`service.go`: `Service.Run(ctx)` does one full cycle — fetch all rows, concurrently upsert each with `updated_at` stamped (tracking created vs. updated counts via `MongoUpserter`), then reconcile deletions by diffing Mongo's existing ids (`MongoIDLister`) against the current Postgres id set and archiving the difference (`DeletedMover`). Advances persisted state only on full success; stops and records the error in `lastResult` otherwise. `LastResult()`/`SeedLastResult()` back the `/sync/status` endpoint and boot-time state hydration.
- `service.go`: `Service.Run(ctx)` does one full cycle in two phases — (1) concurrently hash each Postgres row and drop it against `MongoIDLister.ListIDsAndHashes`' stored `row_hash` to find which rows actually changed (`Unchanged` counter for the rest), stamping `updated_at`+`row_hash` only on the changed ones; (2) `MongoUpserter.BulkUpsert` writes all changed rows in a single `BulkWrite` (tracking created vs. updated counts). Then reconciles deletions by diffing the same existing-id set against the current Postgres id set and archiving the difference (`DeletedMover`). Advances persisted state only on full success; stops and records the error in `lastResult` otherwise. `LastResult()`/`SeedLastResult()` back the `/sync/status` endpoint and boot-time state hydration.
- `scheduler.go`: `Scheduler.Start(ctx)` ticks every `interval` and calls `Run` with a hard `cycleTimeout` (4 min) sub-context per cycle, independent of the ticker interval.
- `scheduler.go`: `Scheduler.Start(ctx)` ticks every `interval` and calls `Run` with a hard `cycleTimeout` (4 min) sub-context per cycle, independent of the ticker interval.
- `state.go`: `StateStore` interface + `PGStateStore` (constructed with an explicit `stateID`: students=1), persists last-synced timestamp in the `sync_state` table so `/sync/students/status` and boot seeding survive restarts.
- `state.go`: `StateStore` interface + `PGStateStore` (constructed with an explicit `stateID`: students=1), persists last-synced timestamp in the `sync_state` table so `/sync/students/status` and boot seeding survive restarts.
- Depends only on small interfaces (`PGReader`, `MongoUpserter`, `MongoIDLister`, `DeletedMover`, `StateStore`) — no real DB/Mongo needed to exercise it.
- Depends only on small interfaces (`PGReader`, `MongoUpserter`, `MongoIDLister`, `DeletedMover`, `StateStore`) — no real DB/Mongo needed to exercise it.
@@ -7,24 +7,56 @@ Sincroniza estudiantes (con padres y planes de pago) desde PostgreSQL hacia Mong
...
@@ -7,24 +7,56 @@ Sincroniza estudiantes (con padres y planes de pago) desde PostgreSQL hacia Mong
1. La tabla `sync_state` se crea/inicializa automáticamente al arrancar (fila `id=1` estudiantes) — no se requiere paso de migración manual (`migrations/0001_create_sync_state.sql` se conserva como referencia).
1. La tabla `sync_state` se crea/inicializa automáticamente al arrancar (fila `id=1` estudiantes) — no se requiere paso de migración manual (`migrations/0001_create_sync_state.sql` se conserva como referencia).
2. Asegúrate de que la colección `students` exista en Mongo con el validador `$jsonSchema` provisto (requeridos: `student_id` int, `enrollment_id` int). La colección `deleted_students` archiva los estudiantes eliminados en el origen — no requiere configuración de esquema.
2. Asegúrate de que la colección `students` exista en Mongo con el validador `$jsonSchema` provisto (requeridos: `student_id` int, `enrollment_id` int). La colección `deleted_students` archiva los estudiantes eliminados en el origen — no requiere configuración de esquema.
3. Define las variables de entorno: `PG_DSN`, `MONGO_URI`, `MONGO_DB=intranet` (o tu base de datos), `SYNC_INTERVAL` (por defecto `5m`), `PORT` (por defecto `8080`). Los nombres de función PG / colección Mongo por pipeline ya no son variables de entorno — se definen en `internal/config/pipelines.go`. Agrega un nuevo pipeline añadiendo una entrada ahí, no variables de entorno.
3. Define las variables de entorno: `PG_DSN`, `MONGO_URI`, `MONGO_DB=intranet` (o tu base de datos), `SYNC_INTERVAL` (por defecto `5m`), `PORT` (por defecto `8080`). Los nombres de función PG / colección Mongo por pipeline ya no son variables de entorno — se definen en `internal/config/pipelines.go`. Agrega un nuevo pipeline añadiendo una entrada ahí, no variables de entorno.
4.`go run ./cmd/server`
4. Horario por pipeline (opcional, `SYNC_<NOMBRE>_...` en mayúsculas, ej. `SYNC_STUDENTS_MODE`, `SYNC_PAYMENT_PLANS_MODE`, `SYNC_PARENTS_MODE`):
-`SYNC_<NOMBRE>_MODE`: `interval` (por defecto) o `daily`.
-`SYNC_<NOMBRE>_INTERVAL`: duración tipo `5m`/`1h` (modo `interval`; si falta, usa `SYNC_INTERVAL`).
-`SYNC_<NOMBRE>_TIME`: hora `HH:MM` (modo `daily`, obligatoria en ese modo). Ej: `SYNC_STUDENTS_MODE=daily` + `SYNC_STUDENTS_TIME=03:00` corre estudiantes una vez al día a las 3am.
5.`go run ./cmd/server`
## Endpoints
## Endpoints
-`GET /health` — verifica la conectividad con Postgres y Mongo.
Todos los endpoints llevan el prefijo `/api/v1`.
-`POST /sync/students/trigger` — ejecuta un ciclo de sincronización de estudiantes de inmediato.
-`GET /sync/students/status` — última ejecución de sincronización de estudiantes, filas sincronizadas (desglose creado/actualizado/eliminado), último error si lo hay.
-`GET /api/v1/health` — verifica la conectividad con Postgres y Mongo.
-`POST /api/v1/sync/students/trigger` / `GET /api/v1/sync/students/status` — pipeline de estudiantes: ejecutar ahora / última ejecución, filas sincronizadas (desglose creado/actualizado/sin cambios/eliminado), último error si lo hay.
-`POST /api/v1/sync/payment_plans/trigger` / `GET /api/v1/sync/payment_plans/status` — pipeline de planes de pago (misma forma).
Cada colección (`students`, `payment_plans`, `parents`, `users`) es un paquete Go propio bajo `internal/<módulo>` (`internal/students`, `internal/paymentplans`, `internal/parents`, `internal/users`):
-`entity.go` — struct del documento Mongo (implementa `SetUpdatedAt` y `SetRowHash`).
-`query.go` — `const Query`, el SQL crudo de origen.
-`reader.go` — `QueryReader`/`NewQueryReader`.
Agregar un módulo nuevo = paquete `internal/<módulo>` + entrada en `internal/config.Pipelines` + case en `cmd/server/main.go` + endpoints en `internal/api`, sin nuevas variables de entorno. Las capas transversales (`sync.Service`, `sync.Scheduler`, `sync.StateStore`, `db.CollectionUpserter`, `db.DeletedMover`) son genéricas y se comparten entre módulos.
## Regla de `_id` en Mongo
**`_id` nunca es el id de negocio.** Todas las colecciones dejan que Mongo genere su propio ObjectID; el id de negocio (`student_id`, `payment_plan_id`, `parent_id`, `user_id`) vive como campo normal del documento, el mismo declarado en `PipelineDef.IDField`. Los upserts y la reconciliación de borrados filtran por ese campo, no por `_id`. Al archivar en `deleted_*` se descarta el `_id` original para que se genere uno nuevo. Requiere índice único sobre `idField` en cada colección Mongo (no lo impone el código).
## Contratos del origen Postgres
## Contratos del origen Postgres
Estudiantes: una consulta SQL cruda (no una función) que une `matricula.ma_estudiante`/`persona.pe_persona`/`matricula.ma_matricula`/`caja.ca_plan_de_pago`, agrupada por estudiante. Cada fila se convierte en un documento Mongo, upsert por `_id = student_id`, con `updated_at` estampado por este servicio. Requiere `student_id` y `enrollment_id` numéricos.
Estudiantes: una consulta SQL cruda (no una función) que une `matricula.ma_estudiante`/`persona.pe_persona`/`matricula.ma_matricula`/`caja.ca_plan_de_pago`, agrupada por estudiante. Cada fila se convierte en un documento Mongo, upsert por `student_id`, con `updated_at` estampado por este servicio. Requiere `student_id` y `enrollment_id` numéricos.
Planes de pago: una consulta SQL propia, un documento Mongo por plan de pago, upsert por `payment_plan_id`.
Padres/apoderados: une `persona.pe_persona` con una subconsulta agregada sobre `matricula.ma_estudiante_apoderado` (una fila por padre/apoderado, con los estudiantes asociados embebidos vía `JSON_AGG`). Upsert por `parent_id`.
Usuarios: una fila por login (`user_id`, `user_login`, `user_password`, `user_creation_date`, `parent_id`, `user_status`). Upsert por `user_id`.
## Comportamiento de sincronización
## Comportamiento de sincronización
Cada ciclo corre en concurrencia (pool de workers acotado, 10 en vuelo por defecto) para que una migración completa termine más rápido que un bucle secuencial:
Cada ciclo tiene dos fases:
1.**Hash + diff (CPU, concurrente)**: un pool de workers acotado (10 en vuelo por defecto) calcula un hash SHA-256 (`row_hash`) de cada fila de Postgres y lo compara contra el `row_hash` ya guardado en Mongo para ese id (traído de antemano en una sola consulta). Las filas cuyo hash coincide se saltan por completo — sin escritura — y se cuentan como `Unchanged`. Solo las filas nuevas o cambiadas reciben `updated_at`/`row_hash` y pasan a la fase 2.
2.**Escritura en lote (I/O, un solo round trip)**: todas las filas cambiadas se mandan en un único `BulkWrite` a Mongo (upsert por `idField`).
-**Registro nuevo**: id aún no en Mongo → insertado, contado como `Created`.
-**Registro nuevo**: id aún no en Mongo → insertado, contado como `Created`.
-**Registro existente**: id ya en Mongo → campos sobrescritos, contado como `Updated`.
-**Registro existente, cambiado**: hash distinto al guardado → campos sobrescritos, contado como `Updated`.
-**Registro existente, sin cambios**: hash igual al guardado → no se escribe nada, contado como `Unchanged`.
-**Registro eliminado**: id presente en Mongo pero ya no devuelto por el origen Postgres → el documento se mueve (no solo se elimina) a la colección de archivo (`deleted_students`) con una marca `deleted_at`, contado como `Deleted`.
-**Registro eliminado**: id presente en Mongo pero ya no devuelto por el origen Postgres → el documento se mueve (no solo se elimina) a la colección de archivo (`deleted_students`) con una marca `deleted_at`, contado como `Deleted`.
El estado persistido (`sync_state`) solo avanza si el ciclo completo (upserts + reconciliación de borrados) tiene éxito; cualquier fallo se corta y se reporta vía `/sync/students/status`.
El estado persistido (`sync_state`) solo avanza si el ciclo completo (hash/upsert + reconciliación de borrados) tiene éxito; cualquier fallo se corta y se reporta vía `/api/v1/sync/<módulo>/status`.