pilotswarm-sdk 0.2.0 → 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.
Files changed (173) hide show
  1. package/dist/agent-loader.d.ts +9 -0
  2. package/dist/agent-loader.d.ts.map +1 -1
  3. package/dist/agent-loader.js +3 -0
  4. package/dist/agent-loader.js.map +1 -1
  5. package/dist/child-notifications.d.ts +10 -0
  6. package/dist/child-notifications.d.ts.map +1 -1
  7. package/dist/child-notifications.js +15 -1
  8. package/dist/child-notifications.js.map +1 -1
  9. package/dist/client.d.ts +2 -2
  10. package/dist/client.d.ts.map +1 -1
  11. package/dist/client.js +14 -20
  12. package/dist/client.js.map +1 -1
  13. package/dist/cms-migrations.d.ts.map +1 -1
  14. package/dist/cms-migrations.js +321 -0
  15. package/dist/cms-migrations.js.map +1 -1
  16. package/dist/cms.d.ts +132 -8
  17. package/dist/cms.d.ts.map +1 -1
  18. package/dist/cms.js +142 -6
  19. package/dist/cms.js.map +1 -1
  20. package/dist/duroxide-schema-migration.d.ts +19 -0
  21. package/dist/duroxide-schema-migration.d.ts.map +1 -0
  22. package/dist/duroxide-schema-migration.js +122 -0
  23. package/dist/duroxide-schema-migration.js.map +1 -0
  24. package/dist/facts-migrations.d.ts.map +1 -1
  25. package/dist/facts-migrations.js +923 -0
  26. package/dist/facts-migrations.js.map +1 -1
  27. package/dist/facts-store.d.ts +278 -28
  28. package/dist/facts-store.d.ts.map +1 -1
  29. package/dist/facts-store.js +262 -32
  30. package/dist/facts-store.js.map +1 -1
  31. package/dist/facts-tools.d.ts +8 -1
  32. package/dist/facts-tools.d.ts.map +1 -1
  33. package/dist/facts-tools.js +458 -25
  34. package/dist/facts-tools.js.map +1 -1
  35. package/dist/graph-store.d.ts +243 -0
  36. package/dist/graph-store.d.ts.map +1 -0
  37. package/dist/graph-store.js +63 -0
  38. package/dist/graph-store.js.map +1 -0
  39. package/dist/graph-tools.d.ts +47 -0
  40. package/dist/graph-tools.d.ts.map +1 -0
  41. package/dist/graph-tools.js +554 -0
  42. package/dist/graph-tools.js.map +1 -0
  43. package/dist/horizon-env.d.ts +61 -0
  44. package/dist/horizon-env.d.ts.map +1 -0
  45. package/dist/horizon-env.js +120 -0
  46. package/dist/horizon-env.js.map +1 -0
  47. package/dist/index.d.ts +15 -4
  48. package/dist/index.d.ts.map +1 -1
  49. package/dist/index.js +10 -2
  50. package/dist/index.js.map +1 -1
  51. package/dist/inspect-tools.d.ts +2 -2
  52. package/dist/inspect-tools.d.ts.map +1 -1
  53. package/dist/inspect-tools.js +303 -6
  54. package/dist/inspect-tools.js.map +1 -1
  55. package/dist/knowledge-index.d.ts +33 -2
  56. package/dist/knowledge-index.d.ts.map +1 -1
  57. package/dist/knowledge-index.js +90 -19
  58. package/dist/knowledge-index.js.map +1 -1
  59. package/dist/managed-session.d.ts.map +1 -1
  60. package/dist/managed-session.js +61 -2
  61. package/dist/managed-session.js.map +1 -1
  62. package/dist/management-client.d.ts +81 -3
  63. package/dist/management-client.d.ts.map +1 -1
  64. package/dist/management-client.js +97 -20
  65. package/dist/management-client.js.map +1 -1
  66. package/dist/orchestration/agents.d.ts +4 -1
  67. package/dist/orchestration/agents.d.ts.map +1 -1
  68. package/dist/orchestration/agents.js +27 -4
  69. package/dist/orchestration/agents.js.map +1 -1
  70. package/dist/orchestration/index.d.ts +2 -2
  71. package/dist/orchestration/index.js +1 -1
  72. package/dist/orchestration/lifecycle.d.ts +3 -0
  73. package/dist/orchestration/lifecycle.d.ts.map +1 -1
  74. package/dist/orchestration/lifecycle.js +6 -1
  75. package/dist/orchestration/lifecycle.js.map +1 -1
  76. package/dist/orchestration/queue.d.ts.map +1 -1
  77. package/dist/orchestration/queue.js +10 -1
  78. package/dist/orchestration/queue.js.map +1 -1
  79. package/dist/orchestration/runtime.d.ts +1 -1
  80. package/dist/orchestration/state.d.ts +1 -0
  81. package/dist/orchestration/state.d.ts.map +1 -1
  82. package/dist/orchestration/state.js +1 -0
  83. package/dist/orchestration/state.js.map +1 -1
  84. package/dist/orchestration/turn.d.ts +2 -2
  85. package/dist/orchestration/turn.d.ts.map +1 -1
  86. package/dist/orchestration/turn.js +42 -12
  87. package/dist/orchestration/turn.js.map +1 -1
  88. package/dist/orchestration-registry.d.ts.map +1 -1
  89. package/dist/orchestration-registry.js +4 -2
  90. package/dist/orchestration-registry.js.map +1 -1
  91. package/dist/orchestration-version.d.ts +1 -1
  92. package/dist/orchestration-version.js +1 -1
  93. package/dist/orchestration.d.ts +2 -2
  94. package/dist/orchestration.js +1 -1
  95. package/dist/orchestration_1_0_46.d.ts +1 -1
  96. package/dist/orchestration_1_0_47.d.ts +1 -1
  97. package/dist/orchestration_1_0_48.d.ts +1 -1
  98. package/dist/orchestration_1_0_49.d.ts +1 -1
  99. package/dist/orchestration_1_0_50.d.ts +1 -1
  100. package/dist/orchestration_1_0_52/agents.d.ts +38 -0
  101. package/dist/orchestration_1_0_52/agents.d.ts.map +1 -0
  102. package/dist/orchestration_1_0_52/agents.js +735 -0
  103. package/dist/orchestration_1_0_52/agents.js.map +1 -0
  104. package/dist/orchestration_1_0_52/index.d.ts +24 -0
  105. package/dist/orchestration_1_0_52/index.d.ts.map +1 -0
  106. package/dist/orchestration_1_0_52/index.js +13 -0
  107. package/dist/orchestration_1_0_52/index.js.map +1 -0
  108. package/dist/orchestration_1_0_52/lifecycle.d.ts +38 -0
  109. package/dist/orchestration_1_0_52/lifecycle.d.ts.map +1 -0
  110. package/dist/orchestration_1_0_52/lifecycle.js +504 -0
  111. package/dist/orchestration_1_0_52/lifecycle.js.map +1 -0
  112. package/dist/orchestration_1_0_52/queue.d.ts +7 -0
  113. package/dist/orchestration_1_0_52/queue.d.ts.map +1 -0
  114. package/dist/orchestration_1_0_52/queue.js +635 -0
  115. package/dist/orchestration_1_0_52/queue.js.map +1 -0
  116. package/dist/orchestration_1_0_52/runtime.d.ts +29 -0
  117. package/dist/orchestration_1_0_52/runtime.d.ts.map +1 -0
  118. package/dist/orchestration_1_0_52/runtime.js +190 -0
  119. package/dist/orchestration_1_0_52/runtime.js.map +1 -0
  120. package/dist/orchestration_1_0_52/state.d.ts +124 -0
  121. package/dist/orchestration_1_0_52/state.d.ts.map +1 -0
  122. package/dist/orchestration_1_0_52/state.js +104 -0
  123. package/dist/orchestration_1_0_52/state.js.map +1 -0
  124. package/dist/orchestration_1_0_52/turn.d.ts +6 -0
  125. package/dist/orchestration_1_0_52/turn.d.ts.map +1 -0
  126. package/dist/orchestration_1_0_52/turn.js +834 -0
  127. package/dist/orchestration_1_0_52/turn.js.map +1 -0
  128. package/dist/orchestration_1_0_52/utils.d.ts +22 -0
  129. package/dist/orchestration_1_0_52/utils.d.ts.map +1 -0
  130. package/dist/orchestration_1_0_52/utils.js +226 -0
  131. package/dist/orchestration_1_0_52/utils.js.map +1 -0
  132. package/dist/resourcemgr-tools.d.ts +2 -2
  133. package/dist/resourcemgr-tools.d.ts.map +1 -1
  134. package/dist/resourcemgr-tools.js +1 -1
  135. package/dist/resourcemgr-tools.js.map +1 -1
  136. package/dist/session-dumper.d.ts +2 -2
  137. package/dist/session-dumper.d.ts.map +1 -1
  138. package/dist/session-dumper.js.map +1 -1
  139. package/dist/session-manager.d.ts +8 -2
  140. package/dist/session-manager.d.ts.map +1 -1
  141. package/dist/session-manager.js +86 -4
  142. package/dist/session-manager.js.map +1 -1
  143. package/dist/session-messages.d.ts +2 -2
  144. package/dist/session-messages.d.ts.map +1 -1
  145. package/dist/session-messages.js +0 -6
  146. package/dist/session-messages.js.map +1 -1
  147. package/dist/session-proxy.d.ts +47 -2
  148. package/dist/session-proxy.d.ts.map +1 -1
  149. package/dist/session-proxy.js +79 -59
  150. package/dist/session-proxy.js.map +1 -1
  151. package/dist/storage-config.d.ts +62 -0
  152. package/dist/storage-config.d.ts.map +1 -0
  153. package/dist/storage-config.js +138 -0
  154. package/dist/storage-config.js.map +1 -0
  155. package/dist/storage-providers.d.ts +25 -0
  156. package/dist/storage-providers.d.ts.map +1 -0
  157. package/dist/storage-providers.js +150 -0
  158. package/dist/storage-providers.js.map +1 -0
  159. package/dist/sweeper-tools.d.ts +2 -2
  160. package/dist/sweeper-tools.d.ts.map +1 -1
  161. package/dist/system-agents.d.ts +2 -2
  162. package/dist/system-agents.d.ts.map +1 -1
  163. package/dist/types.d.ts +103 -2
  164. package/dist/types.d.ts.map +1 -1
  165. package/dist/types.js.map +1 -1
  166. package/dist/worker.d.ts +5 -4
  167. package/dist/worker.d.ts.map +1 -1
  168. package/dist/worker.js +108 -33
  169. package/dist/worker.js.map +1 -1
  170. package/package.json +3 -1
  171. package/plugins/mgmt/agents/agent-tuner.agent.md +56 -4
  172. package/plugins/mgmt/agents/facts-manager.agent.md +81 -3
  173. package/plugins/mgmt/skills/graph-debug/SKILL.md +133 -0
