[ADD] ENDPOINTS DE USERS, STUDENTS, PAYMENTPLANS,

parent 2d716325
...@@ -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.
- `POST /api/v1/sync/payment_plans/trigger` / `GET /api/v1/sync/payment_plans/status` — payment_plans pipeline (same shape).
- `POST /api/v1/sync/parents/trigger` / `GET /api/v1/sync/parents/status` — parents pipeline (same shape).
- `POST /api/v1/sync/users/trigger` / `GET /api/v1/sync/users/status` — users pipeline (same shape).
## Postgres source contract ## Postgres source contract
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.
- `reader.go` — `QueryReader` (+ `NewQueryReader(pool *db.Pool, query string)`), implementa `sync.PGReader.FetchAll`.
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).
- `POST /api/v1/sync/parents/trigger` / `GET /api/v1/sync/parents/status` — pipeline de padres/apoderados (misma forma).
- `POST /api/v1/sync/users/trigger` / `GET /api/v1/sync/users/status` — pipeline de usuarios (misma forma).
## Estructura del proyecto (por colección/módulo)
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`.
// Command server es el punto de entrada del servicio y la "raíz de composición" // Command server es el punto de entrada y raíz de composición del servicio.
// (composition root): el ÚNICO lugar donde se crean las implementaciones
// concretas (Postgres, Mongo) y se inyectan en el dominio. Aquí se arma todo
// el árbol de dependencias y se arranca.
//
// Flujo: config -> conexiones a BD -> por cada pipeline armar
// reader/upserter/mover/state/Service/Scheduler -> arrancar schedulers en
// goroutines -> arrancar servidor HTTP -> apagado ordenado con Ctrl+C/SIGTERM.
package main package main
import ( import (
...@@ -18,29 +11,28 @@ import ( ...@@ -18,29 +11,28 @@ import (
"syscall" "syscall"
"time" "time"
"github.com/joho/godotenv" // carga variables desde un archivo .env "github.com/joho/godotenv"
"intranet-sycronizacion/internal/api" "intranet-sycronizacion/internal/api"
"intranet-sycronizacion/internal/config" "intranet-sycronizacion/internal/config"
"intranet-sycronizacion/internal/db" "intranet-sycronizacion/internal/db"
"intranet-sycronizacion/internal/parents"
"intranet-sycronizacion/internal/paymentplans"
"intranet-sycronizacion/internal/students"
appsync "intranet-sycronizacion/internal/sync" appsync "intranet-sycronizacion/internal/sync"
"intranet-sycronizacion/internal/users"
) )
func main() { func main() {
// context.Background() es el context "raíz" para las tareas de arranque.
bootCtx := context.Background() bootCtx := context.Background()
// Carga el archivo .env si existe. El "_ =" descarta el error a propósito:
// en producción las variables pueden venir del entorno, sin .env.
_ = godotenv.Load() _ = godotenv.Load()
// 1. Cargar y validar configuración. Si falla, log.Fatalf corta el programa.
cfg, err := config.Load() cfg, err := config.Load()
if err != nil { if err != nil {
log.Fatalf("config error: %v", err) log.Fatalf("config error: %v", err)
} }
// 2. Conectar a Postgres y preparar la tabla sync_state.
pgPool, err := db.NewPostgresPool(bootCtx, cfg.PGDSN) pgPool, err := db.NewPostgresPool(bootCtx, cfg.PGDSN)
if err != nil { if err != nil {
log.Fatalf("postgres connect error: %v", err) log.Fatalf("postgres connect error: %v", err)
...@@ -49,88 +41,78 @@ func main() { ...@@ -49,88 +41,78 @@ func main() {
log.Fatalf("schema bootstrap error: %v", err) log.Fatalf("schema bootstrap error: %v", err)
} }
// 3. Conectar a Mongo.
mongoClient, err := db.NewMongoClient(bootCtx, cfg.MongoURI) mongoClient, err := db.NewMongoClient(bootCtx, cfg.MongoURI)
if err != nil { if err != nil {
log.Fatalf("mongo connect error: %v", err) log.Fatalf("mongo connect error: %v", err)
} }
// svcByName: guarda cada Service por nombre de pipeline, para luego pasar
// el de "students" al router.
svcByName := make(map[string]*appsync.Service, len(config.Pipelines)) svcByName := make(map[string]*appsync.Service, len(config.Pipelines))
// ctx se cancela al recibir Ctrl+C (os.Interrupt) o SIGTERM. Todos los
// schedulers escuchan este ctx para apagarse ordenadamente.
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop() defer stop()
// 4. Por cada pipeline declarado en config.Pipelines, armar toda su cadena. // Por cada pipeline declarado en config.Pipelines, armar su cadena reader/upserter/mover/state/Service/Scheduler.
for _, p := range config.Pipelines { for _, p := range config.Pipelines {
// Colecciones Mongo: la viva y la de archivo.
coll := mongoClient.Database(cfg.MongoDB).Collection(p.MongoCollection) coll := mongoClient.Database(cfg.MongoDB).Collection(p.MongoCollection)
deletedColl := mongoClient.Database(cfg.MongoDB).Collection(p.MongoDeletedCollection) deletedColl := mongoClient.Database(cfg.MongoDB).Collection(p.MongoDeletedCollection)
// Elegir el lector según cómo esté declarado el pipeline:
// - PGQuery seteado -> SQL crudo (estudiantes)
// - RequireEnrollmentID -> función PG que exige enrollment_id
// - por defecto -> función PG genérica (solo un id)
var reader appsync.PGReader var reader appsync.PGReader
switch { switch {
case p.PGQuery != "": case p.Name == "students":
reader = db.NewStudentsQueryReader(pgPool, p.PGQuery) reader = students.NewQueryReader(pgPool, p.PGQuery)
case p.Name == "payment_plans":
reader = paymentplans.NewQueryReader(pgPool, p.PGQuery)
case p.Name == "parents":
reader = parents.NewQueryReader(pgPool, p.PGQuery)
case p.Name == "users":
reader = users.NewQueryReader(pgPool, p.PGQuery)
case p.RequireEnrollmentID: case p.RequireEnrollmentID:
reader = db.NewFunctionReader(pgPool, p.PGFunction) reader = db.NewFunctionReader(pgPool, p.PGFunction)
default: default:
reader = db.NewGenericFunctionReader(pgPool, p.PGFunction, p.IDField) reader = db.NewGenericFunctionReader(pgPool, p.PGFunction, p.IDField)
} }
// upserter cumple a la vez MongoUpserter y MongoIDLister (por eso se // upserter cumple a la vez MongoUpserter y MongoIDLister.
// pasa dos veces a NewService). upserter := db.NewCollectionUpserter(coll, p.IDField)
upserter := db.NewCollectionUpserter(coll) mover := db.NewDeletedMover(coll, deletedColl, p.IDField)
mover := db.NewDeletedMover(coll, deletedColl)
state := appsync.NewPGStateStore(pgPool, p.StateID) state := appsync.NewPGStateStore(pgPool, p.StateID)
// Armar el servicio del dominio inyectándole todas sus dependencias.
// time.Now es la función "dame la hora"; en tests se puede reemplazar.
svc := appsync.NewService(reader, upserter, upserter, mover, state, time.Now) svc := appsync.NewService(reader, upserter, upserter, mover, state, time.Now)
// Sembrar el último resultado desde el estado persistido, para que // Sembrar el último resultado desde el estado persistido antes del primer ciclo.
// /sync/students/status muestre algo útil apenas arranca (antes del primer ciclo).
if lastSynced, err := state.Get(bootCtx); err != nil { if lastSynced, err := state.Get(bootCtx); err != nil {
log.Printf("could not load persisted %s sync state at boot, skipping seed: %v", p.Name, err) log.Printf("could not load persisted %s sync state at boot, skipping seed: %v", p.Name, err)
} else if !lastSynced.IsZero() { } else if !lastSynced.IsZero() {
svc.SeedLastResult(lastSynced) svc.SeedLastResult(lastSynced)
} }
// Arrancar el scheduler de este pipeline en su propia goroutine. schedule, err := config.ScheduleFor(p.Name, cfg.SyncInterval)
scheduler := appsync.NewScheduler(svc, cfg.SyncInterval) if err != nil {
log.Fatalf("schedule config error for pipeline %s: %v", p.Name, err)
}
scheduler := appsync.NewScheduler(svc, schedule)
go scheduler.Start(ctx) go scheduler.Start(ctx)
svcByName[p.Name] = svc svcByName[p.Name] = svc
} }
// 5. Construir el router HTTP. Los pings se pasan como funciones anónimas router := api.NewRouter(svcByName["students"], svcByName["payment_plans"], svcByName["parents"], svcByName["users"],
// que envuelven los clientes concretos (así api no depende de pgx/mongo).
router := api.NewRouter(svcByName["students"],
func(pingCtx context.Context) error { return pgPool.Ping(pingCtx) }, func(pingCtx context.Context) error { return pgPool.Ping(pingCtx) },
func(pingCtx context.Context) error { return mongoClient.Ping(pingCtx, nil) }, func(pingCtx context.Context) error { return mongoClient.Ping(pingCtx, nil) },
) )
// 6. Arrancar el servidor HTTP en una goroutine para no bloquear el main.
srv := &http.Server{Addr: ":" + cfg.Port, Handler: router} srv := &http.Server{Addr: ":" + cfg.Port, Handler: router}
go func() { go func() {
// ListenAndServe bloquea hasta que el server se cierra. ErrServerClosed
// es el cierre normal (no un error real), por eso se ignora.
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatalf("server error: %v", err) log.Fatalf("server error: %v", err)
} }
}() }()
// 7. Esperar la señal de apagado. <-ctx.Done() bloquea hasta Ctrl+C/SIGTERM.
<-ctx.Done() <-ctx.Done()
log.Println("shutdown signal received, shutting down") log.Println("shutdown signal received, shutting down")
// 8. Apagado ordenado: dar hasta 10s para terminar peticiones en curso. // Apagado ordenado: dar hasta 10s para terminar peticiones en curso.
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel() defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil { if err := srv.Shutdown(shutdownCtx); err != nil {
......
...@@ -9,26 +9,22 @@ import ( ...@@ -9,26 +9,22 @@ import (
appsync "intranet-sycronizacion/internal/sync" appsync "intranet-sycronizacion/internal/sync"
) )
// Handlers guarda las dependencias que necesitan los manejadores HTTP. // Handlers guarda las dependencias de los manejadores HTTP.
// Cada método (Health, Trigger, Status) es un "handler" de una ruta.
type Handlers struct { type Handlers struct {
studentsSvc *appsync.Service // el servicio de sincronización studentsSvc *appsync.Service
pgPing func(context.Context) error // chequeo de salud de Postgres paymentPlansSvc *appsync.Service
mongoPing func(context.Context) error // chequeo de salud de Mongo parentsSvc *appsync.Service
usersSvc *appsync.Service
pgPing func(context.Context) error
mongoPing func(context.Context) error
} }
// Health responde GET /health. Devuelve 200 si ambas bases responden, o 503 // Health responde GET /health con el estado de Postgres y Mongo.
// (Service Unavailable) si alguna falla, con el detalle del error.
//
// En Gin, *gin.Context (c) representa la petición y la respuesta. Se usa para
// leer datos de entrada y para escribir la respuesta (c.JSON).
func (h *Handlers) Health(c *gin.Context) { func (h *Handlers) Health(c *gin.Context) {
// c.Request.Context() propaga cancelación/timeout de la petición HTTP.
pgErr := h.pgPing(c.Request.Context()) pgErr := h.pgPing(c.Request.Context())
mongoErr := h.mongoPing(c.Request.Context()) mongoErr := h.mongoPing(c.Request.Context())
status := http.StatusOK status := http.StatusOK
// gin.H es simplemente un mapa para armar el cuerpo JSON de respuesta.
body := gin.H{"postgres": "ok", "mongo": "ok"} body := gin.H{"postgres": "ok", "mongo": "ok"}
if pgErr != nil { if pgErr != nil {
status = http.StatusServiceUnavailable status = http.StatusServiceUnavailable
...@@ -38,17 +34,17 @@ func (h *Handlers) Health(c *gin.Context) { ...@@ -38,17 +34,17 @@ func (h *Handlers) Health(c *gin.Context) {
status = http.StatusServiceUnavailable status = http.StatusServiceUnavailable
body["mongo"] = mongoErr.Error() body["mongo"] = mongoErr.Error()
} }
c.JSON(status, body) // escribe status + cuerpo JSON c.JSON(status, body)
} }
// syncResultJSON arma el cuerpo JSON común para /trigger y /status a partir de // syncResultJSON arma el cuerpo JSON común de /trigger y /status.
// un SyncResult del dominio. Evita repetir el mismo mapeo en dos lados.
func syncResultJSON(err error, result appsync.SyncResult) gin.H { func syncResultJSON(err error, result appsync.SyncResult) gin.H {
resp := gin.H{ resp := gin.H{
"ran_at": result.RanAt, "ran_at": result.RanAt,
"rows_synced": result.RowsSynced, "rows_synced": result.RowsSynced,
"created": result.Created, "created": result.Created,
"updated": result.Updated, "updated": result.Updated,
"unchanged": result.Unchanged,
"deleted": result.Deleted, "deleted": result.Deleted,
} }
if err != nil { if err != nil {
...@@ -57,20 +53,22 @@ func syncResultJSON(err error, result appsync.SyncResult) gin.H { ...@@ -57,20 +53,22 @@ func syncResultJSON(err error, result appsync.SyncResult) gin.H {
return resp return resp
} }
// Trigger responde POST /sync/students/trigger: corre una sincronización AHORA y // triggerHandler arma el handler de POST /sync/<pipeline>/trigger: corre una sincronización ahora.
// devuelve cómo salió. func triggerHandler(svc *appsync.Service) gin.HandlerFunc {
func (h *Handlers) Trigger(c *gin.Context) { return func(c *gin.Context) {
err := h.studentsSvc.Run(c.Request.Context()) err := svc.Run(c.Request.Context())
c.JSON(http.StatusOK, syncResultJSON(err, h.studentsSvc.LastResult())) c.JSON(http.StatusOK, syncResultJSON(err, svc.LastResult()))
}
} }
// Status responde GET /sync/students/status: devuelve el resultado del último ciclo // statusHandler arma el handler de GET /sync/<pipeline>/status: devuelve el resultado del último ciclo.
// (sin correr uno nuevo), incluyendo el último error si lo hubo. func statusHandler(svc *appsync.Service) gin.HandlerFunc {
func (h *Handlers) Status(c *gin.Context) { return func(c *gin.Context) {
result := h.studentsSvc.LastResult() result := svc.LastResult()
resp := syncResultJSON(nil, result) resp := syncResultJSON(nil, result)
if result.Err != nil { if result.Err != nil {
resp["last_error"] = result.Err.Error() resp["last_error"] = result.Err.Error()
} }
c.JSON(http.StatusOK, resp) c.JSON(http.StatusOK, resp)
}
} }
// Package api es la capa web (HTTP), construida con el framework Gin. // Package api es la capa web (HTTP), construida con Gin.
// Su única responsabilidad es traducir peticiones HTTP <-> llamadas al dominio
// (internal/sync). No contiene lógica de negocio.
package api package api
import ( import (
...@@ -12,24 +10,24 @@ import ( ...@@ -12,24 +10,24 @@ import (
) )
// NewRouter construye el router de Gin y registra las rutas. // NewRouter construye el router de Gin y registra las rutas.
// func NewRouter(studentsSvc, paymentPlansSvc, parentsSvc, usersSvc *appsync.Service, pgPing, mongoPing func(context.Context) error) *gin.Engine {
// Recibe sus dependencias inyectadas: h := &Handlers{studentsSvc: studentsSvc, paymentPlansSvc: paymentPlansSvc, parentsSvc: parentsSvc, usersSvc: usersSvc, pgPing: pgPing, mongoPing: mongoPing}
// - studentsSvc: el Service del dominio que hace la sincronización.
// - pgPing / mongoPing: funciones para chequear salud de cada base.
// Se pasan como funciones (no como clientes) para que api no dependa
// directamente de pgx ni de mongo.
//
// Devuelve *gin.Engine, que es el manejador HTTP que main.go pone a escuchar.
func NewRouter(studentsSvc *appsync.Service, pgPing, mongoPing func(context.Context) error) *gin.Engine {
// Handlers agrupa las dependencias que usan los manejadores de rutas.
h := &Handlers{studentsSvc: studentsSvc, pgPing: pgPing, mongoPing: mongoPing}
// gin.Default() crea un router con logger y recuperación de panics incluidos.
r := gin.Default() r := gin.Default()
// Registro de rutas: método HTTP + path -> función manejadora. v1 := r.Group("/api/v1")
r.GET("/health", h.Health) // ¿están vivas Postgres y Mongo? v1.GET("/health", h.Health)
r.POST("/sync/students/trigger", h.Trigger) // forzar una sincronización ahora
r.GET("/sync/students/status", h.Status) // ver el resultado del último ciclo v1.POST("/sync/students/trigger", triggerHandler(studentsSvc))
v1.GET("/sync/students/status", statusHandler(studentsSvc))
v1.POST("/sync/payment_plans/trigger", triggerHandler(paymentPlansSvc))
v1.GET("/sync/payment_plans/status", statusHandler(paymentPlansSvc))
v1.POST("/sync/parents/trigger", triggerHandler(parentsSvc))
v1.GET("/sync/parents/status", statusHandler(parentsSvc))
v1.POST("/sync/users/trigger", triggerHandler(usersSvc))
v1.GET("/sync/users/status", statusHandler(usersSvc))
return r return r
} }
// Package config carga y valida la configuración del servicio. // Package config carga y valida la configuración del servicio.
//
// En Go, un "package" agrupa archivos relacionados (aquí config.go y
// pipelines.go). Todo lo que esté en mayúscula (ej. Config, Load) es
// "exportado": otros paquetes pueden usarlo. Lo que está en minúscula
// (ej. studentsQuery) es privado a este paquete.
package config package config
import ( import (
"fmt" // formateo de strings y errores "fmt"
"os" // acceso a variables de entorno (os.Getenv) "os"
"time" // duraciones (time.Duration, time.ParseDuration) "strconv"
"strings"
"time"
appsync "intranet-sycronizacion/internal/sync"
) )
// Config guarda todos los valores de configuración ya leídos y validados. // Config guarda los valores de configuración ya leídos y validados.
//
// Un "struct" es como un objeto/registro: un grupo de campos con nombre.
// Estos campos vienen de variables de entorno (definidas en el archivo .env
// o en el sistema operativo).
type Config struct { type Config struct {
PGDSN string // cadena de conexión a PostgreSQL (env PG_DSN) PGDSN string
MongoURI string // cadena de conexión a MongoDB (env MONGO_URI) MongoURI string
MongoDB string // nombre de la base de datos Mongo (env MONGO_DB) MongoDB string
SyncInterval time.Duration // cada cuánto corre la sincronización (env SYNC_INTERVAL) SyncInterval time.Duration
Port string // puerto del servidor HTTP (env PORT) Port string
} }
// Load lee las variables de entorno, valida las obligatorias y devuelve la // Load lee las variables de entorno, valida las obligatorias y devuelve la configuración.
// configuración lista para usar.
//
// En Go una función puede devolver varios valores. Aquí devuelve dos:
// - Config: la configuración construida.
// - error: nil (nulo) si todo salió bien, o un error si algo falló.
//
// El código que llama debe revisar SIEMPRE ese error antes de usar la Config.
func Load() (Config, error) { func Load() (Config, error) {
// Construimos la Config leyendo cada variable de entorno.
// os.Getenv devuelve "" (cadena vacía) si la variable no existe.
cfg := Config{ cfg := Config{
PGDSN: os.Getenv("PG_DSN"), PGDSN: os.Getenv("PG_DSN"),
MongoURI: os.Getenv("MONGO_URI"), MongoURI: os.Getenv("MONGO_URI"),
...@@ -43,9 +29,6 @@ func Load() (Config, error) { ...@@ -43,9 +29,6 @@ func Load() (Config, error) {
Port: os.Getenv("PORT"), Port: os.Getenv("PORT"),
} }
// Estas tres variables son obligatorias. Si falta alguna, no tiene
// sentido arrancar, así que devolvemos un error y el programa se detiene
// ("fail fast": fallar rápido y claro en vez de romper más adelante).
required := map[string]string{ required := map[string]string{
"PG_DSN": cfg.PGDSN, "PG_DSN": cfg.PGDSN,
"MONGO_URI": cfg.MongoURI, "MONGO_URI": cfg.MongoURI,
...@@ -53,30 +36,77 @@ func Load() (Config, error) { ...@@ -53,30 +36,77 @@ func Load() (Config, error) {
} }
for name, val := range required { for name, val := range required {
if val == "" { if val == "" {
// fmt.Errorf crea un error con un mensaje formateado.
return Config{}, fmt.Errorf("missing required env var %s", name) return Config{}, fmt.Errorf("missing required env var %s", name)
} }
} }
// PORT es opcional: si no vino, usamos 8080 por defecto.
if cfg.Port == "" { if cfg.Port == "" {
cfg.Port = "8080" cfg.Port = "8080"
} }
// SYNC_INTERVAL es opcional: por defecto "5m" (5 minutos).
interval := os.Getenv("SYNC_INTERVAL") interval := os.Getenv("SYNC_INTERVAL")
if interval == "" { if interval == "" {
interval = "5m" interval = "5m"
} }
// time.ParseDuration convierte texto como "5m", "30s" o "1h" en una
// duración usable. Si el texto es inválido, devolvemos error.
d, err := time.ParseDuration(interval) d, err := time.ParseDuration(interval)
if err != nil { if err != nil {
// %w "envuelve" el error original para no perder su detalle.
return Config{}, fmt.Errorf("invalid SYNC_INTERVAL %q: %w", interval, err) return Config{}, fmt.Errorf("invalid SYNC_INTERVAL %q: %w", interval, err)
} }
cfg.SyncInterval = d cfg.SyncInterval = d
// Todo válido: devolvemos la config y nil como error.
return cfg, nil return cfg, nil
} }
// ScheduleFor arma el Schedule de un pipeline a partir de sus variables de entorno opcionales (SYNC_<NOMBRE>_MODE/_INTERVAL/_TIME).
func ScheduleFor(pipelineName string, defaultInterval time.Duration) (appsync.Schedule, error) {
prefix := "SYNC_" + strings.ToUpper(pipelineName)
mode := os.Getenv(prefix + "_MODE")
if mode == "" {
mode = "interval"
}
switch mode {
case "interval":
interval := defaultInterval
if raw := os.Getenv(prefix + "_INTERVAL"); raw != "" {
d, err := time.ParseDuration(raw)
if err != nil {
return nil, fmt.Errorf("invalid %s_INTERVAL %q: %w", prefix, raw, err)
}
interval = d
}
return appsync.IntervalSchedule{Interval: interval}, nil
case "daily":
raw := os.Getenv(prefix + "_TIME")
if raw == "" {
return nil, fmt.Errorf("%s_MODE=daily requires %s_TIME (formato HH:MM)", prefix, prefix)
}
hour, minute, err := parseHHMM(raw)
if err != nil {
return nil, fmt.Errorf("invalid %s_TIME %q: %w", prefix, raw, err)
}
return appsync.DailySchedule{Hour: hour, Minute: minute}, nil
default:
return nil, fmt.Errorf("invalid %s_MODE %q: debe ser \"interval\" o \"daily\"", prefix, mode)
}
}
// parseHHMM parsea una hora de reloj tipo "HH:MM" (24 horas).
func parseHHMM(raw string) (hour, minute int, err error) {
parts := strings.SplitN(raw, ":", 2)
if len(parts) != 2 {
return 0, 0, fmt.Errorf("formato esperado HH:MM")
}
hour, err = strconv.Atoi(parts[0])
if err != nil || hour < 0 || hour > 23 {
return 0, 0, fmt.Errorf("hora inválida")
}
minute, err = strconv.Atoi(parts[1])
if err != nil || minute < 0 || minute > 59 {
return 0, 0, fmt.Errorf("minuto inválido")
}
return hour, minute, nil
}
package config package config
// PipelineDef describe UNA tubería de sincronización (un "pipeline"). import (
// "intranet-sycronizacion/internal/parents"
// La idea de diseño: en vez de meter cada sincronización nueva a mano en el "intranet-sycronizacion/internal/paymentplans"
// código, se declaran como datos en la lista Pipelines (más abajo). Agregar "intranet-sycronizacion/internal/students"
// una sincronización nueva = agregar una entrada a esa lista. El arranque "intranet-sycronizacion/internal/users"
// (cmd/server/main.go) recorre la lista y arma todo automáticamente. )
//
// Cada pipeline lee datos de PostgreSQL y los vuelca en una colección de Mongo. // PipelineDef describe una tubería de sincronización: origen Postgres -> destino Mongo.
type PipelineDef struct { type PipelineDef struct {
Name string // nombre lógico, ej. "students" Name string
PGFunction string // función PostgreSQL a llamar (si no se usa PGQuery) PGFunction string
PGQuery string // SQL crudo; si está seteado, se usa EN VEZ de PGFunction PGQuery string
MongoCollection string // colección Mongo destino, ej. "students" MongoCollection string
MongoDeletedCollection string // colección archivo para registros borrados en el origen MongoDeletedCollection string
IDField string // nombre del campo id, ej. "student_id" IDField string
StateID int // id de la fila en la tabla sync_state (students=1) StateID int
RequireEnrollmentID bool // caso especial: exigir enrollment_id (solo estudiantes) RequireEnrollmentID bool
} }
// studentsQuery es el SQL crudo de la tubería de estudiantes.
//
// En Go, el texto entre comillas invertidas (`...`) es un string "en crudo":
// puede ocupar varias líneas y no interpreta caracteres de escape. Ideal
// para pegar SQL tal cual.
//
// Qué hace el SQL: junta estudiante + persona + matrícula + plan de pago,
// agrupa por estudiante, y arma dos listas JSON de planes de pago separando
// por periodo_academico_id = 14 (año actual) vs. el resto.
const studentsQuery = `
SELECT e.estudiante_id as student_id,
pp.persona_apellido_paterno as student_paternal_last_name,
pp.persona_apellido_materno as student_maternal_last_name,
pp.persona_nombre as student_name,
pp.persona_numero_documento_identidad AS student_dni,
MAX(m.matricula_id) as enrollment_id,
JSON_AGG(
json_build_object(
'payment_plan_id', cpp.plan_de_pago_id,
'payment_plan_amount', cpp.plan_de_pago_subtotal,
'payment_plan_debt', cpp.plan_de_pago_deuda,
'payment_plan_payment_date', cpp.plan_de_pago_fecha_pago,
'payment_plan_due_date', cpp.plan_de_pago_fecha_ven,
'payment_plan_year', cpp.plan_de_pago_anio
)
) FILTER (WHERE m.periodo_academico_id <> 14) as payment_plans,
JSON_AGG(
json_build_object(
'payment_plan_id', cpp.plan_de_pago_id,
'payment_plan_amount', cpp.plan_de_pago_subtotal,
'payment_plan_debt', cpp.plan_de_pago_deuda,
'payment_plan_payment_date', cpp.plan_de_pago_fecha_pago,
'payment_plan_due_date', cpp.plan_de_pago_fecha_ven,
'payment_plan_year', cpp.plan_de_pago_anio
)
) FILTER (WHERE m.periodo_academico_id = 14) as payment_plans_current_year
FROM matricula.ma_estudiante e
INNER JOIN persona.pe_persona pp on e.persona_id = pp.persona_id
INNER JOIN matricula.ma_matricula m on e.estudiante_id = m.estudiante_id
INNER JOIN caja.ca_plan_de_pago cpp on m.matricula_id = cpp.matricula_id
GROUP BY 1, 2, 3, 4, 5
ORDER BY 1 DESC;
`
// Pipelines es la lista de todas las tuberías activas. // Pipelines es la lista de todas las tuberías activas.
//
// []PipelineDef significa "slice (lista) de PipelineDef". Hoy solo hay una
// entrada (estudiantes). Para sumar otra sincronización, se agrega otra
// entrada aquí; no hace falta tocar el resto del código.
var Pipelines = []PipelineDef{ var Pipelines = []PipelineDef{
{ {
Name: "students", Name: "students",
PGQuery: studentsQuery, // usa SQL crudo (no una función PG) PGQuery: students.Query,
MongoCollection: "students", MongoCollection: "students",
MongoDeletedCollection: "deleted_students", MongoDeletedCollection: "deleted_students",
IDField: "student_id", IDField: "student_id",
StateID: 1, StateID: 1,
RequireEnrollmentID: true, // los estudiantes exigen enrollment_id },
{
Name: "payment_plans",
PGQuery: paymentplans.Query,
MongoCollection: "payment_plans",
MongoDeletedCollection: "deleted_payment_plans",
IDField: "payment_plan_id",
StateID: 2,
},
{
Name: "parents",
PGQuery: parents.Query,
MongoCollection: "parents",
MongoDeletedCollection: "deleted_parents",
IDField: "parent_id",
StateID: 3,
},
{
Name: "users",
PGQuery: users.Query,
MongoCollection: "users",
MongoDeletedCollection: "deleted_users",
IDField: "user_id",
StateID: 4,
}, },
} }
...@@ -6,9 +6,11 @@ import ( ...@@ -6,9 +6,11 @@ import (
"fmt" "fmt"
"time" "time"
"go.mongodb.org/mongo-driver/bson" // bson: el formato binario de Mongo (como JSON) "go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo" // cliente oficial de MongoDB "go.mongodb.org/mongo-driver/mongo"
"go.mongodb.org/mongo-driver/mongo/options" // opciones para operaciones (upsert, projection, etc.) "go.mongodb.org/mongo-driver/mongo/options"
appsync "intranet-sycronizacion/internal/sync"
) )
// NewMongoClient conecta a MongoDB y verifica con un Ping. // NewMongoClient conecta a MongoDB y verifica con un Ping.
...@@ -23,87 +25,98 @@ func NewMongoClient(ctx context.Context, uri string) (*mongo.Client, error) { ...@@ -23,87 +25,98 @@ func NewMongoClient(ctx context.Context, uri string) (*mongo.Client, error) {
return client, nil return client, nil
} }
// CollectionUpserter escribe en una colección de Mongo. Cumple DOS interfaces // CollectionUpserter escribe en una colección de Mongo, indexando por idField (no por _id).
// del dominio a la vez: MongoUpserter (Upsert) y MongoIDLister (ListIDs).
type CollectionUpserter struct { type CollectionUpserter struct {
coll *mongo.Collection coll *mongo.Collection
idField string
} }
func NewCollectionUpserter(coll *mongo.Collection) *CollectionUpserter { func NewCollectionUpserter(coll *mongo.Collection, idField string) *CollectionUpserter {
return &CollectionUpserter{coll: coll} return &CollectionUpserter{coll: coll, idField: idField}
} }
// Upsert inserta o actualiza el documento por su _id. // BulkUpsert manda todas las filas en un único BulkWrite (un round trip) en vez de un UpdateOne
// isNew indica si fue una inserción real (no existía) vs. una actualización. // por fila, evitando la latencia de red repetida de escribir de a una.
func (c *CollectionUpserter) Upsert(ctx context.Context, id int, doc map[string]interface{}) (bool, error) { func (c *CollectionUpserter) BulkUpsert(ctx context.Context, rows []appsync.SyncRow) (created, updated int, err error) {
filter := bson.M{"_id": id} // buscar por _id if len(rows) == 0 {
update := bson.M{"$set": doc} // $set: pisar los campos con los del doc return 0, 0, nil
opts := options.Update().SetUpsert(true) // upsert: si no existe, insertarlo }
res, err := c.coll.UpdateOne(ctx, filter, update, opts)
models := make([]mongo.WriteModel, len(rows))
for i, row := range rows {
filter := bson.M{c.idField: row.ID}
update := bson.M{"$set": row.Doc}
models[i] = mongo.NewUpdateOneModel().SetFilter(filter).SetUpdate(update).SetUpsert(true)
}
res, err := c.coll.BulkWrite(ctx, models, options.BulkWrite().SetOrdered(false))
if err != nil { if err != nil {
return false, err return 0, 0, err
} }
// UpsertedCount > 0 significa que se creó un documento nuevo. created = int(res.UpsertedCount)
return res.UpsertedCount > 0, nil updated = len(rows) - created
return created, updated, nil
} }
// ListIDs devuelve todos los _id que hay en la colección. Se usa para detectar // ListIDsAndHashes devuelve, por cada documento existente, su id de negocio y su row_hash
// qué registros se borraron del origen desde el último ciclo. // guardado (vacío si el documento no tiene hash). Se usa para detectar borrados del origen
func (c *CollectionUpserter) ListIDs(ctx context.Context) ([]int, error) { // (las claves del mapa) y para saltar upserts de filas sin cambios (comparando el hash).
// Projection {_id: 1}: traer SOLO el campo _id, no el documento entero (más liviano). func (c *CollectionUpserter) ListIDsAndHashes(ctx context.Context) (map[int]string, error) {
cur, err := c.coll.Find(ctx, bson.M{}, options.Find().SetProjection(bson.M{"_id": 1})) cur, err := c.coll.Find(ctx, bson.M{}, options.Find().SetProjection(bson.M{c.idField: 1, "row_hash": 1}))
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer cur.Close(ctx) // cerrar el cursor al terminar defer cur.Close(ctx)
var ids []int hashes := make(map[int]string)
for cur.Next(ctx) { for cur.Next(ctx) {
// Decodificamos cada documento en un struct temporal con solo el _id. var doc bson.M
var doc struct {
ID int `bson:"_id"`
}
if err := cur.Decode(&doc); err != nil { if err := cur.Decode(&doc); err != nil {
return nil, err return nil, err
} }
ids = append(ids, doc.ID) var id int
switch v := doc[c.idField].(type) {
case int32:
id = int(v)
case int64:
id = int(v)
default:
continue
}
hash, _ := doc["row_hash"].(string)
hashes[id] = hash
} }
return ids, cur.Err() return hashes, cur.Err()
} }
// DeletedMover archiva un registro que desapareció del origen: lo mueve de la // DeletedMover archiva un registro desaparecido del origen a su colección de borrados, estampando deleted_at.
// colección viva a la de borrados (ej. deleted_students), estampando deleted_at.
type DeletedMover struct { type DeletedMover struct {
source *mongo.Collection // colección viva source *mongo.Collection
deleted *mongo.Collection // colección archivo deleted *mongo.Collection
idField string
} }
func NewDeletedMover(source, deleted *mongo.Collection) *DeletedMover { func NewDeletedMover(source, deleted *mongo.Collection, idField string) *DeletedMover {
return &DeletedMover{source: source, deleted: deleted} return &DeletedMover{source: source, deleted: deleted, idField: idField}
} }
// MoveToDeleted copia el documento a la colección de borrados y lo elimina de // MoveToDeleted copia el documento a la colección de borrados y lo elimina de la viva (idempotente).
// la viva. Es idempotente: si el documento ya no está, no hace nada.
func (m *DeletedMover) MoveToDeleted(ctx context.Context, id int) error { func (m *DeletedMover) MoveToDeleted(ctx context.Context, id int) error {
// 1. Leer el documento original de la colección viva.
var doc bson.M var doc bson.M
err := m.source.FindOne(ctx, bson.M{"_id": id}).Decode(&doc) filter := bson.M{m.idField: id}
err := m.source.FindOne(ctx, filter).Decode(&doc)
if errors.Is(err, mongo.ErrNoDocuments) { if errors.Is(err, mongo.ErrNoDocuments) {
// Ya no existe (quizá lo movió otro ciclo): nada que hacer.
return nil return nil
} }
if err != nil { if err != nil {
return fmt.Errorf("find source doc: %w", err) return fmt.Errorf("find source doc: %w", err)
} }
// 2. Marcar cuándo se archivó. delete(doc, "_id")
doc["deleted_at"] = time.Now().UTC() doc["deleted_at"] = time.Now().UTC()
filter := bson.M{"_id": id}
// 3. Escribir (upsert) el documento en la colección de borrados.
if _, err := m.deleted.UpdateOne(ctx, filter, bson.M{"$set": doc}, options.Update().SetUpsert(true)); err != nil { if _, err := m.deleted.UpdateOne(ctx, filter, bson.M{"$set": doc}, options.Update().SetUpsert(true)); err != nil {
return fmt.Errorf("archive doc: %w", err) return fmt.Errorf("archive doc: %w", err)
} }
// 4. Borrarlo de la colección viva.
if _, err := m.source.DeleteOne(ctx, filter); err != nil { if _, err := m.source.DeleteOne(ctx, filter); err != nil {
return fmt.Errorf("delete source doc: %w", err) return fmt.Errorf("delete source doc: %w", err)
} }
......
// Package db contiene las implementaciones CONCRETAS de acceso a datos // Package db contiene las implementaciones concretas de acceso a datos (PostgreSQL y MongoDB).
// (PostgreSQL y MongoDB). Aquí sí se usan las librerías reales (pgx, mongo).
// Estos tipos cumplen las interfaces definidas en internal/sync, y se
// "inyectan" al dominio desde cmd/server/main.go.
package db package db
import ( import (
...@@ -9,14 +6,12 @@ import ( ...@@ -9,14 +6,12 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"github.com/jackc/pgx/v5/pgxpool" // pool de conexiones a Postgres "github.com/jackc/pgx/v5/pgxpool"
appsync "intranet-sycronizacion/internal/sync" // nuestro paquete de dominio (renombrado a appsync) appsync "intranet-sycronizacion/internal/sync"
) )
// Pool envuelve el pool de conexiones de pgx para poder colgarle métodos // Pool envuelve el pool de pgx para poder colgarle métodos propios.
// propios. El "*pgxpool.Pool" embebido (sin nombre de campo) hace que Pool
// herede todos los métodos del pool original.
type Pool struct { type Pool struct {
*pgxpool.Pool *pgxpool.Pool
} }
...@@ -33,8 +28,7 @@ func NewPostgresPool(ctx context.Context, dsn string) (*Pool, error) { ...@@ -33,8 +28,7 @@ func NewPostgresPool(ctx context.Context, dsn string) (*Pool, error) {
return &Pool{pool}, nil return &Pool{pool}, nil
} }
// QueryRow adapta el QueryRow de pgx a la interfaz sync.Row que espera el // QueryRow adapta el QueryRow de pgx a la interfaz sync.Row del dominio.
// dominio. Devuelve algo con método Scan.
func (p *Pool) QueryRow(ctx context.Context, sql string, args ...interface{}) appsync.Row { func (p *Pool) QueryRow(ctx context.Context, sql string, args ...interface{}) appsync.Row {
return p.Pool.QueryRow(ctx, sql, args...) return p.Pool.QueryRow(ctx, sql, args...)
} }
...@@ -45,8 +39,7 @@ func (p *Pool) Exec(ctx context.Context, sql string, args ...interface{}) error ...@@ -45,8 +39,7 @@ func (p *Pool) Exec(ctx context.Context, sql string, args ...interface{}) error
return err return err
} }
// bootstrapSyncStateSQL crea la tabla sync_state si no existe y siembra la // bootstrapSyncStateSQL crea la tabla sync_state si no existe y siembra las filas iniciales.
// fila id=1 (estudiantes). Se corre al arrancar, así no hace falta migración manual.
const bootstrapSyncStateSQL = ` const bootstrapSyncStateSQL = `
CREATE TABLE IF NOT EXISTS sync_state ( CREATE TABLE IF NOT EXISTS sync_state (
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY,
...@@ -54,7 +47,7 @@ CREATE TABLE IF NOT EXISTS sync_state ( ...@@ -54,7 +47,7 @@ CREATE TABLE IF NOT EXISTS sync_state (
); );
INSERT INTO sync_state (id, last_synced_at) INSERT INTO sync_state (id, last_synced_at)
VALUES (1, '1970-01-01T00:00:00Z') VALUES (1, '1970-01-01T00:00:00Z'), (2, '1970-01-01T00:00:00Z'), (3, '1970-01-01T00:00:00Z')
ON CONFLICT (id) DO NOTHING; ON CONFLICT (id) DO NOTHING;
` `
...@@ -67,15 +60,7 @@ func (p *Pool) BootstrapSchema(ctx context.Context) error { ...@@ -67,15 +60,7 @@ func (p *Pool) BootstrapSchema(ctx context.Context) error {
return nil return nil
} }
// --------------------------------------------------------------------------- // FunctionReader (legado) llama una función Postgres que devuelve un sobre JSON, exige student_id + enrollment_id.
// Lectores (readers). Hay tres formas de leer el origen; todas cumplen la
// interfaz sync.PGReader (método FetchAll). main.go elige cuál usar según
// cómo esté declarado el pipeline.
// ---------------------------------------------------------------------------
// FunctionReader (heredado): llama una función de Postgres que devuelve un
// "sobre" JSON, y exige student_id + enrollment_id. Se conserva para pipelines
// aún no migrados a consultas crudas.
type FunctionReader struct { type FunctionReader struct {
pool *Pool pool *Pool
functionName string functionName string
...@@ -85,17 +70,14 @@ func NewFunctionReader(pool *Pool, functionName string) *FunctionReader { ...@@ -85,17 +70,14 @@ func NewFunctionReader(pool *Pool, functionName string) *FunctionReader {
return &FunctionReader{pool: pool, functionName: functionName} return &FunctionReader{pool: pool, functionName: functionName}
} }
// functionEnvelope es la forma del JSON que devuelven las funciones de PG: // functionEnvelope es la forma del JSON que devuelven las funciones PG: status/message/data.
// { "status": bool, "message": text, "data": [ {...}, {...} ] }.
// Las etiquetas `json:"..."` le dicen a Go cómo mapear cada campo del JSON.
type functionEnvelope struct { type functionEnvelope struct {
Status bool `json:"status"` Status bool `json:"status"`
Message string `json:"message"` Message string `json:"message"`
Data []map[string]interface{} `json:"data"` Data []map[string]interface{} `json:"data"`
} }
// parseFunctionEnvelope convierte el JSON crudo en filas listas para sincronizar, // parseFunctionEnvelope convierte el JSON crudo en filas, exigiendo student_id y enrollment_id numéricos.
// exigiendo que cada item tenga student_id y enrollment_id numéricos.
func parseFunctionEnvelope(raw []byte) ([]appsync.SyncRow, error) { func parseFunctionEnvelope(raw []byte) ([]appsync.SyncRow, error) {
var env functionEnvelope var env functionEnvelope
if err := json.Unmarshal(raw, &env); err != nil { if err := json.Unmarshal(raw, &env); err != nil {
...@@ -107,8 +89,6 @@ func parseFunctionEnvelope(raw []byte) ([]appsync.SyncRow, error) { ...@@ -107,8 +89,6 @@ func parseFunctionEnvelope(raw []byte) ([]appsync.SyncRow, error) {
rows := make([]appsync.SyncRow, 0, len(env.Data)) rows := make([]appsync.SyncRow, 0, len(env.Data))
for _, item := range env.Data { for _, item := range env.Data {
// El JSON no distingue enteros de decimales: todo número llega como float64.
// Por eso convertimos a int a mano y verificamos el tipo (comma-ok idiom).
idFloat, ok := item["student_id"].(float64) idFloat, ok := item["student_id"].(float64)
if !ok { if !ok {
return nil, fmt.Errorf("data item missing numeric student_id: %v", item) return nil, fmt.Errorf("data item missing numeric student_id: %v", item)
...@@ -134,9 +114,7 @@ func (f *FunctionReader) FetchAll(ctx context.Context) ([]appsync.SyncRow, error ...@@ -134,9 +114,7 @@ func (f *FunctionReader) FetchAll(ctx context.Context) ([]appsync.SyncRow, error
return parseFunctionEnvelope(raw) return parseFunctionEnvelope(raw)
} }
// parseGenericEnvelope es como parseFunctionEnvelope pero exige un solo campo // parseGenericEnvelope es como parseFunctionEnvelope pero exige solo un id configurable (idField).
// id configurable (idField), sin obligar enrollment_id. Sirve para pipelines
// genéricos que solo necesitan validar un id numérico.
func parseGenericEnvelope(raw []byte, idField string) ([]appsync.SyncRow, error) { func parseGenericEnvelope(raw []byte, idField string) ([]appsync.SyncRow, error) {
var env functionEnvelope var env functionEnvelope
if err := json.Unmarshal(raw, &env); err != nil { if err := json.Unmarshal(raw, &env); err != nil {
...@@ -158,8 +136,7 @@ func parseGenericEnvelope(raw []byte, idField string) ([]appsync.SyncRow, error) ...@@ -158,8 +136,7 @@ func parseGenericEnvelope(raw []byte, idField string) ([]appsync.SyncRow, error)
return rows, nil return rows, nil
} }
// GenericFunctionReader llama una función de Postgres cuyo sobre solo requiere // GenericFunctionReader llama una función Postgres cuyo sobre solo requiere un id numérico.
// un id numérico (a diferencia de FunctionReader, que también exige enrollment_id).
type GenericFunctionReader struct { type GenericFunctionReader struct {
pool *Pool pool *Pool
functionName string functionName string
...@@ -179,85 +156,4 @@ func (f *GenericFunctionReader) FetchAll(ctx context.Context) ([]appsync.SyncRow ...@@ -179,85 +156,4 @@ func (f *GenericFunctionReader) FetchAll(ctx context.Context) ([]appsync.SyncRow
return parseGenericEnvelope(raw, f.idField) return parseGenericEnvelope(raw, f.idField)
} }
// StudentsQueryReader ejecuta el SQL crudo de estudiantes directamente (en vez // Los readers por módulo (students, payment_plans, parents) viven en internal/<módulo>.
// de envolver una función que devuelve JSON) y arma una SyncRow por cada fila
// del resultado. Es el que usa el pipeline de estudiantes.
type StudentsQueryReader struct {
pool *Pool
query string
}
func NewStudentsQueryReader(pool *Pool, query string) *StudentsQueryReader {
return &StudentsQueryReader{pool: pool, query: query}
}
// FetchAll corre la consulta y lee fila por fila.
func (f *StudentsQueryReader) FetchAll(ctx context.Context) ([]appsync.SyncRow, error) {
rows, err := f.pool.Pool.Query(ctx, f.query)
if err != nil {
return nil, fmt.Errorf("query students: %w", err)
}
defer rows.Close() // cerrar el cursor al terminar, pase lo que pase
var result []appsync.SyncRow
for rows.Next() { // avanza a la siguiente fila; false cuando no quedan más
// Los punteros (*string) permiten que el valor sea NULL en la base:
// si la columna es NULL, la variable queda en nil.
var (
studentID int
paternalLastName *string
maternalLastName *string
name *string
dni *string
enrollmentID int
paymentPlans []byte // JSON crudo devuelto por JSON_AGG
paymentPlansCurrentYr []byte
)
// Scan copia cada columna, EN ORDEN, a las variables (por eso van con &).
if err := rows.Scan(&studentID, &paternalLastName, &maternalLastName, &name, &dni,
&enrollmentID, &paymentPlans, &paymentPlansCurrentYr); err != nil {
return nil, fmt.Errorf("scan student row: %w", err)
}
// Convertimos el JSON de planes de pago a una lista usable.
plans, err := decodePaymentPlans(paymentPlans)
if err != nil {
return nil, fmt.Errorf("decode payment_plans for student %d: %w", studentID, err)
}
plansCurrentYr, err := decodePaymentPlans(paymentPlansCurrentYr)
if err != nil {
return nil, fmt.Errorf("decode payment_plans_current_year for student %d: %w", studentID, err)
}
// Armamos el documento que irá a Mongo.
doc := map[string]interface{}{
"student_id": studentID,
"student_paternal_last_name": paternalLastName,
"student_maternal_last_name": maternalLastName,
"student_name": name,
"student_dni": dni,
"enrollment_id": enrollmentID,
"payment_plans": plans,
"payment_plans_current_year": plansCurrentYr,
}
result = append(result, appsync.SyncRow{ID: studentID, Doc: doc})
}
// rows.Err reporta errores ocurridos DURANTE la iteración del cursor.
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate student rows: %w", err)
}
return result, nil
}
// decodePaymentPlans convierte el JSON crudo (bytes) en una lista de planes.
// Si viene NULL (nil), devuelve una lista vacía en vez de error.
func decodePaymentPlans(raw []byte) ([]interface{}, error) {
if raw == nil {
return []interface{}{}, nil
}
var plans []interface{}
if err := json.Unmarshal(raw, &plans); err != nil {
return nil, err
}
return plans, nil
}
...@@ -7,54 +7,41 @@ import ( ...@@ -7,54 +7,41 @@ import (
) )
// Runner es cualquier cosa que sepa ejecutar un ciclo. El Service lo cumple. // Runner es cualquier cosa que sepa ejecutar un ciclo. El Service lo cumple.
// El Scheduler depende de esta interfaz (no del Service concreto), así que se
// lo puede probar con un runner falso.
type Runner interface { type Runner interface {
Run(ctx context.Context) error Run(ctx context.Context) error
} }
// cycleTimeout: tope máximo de tiempo para un solo ciclo. Si un ciclo se cuelga, // cycleTimeout: tope máximo de tiempo para un solo ciclo, independiente del intervalo entre ciclos.
// se corta a los 4 minutos y no bloquea para siempre. Es independiente del
// intervalo entre ciclos.
const cycleTimeout = 4 * time.Minute const cycleTimeout = 4 * time.Minute
// Scheduler dispara el Runner cada "interval" de tiempo. // Scheduler dispara el Runner según su Schedule (intervalo fijo u hora fija del día).
type Scheduler struct { type Scheduler struct {
runner Runner runner Runner
interval time.Duration schedule Schedule
} }
func NewScheduler(runner Runner, interval time.Duration) *Scheduler { func NewScheduler(runner Runner, schedule Schedule) *Scheduler {
return &Scheduler{runner: runner, interval: interval} return &Scheduler{runner: runner, schedule: schedule}
} }
// Start corre en bucle disparando un ciclo en cada "tick" del reloj, hasta que // Start corre en bucle disparando un ciclo cada vez que el Schedule lo indica, hasta cancelar ctx.
// el context (ctx) se cancele (ej. al apagar el servicio).
//
// Normalmente se llama con "go scheduler.Start(ctx)": la palabra clave "go"
// lo lanza en una goroutine (hilo liviano) para que no bloquee el arranque.
func (s *Scheduler) Start(ctx context.Context) { func (s *Scheduler) Start(ctx context.Context) {
// ticker.C es un canal que emite un valor cada "interval". timer := time.NewTimer(s.schedule.Next(time.Now()))
ticker := time.NewTicker(s.interval) defer timer.Stop()
defer ticker.Stop() // liberar el ticker al salir
for { for {
// select espera sobre varios canales a la vez y actúa según cuál dispare.
select { select {
case <-ctx.Done(): case <-ctx.Done():
// El context se canceló: es hora de apagar. Salimos del bucle.
return return
case <-ticker.C: case <-timer.C:
// Pasó el intervalo: corre un ciclo.
s.runOnce(ctx) s.runOnce(ctx)
timer.Reset(s.schedule.Next(time.Now()))
} }
} }
} }
// runOnce ejecuta un ciclo con su propio timeout (cycleTimeout) y registra el // runOnce ejecuta un ciclo con su propio timeout, registrando el error sin detener el bucle.
// error si falla, sin detener el bucle: el próximo tick vuelve a intentar.
func (s *Scheduler) runOnce(ctx context.Context) { func (s *Scheduler) runOnce(ctx context.Context) {
// Creamos un context "hijo" que se cancela solo a los cycleTimeout.
runCtx, cancel := context.WithTimeout(ctx, cycleTimeout) runCtx, cancel := context.WithTimeout(ctx, cycleTimeout)
defer cancel() defer cancel()
if err := s.runner.Run(runCtx); err != nil { if err := s.runner.Run(runCtx); err != nil {
......
...@@ -2,62 +2,98 @@ package sync ...@@ -2,62 +2,98 @@ package sync
import ( import (
"context" "context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt" "fmt"
"sync" // primitivas de concurrencia (Mutex). OJO: es el paquete estándar, "sync"
"sync/atomic" // no confundir con NUESTRO paquete que también se llama "sync". "sync/atomic"
"time" "time"
"golang.org/x/sync/errgroup" // grupo de goroutines con manejo de errores "golang.org/x/sync/errgroup"
) )
// defaultConcurrency: cuántas filas se procesan a la vez (en paralelo). // defaultConcurrency: cuántas filas se procesan a la vez en paralelo.
// Un pool acotado: más rápido que ir de a una, sin saturar la base.
const defaultConcurrency = 10 const defaultConcurrency = 10
// SyncRow es una fila lista para volcar a Mongo: su id y su documento. // SyncRow es una fila lista para volcar a Mongo: su id y su documento.
// map[string]interface{} = un mapa de "nombre de campo" -> "valor de cualquier tipo".
type SyncRow struct { type SyncRow struct {
ID int ID int
Doc map[string]interface{} Doc interface{}
} }
// --------------------------------------------------------------------------- // updatedAtSetter lo implementan las entidades tipadas que quieren que el Service les estampe updated_at.
// Interfaces del dominio. El Service depende solo de estos contratos, no de type updatedAtSetter interface {
// Postgres/Mongo concretos. Las implementaciones viven en internal/db. SetUpdatedAt(t time.Time)
// --------------------------------------------------------------------------- }
// rowHashSetter lo implementan las entidades tipadas que quieren que el Service les estampe row_hash.
type rowHashSetter interface {
SetRowHash(h string)
}
// stampUpdatedAt estampa updated_at en el documento, sea map genérico o entidad tipada.
func stampUpdatedAt(doc interface{}, t time.Time) {
switch v := doc.(type) {
case map[string]interface{}:
v["updated_at"] = t
case updatedAtSetter:
v.SetUpdatedAt(t)
}
}
// stampRowHash guarda el hash de contenido en el documento, sea map genérico o entidad tipada.
func stampRowHash(doc interface{}, h string) {
switch v := doc.(type) {
case map[string]interface{}:
v["row_hash"] = h
case rowHashSetter:
v.SetRowHash(h)
}
}
// computeRowHash calcula un hash de contenido del documento, ANTES de estampar updated_at/row_hash,
// para poder comparar contra el hash guardado en Mongo y saltar el upsert si la fila no cambió.
func computeRowHash(doc interface{}) (string, error) {
b, err := json.Marshal(doc)
if err != nil {
return "", fmt.Errorf("marshal row for hash: %w", err)
}
sum := sha256.Sum256(b)
return hex.EncodeToString(sum[:]), nil
}
// PGReader lee TODAS las filas del origen (Postgres) en un ciclo. // PGReader lee todas las filas del origen (Postgres) en un ciclo.
type PGReader interface { type PGReader interface {
FetchAll(ctx context.Context) ([]SyncRow, error) FetchAll(ctx context.Context) ([]SyncRow, error)
} }
// MongoUpserter inserta o actualiza un documento por id. // MongoUpserter inserta o actualiza un lote de documentos en una sola operación bulk,
// isNew indica si el registro NO existía antes (inserción real) vs. si ya // devolviendo cuántos fueron inserciones nuevas (created) y cuántos actualizaciones (updated).
// existía (actualización). Sirve para contar creados vs. actualizados.
type MongoUpserter interface { type MongoUpserter interface {
Upsert(ctx context.Context, id int, doc map[string]interface{}) (isNew bool, err error) BulkUpsert(ctx context.Context, rows []SyncRow) (created, updated int, err error)
} }
// MongoIDLister lista todos los ids que hay actualmente en la colección Mongo, // MongoIDLister lista los ids actuales en la colección Mongo junto a su row_hash guardado.
// para detectar registros que desaparecieron de Postgres desde el último ciclo. // Las claves del mapa detectan borrados; los valores permiten saltar upserts sin cambios.
type MongoIDLister interface { type MongoIDLister interface {
ListIDs(ctx context.Context) ([]int, error) ListIDsAndHashes(ctx context.Context) (map[int]string, error)
} }
// DeletedMover archiva un registro que ya no está en Postgres: lo saca de la // DeletedMover archiva un registro que ya no está en Postgres a la colección de borrados.
// colección viva y lo mete en la colección de borrados (deleted_students).
type DeletedMover interface { type DeletedMover interface {
MoveToDeleted(ctx context.Context, id int) error MoveToDeleted(ctx context.Context, id int) error
} }
// SyncResult resume cómo salió el último ciclo. Alimenta /sync/students/status. // SyncResult resume cómo salió el último ciclo.
type SyncResult struct { type SyncResult struct {
RanAt time.Time // cuándo corrió RanAt time.Time
RowsSynced int // total de filas procesadas RowsSynced int
Created int // insertados nuevos Created int
Updated int // actualizados existentes Updated int
Deleted int // archivados por desaparecer del origen Unchanged int
Err error // error si el ciclo falló, o nil si fue exitoso Deleted int
Err error
} }
// Service orquesta un ciclo completo de sincronización de UN pipeline. // Service orquesta un ciclo completo de sincronización de UN pipeline.
...@@ -67,17 +103,15 @@ type Service struct { ...@@ -67,17 +103,15 @@ type Service struct {
lister MongoIDLister lister MongoIDLister
mover DeletedMover mover DeletedMover
state StateStore state StateStore
now func() time.Time // función que da "la hora actual"; inyectable para tests now func() time.Time
concurrency int concurrency int
// mu protege el acceso a lastResult desde varias goroutines a la vez. // mu protege lastResult entre goroutines concurrentes.
// Un Mutex es un candado: solo una goroutine puede tenerlo tomado.
mu sync.Mutex mu sync.Mutex
lastResult SyncResult lastResult SyncResult
} }
// NewService arma un Service inyectándole todas sus dependencias (interfaces). // NewService arma un Service inyectándole todas sus dependencias.
// Este estilo (inyección de dependencias) es lo que hace testeable al dominio.
func NewService(reader PGReader, mongo MongoUpserter, lister MongoIDLister, mover DeletedMover, state StateStore, now func() time.Time) *Service { func NewService(reader PGReader, mongo MongoUpserter, lister MongoIDLister, mover DeletedMover, state StateStore, now func() time.Time) *Service {
return &Service{ return &Service{
reader: reader, reader: reader,
...@@ -90,23 +124,15 @@ func NewService(reader PGReader, mongo MongoUpserter, lister MongoIDLister, move ...@@ -90,23 +124,15 @@ func NewService(reader PGReader, mongo MongoUpserter, lister MongoIDLister, move
} }
} }
// Run ejecuta UN ciclo completo de sincronización: // Run ejecuta un ciclo completo: lee Postgres, upsertea en Mongo, archiva borrados,
// 1. lee todas las filas de Postgres, // avanza el estado persistido solo si todo salió bien.
// 2. las inserta/actualiza en Mongo en paralelo (contando creados/actualizados),
// 3. archiva los registros que ya no están en Postgres,
// 4. avanza el estado persistido SOLO si todo salió bien.
//
// Devuelve error si algo falló (y lo deja registrado en lastResult para /status).
func (s *Service) Run(ctx context.Context) error { func (s *Service) Run(ctx context.Context) error {
// Tomamos el candado: garantiza que no corran dos ciclos a la vez y
// protege la escritura de lastResult. defer = ejecutar al salir de la función.
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
runAt := s.now().UTC() runAt := s.now().UTC()
result := SyncResult{RanAt: runAt} result := SyncResult{RanAt: runAt}
// --- Paso 1: leer todo desde Postgres ---
rows, err := s.reader.FetchAll(ctx) rows, err := s.reader.FetchAll(ctx)
if err != nil { if err != nil {
result.Err = fmt.Errorf("fetch all: %w", err) result.Err = fmt.Errorf("fetch all: %w", err)
...@@ -114,50 +140,71 @@ func (s *Service) Run(ctx context.Context) error { ...@@ -114,50 +140,71 @@ func (s *Service) Run(ctx context.Context) error {
return result.Err return result.Err
} }
// Guardamos en un set (mapa) los ids que SÍ vinieron de Postgres.
// struct{} es un valor vacío (ocupa 0 bytes): el mapa se usa solo como conjunto.
pgIDs := make(map[int]struct{}, len(rows)) pgIDs := make(map[int]struct{}, len(rows))
for _, row := range rows { for _, row := range rows {
pgIDs[row.ID] = struct{}{} pgIDs[row.ID] = struct{}{}
} }
// --- Paso 2: upsert en paralelo --- // existingHashes trae, en una sola pasada, el row_hash ya guardado por id (para saltar
// Contadores atómicos: se incrementan de forma segura desde varias goroutines. // upserts de filas sin cambios) y sirve además como base para detectar borrados abajo.
var created, updated int64 var existingHashes map[int]string
if s.lister != nil {
var err error
existingHashes, err = s.lister.ListIDsAndHashes(ctx)
if err != nil {
result.Err = fmt.Errorf("list existing hashes: %w", err)
s.lastResult = result
return result.Err
}
}
// errgroup lanza goroutines y si alguna devuelve error, cancela el resto. // Fase 1 (CPU, concurrente): calcular el hash de cada fila y descartar las que no cambiaron
g, gctx := errgroup.WithContext(ctx) // frente a lo guardado en Mongo. No hay I/O acá, solo sirve para no mandar filas de más al bulk.
g.SetLimit(s.concurrency) // máximo defaultConcurrency corriendo a la vez changed := make([]SyncRow, len(rows))
var changedCount, unchanged int64
g, _ := errgroup.WithContext(ctx)
g.SetLimit(s.concurrency)
for _, row := range rows { for _, row := range rows {
row := row // copia local: necesaria para que cada goroutine vea SU fila row := row
g.Go(func() error { g.Go(func() error {
// Estampamos updated_at (Postgres no lo provee; lo pone este servicio). hash, err := computeRowHash(row.Doc)
row.Doc["updated_at"] = runAt
isNew, err := s.mongo.Upsert(gctx, row.ID, row.Doc)
if err != nil { if err != nil {
return fmt.Errorf("upsert row %d: %w", row.ID, err) return fmt.Errorf("hash row %d: %w", row.ID, err)
} }
if isNew { if existingHash, ok := existingHashes[row.ID]; ok && existingHash == hash {
atomic.AddInt64(&created, 1) atomic.AddInt64(&unchanged, 1)
} else { return nil
atomic.AddInt64(&updated, 1)
} }
stampUpdatedAt(row.Doc, runAt)
stampRowHash(row.Doc, hash)
idx := atomic.AddInt64(&changedCount, 1) - 1
changed[idx] = row
return nil return nil
}) })
} }
// g.Wait espera a que terminen todas y devuelve el primer error (si hubo).
if err := g.Wait(); err != nil { if err := g.Wait(); err != nil {
result.Err = err result.Err = err
s.lastResult = result s.lastResult = result
return result.Err return result.Err
} }
changed = changed[:changedCount]
result.Created = int(created) // Fase 2 (I/O, un solo round trip): escribir todas las filas que cambiaron en un único BulkWrite.
result.Updated = int(updated) created, updated, err := s.mongo.BulkUpsert(ctx, changed)
if err != nil {
result.Err = fmt.Errorf("bulk upsert: %w", err)
s.lastResult = result
return result.Err
}
result.Created = created
result.Updated = updated
result.Unchanged = int(unchanged)
result.RowsSynced = len(rows) result.RowsSynced = len(rows)
// --- Paso 3: archivar los que desaparecieron del origen --- deleted, err := s.reconcileDeleted(ctx, pgIDs, existingHashes)
deleted, err := s.reconcileDeleted(ctx, pgIDs)
if err != nil { if err != nil {
result.Err = fmt.Errorf("reconcile deleted: %w", err) result.Err = fmt.Errorf("reconcile deleted: %w", err)
s.lastResult = result s.lastResult = result
...@@ -165,7 +212,6 @@ func (s *Service) Run(ctx context.Context) error { ...@@ -165,7 +212,6 @@ func (s *Service) Run(ctx context.Context) error {
} }
result.Deleted = deleted result.Deleted = deleted
// --- Paso 4: avanzar el estado persistido (solo si TODO salió bien) ---
if err := s.state.Set(ctx, runAt); err != nil { if err := s.state.Set(ctx, runAt); err != nil {
result.Err = fmt.Errorf("advance sync state: %w", err) result.Err = fmt.Errorf("advance sync state: %w", err)
s.lastResult = result s.lastResult = result
...@@ -176,22 +222,14 @@ func (s *Service) Run(ctx context.Context) error { ...@@ -176,22 +222,14 @@ func (s *Service) Run(ctx context.Context) error {
return nil return nil
} }
// reconcileDeleted encuentra los ids que están en Mongo pero YA NO en Postgres // reconcileDeleted encuentra ids en Mongo (existingHashes) que ya no están en Postgres y los archiva.
// (registros borrados en el origen) y los archiva en la colección de borrados. func (s *Service) reconcileDeleted(ctx context.Context, pgIDs map[int]struct{}, existingHashes map[int]string) (int, error) {
func (s *Service) reconcileDeleted(ctx context.Context, pgIDs map[int]struct{}) (int, error) {
// Si este pipeline no tiene con qué listar o mover, no hay nada que hacer.
if s.lister == nil || s.mover == nil { if s.lister == nil || s.mover == nil {
return 0, nil return 0, nil
} }
existingIDs, err := s.lister.ListIDs(ctx)
if err != nil {
return 0, fmt.Errorf("list existing ids: %w", err)
}
// toDelete = ids que están en Mongo pero no en el set de Postgres.
var toDelete []int var toDelete []int
for _, id := range existingIDs { for id := range existingHashes {
if _, ok := pgIDs[id]; !ok { if _, ok := pgIDs[id]; !ok {
toDelete = append(toDelete, id) toDelete = append(toDelete, id)
} }
...@@ -200,7 +238,6 @@ func (s *Service) reconcileDeleted(ctx context.Context, pgIDs map[int]struct{}) ...@@ -200,7 +238,6 @@ func (s *Service) reconcileDeleted(ctx context.Context, pgIDs map[int]struct{})
return 0, nil return 0, nil
} }
// Archivamos en paralelo, igual que los upserts.
g, gctx := errgroup.WithContext(ctx) g, gctx := errgroup.WithContext(ctx)
g.SetLimit(s.concurrency) g.SetLimit(s.concurrency)
for _, id := range toDelete { for _, id := range toDelete {
...@@ -218,18 +255,14 @@ func (s *Service) reconcileDeleted(ctx context.Context, pgIDs map[int]struct{}) ...@@ -218,18 +255,14 @@ func (s *Service) reconcileDeleted(ctx context.Context, pgIDs map[int]struct{})
return len(toDelete), nil return len(toDelete), nil
} }
// SeedLastResult inicializa lastResult con un timestamp persistido (ej. justo // SeedLastResult inicializa lastResult con un timestamp persistido tras arrancar el proceso.
// después de arrancar el proceso), para que /sync/students/status muestre el último
// ciclo exitoso ANTES de que corra un ciclo nuevo. Deja RowsSynced y Err en
// cero a propósito: no tenemos registro de ellos tras un reinicio.
func (s *Service) SeedLastResult(t time.Time) { func (s *Service) SeedLastResult(t time.Time) {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
s.lastResult = SyncResult{RanAt: t} s.lastResult = SyncResult{RanAt: t}
} }
// LastResult devuelve una copia del último resultado, de forma segura para // LastResult devuelve una copia del último resultado, protegida por el mutex.
// concurrencia (protegida por el mutex). La lee el endpoint /sync/students/status.
func (s *Service) LastResult() SyncResult { func (s *Service) LastResult() SyncResult {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
......
// Package sync es el "dominio" del servicio: la lógica central de la // Package sync es el dominio del servicio: lógica de sincronización sin depender de librerías externas de base de datos ni del framework web.
// sincronización, escrita SIN depender de librerías externas de base de datos
// ni del framework web. Solo depende de interfaces pequeñas (definidas aquí),
// y las implementaciones reales (Postgres/Mongo) se inyectan desde afuera.
//
// Esto es el corazón de la "arquitectura limpia": el dominio no sabe qué base
// de datos concreta se usa; solo sabe "necesito algo que pueda leer filas",
// "algo que pueda guardar". Así se puede testear sin bases de datos reales.
package sync package sync
import ( import (
...@@ -13,51 +6,33 @@ import ( ...@@ -13,51 +6,33 @@ import (
"time" "time"
) )
// --------------------------------------------------------------------------- // Row abstrae el resultado de una consulta de una sola fila (lo satisface pgx.Row).
// Interfaces = "contratos". Definen QUÉ se necesita, no CÓMO se hace.
// El paquete internal/db provee las implementaciones concretas.
// ---------------------------------------------------------------------------
// Row abstrae el resultado de una consulta de una sola fila.
// (En la práctica lo satisface pgx.Row de la librería de Postgres.)
type Row interface { type Row interface {
Scan(dest ...interface{}) error // copia los valores de la fila a las variables dadas Scan(dest ...interface{}) error
} }
// PGExecutor abstrae el acceso mínimo a Postgres que necesita el state store. // PGExecutor abstrae el acceso mínimo a Postgres que necesita el state store.
// Al ser una interfaz, el state store no depende directamente de pgx.
type PGExecutor interface { type PGExecutor interface {
QueryRow(ctx context.Context, sql string, args ...interface{}) Row QueryRow(ctx context.Context, sql string, args ...interface{}) Row
Exec(ctx context.Context, sql string, args ...interface{}) error Exec(ctx context.Context, sql string, args ...interface{}) error
} }
// StateStore guarda y recupera "cuándo fue la última sincronización exitosa". // StateStore guarda y recupera cuándo fue la última sincronización exitosa.
// Se usa para el endpoint /sync/students/status y para retomar estado tras reiniciar.
type StateStore interface { type StateStore interface {
Get(ctx context.Context) (time.Time, error) Get(ctx context.Context) (time.Time, error)
Set(ctx context.Context, t time.Time) error Set(ctx context.Context, t time.Time) error
} }
// --------------------------------------------------------------------------- // PGStateStore persiste el timestamp de última sincronización en la tabla sync_state, una fila por pipeline (stateID).
// Implementación concreta sobre PostgreSQL.
// ---------------------------------------------------------------------------
// PGStateStore persiste el timestamp de última sincronización en la tabla
// sync_state. Hay una fila por pipeline, identificada por stateID
// (estudiantes = 1).
type PGStateStore struct { type PGStateStore struct {
db PGExecutor db PGExecutor
stateID int stateID int
} }
// NewPGStateStore construye un PGStateStore. En Go es convención tener una
// función "NewXxx" que crea e inicializa el struct (no hay constructores como
// en otros lenguajes). Devuelve un puntero (*PGStateStore) para no copiar.
func NewPGStateStore(db PGExecutor, stateID int) *PGStateStore { func NewPGStateStore(db PGExecutor, stateID int) *PGStateStore {
return &PGStateStore{db: db, stateID: stateID} return &PGStateStore{db: db, stateID: stateID}
} }
// $1 es un parámetro posicional de PostgreSQL (evita inyección SQL).
const selectLastSyncedSQL = `SELECT last_synced_at FROM sync_state WHERE id = $1` const selectLastSyncedSQL = `SELECT last_synced_at FROM sync_state WHERE id = $1`
// Get lee el último timestamp sincronizado para este pipeline. // Get lee el último timestamp sincronizado para este pipeline.
...@@ -65,14 +40,12 @@ func (s *PGStateStore) Get(ctx context.Context) (time.Time, error) { ...@@ -65,14 +40,12 @@ func (s *PGStateStore) Get(ctx context.Context) (time.Time, error) {
var t time.Time var t time.Time
row := s.db.QueryRow(ctx, selectLastSyncedSQL, s.stateID) row := s.db.QueryRow(ctx, selectLastSyncedSQL, s.stateID)
if err := row.Scan(&t); err != nil { if err := row.Scan(&t); err != nil {
// time.Time{} es el "valor cero" (fecha vacía). Se devuelve junto al error.
return time.Time{}, err return time.Time{}, err
} }
return t, nil return t, nil
} }
// Inserta la fila si no existe, o actualiza last_synced_at si ya existe // upsertLastSyncedSQL inserta la fila si no existe o actualiza last_synced_at si ya existe.
// (patrón "upsert" con ON CONFLICT).
const upsertLastSyncedSQL = ` const upsertLastSyncedSQL = `
INSERT INTO sync_state (id, last_synced_at) VALUES ($1, $2) INSERT INTO sync_state (id, last_synced_at) VALUES ($1, $2)
ON CONFLICT (id) DO UPDATE SET last_synced_at = EXCLUDED.last_synced_at` ON CONFLICT (id) DO UPDATE SET last_synced_at = EXCLUDED.last_synced_at`
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment