pilotswarm-horizon-store 0.2.2

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 (71) hide show
  1. package/README.md +154 -0
  2. package/dist/src/agent-tools.d.ts +32 -0
  3. package/dist/src/agent-tools.d.ts.map +1 -0
  4. package/dist/src/agent-tools.js +263 -0
  5. package/dist/src/agent-tools.js.map +1 -0
  6. package/dist/src/config.d.ts +93 -0
  7. package/dist/src/config.d.ts.map +1 -0
  8. package/dist/src/config.js +120 -0
  9. package/dist/src/config.js.map +1 -0
  10. package/dist/src/db-retry.d.ts +21 -0
  11. package/dist/src/db-retry.d.ts.map +1 -0
  12. package/dist/src/db-retry.js +94 -0
  13. package/dist/src/db-retry.js.map +1 -0
  14. package/dist/src/embedding-client.d.ts +13 -0
  15. package/dist/src/embedding-client.d.ts.map +1 -0
  16. package/dist/src/embedding-client.js +79 -0
  17. package/dist/src/embedding-client.js.map +1 -0
  18. package/dist/src/graph-model.d.ts +49 -0
  19. package/dist/src/graph-model.d.ts.map +1 -0
  20. package/dist/src/graph-model.js +112 -0
  21. package/dist/src/graph-model.js.map +1 -0
  22. package/dist/src/graph-queries.d.ts +83 -0
  23. package/dist/src/graph-queries.d.ts.map +1 -0
  24. package/dist/src/graph-queries.js +727 -0
  25. package/dist/src/graph-queries.js.map +1 -0
  26. package/dist/src/graph-store.d.ts +57 -0
  27. package/dist/src/graph-store.d.ts.map +1 -0
  28. package/dist/src/graph-store.js +327 -0
  29. package/dist/src/graph-store.js.map +1 -0
  30. package/dist/src/horizon-migrator.d.ts +49 -0
  31. package/dist/src/horizon-migrator.d.ts.map +1 -0
  32. package/dist/src/horizon-migrator.js +231 -0
  33. package/dist/src/horizon-migrator.js.map +1 -0
  34. package/dist/src/horizon-store.d.ts +116 -0
  35. package/dist/src/horizon-store.d.ts.map +1 -0
  36. package/dist/src/horizon-store.js +563 -0
  37. package/dist/src/horizon-store.js.map +1 -0
  38. package/dist/src/index.d.ts +13 -0
  39. package/dist/src/index.d.ts.map +1 -0
  40. package/dist/src/index.js +21 -0
  41. package/dist/src/index.js.map +1 -0
  42. package/dist/src/preconditions.d.ts +16 -0
  43. package/dist/src/preconditions.d.ts.map +1 -0
  44. package/dist/src/preconditions.js +99 -0
  45. package/dist/src/preconditions.js.map +1 -0
  46. package/dist/src/query-builder.d.ts +37 -0
  47. package/dist/src/query-builder.d.ts.map +1 -0
  48. package/dist/src/query-builder.js +82 -0
  49. package/dist/src/query-builder.js.map +1 -0
  50. package/dist/src/sql-util.d.ts +20 -0
  51. package/dist/src/sql-util.d.ts.map +1 -0
  52. package/dist/src/sql-util.js +40 -0
  53. package/dist/src/sql-util.js.map +1 -0
  54. package/dist/src/types.d.ts +16 -0
  55. package/dist/src/types.d.ts.map +1 -0
  56. package/dist/src/types.js +35 -0
  57. package/dist/src/types.js.map +1 -0
  58. package/migrations/0001_facts_table.sql +77 -0
  59. package/migrations/0002_indexes.sql +27 -0
  60. package/migrations/0003_age_bootstrap.sql +13 -0
  61. package/migrations/0004_facts_procs.sql +253 -0
  62. package/migrations/0005_embedder_workflow.sql +119 -0
  63. package/migrations/0006_facts_read_uncrawled_embedded_gate.sql +33 -0
  64. package/migrations/0007_embed_key_value_text.sql +122 -0
  65. package/migrations/0008_embedder_failure_recovery.sql +409 -0
  66. package/migrations/0009_search_text_full_value.sql +52 -0
  67. package/migrations/0010_minimal_two_loop_embedder.sql +392 -0
  68. package/migrations/0011_unified_api_embedder_workflow.sql +273 -0
  69. package/migrations/0012_facts_soft_delete.sql +587 -0
  70. package/migrations/0013_graph_namespaces.sql +60 -0
  71. package/package.json +69 -0