@@ -31,6 +31,36 @@ export function FACTS_MIGRATIONS(schema) {
31
31
  name: "facts_read_unrestricted",
32
32
  sql: migration_0004_facts_read_unrestricted(schema),
33
33
  },
34
+ {
35
+ version: "0005",
36
+ name: "facts_read_scope_keys",
37
+ sql: migration_0005_facts_read_scope_keys(schema),
38
+ },
39
+ {
40
+ version: "0006",
41
+ name: "crawl_queue",
42
+ sql: migration_0006_crawl_queue(schema),
43
+ },
44
+ {
45
+ version: "0007",
46
+ name: "minimal_crawl_queue",
47
+ sql: migration_0007_minimal_crawl_queue(schema),
48
+ },
49
+ {
50
+ version: "0008",
51
+ name: "batch_store_pattern_delete",
52
+ sql: migration_0008_batch_store_pattern_delete(schema),
53
+ },
54
+ {
55
+ version: "0009",
56
+ name: "unified_store_delete_api",
57
+ sql: migration_0009_unified_store_delete_api(schema),
58
+ },
59
+ {
60
+ version: "0010",
61
+ name: "soft_delete_etag",
62
+ sql: migration_0010_soft_delete_etag(schema),
63
+ },
34
64
  ];
35
65
  }
36
66
  // ─── Migration 0001: Baseline ────────────────────────────────────
@@ -437,4 +467,897 @@ END;
437
467
  $$ LANGUAGE plpgsql;
