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.
- package/README.md +154 -0
- package/dist/src/agent-tools.d.ts +32 -0
- package/dist/src/agent-tools.d.ts.map +1 -0
- package/dist/src/agent-tools.js +263 -0
- package/dist/src/agent-tools.js.map +1 -0
- package/dist/src/config.d.ts +93 -0
- package/dist/src/config.d.ts.map +1 -0
- package/dist/src/config.js +120 -0
- package/dist/src/config.js.map +1 -0
- package/dist/src/db-retry.d.ts +21 -0
- package/dist/src/db-retry.d.ts.map +1 -0
- package/dist/src/db-retry.js +94 -0
- package/dist/src/db-retry.js.map +1 -0
- package/dist/src/embedding-client.d.ts +13 -0
- package/dist/src/embedding-client.d.ts.map +1 -0
- package/dist/src/embedding-client.js +79 -0
- package/dist/src/embedding-client.js.map +1 -0
- package/dist/src/graph-model.d.ts +49 -0
- package/dist/src/graph-model.d.ts.map +1 -0
- package/dist/src/graph-model.js +112 -0
- package/dist/src/graph-model.js.map +1 -0
- package/dist/src/graph-queries.d.ts +83 -0
- package/dist/src/graph-queries.d.ts.map +1 -0
- package/dist/src/graph-queries.js +727 -0
- package/dist/src/graph-queries.js.map +1 -0
- package/dist/src/graph-store.d.ts +57 -0
- package/dist/src/graph-store.d.ts.map +1 -0
- package/dist/src/graph-store.js +327 -0
- package/dist/src/graph-store.js.map +1 -0
- package/dist/src/horizon-migrator.d.ts +49 -0
- package/dist/src/horizon-migrator.d.ts.map +1 -0
- package/dist/src/horizon-migrator.js +231 -0
- package/dist/src/horizon-migrator.js.map +1 -0
- package/dist/src/horizon-store.d.ts +116 -0
- package/dist/src/horizon-store.d.ts.map +1 -0
- package/dist/src/horizon-store.js +563 -0
- package/dist/src/horizon-store.js.map +1 -0
- package/dist/src/index.d.ts +13 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/index.js +21 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/preconditions.d.ts +16 -0
- package/dist/src/preconditions.d.ts.map +1 -0
- package/dist/src/preconditions.js +99 -0
- package/dist/src/preconditions.js.map +1 -0
- package/dist/src/query-builder.d.ts +37 -0
- package/dist/src/query-builder.d.ts.map +1 -0
- package/dist/src/query-builder.js +82 -0
- package/dist/src/query-builder.js.map +1 -0
- package/dist/src/sql-util.d.ts +20 -0
- package/dist/src/sql-util.d.ts.map +1 -0
- package/dist/src/sql-util.js +40 -0
- package/dist/src/sql-util.js.map +1 -0
- package/dist/src/types.d.ts +16 -0
- package/dist/src/types.d.ts.map +1 -0
- package/dist/src/types.js +35 -0
- package/dist/src/types.js.map +1 -0
- package/migrations/0001_facts_table.sql +77 -0
- package/migrations/0002_indexes.sql +27 -0
- package/migrations/0003_age_bootstrap.sql +13 -0
- package/migrations/0004_facts_procs.sql +253 -0
- package/migrations/0005_embedder_workflow.sql +119 -0
- package/migrations/0006_facts_read_uncrawled_embedded_gate.sql +33 -0
- package/migrations/0007_embed_key_value_text.sql +122 -0
- package/migrations/0008_embedder_failure_recovery.sql +409 -0
- package/migrations/0009_search_text_full_value.sql +52 -0
- package/migrations/0010_minimal_two_loop_embedder.sql +392 -0
- package/migrations/0011_unified_api_embedder_workflow.sql +273 -0
- package/migrations/0012_facts_soft_delete.sql +587 -0
- package/migrations/0013_graph_namespaces.sql +60 -0
- 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;
|