@@ -0,0 +1,587 @@
1
+ -- 0012_facts_soft_delete — soft-delete facts and protect crawl marks with an etag.
2
+ --
3
+ -- Final fact lifecycle additions:
4
+ -- deleted_at timestamptz -- NULL = live; non-NULL = tombstone hidden from reads
5
+ -- etag bigint -- crawl-relevant optimistic-concurrency token
6
+ --
7
+ -- The facts store is graph-agnostic. Deletes always tombstone + requeue; the
8
+ -- crawler may reconcile the graph and mark crawled, or the Facts Manager TTL
9
+ -- purge may eventually hard-delete the tombstone.
10
+ --
11
+ -- Tokens: {{SCHEMA}}.
12
+
13
+ ALTER TABLE "{{SCHEMA}}".facts ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
14
+ ALTER TABLE "{{SCHEMA}}".facts ADD COLUMN IF NOT EXISTS etag BIGINT NOT NULL DEFAULT 0;
15
+
16
+ CREATE INDEX IF NOT EXISTS idx_facts_tombstones
17
+ ON "{{SCHEMA}}".facts (deleted_at) WHERE deleted_at IS NOT NULL;
18
+
19
+ CREATE OR REPLACE FUNCTION "{{SCHEMA}}".facts_touch() RETURNS trigger
20
+ LANGUAGE plpgsql AS $$
21
+ BEGIN
22
+ IF TG_OP = 'INSERT' THEN
23
+ NEW.etag := COALESCE(NEW.etag, 0) + 1;
24
+ NEW.last_crawled_at := NULL;
25
+ ELSIF NEW.key IS DISTINCT FROM OLD.key
26
+ OR NEW.value IS DISTINCT FROM OLD.value
27
+ OR NEW.deleted_at IS DISTINCT FROM OLD.deleted_at THEN
28
+ NEW.etag := COALESCE(OLD.etag, 0) + 1;
29
+ NEW.last_crawled_at := NULL;
30
+ END IF;
31
+
32
+ -- Embedding is content-derived only. Delete/revive should not force a re-embed.
33
+ IF TG_OP = 'INSERT' OR NEW.key IS DISTINCT FROM OLD.key OR NEW.value IS DISTINCT FROM OLD.value THEN
34
+ NEW.updated_at := now();
35
+ NEW.embedding := NULL;
36
+ NEW.embedding_model := NULL;
37
+ NEW.last_embed_error := NULL;
38
+ END IF;
39
+ RETURN NEW;
40
+ END $$;
41
+
42
+ DROP FUNCTION IF EXISTS "{{SCHEMA}}".facts_store(JSONB);
43
+ CREATE OR REPLACE FUNCTION "{{SCHEMA}}".facts_store(p_facts JSONB)
44
+ RETURNS INT
45
+ LANGUAGE sql AS $$
46
+ WITH input AS (
47
+ SELECT
48
+ e->>'scopeKey' AS scope_key,
49
+ e->>'key' AS key,
50
+ e->'value' AS value,
51
+ e->>'agentId' AS agent_id,
52
+ e->>'sessionId' AS session_id,
53
+ coalesce((e->>'shared')::boolean, false) AS shared,
54
+ coalesce(ARRAY(SELECT jsonb_array_elements_text(e->'tags')), '{}'::text[]) AS tags
55
+ FROM jsonb_array_elements(p_facts) e
56
+ ), upserted AS (
57
+ INSERT INTO "{{SCHEMA}}".facts (scope_key, key, value, agent_id, session_id, shared, transient, tags, updated_at)
58
+ SELECT scope_key, key, value, agent_id, session_id, shared, NOT shared, tags, now()
59
+ FROM input
60
+ ON CONFLICT (scope_key) DO UPDATE SET
61
+ key = EXCLUDED.key,
62
+ value = EXCLUDED.value,
63
+ agent_id = EXCLUDED.agent_id,
64
+ session_id = EXCLUDED.session_id,
65
+ shared = EXCLUDED.shared,
66
+ transient = EXCLUDED.transient,
67
+ tags = EXCLUDED.tags,
68
+ deleted_at = NULL,
69
+ updated_at = now()
70
+ RETURNING 1
71
+ )
72
+ SELECT count(*)::int FROM upserted;
73
+ $$;
74
+
75
+ DROP FUNCTION IF EXISTS "{{SCHEMA}}".facts_delete(TEXT, BOOLEAN, TEXT, TEXT, BOOLEAN);
76
+ CREATE OR REPLACE FUNCTION "{{SCHEMA}}".facts_delete(
77
+ p_key_or_pattern TEXT,
78
+ p_pattern BOOLEAN DEFAULT FALSE,
79
+ p_scope TEXT DEFAULT NULL,
80
+ p_session_id TEXT DEFAULT NULL,
81
+ p_unrestricted BOOLEAN DEFAULT FALSE
82
+ ) RETURNS INT
83
+ LANGUAGE plpgsql AS $$
84
+ DECLARE
85
+ deleted_count INT;
86
+ v_scope TEXT;
87
+ BEGIN
88
+ IF p_pattern THEN
89
+ IF p_key_or_pattern IS NULL OR p_key_or_pattern = '' THEN
90
+ RAISE EXCEPTION 'facts_delete pattern mode requires key';
91
+ END IF;
92
+ v_scope := coalesce(p_scope, 'session');
93
+ IF v_scope NOT IN ('session', 'shared', 'all') THEN
94
+ RAISE EXCEPTION 'facts_delete scope must be session, shared, or all';
95
+ END IF;
96
+ IF v_scope = 'all' AND p_unrestricted IS DISTINCT FROM TRUE THEN
97
+ RAISE EXCEPTION 'facts_delete scope=all requires unrestricted=true';
98
+ END IF;
99
+ IF v_scope = 'session' AND p_session_id IS NULL THEN
100
+ RAISE EXCEPTION 'facts_delete scope=session requires sessionId';
101
+ END IF;
102
+
103
+ UPDATE "{{SCHEMA}}".facts f
104
+ SET deleted_at = now(), updated_at = now()
105
+ WHERE f.deleted_at IS NULL
106
+ AND f.key LIKE p_key_or_pattern
107
+ AND (
108
+ (v_scope = 'shared' AND f.shared = TRUE)
109
+ OR (v_scope = 'session' AND f.shared = FALSE AND f.session_id = p_session_id)
110
+ OR (v_scope = 'all' AND p_unrestricted = TRUE)
111
+ );
112
+ ELSE
113
+ UPDATE "{{SCHEMA}}".facts
114
+ SET deleted_at = now(), updated_at = now()
115
+ WHERE scope_key = p_key_or_pattern
116
+ AND deleted_at IS NULL;
117
+ END IF;
118
+ GET DIAGNOSTICS deleted_count = ROW_COUNT;
119
+ RETURN deleted_count;
120
+ END;
121
+ $$;
122
+
123
+ CREATE OR REPLACE FUNCTION "{{SCHEMA}}".facts_delete_session(p_session_id TEXT) RETURNS INT
124
+ LANGUAGE sql AS $$
125
+ WITH upd AS (
126
+ UPDATE "{{SCHEMA}}".facts
127
+ SET deleted_at = now(), updated_at = now()
128
+ WHERE shared = FALSE
129
+ AND session_id = p_session_id
130
+ AND deleted_at IS NULL
131
+ RETURNING 1
132
+ )
133
+ SELECT count(*)::int FROM upd;
134
+ $$;
135
+
136
+ CREATE OR REPLACE FUNCTION "{{SCHEMA}}".facts_read(
137
+ p_reader TEXT,
138
+ p_granted TEXT[],
139
+ p_unrestricted BOOLEAN,
140
+ p_scope TEXT,
141
+ p_key_pattern TEXT,
142
+ p_scope_keys TEXT[],
143
+ p_tags TEXT[],
144
+ p_agent_id TEXT,
145
+ p_limit INT
146
+ ) RETURNS SETOF "{{SCHEMA}}".facts
147
+ LANGUAGE sql STABLE AS $$
148
+ SELECT f.* FROM "{{SCHEMA}}".facts f
149
+ WHERE f.deleted_at IS NULL
150
+ AND "{{SCHEMA}}".facts_acl(f.shared, f.session_id, p_reader, p_granted, p_unrestricted, p_scope)
151
+ AND (p_key_pattern IS NULL OR f.key LIKE p_key_pattern)
152
+ AND (p_scope_keys IS NULL OR f.scope_key = ANY (p_scope_keys))
153
+ AND (p_tags IS NULL OR f.tags @> p_tags)
154
+ AND (p_agent_id IS NULL OR f.agent_id = p_agent_id)
155
+ ORDER BY f.updated_at DESC
156
+ LIMIT p_limit;
157
+ $$;
158
+
159
+ CREATE OR REPLACE FUNCTION "{{SCHEMA}}".facts_stats(
160
+ p_mode TEXT,
161
+ p_session_ids TEXT[]
162
+ ) RETURNS TABLE (
163
+ namespace TEXT, fact_count BIGINT, total_value_bytes BIGINT,
164
+ oldest_created_at TIMESTAMPTZ, newest_updated_at TIMESTAMPTZ
165
+ )
166
+ LANGUAGE sql STABLE AS $$
167
+ SELECT
168
+ CASE WHEN split_part(f.key, '/', 1) IN ('skills', 'asks', 'intake', 'config')
169
+ THEN split_part(f.key, '/', 1) ELSE '(other)' END AS namespace,
170
+ count(*) AS fact_count,
171
+ sum(octet_length(f.value::text))::bigint AS total_value_bytes,
172
+ min(f.created_at) AS oldest_created_at,
173
+ max(f.updated_at) AS newest_updated_at
174
+ FROM "{{SCHEMA}}".facts f
175
+ WHERE f.deleted_at IS NULL
176
+ AND CASE p_mode
177
+ WHEN 'shared' THEN f.shared
178
+ WHEN 'session' THEN (NOT f.shared AND f.session_id = p_session_ids[1])
179
+ WHEN 'sessions' THEN (NOT f.shared AND f.session_id = ANY (coalesce(p_session_ids, '{}')))
180
+ ELSE FALSE
181
+ END
182
+ GROUP BY 1;
183
+ $$;
184
+
185
+ CREATE OR REPLACE FUNCTION "{{SCHEMA}}".facts_search_lexical(
186
+ p_query TEXT,
187
+ p_reader TEXT,
188
+ p_granted TEXT[],
189
+ p_unrestricted BOOLEAN,
190
+ p_scope TEXT,
191
+ p_ns_prefix TEXT,
192
+ p_tags TEXT[],
193
+ p_pool INT
194
+ ) RETURNS TABLE (
195
+ scope_key TEXT, key TEXT, value JSONB, agent_id TEXT, session_id TEXT,
196
+ shared BOOLEAN, tags TEXT[], created_at TIMESTAMPTZ, updated_at TIMESTAMPTZ,
197
+ rank DOUBLE PRECISION
198
+ )
199
+ LANGUAGE sql STABLE AS $$
200
+ SELECT f.scope_key, f.key, f.value, f.agent_id, f.session_id,
201
+ f.shared, f.tags, f.created_at, f.updated_at,
202
+ (-(f.search_text <@> q.bq))::double precision AS rank
203
+ FROM "{{SCHEMA}}".facts f,
204
+ (SELECT to_bm25query(p_query, '{{SCHEMA}}.idx_facts_lexical') AS bq) q
205
+ WHERE f.deleted_at IS NULL
206
+ AND (f.search_text <@> q.bq) < 0
207
+ AND "{{SCHEMA}}".facts_acl(f.shared, f.session_id, p_reader, p_granted, p_unrestricted, p_scope)
208
+ AND (p_ns_prefix IS NULL OR f.key LIKE p_ns_prefix)
209
+ AND (p_tags IS NULL OR f.tags @> p_tags)
210
+ ORDER BY f.search_text <@> q.bq ASC
211
+ LIMIT p_pool;
212
+ $$;
213
+
214
+ CREATE OR REPLACE FUNCTION "{{SCHEMA}}".facts_search_semantic(
215
+ p_vec vector,
216
+ p_model TEXT,
217
+ p_reader TEXT,
218
+ p_granted TEXT[],
219
+ p_unrestricted BOOLEAN,
220
+ p_scope TEXT,
221
+ p_ns_prefix TEXT,
222
+ p_tags TEXT[],
223
+ p_min DOUBLE PRECISION,
224
+ p_pool INT
225
+ ) RETURNS TABLE (
226
+ scope_key TEXT, key TEXT, value JSONB, agent_id TEXT, session_id TEXT,
227
+ shared BOOLEAN, tags TEXT[], created_at TIMESTAMPTZ, updated_at TIMESTAMPTZ,
228
+ sim DOUBLE PRECISION
229
+ )
230
+ LANGUAGE sql STABLE AS $$
231
+ SELECT f.scope_key, f.key, f.value, f.agent_id, f.session_id,
232
+ f.shared, f.tags, f.created_at, f.updated_at,
233
+ (1 - (f.embedding <=> p_vec))::double precision AS sim
234
+ FROM "{{SCHEMA}}".facts f
235
+ WHERE f.deleted_at IS NULL
236
+ AND f.embedding IS NOT NULL
237
+ AND f.embedding_model IS NOT DISTINCT FROM p_model
238
+ AND "{{SCHEMA}}".facts_acl(f.shared, f.session_id, p_reader, p_granted, p_unrestricted, p_scope)
239
+ AND (p_ns_prefix IS NULL OR f.key LIKE p_ns_prefix)
240
+ AND (p_tags IS NULL OR f.tags @> p_tags)
241
+ AND (1 - (f.embedding <=> p_vec)) >= coalesce(p_min, 0)
242
+ ORDER BY f.embedding <=> p_vec ASC
243
+ LIMIT p_pool;
244
+ $$;
245
+
246
+ CREATE OR REPLACE FUNCTION "{{SCHEMA}}".facts_similar(
247
+ p_scope_key TEXT,
248
+ p_model TEXT,
249
+ p_reader TEXT,
250
+ p_granted TEXT[],
251
+ p_unrestricted BOOLEAN,
252
+ p_scope TEXT,
253
+ p_ns_prefix TEXT,
254
+ p_min DOUBLE PRECISION,
255
+ p_k INT
256
+ ) RETURNS TABLE (
257
+ scope_key TEXT, key TEXT, value JSONB, agent_id TEXT, session_id TEXT,
258
+ shared BOOLEAN, tags TEXT[], created_at TIMESTAMPTZ, updated_at TIMESTAMPTZ,
259
+ sim DOUBLE PRECISION
260
+ )
261
+ LANGUAGE sql STABLE AS $$
262
+ WITH anchor AS (
263
+ SELECT a.embedding, a.embedding_model
264
+ FROM "{{SCHEMA}}".facts a
265
+ WHERE a.deleted_at IS NULL
266
+ AND a.scope_key = p_scope_key
267
+ AND a.embedding IS NOT NULL
268
+ AND (p_model IS NULL OR a.embedding_model IS NOT DISTINCT FROM p_model)
269
+ AND "{{SCHEMA}}".facts_acl(a.shared, a.session_id, p_reader, p_granted, p_unrestricted, p_scope)
270
+ )
271
+ SELECT f.scope_key, f.key, f.value, f.agent_id, f.session_id,
272
+ f.shared, f.tags, f.created_at, f.updated_at,
273
+ (1 - (f.embedding <=> a.embedding))::double precision AS sim
274
+ FROM "{{SCHEMA}}".facts f, anchor a
275
+ WHERE f.deleted_at IS NULL
276
+ AND f.scope_key <> p_scope_key
277
+ AND f.embedding IS NOT NULL
278
+ AND f.embedding_model IS NOT DISTINCT FROM a.embedding_model
279
+ AND "{{SCHEMA}}".facts_acl(f.shared, f.session_id, p_reader, p_granted, p_unrestricted, p_scope)
280
+ AND (p_ns_prefix IS NULL OR f.key LIKE p_ns_prefix)
281
+ AND (1 - (f.embedding <=> a.embedding)) >= coalesce(p_min, 0)
282
+ ORDER BY f.embedding <=> a.embedding ASC
283
+ LIMIT p_k;
284
+ $$;
285
+
286
+ DROP FUNCTION IF EXISTS "{{SCHEMA}}".facts_read_uncrawled(TEXT, INT, BOOLEAN);
287
+ CREATE OR REPLACE FUNCTION "{{SCHEMA}}".facts_read_uncrawled(
288
+ p_ns_prefix TEXT,
289
+ p_limit INT,
290
+ p_embedded_only BOOLEAN DEFAULT FALSE
291
+ ) RETURNS SETOF "{{SCHEMA}}".facts
292
+ LANGUAGE sql STABLE AS $$
293
+ SELECT f.* FROM "{{SCHEMA}}".facts f
294
+ WHERE f.last_crawled_at IS NULL
295
+ AND (p_ns_prefix IS NULL OR f.key LIKE p_ns_prefix)
296
+ AND (f.deleted_at IS NOT NULL OR NOT p_embedded_only OR f.embedding IS NOT NULL)
297
+ ORDER BY f.id
298
+ LIMIT p_limit;
299
+ $$;
300
+
301
+ CREATE OR REPLACE FUNCTION "{{SCHEMA}}".facts_mark_crawled(p_stamps JSONB)
302
+ RETURNS TABLE (marked INT, skipped INT)
303
+ LANGUAGE sql AS $$
304
+ WITH stamps AS (
305
+ SELECT e->>'scopeKey' AS scope_key,
306
+ CASE WHEN (e->>'etag') ~ '^[0-9]+$' THEN (e->>'etag')::BIGINT ELSE NULL END AS etag
307
+ FROM jsonb_array_elements(p_stamps) e
308
+ ), upd AS (
309
+ UPDATE "{{SCHEMA}}".facts f
310
+ SET last_crawled_at = now()
311
+ FROM stamps s
312
+ WHERE f.scope_key = s.scope_key
313
+ AND f.last_crawled_at IS NULL
314
+ AND f.etag = s.etag
315
+ RETURNING f.scope_key
316
+ )
317
+ SELECT (SELECT count(*) FROM upd)::int AS marked,
318
+ ((SELECT count(*) FROM stamps) - (SELECT count(*) FROM upd))::int AS skipped;
319
+ $$;
320
+
321
+ CREATE OR REPLACE FUNCTION "{{SCHEMA}}".facts_purge_expired(
322
+ p_ttl_seconds INT DEFAULT 21600,
323
+ p_limit INT DEFAULT 1000
324
+ ) RETURNS BIGINT
325
+ LANGUAGE plpgsql AS $$
326
+ DECLARE
327
+ purged BIGINT;
328
+ BEGIN
329
+ WITH candidates AS (
330
+ SELECT id
331
+ FROM "{{SCHEMA}}".facts
332
+ WHERE deleted_at IS NOT NULL
333
+ AND (
334
+ last_crawled_at IS NOT NULL
335
+ OR deleted_at < now() - make_interval(secs => GREATEST(p_ttl_seconds, 0))
336
+ )
337
+ -- Prefer already-reconciled tombstones within a batch so a lagging (not
338
+ -- dead) crawler's unreconciled tombstones get maximum time before the TTL
339
+ -- backstop reclaims them and strands their graph evidence.
340
+ ORDER BY (last_crawled_at IS NULL), deleted_at, id
341
+ LIMIT GREATEST(p_limit, 1)
342
+ FOR UPDATE SKIP LOCKED
343
+ ), del AS (
344
+ DELETE FROM "{{SCHEMA}}".facts f
345
+ USING candidates c
346
+ WHERE f.id = c.id
347
+ RETURNING 1
348
+ )
349
+ SELECT count(*) INTO purged FROM del;
350
+ RETURN COALESCE(purged, 0);
351
+ END;
352
+ $$;
353
+
354
+ CREATE OR REPLACE FUNCTION "{{SCHEMA}}".facts_tombstone_stats(
355
+ p_ttl_seconds INT DEFAULT 21600
356
+ ) RETURNS TABLE (
357
+ pending_total BIGINT,
358
+ unreconciled BIGINT,
359
+ ttl_blocked BIGINT,
360
+ oldest_unreconciled_age_seconds DOUBLE PRECISION,
361
+ reconciled_unswept BIGINT
362
+ )
363
+ LANGUAGE sql STABLE AS $$
364
+ SELECT
365
+ count(*) FILTER (WHERE deleted_at IS NOT NULL)::BIGINT AS pending_total,
366
+ count(*) FILTER (WHERE deleted_at IS NOT NULL AND last_crawled_at IS NULL)::BIGINT AS unreconciled,
367
+ count(*) FILTER (
368
+ WHERE deleted_at IS NOT NULL
369
+ AND last_crawled_at IS NULL
370
+ AND deleted_at >= now() - make_interval(secs => GREATEST(p_ttl_seconds, 0))
371
+ )::BIGINT AS ttl_blocked,
372
+ EXTRACT(EPOCH FROM (now() - MIN(deleted_at) FILTER (
373
+ WHERE deleted_at IS NOT NULL AND last_crawled_at IS NULL
374
+ )))::DOUBLE PRECISION AS oldest_unreconciled_age_seconds,
375
+ count(*) FILTER (WHERE deleted_at IS NOT NULL AND last_crawled_at IS NOT NULL)::BIGINT AS reconciled_unswept
376
+ FROM "{{SCHEMA}}".facts;
377
+ $$;
378
+
379
+ CREATE OR REPLACE FUNCTION "{{SCHEMA}}".facts_force_purge(
380
+ p_cutoff TIMESTAMPTZ,
381
+ p_only_unreconciled BOOLEAN DEFAULT FALSE,
382
+ p_key_prefix TEXT DEFAULT NULL,
383
+ p_limit INT DEFAULT 1000
384
+ ) RETURNS BIGINT
385
+ LANGUAGE plpgsql AS $$
386
+ DECLARE
387
+ purged BIGINT;
388
+ BEGIN
389
+ WITH candidates AS (
390
+ SELECT id
391
+ FROM "{{SCHEMA}}".facts
392
+ WHERE deleted_at IS NOT NULL
393
+ AND deleted_at < p_cutoff
394
+ AND (p_only_unreconciled IS DISTINCT FROM TRUE OR last_crawled_at IS NULL)
395
+ AND (p_key_prefix IS NULL OR starts_with(key, p_key_prefix))
396
+ ORDER BY deleted_at, id
397
+ LIMIT GREATEST(p_limit, 1)
398
+ FOR UPDATE SKIP LOCKED
399
+ ), del AS (
400
+ DELETE FROM "{{SCHEMA}}".facts f
401
+ USING candidates c
402
+ WHERE f.id = c.id
403
+ RETURNING 1
404
+ )
405
+ SELECT count(*) INTO purged FROM del;
406
+ RETURN COALESCE(purged, 0);
407
+ END;
408
+ $$;
409
+
410
+ DROP FUNCTION IF EXISTS "{{SCHEMA}}".embedder_workflow(TEXT, INT, INT);
411
+ CREATE OR REPLACE FUNCTION "{{SCHEMA}}".embedder_workflow(p_mode text, p_interval int, p_batch int)
412
+ RETURNS text
413
+ LANGUAGE plpgsql AS $fn$
414
+ DECLARE
415
+ v_url text;
416
+ v_model text;
417
+ v_key text;
418
+ v_keyhdr text;
419
+ v_bearer text;
420
+ v_inputfield text;
421
+ v_timeout int;
422
+ v_headers jsonb;
423
+ v_select_sql text;
424
+ v_too_large_sql text;
425
+ v_resp_ok_sql text;
426
+ v_batch_fail_sql text;
427
+ v_zip_sql text;
428
+ v_retry_fail_sql text;
429
+ v_oversized_fail_sql text;
430
+ v_alias text;
431
+ v_resp_alias text;
432
+ v_limit int;
433
+ BEGIN
434
+ IF p_mode NOT IN ('batch', 'retry') THEN
435
+ RAISE EXCEPTION 'embedder_workflow mode must be batch or retry';
436
+ END IF;
437
+
438
+ v_url := df.getvar('hz_{{SCHEMA}}_url');
439
+ v_model := df.getvar('hz_{{SCHEMA}}_model');
440
+ v_key := df.getvar('hz_{{SCHEMA}}_key');
441
+ v_keyhdr := coalesce(df.getvar('hz_{{SCHEMA}}_keyhdr'), 'api-key');
442
+ v_bearer := coalesce(df.getvar('hz_{{SCHEMA}}_bearer'), 'false');
443
+ v_inputfield := coalesce(df.getvar('hz_{{SCHEMA}}_inputfield'), 'input');
444
+ v_timeout := coalesce(df.getvar('hz_{{SCHEMA}}_timeout'), '30')::int;
445
+
446
+ IF v_url IS NULL OR v_model IS NULL THEN
447
+ RAISE EXCEPTION 'embedder not configured: call configureEmbedder first';
448
+ END IF;
449
+
450
+ v_headers := jsonb_build_object('content-type', 'application/json');
451
+ IF v_key IS NOT NULL AND v_key <> '' THEN
452
+ v_headers := v_headers || jsonb_build_object(
453
+ v_keyhdr,
454
+ CASE WHEN v_bearer = 'true' THEN 'Bearer ' || v_key ELSE v_key END
455
+ );
456
+ END IF;
457
+
458
+ v_alias := p_mode;
459
+ v_resp_alias := CASE WHEN p_mode = 'batch' THEN 'resp' ELSE 'retry_resp' END;
460
+ v_limit := CASE WHEN p_mode = 'batch' THEN greatest(p_batch, 1) ELSE 1 END;
461
+
462
+ v_select_sql := format(
463
+ $q$SELECT jsonb_build_object(%L, jsonb_agg(t.txt ORDER BY t.id), 'model', %L)::text AS body,
464
+ array_agg(t.id ORDER BY t.id)::text AS ids,
465
+ array_agg(t.updated_at ORDER BY t.id)::text AS updated_ats,
466
+ bool_or(t.input_chars > 8000)::text AS has_oversized
467
+ FROM (SELECT id, updated_at,
468
+ coalesce(key, '') || E'\n' || coalesce(value::text, '') AS txt,
469
+ char_length(coalesce(key, '') || E'\n' || coalesce(value::text, '')) AS input_chars
470
+ FROM "{{SCHEMA}}".facts
471
+ WHERE deleted_at IS NULL AND %s
472
+ ORDER BY id
473
+ LIMIT %s) t
474
+ HAVING count(*) > 0$q$,
475
+ v_inputfield,
476
+ v_model,
477
+ CASE WHEN p_mode = 'batch'
478
+ THEN format('last_embed_error IS NULL AND (embedding IS NULL OR embedding_model IS DISTINCT FROM %L)', v_model)
479
+ ELSE 'last_embed_error = -1'
480
+ END,
481
+ v_limit);
482
+
483
+ v_too_large_sql := format($q$SELECT 1 WHERE coalesce(($%s.has_oversized)::boolean, false)$q$, v_alias);
484
+
485
+ v_resp_ok_sql := format(
486
+ $q$SELECT 1 WHERE "{{SCHEMA}}".embed_response_data_count($%s::jsonb) = cardinality(($%s.ids)::bigint[])$q$,
487
+ v_resp_alias,
488
+ v_alias);
489
+
490
+ v_batch_fail_sql := format(
491
+ $q$WITH selected AS (
492
+ SELECT u.id, u.seen_updated_at
493
+ FROM unnest(($%s.ids)::bigint[], ($%s.updated_ats)::timestamptz[]) AS u(id, seen_updated_at)
494
+ )
495
+ UPDATE "{{SCHEMA}}".facts f
496
+ SET last_embed_error = -1,
497
+ embedding = NULL,
498
+ embedding_model = NULL
499
+ FROM selected s
500
+ WHERE f.id = s.id
501
+ AND f.updated_at IS NOT DISTINCT FROM s.seen_updated_at
502
+ AND f.last_embed_error IS NULL$q$,
503
+ v_alias,
504
+ v_alias);
505
+
506
+ v_zip_sql := format(
507
+ $q$WITH resp AS (SELECT ($%s::jsonb->>'body')::jsonb AS j),
508
+ emb AS (SELECT v.ord, v.e->'embedding' AS vec
509
+ FROM resp, jsonb_array_elements(resp.j->'data') WITH ORDINALITY AS v(e, ord)),
510
+ tgt AS (SELECT u.id, u.seen_updated_at, u.ord
511
+ FROM unnest(($%s.ids)::bigint[], ($%s.updated_ats)::timestamptz[]) WITH ORDINALITY AS u(id, seen_updated_at, ord)),
512
+ upd AS (
513
+ UPDATE "{{SCHEMA}}".facts f
514
+ SET embedding = (emb.vec)::text::vector,
515
+ embedding_model = %L,
516
+ last_embed_error = NULL
517
+ FROM emb JOIN tgt ON tgt.ord = emb.ord
518
+ WHERE f.id = tgt.id
519
+ AND f.updated_at IS NOT DISTINCT FROM tgt.seen_updated_at
520
+ RETURNING f.id)
521
+ SELECT count(*) FROM upd$q$,
522
+ v_resp_alias,
523
+ v_alias,
524
+ v_alias,
525
+ v_model);
526
+
527
+ v_retry_fail_sql := format(
528
+ $q$WITH resp AS (
529
+ SELECT $%s::jsonb AS r
530
+ ), tgt AS (
531
+ SELECT u.id, u.seen_updated_at
532
+ FROM unnest(($%s.ids)::bigint[], ($%s.updated_ats)::timestamptz[]) AS u(id, seen_updated_at)
533
+ ), classified AS (
534
+ SELECT tgt.id, tgt.seen_updated_at,
535
+ "{{SCHEMA}}".embed_response_terminal_code(resp.r) AS code
536
+ FROM tgt, resp
537
+ ), upd AS (
538
+ UPDATE "{{SCHEMA}}".facts f
539
+ SET last_embed_error = classified.code,
540
+ embedding = NULL,
541
+ embedding_model = NULL
542
+ FROM classified
543
+ WHERE f.id = classified.id
544
+ AND f.updated_at IS NOT DISTINCT FROM classified.seen_updated_at
545
+ RETURNING f.id)
546
+ SELECT count(*) FROM upd$q$,
547
+ v_resp_alias,
548
+ v_alias,
549
+ v_alias);
550
+
551
+ v_oversized_fail_sql := format(
552
+ $q$WITH tgt AS (
553
+ SELECT u.id, u.seen_updated_at
554
+ FROM unnest(($%s.ids)::bigint[], ($%s.updated_ats)::timestamptz[]) AS u(id, seen_updated_at)
555
+ ), upd AS (
556
+ UPDATE "{{SCHEMA}}".facts f
557
+ SET last_embed_error = 1001,
558
+ embedding = NULL,
559
+ embedding_model = NULL
560
+ FROM tgt
561
+ WHERE f.id = tgt.id
562
+ AND f.updated_at IS NOT DISTINCT FROM tgt.seen_updated_at
563
+ RETURNING f.id)
564
+ SELECT count(*) FROM upd$q$,
565
+ v_alias,
566
+ v_alias);
567
+
568
+ RETURN df.loop(
569
+ df.seq(
570
+ df.sleep(greatest(p_interval, 1)::bigint),
571
+ df.seq(
572
+ df.as(df.sql(v_select_sql), v_alias),
573
+ df.if_rows(v_alias,
574
+ df.seq(
575
+ df.as(df.sql(v_too_large_sql), v_alias || '_too_large'),
576
+ df.if_rows(v_alias || '_too_large',
577
+ CASE WHEN p_mode = 'batch' THEN df.sql(v_batch_fail_sql) ELSE df.sql(v_oversized_fail_sql) END,
578
+ df.seq(
579
+ df.as(df.http(v_url, 'POST', '$' || v_alias || '.body', v_headers, v_timeout), v_resp_alias),
580
+ df.seq(
581
+ df.as(df.sql(v_resp_ok_sql), v_resp_alias || '_ok'),
582
+ df.if_rows(v_resp_alias || '_ok',
583
+ df.sql(v_zip_sql),
584
+ CASE WHEN p_mode = 'batch' THEN df.sql(v_batch_fail_sql) ELSE df.sql(v_retry_fail_sql) END))))),
585
+ df.sql('SELECT 1')))),
586
+ NULL);
587
+ END $fn$;
@@ -0,0 +1,60 @@
1
+ -- 0013_graph_namespaces — graph namespace registry sidecar (graph-fact-search
2
+ -- enhancements).
3
+ --
4
+ -- GRAPH-OWNED, not facts-owned. The graph provider (HorizonDBGraphStore) runs
5
+ -- this inline as part of its idempotent bootstrap (alongside 0003); the facts
6
+ -- provider FILTERS it out. It may run against a database that has no facts
7
+ -- schema, so it creates and owns its own schema. The registry schema MUST be
8
+ -- distinct from the AGE graph name (create_graph() owns a schema of that name).
9
+ --
10
+ -- Tokens: {{REGISTRY_SCHEMA}}. Identifiers are quoted so a mixed-case schema
11
+ -- name matches the quoted, case-preserving identifier used at runtime. All DDL
12
+ -- is idempotent (IF NOT EXISTS / OR REPLACE / ON CONFLICT) so repeated and
13
+ -- concurrent bootstraps converge.
14
+
15
+ CREATE SCHEMA IF NOT EXISTS "{{REGISTRY_SCHEMA}}";
16
+
17
+ CREATE TABLE IF NOT EXISTS "{{REGISTRY_SCHEMA}}".graph_namespaces (
18
+ namespace text PRIMARY KEY,
19
+ archived boolean NOT NULL DEFAULT false,
20
+ frontmatter jsonb NOT NULL,
21
+ source text,
22
+ node_schema jsonb,
23
+ edge_schema jsonb,
24
+ harvest_config jsonb,
25
+ created_at timestamptz NOT NULL DEFAULT now(),
26
+ updated_at timestamptz NOT NULL DEFAULT now()
27
+ );
28
+
29
+ -- Active-row lookups (list excludes archived by default).
30
+ CREATE INDEX IF NOT EXISTS graph_namespaces_active_idx
31
+ ON "{{REGISTRY_SCHEMA}}".graph_namespaces (namespace)
32
+ WHERE archived = false;
33
+
34
+ -- Prefix listing over namespace keys.
35
+ CREATE INDEX IF NOT EXISTS graph_namespaces_prefix_idx
36
+ ON "{{REGISTRY_SCHEMA}}".graph_namespaces (namespace text_pattern_ops);
37
+
38
+ -- updated_at maintained by trigger so it never drifts from app code.
39
+ CREATE OR REPLACE FUNCTION "{{REGISTRY_SCHEMA}}".graph_namespaces_touch()
40
+ RETURNS trigger AS $touch$
41
+ BEGIN
42
+ NEW.updated_at := now();
43
+ RETURN NEW;
44
+ END;
45
+ $touch$ LANGUAGE plpgsql;
46
+
47
+ DROP TRIGGER IF EXISTS graph_namespaces_touch_trg ON "{{REGISTRY_SCHEMA}}".graph_namespaces;
48
+ CREATE TRIGGER graph_namespaces_touch_trg
49
+ BEFORE UPDATE ON "{{REGISTRY_SCHEMA}}".graph_namespaces
50
+ FOR EACH ROW EXECUTE FUNCTION "{{REGISTRY_SCHEMA}}".graph_namespaces_touch();
51
+
52
+ -- Seed the reserved `default` namespace (the unscoped/NULL partition). It always
53
+ -- exists and cannot be archived or deleted by the provider.
54
+ INSERT INTO "{{REGISTRY_SCHEMA}}".graph_namespaces (namespace, archived, frontmatter)
55
+ VALUES (
56
+ 'default',
57
+ false,
58
+ '{"name":"default","description":"Unscoped graph knowledge: records with no namespace. Use when no specific corpus applies."}'::jsonb
59
+ )
60
+ ON CONFLICT (namespace) DO NOTHING;