-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathsqlite.go
More file actions
6179 lines (5863 loc) · 245 KB
/
Copy pathsqlite.go
File metadata and controls
6179 lines (5863 loc) · 245 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
package hopper
import (
"context"
"database/sql"
"errors"
"fmt"
"log/slog"
"os"
"strconv"
"strings"
"time"
)
func openSQLite(ctx context.Context, dsn string) (*DB, error) {
lite, err := sql.Open("sqlite3", dsn+"?_journal_mode=WAL&_busy_timeout=30000&_foreign_keys=ON&_synchronous=NORMAL")
if err != nil {
return nil, fmt.Errorf("hopper: open sqlite: %w", err)
}
// SQLite does not support concurrent writers; restrict to a single
// connection so database/sql never opens parallel write transactions.
lite.SetMaxOpenConns(1)
if err := lite.PingContext(ctx); err != nil {
if closeErr := lite.Close(); closeErr != nil {
slog.Debug("close sqlite after failed ping", "error", closeErr)
}
return nil, fmt.Errorf("hopper: ping sqlite: %w", err)
}
db := newDB()
db.lite = lite
return db, nil
}
func pragmaHasColumn(ctx context.Context, db *sql.DB, column string) int {
return pragmaHasColumnIn(ctx, db, "samples", column)
}
// pragmaHasColumnIn reports whether the named column exists on the given
// table. Uses pragma_table_xinfo (not table_info) so GENERATED columns are
// counted — without this, legacy ALTER TABLE ADD COLUMN migrations would
// fire duplicates against columns that already exist as generated.
func pragmaHasColumnIn(ctx context.Context, db *sql.DB, table, column string) int {
var count int
if err := db.QueryRowContext(ctx,
"SELECT count(*) FROM pragma_table_xinfo(?) WHERE name = ?", table, column,
).Scan(&count); err != nil {
slog.Debug("pragma_table_xinfo failed", "table", table, "column", column, "error", err)
return 0
}
return count
}
// liteSightingCorroborationTriggers keeps samples.corroborated in step with the
// sightings ledger on every write — insert, delete, and the re-key nothing does
// yet — so a source drop settles inside its own transaction instead of leaving
// stale flags for a maintenance command to find later. Mirror of the PG
// sightings_corroborate function; SQLite has no TG_OP, so the one function
// becomes three bodies. See pg.go for why the invariant lives in the database.
//
// Named once because it is created from two places: the migration list, and the
// sightings rebuild, which renames the table out from under these and drops
// them with it.
//
// Dropped before each create: SQLite has no CREATE OR REPLACE TRIGGER, so
// without the drops an existing database keeps whatever body it was built with.
// That is how the version-blind marking of 2026-09-08 would have survived the
// fix on every database that already existed.
var liteSightingCorroborationTriggers = []string{
`DROP TRIGGER IF EXISTS sightings_corroborate_trg`,
`DROP TRIGGER IF EXISTS sightings_uncorroborate_trg`,
`DROP TRIGGER IF EXISTS sightings_resubject_trg`,
`CREATE TRIGGER IF NOT EXISTS sightings_corroborate_trg
AFTER INSERT ON sightings
FOR EACH ROW
BEGIN
UPDATE samples SET corroborated = 1
WHERE corroborated = 0 AND sha256 = NEW.subject;
-- Narrowed to the releases the claim actually names; see the
-- Postgres trigger in schema.sql for why. SQLite has no regex, so
-- "names exact releases" is a GLOB and list membership is a LIKE
-- over a comma-delimited copy.
UPDATE samples SET corroborated = 1
WHERE purl_base = NEW.subject AND purl_base != '' AND corroborated = 0
AND (
NOT (NEW.affected GLOB '[0-9]*')
OR ',' || replace(NEW.affected, ' ', '') || ',' LIKE '%,' || version || ',%'
);
END`,
// Only once the LAST citation is gone: two sources naming one package is
// the normal case, and dropping one must not uncorroborate the sample.
`CREATE TRIGGER IF NOT EXISTS sightings_uncorroborate_trg
AFTER DELETE ON sightings
FOR EACH ROW
WHEN NOT EXISTS (SELECT 1 FROM sightings WHERE subject = OLD.subject)
BEGIN
UPDATE samples SET corroborated = 0
WHERE corroborated = 1 AND sha256 = OLD.subject;
UPDATE samples SET corroborated = 0
WHERE purl_base = OLD.subject AND purl_base != '' AND corroborated = 1;
END`,
// UPDATE OF subject, so a delta-guarded snapshot re-push — which only ever
// rewrites url/note/operator/claim/filename/published_at — fires nothing.
`CREATE TRIGGER IF NOT EXISTS sightings_resubject_trg
AFTER UPDATE OF subject ON sightings
FOR EACH ROW
WHEN OLD.subject IS NOT NEW.subject
BEGIN
UPDATE samples SET corroborated = 0
WHERE corroborated = 1 AND sha256 = OLD.subject
AND NOT EXISTS (SELECT 1 FROM sightings WHERE subject = OLD.subject);
UPDATE samples SET corroborated = 0
WHERE purl_base = OLD.subject AND purl_base != '' AND corroborated = 1
AND NOT EXISTS (SELECT 1 FROM sightings WHERE subject = OLD.subject);
UPDATE samples SET corroborated = 1
WHERE corroborated = 0 AND sha256 = NEW.subject;
UPDATE samples SET corroborated = 1
WHERE purl_base = NEW.subject AND purl_base != '' AND corroborated = 0
AND (
NOT (NEW.affected GLOB '[0-9]*')
OR ',' || replace(NEW.affected, ' ', '') || ',' LIKE '%,' || version || ',%'
);
END`,
}
func (db *DB) migrateSQLite(ctx context.Context) error { //nolint:gocognit,maintidx,revive // sequential migration steps; splitting reduces clarity
slog.Debug("executing initial schema ddl")
if _, err := db.lite.ExecContext(ctx, schemaSQLite); err != nil {
return fmt.Errorf("hopper: migrate sqlite: %w", err)
}
// Add columns introduced after initial schema. SQLite lacks
// ALTER TABLE ... IF NOT EXISTS, so check column existence via PRAGMA.
hasParent := pragmaHasColumn(ctx, db.lite, "parent")
if hasParent == 0 {
for _, ddl := range []string{
`ALTER TABLE samples ADD COLUMN parent TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE samples ADD COLUMN skip TEXT NOT NULL DEFAULT ''`,
`CREATE INDEX IF NOT EXISTS idx_samples_parent ON samples(parent) WHERE parent != ''`,
} {
slog.Debug("executing migration ddl", "ddl", ddl)
if _, err := db.lite.ExecContext(ctx, ddl); err != nil {
return fmt.Errorf("hopper: migrate sqlite: %w", err)
}
}
}
hasFormula := pragmaHasColumn(ctx, db.lite, "formula")
if hasFormula == 0 {
for _, ddl := range []string{
`ALTER TABLE samples ADD COLUMN formula TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE samples ADD COLUMN elements TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE samples ADD COLUMN score INTEGER NOT NULL DEFAULT 0`,
`CREATE INDEX IF NOT EXISTS idx_samples_formula ON samples(formula) WHERE formula != ''`,
// Drains itself as backfill completes: rows leave the index when elements
// is populated. Without it, each batch's gating SELECT seq-scans the heap.
`CREATE INDEX IF NOT EXISTS idx_samples_score ON samples(score) WHERE score != 0`,
`CREATE INDEX IF NOT EXISTS idx_samples_feed_source ON samples(source, label, analyzed_at DESC) WHERE cleave_result IS NOT NULL`,
`CREATE INDEX IF NOT EXISTS idx_samples_feed ON samples(feed) WHERE feed != ''`,
`CREATE INDEX IF NOT EXISTS idx_samples_ecosystem ON samples(ecosystem) WHERE ecosystem != ''`,
} {
slog.Debug("executing migration ddl", "ddl", ddl)
if _, err := db.lite.ExecContext(ctx, ddl); err != nil {
return fmt.Errorf("hopper: migrate sqlite: %w", err)
}
}
}
// Add litmus_result column.
hasLitmusResult := pragmaHasColumn(ctx, db.lite, "litmus_result")
if hasLitmusResult == 0 {
if _, err := db.lite.ExecContext(ctx, `ALTER TABLE samples ADD COLUMN litmus_result TEXT`); err != nil {
return fmt.Errorf("hopper: migrate sqlite: %w", err)
}
}
// Add llm_result column.
hasLLMResult := pragmaHasColumn(ctx, db.lite, "llm_result")
if hasLLMResult == 0 {
if _, err := db.lite.ExecContext(ctx, `ALTER TABLE samples ADD COLUMN llm_result TEXT`); err != nil {
return fmt.Errorf("hopper: migrate sqlite: %w", err)
}
}
// Add litmus_score column.
hasLitmusScore := pragmaHasColumn(ctx, db.lite, "litmus_score")
if hasLitmusScore == 0 {
if _, err := db.lite.ExecContext(ctx, `ALTER TABLE samples ADD COLUMN litmus_score REAL NOT NULL DEFAULT 0`); err != nil {
return fmt.Errorf("hopper: migrate sqlite: %w", err)
}
}
// Add mtime column.
hasMtime := pragmaHasColumn(ctx, db.lite, "mtime")
if hasMtime == 0 {
for _, ddl := range []string{
`ALTER TABLE samples ADD COLUMN mtime DATETIME`,
`CREATE INDEX IF NOT EXISTS idx_samples_mtime ON samples(mtime) WHERE mtime IS NOT NULL`,
`CREATE INDEX IF NOT EXISTS idx_samples_feed_source_mtime ON samples(source, label, mtime DESC) WHERE cleave_result IS NOT NULL`,
`CREATE INDEX IF NOT EXISTS idx_samples_feed_top_created_done ` +
`ON samples(source, label, created_at DESC) ` +
`WHERE cleave_result IS NOT NULL AND parent = '' AND litmus_result IS NOT NULL`,
} {
slog.Debug("executing migration ddl", "ddl", ddl)
if _, err := db.lite.ExecContext(ctx, ddl); err != nil {
return fmt.Errorf("hopper: migrate sqlite: %w", err)
}
}
}
if pragmaHasColumn(ctx, db.lite, "last_error_at") == 0 {
if _, err := db.lite.ExecContext(ctx, `ALTER TABLE samples ADD COLUMN last_error_at DATETIME`); err != nil {
return fmt.Errorf("hopper: migrate sqlite: %w", err)
}
}
if pragmaHasColumn(ctx, db.lite, "first_analyzed_at") == 0 {
if _, err := db.lite.ExecContext(ctx, `ALTER TABLE samples ADD COLUMN first_analyzed_at DATETIME`); err != nil {
return fmt.Errorf("hopper: migrate sqlite: %w", err)
}
}
// Poison-sample protection: claim-attempt counter and skip timestamp. No
// dedicated index — the reaper's periodic "attempts >= N" sweep rides the
// existing idx_samples_unanalyzed over the small pending set.
if pragmaHasColumn(ctx, db.lite, "attempts") == 0 {
if _, err := db.lite.ExecContext(ctx, `ALTER TABLE samples ADD COLUMN attempts INTEGER NOT NULL DEFAULT 0`); err != nil {
return fmt.Errorf("hopper: migrate sqlite: %w", err)
}
}
if pragmaHasColumn(ctx, db.lite, "skipped_at") == 0 {
if _, err := db.lite.ExecContext(ctx, `ALTER TABLE samples ADD COLUMN skipped_at DATETIME`); err != nil {
return fmt.Errorf("hopper: migrate sqlite: %w", err)
}
}
hasMarkerMtime := pragmaHasColumn(ctx, db.lite, "marker_mtime")
if hasMarkerMtime == 0 {
if _, err := db.lite.ExecContext(ctx, `ALTER TABLE samples ADD COLUMN marker_mtime DATETIME`); err != nil {
return fmt.Errorf("hopper: migrate sqlite: %w", err)
}
}
if _, err := db.lite.ExecContext(ctx, `CREATE INDEX IF NOT EXISTS idx_samples_file_type ON samples(file_type)`); err != nil {
return fmt.Errorf("hopper: migrate sqlite: %w", err)
}
for _, ddl := range []string{
`CREATE INDEX IF NOT EXISTS idx_samples_feed_top_created_done ` +
`ON samples(source, label, created_at DESC) ` +
`WHERE cleave_result IS NOT NULL AND parent = '' AND litmus_result IS NOT NULL`,
} {
if _, err := db.lite.ExecContext(ctx, ddl); err != nil {
return fmt.Errorf("hopper: migrate sqlite: %w", err)
}
}
hasMaxCrit := pragmaHasColumn(ctx, db.lite, "max_crit")
if hasMaxCrit == 0 {
if _, err := db.lite.ExecContext(ctx, `ALTER TABLE samples ADD COLUMN max_crit INTEGER NOT NULL DEFAULT 0`); err != nil {
return fmt.Errorf("hopper: migrate sqlite: %w", err)
}
}
hasSuspicious := pragmaHasColumn(ctx, db.lite, "suspicious_count")
if hasSuspicious == 0 {
if _, err := db.lite.ExecContext(ctx, `ALTER TABLE samples ADD COLUMN suspicious_count INTEGER NOT NULL DEFAULT 0`); err != nil {
return fmt.Errorf("hopper: migrate sqlite: %w", err)
}
}
// Pull-based work scheduling: claim tracking columns.
hasClaimedBy := pragmaHasColumn(ctx, db.lite, "claimed_by")
if hasClaimedBy == 0 {
for _, ddl := range []string{
`ALTER TABLE samples ADD COLUMN claimed_by TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE samples ADD COLUMN claimed_at DATETIME`,
`CREATE INDEX IF NOT EXISTS idx_samples_claimable ON samples(id) WHERE cleave_result IS NULL AND claimed_by = ''`,
} {
if _, err := db.lite.ExecContext(ctx, ddl); err != nil {
return fmt.Errorf("hopper: migrate sqlite: %w", err)
}
}
}
// Traits-version rescan column.
hasTraitsVersion := pragmaHasColumn(ctx, db.lite, "traits_version")
if hasTraitsVersion == 0 {
for _, ddl := range []string{
`ALTER TABLE samples ADD COLUMN traits_version TEXT NOT NULL DEFAULT ''`,
`CREATE INDEX IF NOT EXISTS idx_samples_stale_traits ` +
`ON samples(traits_version, analyzed_at) ` +
`WHERE cleave_result IS NOT NULL AND skip = '' AND parent = ''`,
} {
if _, err := db.lite.ExecContext(ctx, ddl); err != nil {
return fmt.Errorf("hopper: migrate sqlite: %w", err)
}
}
}
// cyclotron_attempted_at: stamped when cyclotron seeds a sample, used to
// gate FP/FN seed queries with a per-sample retry cooldown.
if pragmaHasColumn(ctx, db.lite, "cyclotron_attempted_at") == 0 {
if _, err := db.lite.ExecContext(ctx, `ALTER TABLE samples ADD COLUMN cyclotron_attempted_at DATETIME`); err != nil {
return fmt.Errorf("hopper: migrate sqlite: %w", err)
}
}
// Rescan queue: rescan_priority (2 = interactive/ahead of new, 1 = bulk
// repair/behind new, 0 = not queued) + rescan_requested_at (when queued, for
// FIFO ordering). Supersedes the timestamp-only forced_rescan_at, which is
// dropped without preserving its handful of pending rows. See the matching PG
// migration for the full rationale.
if pragmaHasColumn(ctx, db.lite, "rescan_priority") == 0 {
if _, err := db.lite.ExecContext(ctx,
`ALTER TABLE samples ADD COLUMN rescan_priority INTEGER NOT NULL DEFAULT 0`); err != nil {
return fmt.Errorf("hopper: migrate sqlite: %w", err)
}
}
if pragmaHasColumn(ctx, db.lite, "rescan_requested_at") == 0 {
if _, err := db.lite.ExecContext(ctx,
`ALTER TABLE samples ADD COLUMN rescan_requested_at DATETIME`); err != nil {
return fmt.Errorf("hopper: migrate sqlite: %w", err)
}
}
if pragmaHasColumn(ctx, db.lite, "forced_rescan_at") == 1 {
if _, err := db.lite.ExecContext(ctx,
`ALTER TABLE samples DROP COLUMN forced_rescan_at`); err != nil {
return fmt.Errorf("hopper: migrate sqlite: %w", err)
}
}
for _, ddl := range []string{
`DROP INDEX IF EXISTS idx_samples_forced_rescan`,
`CREATE INDEX IF NOT EXISTS idx_samples_rescan_queue ` +
`ON samples(rescan_priority, rescan_requested_at) WHERE rescan_priority > 0`,
// Mirrors idx_samples_pending_sighted: the sighted claim tier. See pg.go
// for why the tier reads the denormalized flag instead of joining.
`CREATE INDEX IF NOT EXISTS idx_samples_pending_sighted ` +
`ON samples(id) ` +
`WHERE corroborated = 1 AND cleave_result IS NULL AND skip = '' AND parent = ''`,
} {
if _, err := db.lite.ExecContext(ctx, ddl); err != nil {
return fmt.Errorf("hopper: migrate sqlite: %w", err)
}
}
// Forager direct-insert provenance: url, domain (eTLD+1, populated by
// Go via publicsuffix), name, version. See pg.go for rationale.
for _, col := range []struct{ name, ddl string }{
{"url", `ALTER TABLE samples ADD COLUMN url TEXT NOT NULL DEFAULT ''`},
{"domain", `ALTER TABLE samples ADD COLUMN domain TEXT NOT NULL DEFAULT ''`},
{"package", `ALTER TABLE samples ADD COLUMN package TEXT NOT NULL DEFAULT ''`},
{"version", `ALTER TABLE samples ADD COLUMN version TEXT NOT NULL DEFAULT ''`},
{"purl_base", `ALTER TABLE samples ADD COLUMN purl_base TEXT NOT NULL DEFAULT ''`},
{"provenance", `ALTER TABLE samples ADD COLUMN provenance TEXT`},
{"fetched_at", `ALTER TABLE samples ADD COLUMN fetched_at DATETIME`},
// top_traits: JSON []TopTrait of the strongest suspicious+ trait ids,
// written by the Go result-store paths (ParseCleaveResult). Defaults
// to '' — pre-existing dev/test rows simply have no headline traits.
{"top_traits", `ALTER TABLE samples ADD COLUMN top_traits TEXT NOT NULL DEFAULT ''`},
// trait_graph: JSON []TraitNode — the strongest traits and the
// dependency edges between them, so a feed row can draw the same
// malecule as the detail page. Written by the same Go result-store
// paths; '' means "no graph recorded", and readers fall back.
{"trait_graph", `ALTER TABLE samples ADD COLUMN trait_graph TEXT NOT NULL DEFAULT ''`},
} {
if pragmaHasColumn(ctx, db.lite, col.name) == 0 {
if _, err := db.lite.ExecContext(ctx, col.ddl); err != nil {
return fmt.Errorf("hopper: migrate sqlite samples.%s: %w", col.name, err)
}
}
}
for _, ddl := range []string{
`CREATE INDEX IF NOT EXISTS idx_samples_domain ON samples(domain) WHERE domain != ''`,
`CREATE INDEX IF NOT EXISTS idx_samples_package_version ON samples(package, version) WHERE package != ''`,
`CREATE INDEX IF NOT EXISTS idx_samples_purl_base ON samples(purl_base) WHERE purl_base != ''`,
`CREATE INDEX IF NOT EXISTS idx_samples_purl_lookup ` +
`ON samples (purl_base, version, analyzed_at DESC) ` +
`WHERE purl_base != '' AND litmus_result IS NOT NULL AND cleave_result IS NOT NULL`,
`DROP INDEX IF EXISTS idx_samples_purl_analyzed`,
} {
if _, err := db.lite.ExecContext(ctx, ddl); err != nil {
return fmt.Errorf("hopper: migrate sqlite samples index: %w", err)
}
}
// External-corroboration flag + ledger (see schema.sql). SQLite mirror of the
// PG migration: the boolean is an INTEGER 0/1 column, maintained by
// AddSightings, and drives the feed's corroborated-only filter.
// See schema.sql: set once when a worker is first handed the sample, so
// (claimed_first_at - created_at) measures pickup latency.
if pragmaHasColumn(ctx, db.lite, "claimed_first_at") == 0 {
if _, err := db.lite.ExecContext(ctx,
`ALTER TABLE samples ADD COLUMN claimed_first_at DATETIME`); err != nil {
return fmt.Errorf("hopper: migrate sqlite samples.claimed_first_at: %w", err)
}
}
if pragmaHasColumn(ctx, db.lite, "corroborated") == 0 {
if _, err := db.lite.ExecContext(ctx,
`ALTER TABLE samples ADD COLUMN corroborated INTEGER NOT NULL DEFAULT 0`); err != nil {
return fmt.Errorf("hopper: migrate sqlite samples.corroborated: %w", err)
}
}
for _, ddl := range append([]string{
`CREATE INDEX IF NOT EXISTS idx_samples_corroborated ` +
`ON samples(created_at) ` +
`WHERE corroborated = 1 AND parent = '' AND cleave_result IS NOT NULL AND litmus_result IS NOT NULL`,
`CREATE INDEX IF NOT EXISTS idx_samples_sighted_created ` +
`ON samples(created_at DESC) WHERE corroborated = 1 AND parent = '' AND skip = ''`,
// popular_packages: see the Postgres migration for why this is keyed on
// the version-less identity and why it is not a sighting.
`CREATE TABLE IF NOT EXISTS popular_packages (
purl_base TEXT PRIMARY KEY,
ecosystem TEXT NOT NULL,
rank INTEGER NOT NULL,
source TEXT NOT NULL,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
)`,
`CREATE INDEX IF NOT EXISTS idx_popular_rank ON popular_packages(rank)`,
`CREATE TABLE IF NOT EXISTS sightings (
source TEXT NOT NULL,
relayer TEXT NOT NULL DEFAULT '',
subject TEXT NOT NULL,
url TEXT NOT NULL DEFAULT '',
note TEXT NOT NULL DEFAULT '',
operator TEXT NOT NULL DEFAULT '',
affected TEXT NOT NULL DEFAULT '',
claim TEXT NOT NULL DEFAULT 'malicious',
filename TEXT NOT NULL DEFAULT '',
handle TEXT NOT NULL DEFAULT '',
basis TEXT NOT NULL DEFAULT 'predicted',
published_at DATETIME,
first_seen DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
attempted_at TIMESTAMP,
PRIMARY KEY (source, subject, affected)
)`,
`CREATE INDEX IF NOT EXISTS idx_sightings_subject ON sightings(subject)`,
`CREATE INDEX IF NOT EXISTS idx_sightings_recent ON sightings(first_seen DESC) WHERE claim = 'malicious'`,
`CREATE INDEX IF NOT EXISTS idx_sightings_acquisition_recent ` +
`ON sightings(first_seen DESC) WHERE claim IN ('malicious', 'suspicious')`,
}, liteSightingCorroborationTriggers...) {
if _, err := db.lite.ExecContext(ctx, ddl); err != nil {
return fmt.Errorf("hopper: migrate sqlite sightings: %w", err)
}
}
// basis on a ledger that predates it. SQLite has no ADD COLUMN IF NOT
// EXISTS, so the PRAGMA decides. 'predicted' for existing rows is the
// fail-safe: adopting the column under-counts confidence until each feed
// re-pushes, rather than crediting claims nobody adjudicated.
if pragmaHasColumnIn(ctx, db.lite, "sightings", "basis") == 0 {
if _, err := db.lite.ExecContext(ctx,
`ALTER TABLE sightings ADD COLUMN basis TEXT NOT NULL DEFAULT 'predicted'`); err != nil {
return fmt.Errorf("hopper: migrate sqlite sightings basis: %w", err)
}
}
if pragmaHasColumnIn(ctx, db.lite, "sightings", "handle") == 0 {
if _, err := db.lite.ExecContext(ctx,
`ALTER TABLE sightings ADD COLUMN handle TEXT NOT NULL DEFAULT ''`); err != nil {
return fmt.Errorf("hopper: migrate sqlite sightings handle: %w", err)
}
}
if pragmaHasColumnIn(ctx, db.lite, "sightings", "relayer") == 0 {
if _, err := db.lite.ExecContext(ctx,
`ALTER TABLE sightings ADD COLUMN relayer TEXT NOT NULL DEFAULT ''`); err != nil {
return fmt.Errorf("hopper: migrate sqlite sightings relayer: %w", err)
}
}
for _, ddl := range []string{
`CREATE TABLE IF NOT EXISTS sighting_acquisitions (
target TEXT PRIMARY KEY,
attempts INTEGER NOT NULL DEFAULT 0,
acquired INTEGER NOT NULL DEFAULT 0,
last_attempt DATETIME,
next_attempt DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
last_error TEXT NOT NULL DEFAULT '',
finished_at DATETIME
)`,
`CREATE INDEX IF NOT EXISTS idx_sighting_acquisitions_due ` +
`ON sighting_acquisitions(next_attempt) WHERE acquired = 0`,
} {
if _, err := db.lite.ExecContext(ctx, ddl); err != nil {
return fmt.Errorf("hopper: migrate sqlite sighting acquisitions: %w", err)
}
}
// finished_at on a table that predates it. SQLite has no ADD COLUMN IF NOT
// EXISTS, so the PRAGMA decides. NULL for existing rows is the honest
// adoption value: those attempts happened before anything recorded whether
// they reported an outcome.
if pragmaHasColumnIn(ctx, db.lite, "sighting_acquisitions", "finished_at") == 0 {
if _, err := db.lite.ExecContext(ctx,
`ALTER TABLE sighting_acquisitions ADD COLUMN finished_at DATETIME`); err != nil {
return fmt.Errorf("hopper: migrate sqlite sighting_acquisitions.finished_at: %w", err)
}
// Adopt rows that predate the column, inside the same one-shot branch:
// they were written when an unfinished target was retried after its
// lease, so none of them is abandoned in the new sense. See pg.go.
if _, err := db.lite.ExecContext(ctx,
`UPDATE sighting_acquisitions SET finished_at = last_attempt
WHERE finished_at IS NULL AND last_attempt IS NOT NULL`); err != nil {
return fmt.Errorf("hopper: adopt sqlite sighting_acquisitions.finished_at: %w", err)
}
}
if _, err := db.lite.ExecContext(ctx,
`CREATE INDEX IF NOT EXISTS idx_sighting_acquisitions_unfinished `+
`ON sighting_acquisitions(last_attempt) WHERE finished_at IS NULL`); err != nil {
return fmt.Errorf("hopper: migrate sqlite sighting_acquisitions unfinished index: %w", err)
}
if err := db.migrateLiteSightingsKey(ctx); err != nil {
return err
}
// After the key rebuild, which recreates the table from an older shape, so
// the column is added to whichever table survives that step. The queue index
// is created here too rather than in the DDL list above: it is partial on
// attempted_at, so it cannot be built before the column exists.
if err := db.migrateLiteSightingsAttemptedAt(ctx); err != nil {
return err
}
if _, err := db.lite.ExecContext(ctx, liteSightingsAcquirableIndex); err != nil {
return fmt.Errorf("hopper: migrate sqlite sightings queue index: %w", err)
}
// Worker heartbeat table for dashboard.
if _, err := db.lite.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS workers (
name TEXT PRIMARY KEY,
last_seen DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
slots INTEGER NOT NULL DEFAULT 1,
version TEXT NOT NULL DEFAULT '',
traits TEXT NOT NULL DEFAULT '',
analyzed INTEGER NOT NULL DEFAULT 0,
errors INTEGER NOT NULL DEFAULT 0
)`); err != nil {
return fmt.Errorf("hopper: migrate sqlite: %w", err)
}
// Partial indexes matching PG for review and dashboard queries.
for _, ddl := range []string{
// serves falsePositives, truePositives, falseNegatives, benignReview, badReview
`CREATE INDEX IF NOT EXISTS idx_samples_misclassified_review ` +
`ON samples(label, max_crit, suspicious_count) ` +
`WHERE label_source = 'marker' AND skip = 'misclassified' ` +
`AND cleave_result IS NOT NULL AND status = ''`,
// serves conflictReview and CountAnalyzed
`CREATE INDEX IF NOT EXISTS idx_samples_litmus_done ` +
`ON samples(id) WHERE litmus_result IS NOT NULL`,
// CountPending / claimable ordering
`DROP INDEX IF EXISTS idx_samples_claimable`,
`CREATE INDEX IF NOT EXISTS idx_samples_claimable ` +
`ON samples(updated_at, id) ` +
`WHERE cleave_result IS NULL AND skip = '' AND parent = ''`,
`CREATE INDEX IF NOT EXISTS idx_samples_claimable_sha ` +
`ON samples(sha256) ` +
`WHERE cleave_result IS NULL AND skip = '' AND parent = ''`,
// NewestAnalyzedAt
`CREATE INDEX IF NOT EXISTS idx_samples_analyzed_at ` +
`ON samples(analyzed_at) WHERE analyzed_at IS NOT NULL`,
// Workflow dashboard freshness and backlog grouping.
`CREATE INDEX IF NOT EXISTS idx_samples_top_created ` +
`ON samples(created_at DESC, id) WHERE parent = ''`,
`CREATE INDEX IF NOT EXISTS idx_samples_top_ready_created ` +
`ON samples(created_at DESC, id) ` +
`WHERE parent = '' AND cleave_result IS NOT NULL AND litmus_result IS NOT NULL`,
`CREATE INDEX IF NOT EXISTS idx_samples_top_ready_first_analyzed_coalesce ` +
`ON samples(COALESCE(first_analyzed_at, analyzed_at) DESC, id) ` +
`WHERE parent = '' AND cleave_result IS NOT NULL AND litmus_result IS NOT NULL AND COALESCE(first_analyzed_at, analyzed_at) IS NOT NULL`,
`CREATE INDEX IF NOT EXISTS idx_samples_pending_cleave_group ` +
`ON samples(source, feed, ecosystem, updated_at) ` +
`WHERE parent = '' AND skip = '' AND cleave_result IS NULL`,
`CREATE INDEX IF NOT EXISTS idx_samples_pending_litmus_group ` +
`ON samples(source, feed, ecosystem, updated_at) ` +
`WHERE parent = '' AND skip = '' AND cleave_result IS NOT NULL AND litmus_result IS NULL`,
// Analyzer claims moved to memory. Cyclotron's sparse triage leases use
// the columns but not the old dashboard-oriented index.
`DROP INDEX IF EXISTS idx_samples_claimed`,
`UPDATE samples SET skip = 'skip-benign-archive-item' WHERE skip = 'weak-findings'`,
} {
if _, err := db.lite.ExecContext(ctx, ddl); err != nil {
return fmt.Errorf("hopper: migrate sqlite index: %w", err)
}
}
// sample_locations: one row per (sha256, path) observation. See the
// pg.go equivalent for rationale — both backends use the same schema.
for _, ddl := range []string{
`CREATE TABLE IF NOT EXISTS sample_locations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sha256 TEXT NOT NULL REFERENCES samples(sha256) ON DELETE CASCADE,
path TEXT NOT NULL CHECK (path <> ''),
parent_sha256 TEXT NOT NULL DEFAULT '',
rel TEXT NOT NULL DEFAULT '',
filename TEXT NOT NULL DEFAULT '',
source TEXT NOT NULL DEFAULT '',
feed TEXT NOT NULL DEFAULT '',
ecosystem TEXT NOT NULL DEFAULT '',
mtime DATETIME,
first_seen_at DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%f', 'now')),
last_seen_at DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%f', 'now')),
UNIQUE (sha256, path)
)`,
`CREATE INDEX IF NOT EXISTS idx_sl_sha256 ON sample_locations(sha256)`,
`CREATE INDEX IF NOT EXISTS idx_sl_parent ON sample_locations(parent_sha256) WHERE parent_sha256 <> ''`,
// Keyed on first_seen_at, not mtime — see the pg.go DDL for why the drain
// must not order on a clock its producers control or leave NULL.
`CREATE INDEX IF NOT EXISTS idx_sl_incoming_seen ON sample_locations(first_seen_at, sha256, path) ` +
`WHERE parent_sha256 = '' AND path GLOB 'incoming/*'`,
`CREATE TABLE IF NOT EXISTS sample_location_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sha256 TEXT NOT NULL,
path TEXT NOT NULL CHECK (path <> ''),
parent_sha256 TEXT NOT NULL DEFAULT '',
rel TEXT NOT NULL DEFAULT '',
filename TEXT NOT NULL DEFAULT '',
source TEXT NOT NULL DEFAULT '',
feed TEXT NOT NULL DEFAULT '',
ecosystem TEXT NOT NULL DEFAULT '',
mtime DATETIME,
first_seen_at DATETIME NOT NULL,
last_seen_at DATETIME NOT NULL,
retired_at DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%f', 'now')),
reason TEXT NOT NULL CHECK (reason <> ''),
successor_path TEXT NOT NULL DEFAULT ''
)`,
`CREATE INDEX IF NOT EXISTS idx_slh_sha256_retired ON sample_location_history(sha256, retired_at DESC, id DESC)`,
} {
if _, err := db.lite.ExecContext(ctx, ddl); err != nil {
return fmt.Errorf("hopper: migrate sqlite sample_locations: %w", err)
}
}
// rel: edge type to parent_sha256 ("" contained, "fetched", "unpacked",
// "registry") — added after the table shipped, so existing DBs need the
// column grafted on (SQLite has no ADD COLUMN IF NOT EXISTS).
if pragmaHasColumnIn(ctx, db.lite, "sample_locations", "rel") == 0 {
if _, err := db.lite.ExecContext(ctx,
`ALTER TABLE sample_locations ADD COLUMN rel TEXT NOT NULL DEFAULT ''`); err != nil {
return fmt.Errorf("hopper: migrate sqlite sample_locations rel: %w", err)
}
}
// One-shot backfill, gated on emptiness.
var locCount int
if err := db.lite.QueryRowContext(ctx, `SELECT count(*) FROM sample_locations`).Scan(&locCount); err != nil {
return fmt.Errorf("hopper: count sample_locations: %w", err)
}
if locCount == 0 {
if _, err := db.lite.ExecContext(ctx, `
INSERT INTO sample_locations
(sha256, path, parent_sha256, filename, source, feed, ecosystem, mtime, first_seen_at, last_seen_at)
SELECT sha256, path, parent, filename, source, feed, ecosystem, mtime, created_at, updated_at
FROM samples WHERE path <> ''
ON CONFLICT (sha256, path) DO NOTHING`); err != nil {
return fmt.Errorf("hopper: backfill sample_locations: %w", err)
}
}
// Internal key/value store for resumable maintenance and migration state.
if _, err := db.lite.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS hopper_kv (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
)`); err != nil {
return fmt.Errorf("hopper: migrate sqlite hopper_kv: %w", err)
}
// label_events: append-only audit of every label/skip transition applied
// by pool reconciliation. Lets a data scientist reconstruct a sample's
// ground-truth at a point in time and audit demote/conflict/missing
// decisions; never read on the hot path.
for _, ddl := range []string{
`CREATE TABLE IF NOT EXISTS label_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sha256 TEXT NOT NULL,
from_label TEXT NOT NULL DEFAULT '',
to_label TEXT NOT NULL DEFAULT '',
from_skip TEXT NOT NULL DEFAULT '',
to_skip TEXT NOT NULL DEFAULT '',
reason TEXT NOT NULL,
observed_at DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
)`,
`CREATE INDEX IF NOT EXISTS idx_label_events_sha ON label_events(sha256, observed_at)`,
} {
if _, err := db.lite.ExecContext(ctx, ddl); err != nil {
return fmt.Errorf("hopper: migrate sqlite label_events: %w", err)
}
}
// claims: analyzer-derived identity assertions + the asset_claims union
// view. Mirrors the PG runtime migration; see claims.go.
for _, ddl := range []string{
`CREATE TABLE IF NOT EXISTS claims (
sha256 TEXT NOT NULL REFERENCES samples(sha256) ON DELETE CASCADE,
source TEXT NOT NULL,
name TEXT NOT NULL,
version TEXT NOT NULL DEFAULT '',
signer TEXT NOT NULL DEFAULT '',
verified INTEGER NOT NULL DEFAULT 0,
trust TEXT NOT NULL DEFAULT '',
observed_at DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
PRIMARY KEY (sha256, source)
)`,
`CREATE INDEX IF NOT EXISTS idx_claims_name ON claims(name, version)`,
`CREATE INDEX IF NOT EXISTS idx_claims_signer ON claims(signer) WHERE signer != ''`,
`DROP VIEW IF EXISTS asset_claims`,
`CREATE VIEW asset_claims AS
SELECT sha256, 'registry' AS source, package AS name, version,
'' AS signer, 0 AS verified, '' AS trust, domain,
created_at AS observed_at
FROM samples WHERE purl_base != ''
UNION ALL
SELECT sha256, 'filename' AS source, package AS name, version,
'' AS signer, 0 AS verified, '' AS trust, domain,
created_at AS observed_at
FROM samples WHERE purl_base = '' AND package != ''
UNION ALL
SELECT sha256, source, name, version, signer, verified, trust,
'' AS domain, observed_at
FROM claims`,
} {
if _, err := db.lite.ExecContext(ctx, ddl); err != nil {
return fmt.Errorf("hopper: migrate sqlite claims: %w", err)
}
}
// walk_staging holds (sha256, path) for every standalone file seen in the
// current walk; reconciliation anti-joins it against samples. See the pg.go
// equivalent. SQLite has no UNLOGGED tables, but this DB is the local cache
// (not the durable store), so a plain table is fine.
for _, ddl := range []string{
`CREATE TABLE IF NOT EXISTS walk_staging (
sha256 TEXT NOT NULL,
path TEXT NOT NULL
)`,
`CREATE INDEX IF NOT EXISTS idx_samples_reconcile_toplevel ON samples(sha256)
WHERE parent = '' AND (skip = '' OR skip = 'conflict')`,
} {
if _, err := db.lite.ExecContext(ctx, ddl); err != nil {
return fmt.Errorf("hopper: migrate sqlite walk_staging: %w", err)
}
}
// One-time, chunked, resumable backfill of archive member edges; a non-fatal
// failure just resumes on the next boot rather than blocking startup.
if err := db.reconcileLocationParentEdges(ctx); err != nil {
slog.Warn("sample_locations parent-edge backfill incomplete; will resume on next boot", "error", err)
}
// After the edge backfill, which is what its predicate reads: a row whose
// edges have not been backfilled yet would look parentless and be repaired
// on no evidence.
if err := db.repairReferenceParents(ctx); err != nil {
slog.Warn("reference-parent repair incomplete; will resume on next boot", "error", err)
}
return nil
}
const liteSampleCols = `id, sha256, source, feed, ecosystem,
filename, file_type, size_bytes, label, label_source,
cleave_result, litmus_result, llm_result, litmus_score,
path, status, note, canonical_sha256, parent, skip,
formula, elements, score, max_crit, suspicious_count,
created_at, updated_at, analyzed_at, first_analyzed_at, last_error_at, mtime, marker_mtime,
traits_version,
url, domain, package, version, purl_base,
COALESCE(top_traits, '') AS top_traits,
COALESCE(trait_graph, '') AS trait_graph`
// liteSampleColsLight excludes all result blobs to avoid loading large JSON
// when only metadata is needed.
const liteSampleColsLight = `id, sha256, source, feed, ecosystem,
filename, file_type, size_bytes, label, label_source,
litmus_score,
path, status, note, canonical_sha256, parent, skip,
formula, elements, score, max_crit, suspicious_count,
created_at, updated_at, analyzed_at, first_analyzed_at, last_error_at, mtime, marker_mtime,
traits_version,
url, domain, package, version, purl_base,
COALESCE(top_traits, '') AS top_traits,
COALESCE(trait_graph, '') AS trait_graph`
// liteSampleColsFeed is the SQLite counterpart of pgSampleColsFeed: liteSampleCols
// with cleave_result — the one blob the feed never renders — replaced by a NULL
// literal so the projection stays positionally identical and scanLiteSamples
// reads it unchanged. litmus_result stays for the row's criticality, and the
// small llm_result stays for the row's rationale. Keeps the two backends' feed
// contract identical (a feed row carries no cleave_result).
const liteSampleColsFeed = `id, sha256, source, feed, ecosystem,
filename, file_type, size_bytes, label, label_source,
NULL AS cleave_result, litmus_result, llm_result, litmus_score,
path, status, note, canonical_sha256, parent, skip,
formula, elements, score, max_crit, suspicious_count,
created_at, updated_at, analyzed_at, first_analyzed_at, last_error_at, mtime, marker_mtime,
traits_version,
url, domain, package, version, purl_base,
COALESCE(top_traits, '') AS top_traits,
COALESCE(trait_graph, '') AS trait_graph`
// liteSampleColsRegistryExtra is the SQLite counterpart of pgSampleColsRegistryExtra:
// the marketplace title, capped short description, and install count from the
// provenance sidecar's registry record, read by scanLiteSamplesFeed.
const liteSampleColsRegistryExtra = `,
COALESCE(json_extract(provenance, '$.registry.record.title'), '') AS registry_title,
COALESCE(substr(json_extract(provenance, '$.registry.record.description'), 1, 300), '') AS registry_description,
COALESCE(json_extract(provenance, '$.registry.record.downloads_total'), 0) AS registry_downloads,
corroborated`
func scanLiteSamplesLight(rows *sql.Rows) ([]*Sample, error) {
defer rows.Close() //nolint:errcheck // best-effort cleanup
var out []*Sample
for rows.Next() {
s := &Sample{}
var status sql.NullString
var analyzedAt, firstAnalyzedAt, lastErrorAt, mtime, markerMtime sql.NullTime
if err := rows.Scan(
&s.ID, &s.SHA256, &s.Source, &s.Feed, &s.Ecosystem, &s.Filename,
&s.FileType, &s.SizeBytes, &s.Label, &s.LabelSource, &s.LitmusScore,
&s.Path, &status, &s.Note, &s.CanonicalSHA256,
&s.Parent, &s.Skip, &s.Formula, &s.Elements,
&s.Score, &s.MaxCrit, &s.SuspiciousCount,
&s.CreatedAt, &s.UpdatedAt, &analyzedAt, &firstAnalyzedAt, &lastErrorAt, &mtime, &markerMtime,
&s.TraitsVersion,
&s.URL, &s.Domain, &s.Package, &s.Version, &s.PURLBase,
&s.TopTraits,
&s.TraitGraph,
); err != nil {
return nil, err
}
s.Status = status.String
if analyzedAt.Valid {
s.AnalyzedAt = &analyzedAt.Time
}
if firstAnalyzedAt.Valid {
s.FirstAnalyzedAt = &firstAnalyzedAt.Time
}
if lastErrorAt.Valid {
s.LastErrorAt = &lastErrorAt.Time
}
if mtime.Valid {
s.Mtime = &mtime.Time
}
if markerMtime.Valid {
s.MarkerMtime = &markerMtime.Time
}
out = append(out, s)
}
return out, rows.Err()
}
func (db *DB) workflowHealthSQLite(ctx context.Context) (WorkflowHealth, error) {
var h WorkflowHealth
var latestAdded, latestUpdated, latestAnalyzed, latestReady sqliteNullTime
err := db.lite.QueryRowContext(ctx, `
SELECT
(SELECT created_at FROM samples WHERE parent = '' ORDER BY created_at DESC LIMIT 1),
(SELECT max(updated_at) FROM samples WHERE parent = ''),
(SELECT max(analyzed_at) FROM samples WHERE parent = '' AND analyzed_at IS NOT NULL),
(SELECT COALESCE(first_analyzed_at, analyzed_at) FROM samples
WHERE parent = '' AND cleave_result IS NOT NULL AND litmus_result IS NOT NULL
AND COALESCE(first_analyzed_at, analyzed_at) IS NOT NULL
ORDER BY COALESCE(first_analyzed_at, analyzed_at) DESC LIMIT 1),
(SELECT count(*) FROM samples WHERE parent = '' AND skip = '' AND cleave_result IS NULL),
(SELECT count(*) FROM samples WHERE parent = '' AND skip = '' AND cleave_result IS NOT NULL AND litmus_result IS NULL)`,
).Scan(&latestAdded, &latestUpdated, &latestAnalyzed, &latestReady, &h.PendingCleave, &h.PendingLitmus)
if err != nil {
return h, fmt.Errorf("hopper: workflow health: %w", err)
}
h.LatestAdded = nullTime(latestAdded.NullTime)
h.LatestUpdated = nullTime(latestUpdated.NullTime)
h.LatestAnalyzed = nullTime(latestAnalyzed.NullTime)
h.LatestReady = nullTime(latestReady.NullTime)
return h, nil
}
func (db *DB) workflowBacklogsSQLite(ctx context.Context, limit int) ([]WorkflowBacklog, error) {
rows, err := db.lite.QueryContext(ctx, `
SELECT source, feed, ecosystem,
min(updated_at), max(updated_at),
sum(pending_cleave), sum(pending_litmus)
FROM (
SELECT source, feed, ecosystem, updated_at,
1 AS pending_cleave,
0 AS pending_litmus
FROM samples
WHERE parent = '' AND skip = '' AND cleave_result IS NULL
UNION ALL
SELECT source, feed, ecosystem, updated_at,
0 AS pending_cleave,
1 AS pending_litmus
FROM samples
WHERE parent = '' AND skip = ''
AND cleave_result IS NOT NULL AND litmus_result IS NULL
) pending
GROUP BY source, feed, ecosystem
ORDER BY (sum(pending_cleave) + sum(pending_litmus)) DESC
LIMIT ?`, limit)
if err != nil {
return nil, fmt.Errorf("hopper: workflow backlogs: %w", err)
}
defer rows.Close() //nolint:errcheck // best-effort cleanup
out := make([]WorkflowBacklog, 0, limit)
for rows.Next() {
var b WorkflowBacklog
var oldest, newest sql.NullTime
if err := rows.Scan(&b.Source, &b.Feed, &b.Ecosystem, &oldest, &newest, &b.PendingCleave, &b.PendingLitmus); err != nil {
return nil, fmt.Errorf("hopper: scan workflow backlog: %w", err)
}
b.OldestPending = nullTime(oldest)
b.NewestPending = nullTime(newest)
out = append(out, b)
}
return out, rows.Err()
}
func (db *DB) workflowLatestAddedSQLite(ctx context.Context, limit int) ([]WorkflowSample, error) {
return db.workflowSamplesSQLite(ctx, `WHERE parent = '' ORDER BY created_at DESC LIMIT ?`, limit)
}
func (db *DB) workflowLatestReadySQLite(ctx context.Context, limit int) ([]WorkflowSample, error) {
return db.workflowSamplesSQLite(ctx,
`WHERE parent = '' AND cleave_result IS NOT NULL AND litmus_result IS NOT NULL `+
`AND COALESCE(first_analyzed_at, analyzed_at) IS NOT NULL `+
`ORDER BY COALESCE(first_analyzed_at, analyzed_at) DESC, id LIMIT ?`, limit)
}
func (db *DB) workflowOldestPendingSQLite(ctx context.Context, limit int) ([]WorkflowSample, error) {
return db.workflowSamplesSQLite(ctx,
`WHERE parent = '' AND cleave_result IS NULL AND skip = '' ORDER BY updated_at ASC, id LIMIT ?`, limit)
}
func (db *DB) workflowSamplesSQLite(ctx context.Context, where string, limit int) ([]WorkflowSample, error) {
//nolint:gosec // G202: litmusClassSQLiteInline is a package constant and where is fixed internal SQL; no value is interpolated
rows, err := db.lite.QueryContext(ctx, `
SELECT sha256, source, feed, ecosystem, filename, path,
created_at, updated_at, analyzed_at, COALESCE(first_analyzed_at, analyzed_at),
cleave_result IS NOT NULL,
litmus_result IS NOT NULL,
-- Criticality (0=benign, 1=suspicious, 2=hostile, null=unstated):
-- legacy records carried 'class' directly; everything since states a
-- level. See litmusClassSQLiteExpr.
`+litmusClassSQLiteInline+`
FROM samples `+where, limit)
if err != nil {
return nil, fmt.Errorf("hopper: workflow samples: %w", err)
}
defer rows.Close() //nolint:errcheck // best-effort cleanup
out := make([]WorkflowSample, 0, limit)
for rows.Next() {
var s WorkflowSample
var analyzed, firstAnalyzed sqliteNullTime
// NULL means the envelope states no verdict this package can read; it
// surfaces as ClassUnknown, not as the benign zero value.
var criticality sql.NullInt32
if err := rows.Scan(&s.SHA256, &s.Source, &s.Feed, &s.Ecosystem, &s.Filename, &s.Path,
&s.CreatedAt, &s.UpdatedAt, &analyzed, &firstAnalyzed, &s.HasCleave, &s.HasLitmus, &criticality); err != nil {
return nil, fmt.Errorf("hopper: scan workflow sample: %w", err)
}
s.Criticality = ClassUnknown
if criticality.Valid {
s.Criticality = int(criticality.Int32)
}
if analyzed.Valid {
s.AnalyzedAt = &analyzed.Time
}
if firstAnalyzed.Valid {
s.FirstAnalyzedAt = &firstAnalyzed.Time
}
out = append(out, s)
}
return out, rows.Err()
}
// scanLiteSample reads one full sample row plus the registry extras — its only
// caller (sampleBySHA256SQLite) selects liteSampleCols + liteSampleColsRegistryExtra.
func scanLiteSample(row *sql.Row) (*Sample, error) {
s := &Sample{}
var cleaveResult, litmusResult, llmResult, status sql.NullString
var analyzedAt, firstAnalyzedAt, lastErrorAt, mtime, markerMtime sql.NullTime
err := row.Scan(
&s.ID, &s.SHA256, &s.Source, &s.Feed, &s.Ecosystem, &s.Filename,
&s.FileType, &s.SizeBytes, &s.Label, &s.LabelSource, &cleaveResult, &litmusResult, &llmResult, &s.LitmusScore,
&s.Path, &status, &s.Note, &s.CanonicalSHA256, &s.Parent, &s.Skip, &s.Formula,
&s.Elements, &s.Score, &s.MaxCrit, &s.SuspiciousCount,
&s.CreatedAt, &s.UpdatedAt, &analyzedAt, &firstAnalyzedAt, &lastErrorAt, &mtime, &markerMtime,
&s.TraitsVersion,
&s.URL, &s.Domain, &s.Package, &s.Version, &s.PURLBase,
&s.TopTraits,
&s.TraitGraph,
&s.RegistryTitle, &s.RegistryDescription, &s.RegistryDownloads, &s.Corroborated,
)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
if cleaveResult.Valid {
s.CleaveResult = []byte(cleaveResult.String)
}
if litmusResult.Valid {
s.LitmusResult = []byte(litmusResult.String)
}
if llmResult.Valid {
s.LLMResult = []byte(llmResult.String)
}
s.Status = status.String
if analyzedAt.Valid {
s.AnalyzedAt = &analyzedAt.Time