data_drain 0.1.19 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,812 @@
1
+ # Plan de Ejecución — v0.2.0
2
+
3
+ **Release objetivo:** v0.2.0 — Hardening de seguridad y testing
4
+ **Items del roadmap:** 1, 2, 3, 4 ([ver IMPROVEMENT_PLAN.md](../IMPROVEMENT_PLAN.md#p0--seguridad-y-correctitud-v020))
5
+ **Branch:** `feature/v0.2.0` → mergeado a `main` (commit `e1d121b`)
6
+ **Estado:** ✅ Completado — v0.2.0 release el 2026-04-13
7
+ **Última actualización:** 2026-04-13
8
+
9
+ ---
10
+
11
+ ## Resumen
12
+
13
+ Este release cierra los gaps P0 de DataDrain: SQL injection en `table_name`, credenciales S3 expuestas, memory leak de conexión DuckDB, y la cobertura de tests insuficiente. Resultado esperado: gema producción-ready para datos sensibles con > 80% line coverage.
14
+
15
+ **Items del release:**
16
+
17
+ | Item | Resumen | Estimación |
18
+ |------|---------|-----------|
19
+ | 2 | Validación regex de `table_name`/`primary_key` | 1-2h |
20
+ | 1 | Migrar credenciales S3 a `credential_chain` | 2-4h |
21
+ | 3 | Cleanup conexión DuckDB thread-local (`Record.disconnect!`) | 4-6h |
22
+ | 4 | Cobertura tests P0 (Record, Storage, GlueRunner, Observability, Configuration) | 1-2 días |
23
+
24
+ **Total estimado:** 2-3 días de trabajo enfocado.
25
+
26
+ ---
27
+
28
+ ## Review de agentes — incorporado
29
+
30
+ Este plan fue revisado por **opencode/big-pickle** (`v0.2.0.agente-review.md`, 2026-04-13). Los 4 issues + 2 preguntas planteados fueron incorporados:
31
+
32
+ | Issue / Q | Resolución | Ubicación en este plan |
33
+ |-----------|-----------|----------------------|
34
+ | Issue 1: `aws_region` también necesita escape | `safe_region = escape_sql(region)` antes de heredoc | Fase 3.2 |
35
+ | Issue 2: convención `:integration` tag | Definida con `define_derived_metadata` | Fase 0.4 |
36
+ | Issue 3: aws-sdk-s3 versión | Verificación añadida a Fase 0.1 | Fase 0.1 |
37
+ | Issue 4: orden ParquetFixtures vs spec_helper | Trigger en spec_helper, módulo en support | Fase 5.2 |
38
+ | Q1: caracteres a escapar en DuckDB | Verificación manual en `bin/console` antes de Fase 1 | Fase 0.1 |
39
+ | Q2: callers con `public.table` | `rg` en monorepo Wispro antes de Fase 1 | Fase 0.1 |
40
+ | R1: comentario en spec_helper sobre cleanup | Aplicado a `config.after(:each)` | Fase 0.5 |
41
+
42
+ ---
43
+
44
+ ## Orden de ejecución y dependencias
45
+
46
+ ```
47
+ Fase 0 (setup)
48
+
49
+
50
+ Fase 1: Item 2 (validación regex) ──────► sin deps, calienta
51
+
52
+
53
+ Fase 2: Setup tests baseline (Configuration, Observability, JsonType, Storage factory)
54
+
55
+ ├──► Fase 3: Item 1 (S3 secret) + tests Storage::S3
56
+ │ │
57
+ │ ▼
58
+ ├──► Fase 4: Tests Storage::Local + GlueRunner
59
+
60
+
61
+ Fase 5: Item 3 (disconnect!) + tests Record
62
+
63
+
64
+ Fase 6: Tests Engine + FileIngestor (refuerzo + nuevos casos)
65
+
66
+
67
+ Fase 7: Lint + Coverage report + CHANGELOG + version bump + commit final
68
+ ```
69
+
70
+ **Razonamiento del orden:**
71
+ - Item 2 primero: cheap, sin deps, establece patrón de validación + tests.
72
+ - Tests baseline antes de los items grandes: te dan una red para no romper.
73
+ - Item 1 después de tener `storage/s3_spec.rb` esqueleto.
74
+ - Item 3 último porque requiere tests de Record ya escritos para validar idempotencia.
75
+
76
+ ---
77
+
78
+ ## Pre-requisitos (Fase 0)
79
+
80
+ ### 0.1 Verificar entorno
81
+
82
+ - [ ] `bundle install` corre limpio
83
+ - [ ] `bundle exec rspec` actual pasa (4 tests verdes)
84
+ - [ ] `bundle exec rubocop` actual sin ofensas
85
+ - [ ] DuckDB version en `Gemfile.lock` ≥ 1.4 (para `CREATE SECRET`)
86
+ - [ ] **aws-sdk-s3 ≥ 1.114** (para `Aws::S3::Client.new(stub_responses: true)`):
87
+ ```bash
88
+ bundle exec ruby -e "require 'bundler'; puts Bundler.load.specs.find { |s| s.name == 'aws-sdk-s3' }.version"
89
+ ```
90
+ Si < 1.114 → bumpear `Gemfile`.
91
+ - [ ] **Q1 resuelta — caracteres a escapar en DuckDB SQL strings.** Probar en `bin/console`:
92
+ ```ruby
93
+ conn.query("CREATE OR REPLACE SECRET t (TYPE S3, REGION 'us-east-1''); SELECT 1;--')")
94
+ # Confirmar si SQL estándar (solo `'` → `''`) es suficiente o si DuckDB tiene reglas extra
95
+ ```
96
+ Documentar resultado acá:
97
+ > Resultado: ___________________
98
+ - [ ] **Q2 resuelta — buscar callers con `public.table` o schema en monorepo Wispro:**
99
+ ```bash
100
+ rg "table_name.*[\"']\w+\.\w+" --type ruby
101
+ rg "DataDrain::Engine.new" -A 5 --type ruby
102
+ ```
103
+ Si hay matches con schema explícito → coordinar con Item 2 (relajar regex o forzar split de schema).
104
+ Documentar:
105
+ > Callers encontrados con schema: ___________________
106
+
107
+ ### 0.2 Crear branch
108
+
109
+ - [ ] `git checkout -b feature/v0.2.0`
110
+
111
+ ### 0.3 Agregar SimpleCov
112
+
113
+ - [ ] Agregar a `Gemfile`:
114
+ ```ruby
115
+ group :test do
116
+ gem "simplecov", require: false
117
+ end
118
+ ```
119
+ - [ ] Agregar al inicio de `spec/spec_helper.rb`:
120
+ ```ruby
121
+ require "simplecov"
122
+ SimpleCov.start do
123
+ add_filter "/spec/"
124
+ minimum_coverage 80 # falla si baja del 80%
125
+ end
126
+ ```
127
+ - [ ] `bundle install`
128
+ - [ ] `bundle exec rspec` y verificar baseline de cobertura actual reportada en `coverage/index.html`
129
+ - [ ] Agregar `coverage/` a `.gitignore` si no está
130
+ - [ ] Commit: `chore: add simplecov for coverage tracking`
131
+
132
+ ### 0.4 Estructura de fixtures
133
+
134
+ - [ ] Crear `spec/fixtures/`
135
+ - [ ] Crear `spec/fixtures/sample.csv` con 5 filas dummy:
136
+ ```csv
137
+ id,timestamp,isp_id,value
138
+ 1,2026-01-01 10:00:00,42,100
139
+ 2,2026-01-02 11:00:00,42,200
140
+ 3,2026-02-01 12:00:00,43,150
141
+ 4,2026-02-15 13:00:00,42,175
142
+ 5,2026-03-01 14:00:00,43,225
143
+ ```
144
+ - [ ] Decidir si tests integration con Postgres son parte de este release (recomendación: marcar como `:integration` y skip por default en CI; correr a mano)
145
+ - [ ] **Definir convención `:integration` tag** en `spec/spec_helper.rb`:
146
+ ```ruby
147
+ RSpec.configure do |config|
148
+ config.define_derived_metadata(:integration) do |metadata|
149
+ metadata[:skip] = "Integration test — requiere Postgres real (correr con --tag integration)"
150
+ end
151
+ end
152
+ ```
153
+ Tests integration se marcan con `it "...", :integration do`. Para correrlos: `bundle exec rspec --tag integration` (requiere remover/sobrescribir el skip; alternativa: usar `metadata[:skip] = !ENV["RUN_INTEGRATION"]` para activar con env var).
154
+ - [ ] Commit: `chore: add test fixtures structure`
155
+
156
+ ### 0.5 Helper de specs
157
+
158
+ - [ ] Crear `spec/support/` y agregar requires en `spec_helper.rb`:
159
+ ```ruby
160
+ Dir[File.expand_path("support/**/*.rb", __dir__)].sort.each { |f| require f }
161
+ ```
162
+ - [ ] Crear `spec/support/configuration_helper.rb`:
163
+ ```ruby
164
+ module ConfigurationHelper
165
+ def with_config(**overrides)
166
+ original = DataDrain.configuration
167
+ DataDrain.configure { |c| overrides.each { |k, v| c.send("#{k}=", v) } }
168
+ yield
169
+ ensure
170
+ DataDrain.instance_variable_set(:@configuration, original)
171
+ DataDrain::Storage.reset_adapter!
172
+ end
173
+ end
174
+
175
+ RSpec.configure do |config|
176
+ config.include ConfigurationHelper
177
+
178
+ # Limpia la config global y el adapter cacheado entre tests para evitar leaks
179
+ # de estado. Tests que escriben archivos al disco deben usar `Dir.mktmpdir` o
180
+ # un `tmp/test_lake/` descartable; este hook NO los limpia (responsabilidad
181
+ # del test).
182
+ config.after(:each) do
183
+ DataDrain.reset_configuration!
184
+ end
185
+ end
186
+ ```
187
+ - [ ] Commit: `chore: add spec support helpers`
188
+
189
+ ### Checkpoint Fase 0
190
+
191
+ - [ ] `bundle exec rspec` pasa (4 tests + 0% nuevos)
192
+ - [ ] Coverage report generado
193
+ - [ ] 3 commits limpios en `feature/v0.2.0`
194
+
195
+ ---
196
+
197
+ ## Fase 1 — Item 2: Validación regex (P0)
198
+
199
+ **Roadmap:** [Item 2](../IMPROVEMENT_PLAN.md#item-2--validación-regex-de-table_name-primary_key-anti-sql-injection)
200
+
201
+ ### 1.1 Implementar validación en Engine
202
+
203
+ - [ ] Editar `lib/data_drain/engine.rb`:
204
+ - Agregar constante de clase:
205
+ ```ruby
206
+ IDENTIFIER_REGEX = /\A[a-zA-Z_][a-zA-Z0-9_]*\z/.freeze
207
+ ```
208
+ - En `#initialize`, después de capturar `@table_name` y `@primary_key`:
209
+ ```ruby
210
+ validate_identifier!(:table_name, @table_name)
211
+ validate_identifier!(:primary_key, @primary_key)
212
+ ```
213
+ - Agregar método privado:
214
+ ```ruby
215
+ def validate_identifier!(name, value)
216
+ return if IDENTIFIER_REGEX.match?(value.to_s)
217
+
218
+ raise DataDrain::ConfigurationError,
219
+ "#{name} '#{value}' no es un identificador SQL válido"
220
+ end
221
+ ```
222
+
223
+ ### 1.2 Implementar validación en FileIngestor
224
+
225
+ - [ ] Editar `lib/data_drain/file_ingestor.rb`:
226
+ - Agregar misma constante o referenciar `Engine::IDENTIFIER_REGEX` (mejor: extraer a `DataDrain::Validations` módulo si vamos a tenerla en 2 clases)
227
+ - **Decisión:** extraer a módulo `lib/data_drain/validations.rb`:
228
+ ```ruby
229
+ module DataDrain
230
+ module Validations
231
+ IDENTIFIER_REGEX = /\A[a-zA-Z_][a-zA-Z0-9_]*\z/.freeze
232
+
233
+ module_function
234
+
235
+ def validate_identifier!(name, value)
236
+ return if IDENTIFIER_REGEX.match?(value.to_s)
237
+
238
+ raise DataDrain::ConfigurationError,
239
+ "#{name} '#{value}' no es un identificador SQL válido"
240
+ end
241
+ end
242
+ end
243
+ ```
244
+ - Require en `lib/data_drain.rb` después de `errors`
245
+ - En `Engine#initialize`: `Validations.validate_identifier!(:table_name, @table_name)`
246
+ - En `FileIngestor#initialize`: `Validations.validate_identifier!(:folder_name, @folder_name)`
247
+
248
+ ### 1.3 Tests
249
+
250
+ - [ ] Agregar a `spec/data_drain/engine_spec.rb`:
251
+ ```ruby
252
+ describe "validación de identificadores" do
253
+ it "rechaza table_name con punto y coma" do
254
+ expect {
255
+ DataDrain::Engine.new(valid_options.merge(table_name: "x; DROP TABLE y"))
256
+ }.to raise_error(DataDrain::ConfigurationError, /table_name.*no es un identificador/)
257
+ end
258
+
259
+ it "rechaza primary_key con espacios" do
260
+ expect {
261
+ DataDrain::Engine.new(valid_options.merge(primary_key: "id desc"))
262
+ }.to raise_error(DataDrain::ConfigurationError)
263
+ end
264
+
265
+ it "acepta identificador válido con guión bajo y números" do
266
+ expect {
267
+ DataDrain::Engine.new(valid_options.merge(table_name: "my_table_2"))
268
+ }.not_to raise_error
269
+ end
270
+ end
271
+ ```
272
+ - [ ] Crear `spec/data_drain/validations_spec.rb`:
273
+ ```ruby
274
+ RSpec.describe DataDrain::Validations do
275
+ describe ".validate_identifier!" do
276
+ it "no levanta para identificadores válidos" do
277
+ %w[users users_v2 _table TableName].each do |id|
278
+ expect { described_class.validate_identifier!(:x, id) }.not_to raise_error
279
+ end
280
+ end
281
+
282
+ it "levanta para identificadores inválidos" do
283
+ %w[1table table-name table.name].push("x; DROP", "").each do |id|
284
+ expect {
285
+ described_class.validate_identifier!(:x, id)
286
+ }.to raise_error(DataDrain::ConfigurationError)
287
+ end
288
+ end
289
+ end
290
+ end
291
+ ```
292
+
293
+ ### 1.4 Validación local
294
+
295
+ - [ ] `bundle exec rspec spec/data_drain/validations_spec.rb spec/data_drain/engine_spec.rb`
296
+ - [ ] `bundle exec rubocop lib/data_drain/validations.rb lib/data_drain/engine.rb lib/data_drain/file_ingestor.rb`
297
+
298
+ ### 1.5 Docs
299
+
300
+ - [ ] Actualizar `skill/references/antipatrones.md` item 13: agregar "Ahora la gema valida `table_name` y `primary_key` con regex; `select_sql` y `where_clause` siguen siendo trusted (no se validan)".
301
+ - [ ] Actualizar `skill/references/api-detallada.md` con nota sobre validación.
302
+ - [ ] Actualizar `CLAUDE.md` sección "Convenciones críticas" con regla nueva.
303
+
304
+ ### 1.6 Commit
305
+
306
+ - [ ] `git add lib/data_drain/validations.rb lib/data_drain/engine.rb lib/data_drain/file_ingestor.rb lib/data_drain.rb`
307
+ - [ ] `git add spec/data_drain/validations_spec.rb spec/data_drain/engine_spec.rb`
308
+ - [ ] `git add skill/ CLAUDE.md`
309
+ - [ ] Commit: `feat(security): validar table_name/primary_key con regex (item 2)`
310
+
311
+ ### Checkpoint Fase 1
312
+
313
+ - [ ] Tests verdes
314
+ - [ ] Rubocop limpio
315
+ - [ ] Coverage no bajó
316
+ - [ ] Identificadores comunes (`users`, `my_table`) siguen funcionando
317
+
318
+ ---
319
+
320
+ ## Fase 2 — Tests baseline
321
+
322
+ **Roadmap:** [Item 4 — parcial](../IMPROVEMENT_PLAN.md#item-4--cobertura-de-tests-p0-record-storage-gluerunner-observability)
323
+
324
+ Tests cheap sin dependencias externas. Establecen base de cobertura.
325
+
326
+ ### 2.1 `configuration_spec.rb`
327
+
328
+ - [ ] Crear `spec/data_drain/configuration_spec.rb`
329
+ - [ ] Tests:
330
+ - Defaults correctos (`storage_mode == :local`, `batch_size == 5000`, etc.)
331
+ - `duckdb_connection_string` formato URI con `idle_in_transaction_session_timeout` interpolado
332
+ - `idle_in_transaction_session_timeout = 0` no se omite (se incluye en URI como `0`)
333
+ - [ ] Validar: `bundle exec rspec spec/data_drain/configuration_spec.rb`
334
+
335
+ ### 2.2 `observability_spec.rb`
336
+
337
+ - [ ] Crear `spec/data_drain/observability_spec.rb`
338
+ - [ ] Crear clase de test:
339
+ ```ruby
340
+ class TestComponent
341
+ include DataDrain::Observability
342
+ attr_accessor :logger
343
+ def emit(level, event, meta = {}); safe_log(level, event, meta); end
344
+ end
345
+ ```
346
+ - [ ] Tests:
347
+ - `safe_log` no-op si `@logger` nil
348
+ - Formato KV con `component=` `event=` primero
349
+ - Filtra `password`, `token`, `secret`, `api_key`, `auth` (versión actual; item 9 ampliará)
350
+ - `rescue StandardError` no propaga (logger que levanta)
351
+ - `exception_metadata` trunca a 200, escapa `"`
352
+ - `observability_name` extrae primer namespace en snake_case
353
+ - Funciona con `extend` (clase con `@logger` de clase)
354
+ - [ ] Validar: `bundle exec rspec spec/data_drain/observability_spec.rb`
355
+
356
+ ### 2.3 `types/json_type_spec.rb`
357
+
358
+ - [ ] Crear `spec/data_drain/types/json_type_spec.rb`
359
+ - [ ] Tests:
360
+ - `cast(nil)` → nil
361
+ - `cast({"a" => 1})` → `{"a" => 1}`
362
+ - `cast([1,2,3])` → `[1,2,3]`
363
+ - `cast('{"a":1}')` → `{"a" => 1}`
364
+ - `cast("not json")` → `"not json"` (no levanta)
365
+ - [ ] Validar
366
+
367
+ ### 2.4 `storage_spec.rb` (factory)
368
+
369
+ - [ ] Crear `spec/data_drain/storage_spec.rb`
370
+ - [ ] Tests:
371
+ - `Storage.adapter` con `:local` retorna `Local` instance
372
+ - `Storage.adapter` con `:s3` retorna `S3` instance
373
+ - `Storage.adapter` con `:foo` levanta `InvalidAdapterError`
374
+ - `Storage.adapter` cachea (misma instancia entre llamadas)
375
+ - `Storage.reset_adapter!` invalida cache
376
+ - Después de `reset_adapter!`, próxima llamada usa nuevo `storage_mode`
377
+ - [ ] Validar
378
+
379
+ ### 2.5 Commit
380
+
381
+ - [ ] `git add spec/data_drain/configuration_spec.rb spec/data_drain/observability_spec.rb spec/data_drain/types/ spec/data_drain/storage_spec.rb`
382
+ - [ ] Commit: `test: cobertura baseline (Configuration, Observability, JsonType, Storage factory)`
383
+
384
+ ### Checkpoint Fase 2
385
+
386
+ - [ ] `bundle exec rspec` corre todos los specs sin error
387
+ - [ ] Coverage report sube ≥ 50%
388
+ - [ ] Tiempo total de suite < 5s
389
+
390
+ ---
391
+
392
+ ## Fase 3 — Item 1: S3 credential_chain (P0)
393
+
394
+ **Roadmap:** [Item 1](../IMPROVEMENT_PLAN.md#item-1--migrar-credenciales-s3-a-credential_chain-de-duckdb)
395
+
396
+ ### 3.1 Investigación previa
397
+
398
+ - [ ] Verificar versión DuckDB en uso:
399
+ ```bash
400
+ bundle exec ruby -e "require 'duckdb'; puts DuckDB::LIBRARY_VERSION"
401
+ ```
402
+ Debe ser ≥ 0.10. Si no, bumpear `Gemfile`.
403
+ - [ ] Probar manualmente `CREATE SECRET` en consola DuckDB:
404
+ ```bash
405
+ bin/console
406
+ > db = DuckDB::Database.open(":memory:"); conn = db.connect
407
+ > conn.query("INSTALL httpfs; LOAD httpfs;")
408
+ > conn.query("CREATE SECRET test (TYPE S3, PROVIDER credential_chain, REGION 'us-east-1');")
409
+ > conn.query("FROM duckdb_secrets();")
410
+ ```
411
+ - [ ] Si AWS env vars (`AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`) están seteadas, probar listar un bucket de prueba:
412
+ ```ruby
413
+ conn.query("FROM read_parquet('s3://your-test-bucket/path/*.parquet') LIMIT 1")
414
+ ```
415
+
416
+ ### 3.2 Refactor `Storage::S3#setup_duckdb`
417
+
418
+ - [ ] Editar `lib/data_drain/storage/s3.rb`:
419
+ ```ruby
420
+ def setup_duckdb(connection)
421
+ connection.query("INSTALL httpfs; LOAD httpfs;")
422
+ create_s3_secret(connection)
423
+ end
424
+
425
+ private
426
+
427
+ def create_s3_secret(connection)
428
+ region = @config.aws_region
429
+ raise DataDrain::ConfigurationError, "aws_region es obligatorio para storage_mode=:s3" if region.nil?
430
+
431
+ safe_region = escape_sql(region)
432
+
433
+ if @config.aws_access_key_id && @config.aws_secret_access_key
434
+ connection.query(<<~SQL)
435
+ CREATE OR REPLACE SECRET data_drain_s3 (
436
+ TYPE S3,
437
+ KEY_ID '#{escape_sql(@config.aws_access_key_id)}',
438
+ SECRET '#{escape_sql(@config.aws_secret_access_key)}',
439
+ REGION '#{safe_region}'
440
+ );
441
+ SQL
442
+ else
443
+ connection.query(<<~SQL)
444
+ CREATE OR REPLACE SECRET data_drain_s3 (
445
+ TYPE S3,
446
+ PROVIDER credential_chain,
447
+ REGION '#{safe_region}'
448
+ );
449
+ SQL
450
+ end
451
+ end
452
+
453
+ # NOTA: aws_region también pasa por escape_sql (review big-pickle issue 1).
454
+ # Aunque AWS no permite `'` en region names, defendemos en profundidad.
455
+ def escape_sql(value)
456
+ value.to_s.gsub("'", "''")
457
+ end
458
+ ```
459
+
460
+ ### 3.3 Tests `storage/s3_spec.rb`
461
+
462
+ - [ ] Crear `spec/data_drain/storage/s3_spec.rb`
463
+ - [ ] Setup: usar `aws-sdk-s3` con `Aws::S3::Client.new(stub_responses: true)` para `destroy_partitions`
464
+ - [ ] Mockear DuckDB connection (objeto que registra queries):
465
+ ```ruby
466
+ let(:duckdb_conn) do
467
+ queries = []
468
+ Class.new do
469
+ define_method(:query) { |q| queries << q }
470
+ define_method(:queries) { queries }
471
+ end.new
472
+ end
473
+ ```
474
+ - [ ] Tests:
475
+ - `setup_duckdb` con `aws_access_key_id` seteado → emite `CREATE SECRET ... KEY_ID ... SECRET`
476
+ - `setup_duckdb` sin credenciales → emite `CREATE SECRET ... PROVIDER credential_chain`
477
+ - Sin `aws_region` levanta `ConfigurationError`
478
+ - `escape_sql` duplica comillas simples
479
+ - `aws_region` con `'` (caso patológico) también se escapa en el `CREATE SECRET` (no rompe SQL)
480
+ - `build_path` retorna `s3://...`
481
+ - `destroy_partitions` con stub de S3:
482
+ - prefix correcto
483
+ - regex matching
484
+ - delete_objects llamado en lotes de 1000 cuando hay > 1000
485
+ - retorna count
486
+
487
+ ### 3.4 Validación local
488
+
489
+ - [ ] `bundle exec rspec spec/data_drain/storage/s3_spec.rb`
490
+ - [ ] `bundle exec rubocop lib/data_drain/storage/s3.rb`
491
+
492
+ ### 3.5 Docs
493
+
494
+ - [ ] Actualizar `skill/references/api-detallada.md` sección Storage::S3
495
+ - [ ] Actualizar `skill/SKILL.md` glosario y FAQ (mencionar credential_chain como default)
496
+ - [ ] Actualizar `README.md` sección Configuración: aclarar que `aws_access_key_id`/`aws_secret_access_key` son opcionales si IAM rol o env vars
497
+ - [ ] Actualizar `CLAUDE.md` sección "Seguridad"
498
+
499
+ ### 3.6 Commit
500
+
501
+ - [ ] `git add lib/data_drain/storage/s3.rb spec/data_drain/storage/s3_spec.rb`
502
+ - [ ] `git add skill/ README.md CLAUDE.md`
503
+ - [ ] Commit: `security(s3): migrar a CREATE SECRET con credential_chain (item 1)`
504
+
505
+ ### Checkpoint Fase 3
506
+
507
+ - [ ] Tests S3 verdes (mockeados)
508
+ - [ ] Test manual con bucket real (si hay): consulta de un Parquet vía `Record.where`
509
+ - [ ] Coverage sube
510
+ - [ ] Documentado backward-compat
511
+
512
+ ---
513
+
514
+ ## Fase 4 — Tests Storage::Local + GlueRunner
515
+
516
+ ### 4.1 `storage/local_spec.rb`
517
+
518
+ - [ ] Crear `spec/data_drain/storage/local_spec.rb`
519
+ - [ ] Usar `Dir.mktmpdir` por test para aislamiento
520
+ - [ ] Tests:
521
+ - `prepare_export_path` crea directorio anidado
522
+ - `build_path` con/sin `partition_path`
523
+ - `destroy_partitions` con todas las keys → borra directorio específico
524
+ - `destroy_partitions` con keys parciales → wildcard glob
525
+ - `destroy_partitions` retorna count correcto
526
+ - `destroy_partitions` con pattern que no matchea → retorna 0
527
+
528
+ ### 4.2 `glue_runner_spec.rb`
529
+
530
+ - [ ] Crear `spec/data_drain/glue_runner_spec.rb`
531
+ - [ ] Usar `Aws::Glue::Client.new(stub_responses: true)` y stubs para `start_job_run`/`get_job_run`
532
+ - [ ] Tests:
533
+ - SUCCEEDED inmediato → retorna `true`
534
+ - RUNNING → SUCCEEDED → retorna `true` (test con polling_interval bajísimo o stubear sleep)
535
+ - FAILED → levanta RuntimeError con mensaje
536
+ - STOPPED → idem
537
+ - TIMEOUT → idem
538
+ - `error_message` truncado a 200 chars + escape de `"`
539
+ - Logs emitidos: `glue_runner.start`, `glue_runner.polling`, `glue_runner.complete|failed`
540
+ - **Para evitar sleep real:** stubear `Kernel.sleep` o mover a `class << self; def polling_sleep(s); sleep s; end; end` y stubear
541
+
542
+ ### 4.3 Validación + commit
543
+
544
+ - [ ] `bundle exec rspec spec/data_drain/storage/local_spec.rb spec/data_drain/glue_runner_spec.rb`
545
+ - [ ] Commit: `test: cobertura Storage::Local y GlueRunner`
546
+
547
+ ### Checkpoint Fase 4
548
+
549
+ - [ ] Coverage > 70%
550
+ - [ ] Tiempo de suite < 10s
551
+
552
+ ---
553
+
554
+ ## Fase 5 — Item 3: `Record.disconnect!` + tests Record
555
+
556
+ **Roadmap:** [Item 3](../IMPROVEMENT_PLAN.md#item-3--cleanup-de-conexión-duckdb-thread-local)
557
+
558
+ ### 5.1 Implementar `Record.disconnect!`
559
+
560
+ - [ ] Editar `lib/data_drain/record.rb`:
561
+ ```ruby
562
+ # Cierra la conexión DuckDB del thread actual y limpia Thread.current.
563
+ # Idempotente: llamarlo varias veces no levanta.
564
+ #
565
+ # Útil en middlewares de Sidekiq/Puma para evitar memory leak en threads
566
+ # de larga vida.
567
+ #
568
+ # @return [void]
569
+ def self.disconnect!
570
+ entry = Thread.current.delete(:data_drain_duckdb)
571
+ return unless entry
572
+
573
+ entry[:conn]&.close
574
+ entry[:db]&.close
575
+ rescue StandardError
576
+ # silencio en cleanup
577
+ end
578
+ ```
579
+
580
+ ### 5.2 Tests `record_spec.rb`
581
+
582
+ - [ ] Crear `spec/data_drain/record_spec.rb`
583
+ - [ ] Definir clase de test:
584
+ ```ruby
585
+ class TestArchived < DataDrain::Record
586
+ self.bucket = "spec/fixtures"
587
+ self.folder_name = "test_archive"
588
+ self.partition_keys = [:year, :month]
589
+
590
+ attribute :id, :string
591
+ attribute :value, :integer
592
+ attribute :created_at, :datetime
593
+ end
594
+ ```
595
+ - [ ] Generar fixtures Parquet en `spec/fixtures/test_archive/year=2026/month=3/data.parquet` usando DuckDB:
596
+ ```ruby
597
+ # spec/support/parquet_fixtures.rb
598
+ module ParquetFixtures
599
+ def self.generate!
600
+ path = "spec/fixtures/test_archive"
601
+ FileUtils.rm_rf(path)
602
+ db = DuckDB::Database.open(":memory:")
603
+ conn = db.connect
604
+ conn.query(<<~SQL)
605
+ COPY (
606
+ SELECT 'uuid-1' AS id, 100 AS value, TIMESTAMP '2026-03-01' AS created_at, 2026 AS year, 3 AS month
607
+ UNION ALL SELECT 'uuid-2', 200, TIMESTAMP '2026-03-15', 2026, 3
608
+ ) TO '#{path}' (FORMAT PARQUET, PARTITION_BY (year, month), OVERWRITE_OR_IGNORE 1);
609
+ SQL
610
+ end
611
+ end
612
+ ```
613
+ - [ ] **Orden de carga (review big-pickle issue 4):** `spec/support/*.rb` se carga vía `Dir[...].sort.each` (Fase 0.5) ANTES de los tests, pero el bloque `before(:suite)` que dispara fixtures debe registrarse en el spec_helper, no en el archivo de support. Hacerlo así:
614
+ ```ruby
615
+ # spec/spec_helper.rb (al final, después de Dir[...].sort.each)
616
+ RSpec.configure do |config|
617
+ config.before(:suite) do
618
+ ParquetFixtures.generate! # genera fixtures Parquet
619
+ # Si hay setup global de DataDrain (configure), va antes de generate!
620
+ end
621
+ end
622
+ ```
623
+ Razón: si `ParquetFixtures.generate!` se invoca en `support/parquet_fixtures.rb` directamente, se ejecuta en tiempo de `require` (antes de cualquier `before(:suite)` de spec_helper). Manteniendo solo la definición del módulo en `support/` y el trigger en `spec_helper.rb`, garantizamos orden.
624
+ - [ ] Tests:
625
+ - `.where(year: 2026, month: 3)` retorna 2 instancias
626
+ - `.where(year: 2026, month: 3, limit: 1)` retorna 1
627
+ - `.find("uuid-1", year: 2026, month: 3)` retorna instancia con value 100
628
+ - `.find("nonexistent", year: 2026, month: 3)` retorna nil
629
+ - `.find("foo' OR 1=1 --", year: 2026, month: 3)` retorna nil (sanitización)
630
+ - `.where(year: 2099, month: 12)` retorna `[]` (Parquet no existe, no levanta)
631
+ - `build_query_path` respeta orden `[:year, :month]` aunque kwargs sean `(month: 3, year: 2026)`
632
+ - `.connection` retorna instancia, segunda llamada en mismo thread retorna misma
633
+ - `.connection` en thread distinto retorna instancia distinta
634
+ - `.disconnect!` limpia `Thread.current[:data_drain_duckdb]`
635
+ - `.disconnect!` llamado dos veces no levanta
636
+ - Después de `.disconnect!`, `.connection` reabre
637
+
638
+ ### 5.3 Validación
639
+
640
+ - [ ] `bundle exec rspec spec/data_drain/record_spec.rb`
641
+ - [ ] `bundle exec rubocop lib/data_drain/record.rb`
642
+
643
+ ### 5.4 Docs
644
+
645
+ - [ ] Actualizar `CLAUDE.md` sección "Conexiones thread-local" con snippet Sidekiq middleware:
646
+ ```ruby
647
+ # config/initializers/sidekiq.rb
648
+ Sidekiq.configure_server do |config|
649
+ config.server_middleware do |chain|
650
+ chain.add Class.new {
651
+ def call(_worker, _job, _queue)
652
+ yield
653
+ ensure
654
+ DataDrain::Record.disconnect!
655
+ end
656
+ }
657
+ end
658
+ end
659
+ ```
660
+ - [ ] Actualizar `skill/references/api-detallada.md` sección Record.disconnect!
661
+ - [ ] Actualizar `skill/references/antipatrones.md` item 12 (ahora hay forma correcta de cerrar)
662
+
663
+ ### 5.5 Commit
664
+
665
+ - [ ] `git add lib/data_drain/record.rb spec/data_drain/record_spec.rb spec/support/parquet_fixtures.rb`
666
+ - [ ] `git add CLAUDE.md skill/`
667
+ - [ ] Commit: `feat(record): agregar Record.disconnect! para cleanup thread-local (item 3)`
668
+
669
+ ### Checkpoint Fase 5
670
+
671
+ - [ ] Tests Record verdes
672
+ - [ ] Fixtures Parquet generados en `before(:suite)`
673
+ - [ ] Documentación de uso Sidekiq publicada
674
+
675
+ ---
676
+
677
+ ## Fase 6 — Tests Engine + FileIngestor
678
+
679
+ Refuerzo y expansión de specs existentes.
680
+
681
+ ### 6.1 Engine
682
+
683
+ - [ ] Revisar `spec/data_drain/engine_spec.rb`. Agregar:
684
+ - Test `pg_count == 0` → retorna `true` sin export ni purge, log `engine.skip_empty`
685
+ - Test `skip_export: true` → no llama `export_to_parquet`, sí llama `verify_integrity`
686
+ - Test `verify_integrity` retorna false → no llama `purge_from_postgres`, retorna false
687
+ - Test heartbeat: 100 lotes simulados → emite `engine.purge_heartbeat`
688
+ - Test `throttle_delay > 0` → llama sleep entre lotes (stubear Kernel.sleep)
689
+ - Test `idle_in_transaction_session_timeout = 0` se setea
690
+ - Test `idle_in_transaction_session_timeout = nil` no se setea
691
+
692
+ ### 6.2 FileIngestor
693
+
694
+ - [ ] Revisar `spec/data_drain/file_ingestor_spec.rb`. Agregar:
695
+ - Test archivo no existe → retorna false, log `file_ingestor.file_not_found`
696
+ - Test count == 0 → cleanup + retorna true (con `delete_after_upload: true` borra)
697
+ - Test JSON → usa `read_json_auto`
698
+ - Test Parquet → usa `read_parquet`
699
+ - Test extensión no soportada → levanta `DataDrain::Error`
700
+ - Test `delete_after_upload: false` → archivo no se borra
701
+
702
+ ### 6.3 Validación + commit
703
+
704
+ - [ ] `bundle exec rspec`
705
+ - [ ] Coverage ≥ 80%
706
+ - [ ] Commit: `test: expandir cobertura Engine y FileIngestor`
707
+
708
+ ### Checkpoint Fase 6
709
+
710
+ - [ ] Coverage ≥ 80% líneas (criterio del item 4)
711
+ - [ ] Tiempo suite total < 30s
712
+ - [ ] No flakes en 3 corridas seguidas: `for i in 1 2 3; do bundle exec rspec || break; done`
713
+
714
+ ---
715
+
716
+ ## Fase 7 — Release
717
+
718
+ ### 7.1 Lint global
719
+
720
+ - [ ] `bundle exec rubocop` sin ofensas en archivos modificados
721
+ - [ ] Si rubocop reporta cosas en archivos NO tocados, dejar como están (regla: no flag código no tocado)
722
+
723
+ ### 7.2 Coverage final
724
+
725
+ - [ ] `bundle exec rspec` y verificar `coverage/index.html`
726
+ - [ ] Anotar % en CHANGELOG
727
+
728
+ ### 7.3 CHANGELOG
729
+
730
+ - [ ] Editar `CHANGELOG.md`. Agregar al tope:
731
+ ```markdown
732
+ ## [0.2.0] - 2026-XX-XX
733
+
734
+ ### Security
735
+ - **BREAKING (preventivo):** `table_name` y `primary_key` se validan contra regex `\A[a-zA-Z_][a-zA-Z0-9_]*\z`. Identificadores con caracteres especiales (puntos, espacios, comillas) ahora levantan `DataDrain::ConfigurationError`. (item 2)
736
+ - Storage::S3 migra a `CREATE SECRET (TYPE S3, PROVIDER credential_chain)`. Si `aws_access_key_id`/`aws_secret_access_key` están seteados, se mantiene comportamiento explícito; si no, usa AWS credential chain (IAM roles, env vars, ~/.aws/credentials). (item 1)
737
+
738
+ ### Features
739
+ - `Record.disconnect!` cierra y limpia la conexión DuckDB thread-local. Recomendado en middlewares Sidekiq/Puma para evitar memory leak. Idempotente. (item 3)
740
+
741
+ ### Tests
742
+ - Cobertura 4 specs → ~XX specs (Record, Storage::Local, Storage::S3, GlueRunner, Observability, Configuration, JsonType, Validations).
743
+ - Cobertura líneas: ~XX% (medida con SimpleCov, mínimo 80%).
744
+ ```
745
+ - [ ] Reemplazar fecha y porcentajes reales
746
+
747
+ ### 7.4 Bump de versión
748
+
749
+ - [ ] Editar `lib/data_drain/version.rb`: `VERSION = "0.2.0"`
750
+ - [ ] `bundle install` (actualiza Gemfile.lock)
751
+
752
+ ### 7.5 Skill regenerada
753
+
754
+ - [ ] Invocar `skill-builder` en modo completo para regenerar `skill/` con cambios:
755
+ ```
756
+ Modo: completo (gem-release lo dispara automáticamente)
757
+ ```
758
+ O ejecutar manualmente actualizaciones de `skill/SKILL.md` y `references/`.
759
+
760
+ ### 7.6 Commit final del release
761
+
762
+ - [ ] `git add CHANGELOG.md lib/data_drain/version.rb Gemfile.lock skill/`
763
+ - [ ] Commit: `chore: release v0.2.0 — hardening de seguridad y testing`
764
+
765
+ ### 7.7 Tag y push
766
+
767
+ - [ ] `git tag v0.2.0`
768
+ - [ ] `git push origin feature/v0.2.0`
769
+ - [ ] `git push origin v0.2.0`
770
+ - [ ] Crear PR a `main` (vía `gh pr create`) con cuerpo basado en CHANGELOG
771
+
772
+ ### 7.8 Post-merge
773
+
774
+ - [ ] Mergear PR
775
+ - [ ] Si aplica: invocar skill `gem-release` para empaquetar
776
+ - [ ] Actualizar `docs/IMPROVEMENT_PLAN.md` marcando items 1, 2, 3, 4 como `[x]`
777
+ - [ ] Mover este plan a `docs/execution/archive/v0.2.0.md`
778
+
779
+ ---
780
+
781
+ ## Validación final del release
782
+
783
+ - [ ] Tests verdes en CI (cuando exista, item 14) o local
784
+ - [ ] Coverage ≥ 80%
785
+ - [ ] Rubocop sin ofensas
786
+ - [ ] CHANGELOG completo
787
+ - [ ] Version bumped
788
+ - [ ] Tag creado
789
+ - [ ] PR mergeado
790
+ - [ ] Items 1-4 marcados `[x]` en roadmap
791
+
792
+ ---
793
+
794
+ ## Plan B: si algún item se atasca
795
+
796
+ | Si... | Entonces... |
797
+ |-------|-------------|
798
+ | Item 1 (S3 secret) requiere DuckDB > 1.4 | Bumpear `Gemfile` o documentar y postergar a v0.3.0 |
799
+ | Tests Record requieren Postgres real | Marcar como `:integration`, skip en suite default |
800
+ | Tests con `Aws::S3::Client.stub_responses` no soportan `list_objects_v2` paginado | Usar `WebMock` con respuestas XML reales de S3 |
801
+ | Coverage no llega a 80% | Bajar umbral a 70% en este release, item 4 sigue abierto en v0.2.1 |
802
+ | Rubocop versión actual reporta nuevas ofensas en código tocado | Fix puntual o `# rubocop:disable` con razón en comentario |
803
+
804
+ ---
805
+
806
+ ## Notas para el agente que ejecuta
807
+
808
+ - **Cada commit debe ser autocontenido y atómico.** Si un cambio rompe tests, no commit.
809
+ - **Antes de commit, correr `rspec` Y `rubocop`** para los archivos tocados.
810
+ - **No crear archivos no listados** sin pedir confirmación al usuario.
811
+ - **Si surge bloqueo, marcar el subtask como `[!]` y consultar al usuario** antes de tomar shortcut.
812
+ - **No saltarse fases.** El orden está pensado para minimizar riesgo y maximizar cobertura.