438
468
  `;
439
469
  }
470
+ // ─── Migration 0005: facts_read_facts exposes scope_key + accepts scopeKeys ──
471
+ function migration_0005_facts_read_scope_keys(schema) {
472
+ const s = `"${schema}"`;
473
+ const t = `${s}.facts`;
474
+ return `
475
+ -- 0005_facts_read_scope_keys (enhancedfactstore 02 §1c): the read proc now
476
+ -- (a) RETURNS the canonical scope_key (so graph 'evidence' arrays can reference
477
+ -- a real fact and resolve back), and
478
+ -- (b) accepts an optional p_scope_keys TEXT[] for bulk read-by-key — the way
479
+ -- graph evidence resolves into facts. ACL still applies; the scope_keys
480
+ -- filter only narrows. Additive + idempotent.
481
+ --
482
+ -- The return shape changes (adds scope_key), so the previous 9-arg proc must be
483
+ -- DROPped before recreating.
484
+
485
+ DROP FUNCTION IF EXISTS ${s}.facts_read_facts(TEXT, TEXT, TEXT[], TEXT, TEXT[], TEXT, TEXT, INT, BOOLEAN) CASCADE;
486
+
487
+ CREATE OR REPLACE FUNCTION ${s}.facts_read_facts(
488
+ p_scope TEXT,
489
+ p_reader_session_id TEXT,
490
+ p_granted_ids TEXT[],
491
+ p_key_pattern TEXT,
492
+ p_tags TEXT[],
493
+ p_session_id TEXT,
494
+ p_agent_id TEXT,
495
+ p_limit INT,
496
+ p_unrestricted BOOLEAN DEFAULT FALSE,
497
+ p_scope_keys TEXT[] DEFAULT NULL
498
+ ) RETURNS TABLE (
499
+ scope_key TEXT,
500
+ key TEXT,
501
+ value JSONB,
502
+ agent_id TEXT,
503
+ session_id TEXT,
504
+ shared BOOLEAN,
505
+ tags TEXT[],
506
+ created_at TIMESTAMPTZ,
507
+ updated_at TIMESTAMPTZ
508
+ ) AS $$
509
+ DECLARE
510
+ base_sql TEXT;
511
+ where_clauses TEXT[] := ARRAY[]::TEXT[];
512
+ final_sql TEXT;
513
+ BEGIN
514
+ base_sql := 'SELECT f.scope_key, f.key, f.value, f.agent_id, f.session_id, f.shared, f.tags, f.created_at, f.updated_at FROM ${t} f WHERE ';
515
+
516
+ IF p_unrestricted THEN
517
+ -- Bypass visibility entirely; only optional filters apply.
518
+ where_clauses := array_append(where_clauses, 'TRUE');
519
+ ELSIF p_scope = 'shared' THEN
520
+ where_clauses := array_append(where_clauses, 'f.shared = TRUE');
521
+ ELSIF p_scope = 'session' THEN
522
+ IF p_reader_session_id IS NULL THEN
523
+ RETURN;
524
+ END IF;
525
+ where_clauses := array_append(where_clauses,
526
+ 'f.shared = FALSE AND f.session_id = ' || quote_literal(p_reader_session_id));
527
+ ELSIF p_reader_session_id IS NOT NULL THEN
528
+ -- "accessible" or "descendants"
529
+ DECLARE
530
+ vis_parts TEXT[] := ARRAY[
531
+ 'f.shared = TRUE',
532
+ '(f.shared = FALSE AND f.session_id = ' || quote_literal(p_reader_session_id) || ')'
533
+ ];
534
+ BEGIN
535
+ IF p_granted_ids IS NOT NULL AND array_length(p_granted_ids, 1) > 0 THEN
536
+ vis_parts := array_append(vis_parts,
537
+ '(f.shared = FALSE AND f.session_id = ANY(' || quote_literal(p_granted_ids)::TEXT || '::TEXT[]))');
538
+ END IF;
539
+ where_clauses := array_append(where_clauses, '(' || array_to_string(vis_parts, ' OR ') || ')');
540
+ END;
541
+ ELSE
542
+ where_clauses := array_append(where_clauses, 'f.shared = TRUE');
543
+ END IF;
544
+
545
+ -- Bulk read-by-key (narrows within the visibility filter above). A non-null
546
+ -- but EMPTY array matches nothing (caller asked for zero facts), so it can
547
+ -- only narrow — never widen — what the visibility clause already permits.
548
+ IF p_scope_keys IS NOT NULL THEN
549
+ where_clauses := array_append(where_clauses,
550
+ 'f.scope_key = ANY(' || quote_literal(p_scope_keys)::TEXT || '::TEXT[])');
551
+ END IF;
552
+
553
+ -- Optional filters
554
+ IF p_key_pattern IS NOT NULL THEN
555
+ where_clauses := array_append(where_clauses,
556
+ 'f.key LIKE ' || quote_literal(p_key_pattern));
557
+ END IF;
558
+ IF p_tags IS NOT NULL AND array_length(p_tags, 1) > 0 THEN
559
+ where_clauses := array_append(where_clauses,
560
+ 'f.tags @> ' || quote_literal(p_tags)::TEXT || '::TEXT[]');
561
+ END IF;
562
+ IF p_session_id IS NOT NULL THEN
563
+ where_clauses := array_append(where_clauses,
564
+ 'f.session_id = ' || quote_literal(p_session_id));
565
+ END IF;
566
+ IF p_agent_id IS NOT NULL THEN
567
+ where_clauses := array_append(where_clauses,
568
+ 'f.agent_id = ' || quote_literal(p_agent_id));
569
+ END IF;
570
+
571
+ final_sql := base_sql || array_to_string(where_clauses, ' AND ')
572
+ || ' ORDER BY f.updated_at DESC LIMIT ' || p_limit;
573
+
574
+ RETURN QUERY EXECUTE final_sql;
575
+ END;
576
+ $$ LANGUAGE plpgsql;
577
+ `;
578
+ }
579
+ // ─── Migration 0006: crawl queue (base-store, vanilla PG — no extension) ─────
580
+ function migration_0006_crawl_queue(schema) {
581
+ const s = `"${schema}"`;
582
+ const t = `${s}.facts`;
583
+ return `
584
+ -- 0006_crawl_queue (enhancedfactstore 07 D3): the facts↔graph crawl bridge lives
585
+ -- on the BASE facts store as plain facts-table bookkeeping — so a base-Postgres
586
+ -- deployment can feed a separate GraphStore harvester. Vanilla PG, no extension.
587
+ --
588
+ -- - content_hash : trigger-maintained md5 of (key, value); the receipt
589
+ -- key for the read→mark race guard.
590
+ -- - last_crawled_at : NULL ⇒ pending crawl. Reset to NULL whenever content
591
+ -- changes (the facts_touch trigger).
592
+ -- - facts_read_uncrawled / facts_mark_crawled : the harvester work queue.
593
+ --
594
+ -- Additive + idempotent. Inert unless a graph harvester runs.
595
+
596
+ ALTER TABLE ${t} ADD COLUMN IF NOT EXISTS content_hash TEXT;
597
+ ALTER TABLE ${t} ADD COLUMN IF NOT EXISTS last_crawled_at TIMESTAMPTZ;
598
+
599
+ -- Backfill content_hash for rows that predate this migration. last_crawled_at
600
+ -- stays NULL (pending) — correct: nothing has crawled them yet.
601
+ UPDATE ${t}
602
+ SET content_hash = md5(coalesce(key, '') || E'\\x1f' || coalesce(value::text, ''))
603
+ WHERE content_hash IS NULL;
604
+
605
+ -- Work-queue index: only the pending rows.
606
+ CREATE INDEX IF NOT EXISTS idx_${schema}_facts_uncrawled
607
+ ON ${t} (id) WHERE last_crawled_at IS NULL;
608
+
609
+ -- Write-resets-pending-state: recompute content_hash on every write; when it
610
+ -- CHANGES, reset last_crawled_at → NULL so a harvester re-crawls. Identical
611
+ -- content writes change nothing (so re-storing an unchanged fact is a no-op for
612
+ -- the queue).
613
+ CREATE OR REPLACE FUNCTION ${s}.facts_touch() RETURNS trigger
614
+ LANGUAGE plpgsql AS $$
615
+ DECLARE
616
+ new_hash TEXT;
617
+ BEGIN
618
+ new_hash := md5(coalesce(NEW.key, '') || E'\\x1f' || coalesce(NEW.value::text, ''));
619
+ -- Keep content_hash authoritative on every write (defends against any
620
+ -- direct content_hash tampering by other code paths).
621
+ NEW.content_hash := new_hash;
622
+ -- Re-queue (reset crawl stamp) ONLY when the embeddable content changed.
623
+ IF TG_OP = 'INSERT' OR new_hash IS DISTINCT FROM OLD.content_hash THEN
624
+ NEW.last_crawled_at := NULL;
625
+ END IF;
626
+ RETURN NEW;
627
+ END $$;
628
+
629
+ DROP TRIGGER IF EXISTS facts_touch ON ${t};
630
+ CREATE TRIGGER facts_touch
631
+ BEFORE INSERT OR UPDATE ON ${t}
632
+ FOR EACH ROW EXECUTE FUNCTION ${s}.facts_touch();
633
+
634
+ -- Harvester work queue: pending facts (last_crawled_at IS NULL), across ALL
635
+ -- scopes (privileged), optionally narrowed by a key prefix. Returns the
636
+ -- content_hash so the caller can produce a mark receipt.
637
+ CREATE OR REPLACE FUNCTION ${s}.facts_read_uncrawled(
638
+ p_ns_prefix TEXT,
639
+ p_limit INT
640
+ ) RETURNS TABLE (
641
+ scope_key TEXT,
642
+ key TEXT,
643
+ value JSONB,
644
+ agent_id TEXT,
645
+ session_id TEXT,
646
+ shared BOOLEAN,
647
+ tags TEXT[],
648
+ created_at TIMESTAMPTZ,
649
+ updated_at TIMESTAMPTZ,
650
+ content_hash TEXT
651
+ ) LANGUAGE sql STABLE AS $$
652
+ SELECT f.scope_key, f.key, f.value, f.agent_id, f.session_id, f.shared,
653
+ f.tags, f.created_at, f.updated_at, f.content_hash
654
+ FROM ${t} f
655
+ WHERE f.last_crawled_at IS NULL
656
+ AND (p_ns_prefix IS NULL OR starts_with(f.key, p_ns_prefix))
657
+ ORDER BY f.id
658
+ LIMIT p_limit;
659
+ $$;
660
+
661
+ -- Stamp last_crawled_at = now(), but ONLY where content_hash still matches the
662
+ -- supplied receipt (a fact edited mid-crawl keeps last_crawled_at = NULL and
663
+ -- re-enters the queue). Mismatches are skipped, not errors.
664
+ CREATE OR REPLACE FUNCTION ${s}.facts_mark_crawled(p_stamps JSONB)
665
+ RETURNS TABLE (marked INT, skipped INT)
666
+ LANGUAGE sql AS $$
667
+ WITH stamps AS (
668
+ SELECT e->>'scopeKey' AS scope_key, e->>'contentHash' AS content_hash
669
+ FROM jsonb_array_elements(p_stamps) e
670
+ ),
671
+ upd AS (
672
+ UPDATE ${t} f
673
+ SET last_crawled_at = now()
674
+ FROM stamps s
675
+ WHERE f.scope_key = s.scope_key
676
+ AND f.content_hash = s.content_hash
677
+ RETURNING f.scope_key
678
+ )
679
+ SELECT (SELECT count(*) FROM upd)::int AS marked,
680
+ ((SELECT count(*) FROM stamps) - (SELECT count(*) FROM upd))::int AS skipped;
681
+ $$;
682
+ `;
683
+ }
684
+ function migration_0007_minimal_crawl_queue(schema) {
685
+ const s = `"${schema}"`;
686
+ const t = `${s}.facts`;
687
+ return `
688
+ -- 0007_minimal_crawl_queue: remove content_hash from the base facts final
689
+ -- schema. Crawl state is intentionally minimal: last_crawled_at NULL means
690
+ -- pending, and facts_mark_crawled accepts scopeKey-only receipts.
691
+
692
+ CREATE OR REPLACE FUNCTION ${s}.facts_touch() RETURNS trigger
693
+ LANGUAGE plpgsql AS $$
694
+ BEGIN
695
+ IF TG_OP = 'INSERT' OR NEW.key IS DISTINCT FROM OLD.key OR NEW.value IS DISTINCT FROM OLD.value THEN
696
+ NEW.last_crawled_at := NULL;
697
+ END IF;
698
+ RETURN NEW;
699
+ END $$;
700
+
701
+ ALTER TABLE ${t} DROP COLUMN IF EXISTS content_hash;
702
+
703
+ DROP FUNCTION IF EXISTS ${s}.facts_read_uncrawled(TEXT, INT);
704
+
705
+ CREATE OR REPLACE FUNCTION ${s}.facts_read_uncrawled(
706
+ p_ns_prefix TEXT,
707
+ p_limit INT
708
+ ) RETURNS TABLE (
709
+ scope_key TEXT,
710
+ key TEXT,
711
+ value JSONB,
712
+ agent_id TEXT,
713
+ session_id TEXT,
714
+ shared BOOLEAN,
715
+ tags TEXT[],
716
+ created_at TIMESTAMPTZ,
717
+ updated_at TIMESTAMPTZ
718
+ ) LANGUAGE sql STABLE AS $$
719
+ SELECT f.scope_key, f.key, f.value, f.agent_id, f.session_id, f.shared,
720
+ f.tags, f.created_at, f.updated_at
721
+ FROM ${t} f
722
+ WHERE f.last_crawled_at IS NULL
723
+ AND (p_ns_prefix IS NULL OR starts_with(f.key, p_ns_prefix))
724
+ ORDER BY f.id
725
+ LIMIT p_limit;
726
+ $$;
727
+
728
+ CREATE OR REPLACE FUNCTION ${s}.facts_mark_crawled(p_stamps JSONB)
729
+ RETURNS TABLE (marked INT, skipped INT)
730
+ LANGUAGE sql AS $$
731
+ WITH stamps AS (
732
+ SELECT e->>'scopeKey' AS scope_key
733
+ FROM jsonb_array_elements(p_stamps) e
734
+ ),
735
+ upd AS (
736
+ UPDATE ${t} f
737
+ SET last_crawled_at = now()
738
+ FROM stamps s
739
+ WHERE f.scope_key = s.scope_key
740
+ AND f.last_crawled_at IS NULL
741
+ RETURNING f.scope_key
742
+ )
743
+ SELECT (SELECT count(*) FROM upd)::int AS marked,
744
+ ((SELECT count(*) FROM stamps) - (SELECT count(*) FROM upd))::int AS skipped;
745
+ $$;
746
+ `;
747
+ }
748
+ function migration_0008_batch_store_pattern_delete(schema) {
749
+ const s = `"${schema}"`;
750
+ const t = `${s}.facts`;
751
+ return `
752
+ -- 0008_batch_store_pattern_delete: batch fact ingestion and explicit pattern
753
+ -- deletes for the base FactStore. Stored procedures stay the only write/delete
754
+ -- data-access surface.
755
+
756
+ CREATE OR REPLACE FUNCTION ${s}.facts_store_facts(p_facts JSONB)
757
+ RETURNS INT
758
+ LANGUAGE sql AS $$
759
+ WITH input AS (
760
+ SELECT
761
+ e->>'scopeKey' AS scope_key,
762
+ e->>'key' AS key,
763
+ e->'value' AS value,
764
+ e->>'agentId' AS agent_id,
765
+ e->>'sessionId' AS session_id,
766
+ coalesce((e->>'shared')::boolean, false) AS shared,
767
+ coalesce((e->>'transient')::boolean, true) AS transient,
768
+ coalesce(ARRAY(SELECT jsonb_array_elements_text(e->'tags')), '{}'::text[]) AS tags
769
+ FROM jsonb_array_elements(p_facts) e
770
+ ), upserted AS (
771
+ INSERT INTO ${t}
772
+ (scope_key, key, value, agent_id, session_id, shared, transient, tags)
773
+ SELECT scope_key, key, value, agent_id, session_id, shared, transient, tags
774
+ FROM input
775
+ ON CONFLICT (scope_key) DO UPDATE SET
776
+ value = EXCLUDED.value,
777
+ agent_id = EXCLUDED.agent_id,
778
+ session_id = EXCLUDED.session_id,
779
+ shared = EXCLUDED.shared,
780
+ transient = EXCLUDED.transient,
781
+ tags = EXCLUDED.tags,
782
+ updated_at = now()
783
+ RETURNING 1
784
+ )
785
+ SELECT count(*)::int FROM upserted;
786
+ $$;
787
+
788
+ CREATE OR REPLACE FUNCTION ${s}.facts_store_fact(
789
+ p_scope_key TEXT,
790
+ p_key TEXT,
791
+ p_value JSONB,
792
+ p_agent_id TEXT,
793
+ p_session_id TEXT,
794
+ p_shared BOOLEAN,
795
+ p_transient BOOLEAN,
796
+ p_tags TEXT[]
797
+ ) RETURNS VOID AS $$
798
+ BEGIN
799
+ PERFORM ${s}.facts_store_facts(jsonb_build_array(jsonb_build_object(
800
+ 'scopeKey', p_scope_key,
801
+ 'key', p_key,
802
+ 'value', p_value,
803
+ 'agentId', p_agent_id,
804
+ 'sessionId', p_session_id,
805
+ 'shared', p_shared,
806
+ 'transient', p_transient,
807
+ 'tags', coalesce(p_tags, '{}'::text[])
808
+ )));
809
+ END;
810
+ $$ LANGUAGE plpgsql;
811
+
812
+ CREATE OR REPLACE FUNCTION ${s}.facts_delete_facts(
813
+ p_key_pattern TEXT,
814
+ p_scope TEXT,
815
+ p_session_id TEXT,
816
+ p_unrestricted BOOLEAN DEFAULT FALSE
817
+ ) RETURNS BIGINT AS $$
818
+ DECLARE
819
+ deleted_count BIGINT;
820
+ BEGIN
821
+ IF p_key_pattern IS NULL OR p_key_pattern = '' THEN
822
+ RAISE EXCEPTION 'facts_delete_facts requires a key pattern';
823
+ END IF;
824
+ IF p_scope NOT IN ('session', 'shared', 'all') THEN
825
+ RAISE EXCEPTION 'facts_delete_facts scope must be session, shared, or all';
826
+ END IF;
827
+ IF p_scope = 'all' AND p_unrestricted IS DISTINCT FROM TRUE THEN
828
+ RAISE EXCEPTION 'facts_delete_facts scope=all requires unrestricted=true';
829
+ END IF;
830
+ IF p_scope = 'session' AND p_session_id IS NULL THEN
831
+ RAISE EXCEPTION 'facts_delete_facts scope=session requires sessionId';
832
+ END IF;
833
+
834
+ DELETE FROM ${t} f
835
+ WHERE f.key LIKE p_key_pattern
836
+ AND (
837
+ (p_scope = 'shared' AND f.shared = TRUE)
838
+ OR (p_scope = 'session' AND f.shared = FALSE AND f.session_id = p_session_id)
839
+ OR (p_scope = 'all' AND p_unrestricted = TRUE)
840
+ );
841
+ GET DIAGNOSTICS deleted_count = ROW_COUNT;
842
+ RETURN deleted_count;
843
+ END;
844
+ $$ LANGUAGE plpgsql;
845
+ `;
846
+ }
847
+ function migration_0009_unified_store_delete_api(schema) {
848
+ const s = `"${schema}"`;
849
+ const t = `${s}.facts`;
850
+ return `
851
+ -- 0009_unified_store_delete_api: collapse plural stored procedure names back
852
+ -- into the singular store/delete API. Batch store is facts_store_fact(jsonb);
853
+ -- pattern delete is facts_delete_fact(key, pattern, scope, sessionId, unrestricted).
854
+
855
+ DROP FUNCTION IF EXISTS ${s}.facts_store_fact(JSONB);
856
+ DROP FUNCTION IF EXISTS ${s}.facts_store_fact(TEXT, TEXT, JSONB, TEXT, TEXT, BOOLEAN, BOOLEAN, TEXT[]);
857
+ CREATE OR REPLACE FUNCTION ${s}.facts_store_fact(p_facts JSONB)
858
+ RETURNS INT
859
+ LANGUAGE sql AS $$
860
+ WITH input AS (
861
+ SELECT
862
+ e->>'scopeKey' AS scope_key,
863
+ e->>'key' AS key,
864
+ e->'value' AS value,
865
+ e->>'agentId' AS agent_id,
866
+ e->>'sessionId' AS session_id,
867
+ coalesce((e->>'shared')::boolean, false) AS shared,
868
+ coalesce((e->>'transient')::boolean, true) AS transient,
869
+ coalesce(ARRAY(SELECT jsonb_array_elements_text(e->'tags')), '{}'::text[]) AS tags
870
+ FROM jsonb_array_elements(p_facts) e
871
+ ), upserted AS (
872
+ INSERT INTO ${t}
873
+ (scope_key, key, value, agent_id, session_id, shared, transient, tags)
874
+ SELECT scope_key, key, value, agent_id, session_id, shared, transient, tags
875
+ FROM input
876
+ ON CONFLICT (scope_key) DO UPDATE SET
877
+ value = EXCLUDED.value,
878
+ agent_id = EXCLUDED.agent_id,
879
+ session_id = EXCLUDED.session_id,
880
+ shared = EXCLUDED.shared,
881
+ transient = EXCLUDED.transient,
882
+ tags = EXCLUDED.tags,
883
+ updated_at = now()
884
+ RETURNING 1
885
+ )
886
+ SELECT count(*)::int FROM upserted;
887
+ $$;
888
+
889
+ DROP FUNCTION IF EXISTS ${s}.facts_delete_fact(TEXT);
890
+ DROP FUNCTION IF EXISTS ${s}.facts_delete_fact(TEXT, BOOLEAN, TEXT, TEXT, BOOLEAN);
891
+ CREATE OR REPLACE FUNCTION ${s}.facts_delete_fact(
892
+ p_key_or_pattern TEXT,
893
+ p_pattern BOOLEAN DEFAULT FALSE,
894
+ p_scope TEXT DEFAULT NULL,
895
+ p_session_id TEXT DEFAULT NULL,
896
+ p_unrestricted BOOLEAN DEFAULT FALSE
897
+ ) RETURNS BIGINT AS $$
898
+ DECLARE
899
+ deleted_count BIGINT;
900
+ v_scope TEXT;
901
+ BEGIN
902
+ IF p_pattern THEN
903
+ IF p_key_or_pattern IS NULL OR p_key_or_pattern = '' THEN
904
+ RAISE EXCEPTION 'facts_delete_fact pattern mode requires key';
905
+ END IF;
906
+ v_scope := coalesce(p_scope, 'session');
907
+ IF v_scope NOT IN ('session', 'shared', 'all') THEN
908
+ RAISE EXCEPTION 'facts_delete_fact scope must be session, shared, or all';
909
+ END IF;
910
+ IF v_scope = 'all' AND p_unrestricted IS DISTINCT FROM TRUE THEN
911
+ RAISE EXCEPTION 'facts_delete_fact scope=all requires unrestricted=true';
912
+ END IF;
913
+ IF v_scope = 'session' AND p_session_id IS NULL THEN
914
+ RAISE EXCEPTION 'facts_delete_fact scope=session requires sessionId';
915
+ END IF;
916
+
917
+ DELETE FROM ${t} f
918
+ WHERE f.key LIKE p_key_or_pattern
919
+ AND (
920
+ (v_scope = 'shared' AND f.shared = TRUE)
921
+ OR (v_scope = 'session' AND f.shared = FALSE AND f.session_id = p_session_id)
922
+ OR (v_scope = 'all' AND p_unrestricted = TRUE)
923
+ );
924
+ ELSE
925
+ DELETE FROM ${t} WHERE scope_key = p_key_or_pattern;
926
+ END IF;
927
+ GET DIAGNOSTICS deleted_count = ROW_COUNT;
928
+ RETURN deleted_count;
929
+ END;
930
+ $$ LANGUAGE plpgsql;
931
+
932
+ DROP FUNCTION IF EXISTS ${s}.facts_store_facts(JSONB);
933
+ DROP FUNCTION IF EXISTS ${s}.facts_delete_facts(TEXT, TEXT, TEXT, BOOLEAN);
934
+ `;
935
+ }
936
+ function migration_0010_soft_delete_etag(schema) {
937
+ const s = `"${schema}"`;
938
+ const t = `${s}.facts`;
939
+ return `
940
+ -- 0010_soft_delete_etag: soft-delete facts and protect crawl mark writes with
941
+ -- a per-row etag. The facts store stays graph-agnostic: deleted facts remain in
942
+ -- the crawl queue until a crawler marks them, or until the Facts Manager TTL
943
+ -- purge removes expired tombstones.
944
+
945
+ ALTER TABLE ${t} ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
946
+ ALTER TABLE ${t} ADD COLUMN IF NOT EXISTS etag BIGINT NOT NULL DEFAULT 0;
947
+
948
+ CREATE INDEX IF NOT EXISTS idx_${schema}_facts_tombstones
949
+ ON ${t} (deleted_at) WHERE deleted_at IS NOT NULL;
950
+
951
+ -- Crawl-relevant state changes bump the etag and re-enter the crawl queue.
952
+ -- Embedding state does not exist in the base store, so only crawl bookkeeping is
953
+ -- handled here. last_crawled_at-only writes (facts_mark_crawled) do not bump etag.
954
+ CREATE OR REPLACE FUNCTION ${s}.facts_touch() RETURNS trigger
955
+ LANGUAGE plpgsql AS $$
956
+ BEGIN
957
+ IF TG_OP = 'INSERT' THEN
958
+ NEW.etag := COALESCE(NEW.etag, 0) + 1;
959
+ NEW.last_crawled_at := NULL;
960
+ ELSIF NEW.key IS DISTINCT FROM OLD.key
961
+ OR NEW.value IS DISTINCT FROM OLD.value
962
+ OR NEW.deleted_at IS DISTINCT FROM OLD.deleted_at THEN
963
+ NEW.etag := COALESCE(OLD.etag, 0) + 1;
964
+ NEW.last_crawled_at := NULL;
965
+ END IF;
966
+ RETURN NEW;
967
+ END $$;
968
+
969
+ DROP FUNCTION IF EXISTS ${s}.facts_store_fact(JSONB);
970
+ CREATE OR REPLACE FUNCTION ${s}.facts_store_fact(p_facts JSONB)
971
+ RETURNS INT
972
+ LANGUAGE sql AS $$
973
+ WITH input AS (
974
+ SELECT
975
+ e->>'scopeKey' AS scope_key,
976
+ e->>'key' AS key,
977
+ e->'value' AS value,
978
+ e->>'agentId' AS agent_id,
979
+ e->>'sessionId' AS session_id,
980
+ coalesce((e->>'shared')::boolean, false) AS shared,
981
+ coalesce((e->>'transient')::boolean, true) AS transient,
982
+ coalesce(ARRAY(SELECT jsonb_array_elements_text(e->'tags')), '{}'::text[]) AS tags
983
+ FROM jsonb_array_elements(p_facts) e
984
+ ), upserted AS (
985
+ INSERT INTO ${t}
986
+ (scope_key, key, value, agent_id, session_id, shared, transient, tags)
987
+ SELECT scope_key, key, value, agent_id, session_id, shared, transient, tags
988
+ FROM input
989
+ ON CONFLICT (scope_key) DO UPDATE SET
990
+ key = EXCLUDED.key,
991
+ value = EXCLUDED.value,
992
+ agent_id = EXCLUDED.agent_id,
993
+ session_id = EXCLUDED.session_id,
994
+ shared = EXCLUDED.shared,
995
+ transient = EXCLUDED.transient,
996
+ tags = EXCLUDED.tags,
997
+ deleted_at = NULL,
998
+ updated_at = now()
999
+ RETURNING 1
1000
+ )
1001
+ SELECT count(*)::int FROM upserted;
1002
+ $$;
1003
+
1004
+ DROP FUNCTION IF EXISTS ${s}.facts_delete_fact(TEXT, BOOLEAN, TEXT, TEXT, BOOLEAN);
1005
+ CREATE OR REPLACE FUNCTION ${s}.facts_delete_fact(
1006
+ p_key_or_pattern TEXT,
1007
+ p_pattern BOOLEAN DEFAULT FALSE,
1008
+ p_scope TEXT DEFAULT NULL,
1009
+ p_session_id TEXT DEFAULT NULL,
1010
+ p_unrestricted BOOLEAN DEFAULT FALSE
1011
+ ) RETURNS BIGINT AS $$
1012
+ DECLARE
1013
+ deleted_count BIGINT;
1014
+ v_scope TEXT;
1015
+ BEGIN
1016
+ IF p_pattern THEN
1017
+ IF p_key_or_pattern IS NULL OR p_key_or_pattern = '' THEN
1018
+ RAISE EXCEPTION 'facts_delete_fact pattern mode requires key';
1019
+ END IF;
1020
+ v_scope := coalesce(p_scope, 'session');
1021
+ IF v_scope NOT IN ('session', 'shared', 'all') THEN
1022
+ RAISE EXCEPTION 'facts_delete_fact scope must be session, shared, or all';
1023
+ END IF;
1024
+ IF v_scope = 'all' AND p_unrestricted IS DISTINCT FROM TRUE THEN
1025
+ RAISE EXCEPTION 'facts_delete_fact scope=all requires unrestricted=true';
1026
+ END IF;
1027
+ IF v_scope = 'session' AND p_session_id IS NULL THEN
1028
+ RAISE EXCEPTION 'facts_delete_fact scope=session requires sessionId';
1029
+ END IF;
1030
+
1031
+ UPDATE ${t} f
1032
+ SET deleted_at = now(), updated_at = now()
1033
+ WHERE f.deleted_at IS NULL
1034
+ AND f.key LIKE p_key_or_pattern
1035
+ AND (
1036
+ (v_scope = 'shared' AND f.shared = TRUE)
1037
+ OR (v_scope = 'session' AND f.shared = FALSE AND f.session_id = p_session_id)
1038
+ OR (v_scope = 'all' AND p_unrestricted = TRUE)
1039
+ );
1040
+ ELSE
1041
+ UPDATE ${t}
1042
+ SET deleted_at = now(), updated_at = now()
1043
+ WHERE scope_key = p_key_or_pattern
1044
+ AND deleted_at IS NULL;
1045
+ END IF;
1046
+ GET DIAGNOSTICS deleted_count = ROW_COUNT;
1047
+ RETURN deleted_count;
1048
+ END;
1049
+ $$ LANGUAGE plpgsql;
1050
+
1051
+ CREATE OR REPLACE FUNCTION ${s}.facts_delete_session_facts(
1052
+ p_session_id TEXT
1053
+ ) RETURNS BIGINT AS $$
1054
+ DECLARE
1055
+ deleted_count BIGINT;
1056
+ BEGIN
1057
+ UPDATE ${t}
1058
+ SET deleted_at = now(), updated_at = now()
1059
+ WHERE session_id = p_session_id
1060
+ AND shared = FALSE
1061
+ AND deleted_at IS NULL;
1062
+ GET DIAGNOSTICS deleted_count = ROW_COUNT;
1063
+ RETURN deleted_count;
1064
+ END;
1065
+ $$ LANGUAGE plpgsql;
1066
+
1067
+ DROP FUNCTION IF EXISTS ${s}.facts_read_facts(TEXT, TEXT, TEXT[], TEXT, TEXT[], TEXT, TEXT, INT, BOOLEAN, TEXT[]) CASCADE;
1068
+ CREATE OR REPLACE FUNCTION ${s}.facts_read_facts(
1069
+ p_scope TEXT,
1070
+ p_reader_session_id TEXT,
1071
+ p_granted_ids TEXT[],
1072
+ p_key_pattern TEXT,
1073
+ p_tags TEXT[],
1074
+ p_session_id TEXT,
1075
+ p_agent_id TEXT,
1076
+ p_limit INT,
1077
+ p_unrestricted BOOLEAN DEFAULT FALSE,
1078
+ p_scope_keys TEXT[] DEFAULT NULL
1079
+ ) RETURNS TABLE (
1080
+ scope_key TEXT,
1081
+ key TEXT,
1082
+ value JSONB,
1083
+ agent_id TEXT,
1084
+ session_id TEXT,
1085
+ shared BOOLEAN,
1086
+ tags TEXT[],
1087
+ created_at TIMESTAMPTZ,
1088
+ updated_at TIMESTAMPTZ,
1089
+ deleted_at TIMESTAMPTZ,
1090
+ etag BIGINT
1091
+ ) AS $$
1092
+ DECLARE
1093
+ base_sql TEXT;
1094
+ where_clauses TEXT[] := ARRAY[]::TEXT[];
1095
+ final_sql TEXT;
1096
+ BEGIN
1097
+ base_sql := 'SELECT f.scope_key, f.key, f.value, f.agent_id, f.session_id, f.shared, f.tags, f.created_at, f.updated_at, f.deleted_at, f.etag FROM ${t} f WHERE f.deleted_at IS NULL AND ';
1098
+
1099
+ IF p_unrestricted THEN
1100
+ where_clauses := array_append(where_clauses, 'TRUE');
1101
+ ELSIF p_scope = 'shared' THEN
1102
+ where_clauses := array_append(where_clauses, 'f.shared = TRUE');
1103
+ ELSIF p_scope = 'session' THEN
1104
+ IF p_reader_session_id IS NULL THEN
1105
+ RETURN;
1106
+ END IF;
1107
+ where_clauses := array_append(where_clauses,
1108
+ 'f.shared = FALSE AND f.session_id = ' || quote_literal(p_reader_session_id));
1109
+ ELSIF p_reader_session_id IS NOT NULL THEN
1110
+ DECLARE
1111
+ vis_parts TEXT[] := ARRAY[
1112
+ 'f.shared = TRUE',
1113
+ '(f.shared = FALSE AND f.session_id = ' || quote_literal(p_reader_session_id) || ')'
1114
+ ];
1115
+ BEGIN
1116
+ IF p_granted_ids IS NOT NULL AND array_length(p_granted_ids, 1) > 0 THEN
1117
+ vis_parts := array_append(vis_parts,
1118
+ '(f.shared = FALSE AND f.session_id = ANY(' || quote_literal(p_granted_ids)::TEXT || '::TEXT[]))');
1119
+ END IF;
1120
+ where_clauses := array_append(where_clauses, '(' || array_to_string(vis_parts, ' OR ') || ')');
1121
+ END;
1122
+ ELSE
1123
+ where_clauses := array_append(where_clauses, 'f.shared = TRUE');
1124
+ END IF;
1125
+
1126
+ IF p_scope_keys IS NOT NULL THEN
1127
+ where_clauses := array_append(where_clauses,
1128
+ 'f.scope_key = ANY(' || quote_literal(p_scope_keys)::TEXT || '::TEXT[])');
1129
+ END IF;
1130
+ IF p_key_pattern IS NOT NULL THEN
1131
+ where_clauses := array_append(where_clauses,
1132
+ 'f.key LIKE ' || quote_literal(p_key_pattern));
1133
+ END IF;
1134
+ IF p_tags IS NOT NULL AND array_length(p_tags, 1) > 0 THEN
1135
+ where_clauses := array_append(where_clauses,
1136
+ 'f.tags @> ' || quote_literal(p_tags)::TEXT || '::TEXT[]');
1137
+ END IF;
1138
+ IF p_session_id IS NOT NULL THEN
1139
+ where_clauses := array_append(where_clauses,
1140
+ 'f.session_id = ' || quote_literal(p_session_id));
1141
+ END IF;
1142
+ IF p_agent_id IS NOT NULL THEN
1143
+ where_clauses := array_append(where_clauses,
1144
+ 'f.agent_id = ' || quote_literal(p_agent_id));
1145
+ END IF;
1146
+
1147
+ final_sql := base_sql || array_to_string(where_clauses, ' AND ')
1148
+ || ' ORDER BY f.updated_at DESC LIMIT ' || p_limit;
1149
+
1150
+ RETURN QUERY EXECUTE final_sql;
1151
+ END;
1152
+ $$ LANGUAGE plpgsql;
1153
+
1154
+ CREATE OR REPLACE FUNCTION ${s}.facts_get_session_facts_stats(
1155
+ p_session_id TEXT
1156
+ ) RETURNS TABLE (
1157
+ namespace TEXT,
1158
+ fact_count BIGINT,
1159
+ total_value_bytes BIGINT,
1160
+ oldest_created_at TIMESTAMPTZ,
1161
+ newest_updated_at TIMESTAMPTZ
1162
+ ) AS $$
1163
+ BEGIN
1164
+ RETURN QUERY
1165
+ SELECT
1166
+ ${s}.facts_namespace_for_key(f.key) AS namespace,
1167
+ COUNT(*)::BIGINT AS fact_count,
1168
+ COALESCE(SUM(pg_column_size(f.value)), 0)::BIGINT AS total_value_bytes,
1169
+ MIN(f.created_at) AS oldest_created_at,
1170
+ MAX(f.updated_at) AS newest_updated_at
1171
+ FROM ${t} f
1172
+ WHERE f.session_id = p_session_id
1173
+ AND f.shared = FALSE
1174
+ AND f.deleted_at IS NULL
1175
+ GROUP BY 1
1176
+ ORDER BY fact_count DESC, namespace;
1177
+ END;
1178
+ $$ LANGUAGE plpgsql;
1179
+
1180
+ CREATE OR REPLACE FUNCTION ${s}.facts_get_facts_stats_for_sessions(
1181
+ p_session_ids TEXT[]
1182
+ ) RETURNS TABLE (
1183
+ namespace TEXT,
1184
+ fact_count BIGINT,
1185
+ total_value_bytes BIGINT,
1186
+ oldest_created_at TIMESTAMPTZ,
1187
+ newest_updated_at TIMESTAMPTZ
1188
+ ) AS $$
1189
+ BEGIN
1190
+ RETURN QUERY
1191
+ SELECT
1192
+ ${s}.facts_namespace_for_key(f.key) AS namespace,
1193
+ COUNT(*)::BIGINT AS fact_count,
1194
+ COALESCE(SUM(pg_column_size(f.value)), 0)::BIGINT AS total_value_bytes,
1195
+ MIN(f.created_at) AS oldest_created_at,
1196
+ MAX(f.updated_at) AS newest_updated_at
1197
+ FROM ${t} f
1198
+ WHERE f.session_id = ANY(p_session_ids)
1199
+ AND f.shared = FALSE
1200
+ AND f.deleted_at IS NULL
1201
+ GROUP BY 1
1202
+ ORDER BY fact_count DESC, namespace;
1203
+ END;
1204
+ $$ LANGUAGE plpgsql;
1205
+
1206
+ CREATE OR REPLACE FUNCTION ${s}.facts_get_shared_facts_stats()
1207
+ RETURNS TABLE (
1208
+ namespace TEXT,
1209
+ fact_count BIGINT,
1210
+ total_value_bytes BIGINT,
1211
+ oldest_created_at TIMESTAMPTZ,
1212
+ newest_updated_at TIMESTAMPTZ
1213
+ ) AS $$
1214
+ BEGIN
1215
+ RETURN QUERY
1216
+ SELECT
1217
+ ${s}.facts_namespace_for_key(f.key) AS namespace,
1218
+ COUNT(*)::BIGINT AS fact_count,
1219
+ COALESCE(SUM(pg_column_size(f.value)), 0)::BIGINT AS total_value_bytes,
1220
+ MIN(f.created_at) AS oldest_created_at,
1221
+ MAX(f.updated_at) AS newest_updated_at
1222
+ FROM ${t} f
1223
+ WHERE f.shared = TRUE
1224
+ AND f.deleted_at IS NULL
1225
+ GROUP BY 1
1226
+ ORDER BY fact_count DESC, namespace;
1227
+ END;
1228
+ $$ LANGUAGE plpgsql;
1229
+
1230
+ DROP FUNCTION IF EXISTS ${s}.facts_read_uncrawled(TEXT, INT);
1231
+ CREATE OR REPLACE FUNCTION ${s}.facts_read_uncrawled(
1232
+ p_ns_prefix TEXT,
1233
+ p_limit INT
1234
+ ) RETURNS TABLE (
1235
+ scope_key TEXT,
1236
+ key TEXT,
1237
+ value JSONB,
1238
+ agent_id TEXT,
1239
+ session_id TEXT,
1240
+ shared BOOLEAN,
1241
+ tags TEXT[],
1242
+ created_at TIMESTAMPTZ,
1243
+ updated_at TIMESTAMPTZ,
1244
+ deleted_at TIMESTAMPTZ,
1245
+ etag BIGINT
1246
+ ) LANGUAGE sql STABLE AS $$
1247
+ SELECT f.scope_key, f.key, f.value, f.agent_id, f.session_id, f.shared,
1248
+ f.tags, f.created_at, f.updated_at, f.deleted_at, f.etag
1249
+ FROM ${t} f
1250
+ WHERE f.last_crawled_at IS NULL
1251
+ AND (p_ns_prefix IS NULL OR starts_with(f.key, p_ns_prefix))
1252
+ ORDER BY f.id
1253
+ LIMIT p_limit;
1254
+ $$;
1255
+
1256
+ CREATE OR REPLACE FUNCTION ${s}.facts_mark_crawled(p_stamps JSONB)
1257
+ RETURNS TABLE (marked INT, skipped INT)
1258
+ LANGUAGE sql AS $$
1259
+ WITH stamps AS (
1260
+ SELECT e->>'scopeKey' AS scope_key,
1261
+ CASE WHEN (e->>'etag') ~ '^[0-9]+$' THEN (e->>'etag')::BIGINT ELSE NULL END AS etag
1262
+ FROM jsonb_array_elements(p_stamps) e
1263
+ ), upd AS (
1264
+ UPDATE ${t} f
1265
+ SET last_crawled_at = now()
1266
+ FROM stamps s
1267
+ WHERE f.scope_key = s.scope_key
1268
+ AND f.last_crawled_at IS NULL
1269
+ AND f.etag = s.etag
1270
+ RETURNING f.scope_key
1271
+ )
1272
+ SELECT (SELECT count(*) FROM upd)::int AS marked,
1273
+ ((SELECT count(*) FROM stamps) - (SELECT count(*) FROM upd))::int AS skipped;
1274
+ $$;
1275
+
1276
+ CREATE OR REPLACE FUNCTION ${s}.facts_purge_expired(
1277
+ p_ttl_seconds INT DEFAULT 21600,
1278
+ p_limit INT DEFAULT 1000
1279
+ ) RETURNS BIGINT AS $$
1280
+ DECLARE
1281
+ purged BIGINT;
1282
+ BEGIN
1283
+ WITH candidates AS (
1284
+ SELECT id
1285
+ FROM ${t}
1286
+ WHERE deleted_at IS NOT NULL
1287
+ AND (
1288
+ last_crawled_at IS NOT NULL
1289
+ OR deleted_at < now() - make_interval(secs => GREATEST(p_ttl_seconds, 0))
1290
+ )
1291
+ -- Prefer already-reconciled tombstones within a batch so a lagging (not
1292
+ -- dead) crawler's unreconciled tombstones get maximum time before the TTL
1293
+ -- backstop reclaims them and strands their graph evidence.
1294
+ ORDER BY (last_crawled_at IS NULL), deleted_at, id
1295
+ LIMIT GREATEST(p_limit, 1)
1296
+ FOR UPDATE SKIP LOCKED
1297
+ ), del AS (
1298
+ DELETE FROM ${t} f
1299
+ USING candidates c
1300
+ WHERE f.id = c.id
1301
+ RETURNING 1
1302
+ )
1303
+ SELECT count(*) INTO purged FROM del;
1304
+ RETURN COALESCE(purged, 0);
1305
+ END;
1306
+ $$ LANGUAGE plpgsql;
1307
+
1308
+ CREATE OR REPLACE FUNCTION ${s}.facts_tombstone_stats(
1309
+ p_ttl_seconds INT DEFAULT 21600
1310
+ ) RETURNS TABLE (
1311
+ pending_total BIGINT,
1312
+ unreconciled BIGINT,
1313
+ ttl_blocked BIGINT,
1314
+ oldest_unreconciled_age_seconds DOUBLE PRECISION,
1315
+ reconciled_unswept BIGINT
1316
+ ) LANGUAGE sql STABLE AS $$
1317
+ SELECT
1318
+ count(*) FILTER (WHERE deleted_at IS NOT NULL)::BIGINT AS pending_total,
1319
+ count(*) FILTER (WHERE deleted_at IS NOT NULL AND last_crawled_at IS NULL)::BIGINT AS unreconciled,
1320
+ count(*) FILTER (
1321
+ WHERE deleted_at IS NOT NULL
1322
+ AND last_crawled_at IS NULL
1323
+ AND deleted_at >= now() - make_interval(secs => GREATEST(p_ttl_seconds, 0))
1324
+ )::BIGINT AS ttl_blocked,
1325
+ EXTRACT(EPOCH FROM (now() - MIN(deleted_at) FILTER (
1326
+ WHERE deleted_at IS NOT NULL AND last_crawled_at IS NULL
1327
+ )))::DOUBLE PRECISION AS oldest_unreconciled_age_seconds,
1328
+ count(*) FILTER (WHERE deleted_at IS NOT NULL AND last_crawled_at IS NOT NULL)::BIGINT AS reconciled_unswept
1329
+ FROM ${t};
1330
+ $$;
1331
+
1332
+ CREATE OR REPLACE FUNCTION ${s}.facts_force_purge(
1333
+ p_cutoff TIMESTAMPTZ,
1334
+ p_only_unreconciled BOOLEAN DEFAULT FALSE,
1335
+ p_key_prefix TEXT DEFAULT NULL,
1336
+ p_limit INT DEFAULT 1000
1337
+ ) RETURNS BIGINT AS $$
1338
+ DECLARE
1339
+ purged BIGINT;
1340
+ BEGIN
1341
+ WITH candidates AS (
1342
+ SELECT id
1343
+ FROM ${t}
1344
+ WHERE deleted_at IS NOT NULL
1345
+ AND deleted_at < p_cutoff
1346
+ AND (p_only_unreconciled IS DISTINCT FROM TRUE OR last_crawled_at IS NULL)
1347
+ AND (p_key_prefix IS NULL OR starts_with(key, p_key_prefix))
1348
+ ORDER BY deleted_at, id
1349
+ LIMIT GREATEST(p_limit, 1)
1350
+ FOR UPDATE SKIP LOCKED
1351
+ ), del AS (
1352
+ DELETE FROM ${t} f
1353
+ USING candidates c
1354
+ WHERE f.id = c.id
1355
+ RETURNING 1
1356
+ )
1357
+ SELECT count(*) INTO purged FROM del;
1358
+ RETURN COALESCE(purged, 0);
1359
+ END;
1360
+ $$ LANGUAGE plpgsql;
1361
+ `;
1362
+ }
440
1363
  //# sourceMappingURL=facts-migrations.js.map