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,392 @@
1
+ -- 0010_minimal_two_loop_embedder — finalize the minimal embedder state model
2
+ -- and split pg_durable embedding into independent batch and retry loops.
3
+ --
4
+ -- Final embedding state:
5
+ -- embedding, embedding_model, last_embed_error
6
+ --
7
+ -- last_embed_error convention:
8
+ -- NULL = healthy/eligible
9
+ -- -1 = internal single-row retry marker
10
+ -- > 0 = terminal row-level embedding failure
11
+ --
12
+ -- The embedder never reads or writes last_crawled_at. Graph crawler paths own
13
+ -- that column.
14
+ --
15
+ -- Tokens: {{SCHEMA}}.
16
+
17
+ ALTER TABLE "{{SCHEMA}}".facts ADD COLUMN IF NOT EXISTS last_embed_error INT;
18
+ ALTER TABLE "{{SCHEMA}}".facts DROP COLUMN IF EXISTS content_hash;
19
+ ALTER TABLE "{{SCHEMA}}".facts DROP COLUMN IF EXISTS last_embedded_hash;
20
+ ALTER TABLE "{{SCHEMA}}".facts DROP COLUMN IF EXISTS embedded_at;
21
+ ALTER TABLE "{{SCHEMA}}".facts DROP COLUMN IF EXISTS last_embed_error_at;
22
+ ALTER TABLE "{{SCHEMA}}".facts DROP COLUMN IF EXISTS embed_retry_at;
23
+
24
+ DROP FUNCTION IF EXISTS "{{SCHEMA}}".embedder_workflow(int, int);
25
+
26
+ DROP INDEX IF EXISTS "{{SCHEMA}}".idx_facts_embed_retry;
27
+ DROP INDEX IF EXISTS "{{SCHEMA}}".idx_facts_last_embed_error;
28
+ CREATE INDEX IF NOT EXISTS idx_facts_embed_retry
29
+ ON "{{SCHEMA}}".facts (id) WHERE last_embed_error = -1;
30
+ CREATE INDEX IF NOT EXISTS idx_facts_last_embed_error
31
+ ON "{{SCHEMA}}".facts (last_embed_error, id) WHERE last_embed_error > 0;
32
+
33
+ CREATE OR REPLACE FUNCTION "{{SCHEMA}}".embed_error_code(p_status INT, p_body TEXT) RETURNS INT
34
+ LANGUAGE sql IMMUTABLE AS $$
35
+ SELECT CASE
36
+ WHEN p_body ILIKE '%maximum input length%'
37
+ OR p_body ILIKE '%maximum context length%'
38
+ OR p_body ILIKE '%too many tokens%'
39
+ OR p_body ILIKE '%too%many%token%'
40
+ OR p_body ILIKE '%input%too%long%'
41
+ OR p_body ILIKE '%token%limit%'
42
+ OR p_body ILIKE '%exceed%token%'
43
+ OR p_body ILIKE '%requested%token%'
44
+ THEN 1001
45
+ WHEN p_status = 400 THEN 1400
46
+ WHEN p_status = 401 THEN 1401
47
+ WHEN p_status = 403 THEN 1403
48
+ WHEN p_status = 429 THEN 1429
49
+ WHEN p_status BETWEEN 500 AND 599 THEN 1500
50
+ ELSE 9999
51
+ END;
52
+ $$;
53
+
54
+ CREATE OR REPLACE FUNCTION "{{SCHEMA}}".facts_touch() RETURNS trigger
55
+ LANGUAGE plpgsql AS $$
56
+ BEGIN
57
+ IF TG_OP = 'INSERT' OR NEW.key IS DISTINCT FROM OLD.key OR NEW.value IS DISTINCT FROM OLD.value THEN
58
+ NEW.updated_at := now();
59
+ NEW.last_crawled_at := NULL;
60
+ NEW.embedding := NULL;
61
+ NEW.embedding_model := NULL;
62
+ NEW.last_embed_error := NULL;
63
+ END IF;
64
+ RETURN NEW;
65
+ END $$;
66
+
67
+ CREATE OR REPLACE FUNCTION "{{SCHEMA}}".facts_store_batch(p_facts JSONB)
68
+ RETURNS INT
69
+ LANGUAGE sql AS $$
70
+ WITH input AS (
71
+ SELECT
72
+ e->>'scopeKey' AS scope_key,
73
+ e->>'key' AS key,
74
+ e->'value' AS value,
75
+ e->>'agentId' AS agent_id,
76
+ e->>'sessionId' AS session_id,
77
+ coalesce((e->>'shared')::boolean, false) AS shared,
78
+ coalesce(ARRAY(SELECT jsonb_array_elements_text(e->'tags')), '{}'::text[]) AS tags
79
+ FROM jsonb_array_elements(p_facts) e
80
+ ), upserted AS (
81
+ INSERT INTO "{{SCHEMA}}".facts (scope_key, key, value, agent_id, session_id, shared, transient, tags, updated_at)
82
+ SELECT scope_key, key, value, agent_id, session_id, shared, NOT shared, tags, now()
83
+ FROM input
84
+ ON CONFLICT (scope_key) DO UPDATE SET
85
+ value = EXCLUDED.value,
86
+ agent_id = EXCLUDED.agent_id,
87
+ session_id = EXCLUDED.session_id,
88
+ shared = EXCLUDED.shared,
89
+ transient = EXCLUDED.transient,
90
+ tags = EXCLUDED.tags,
91
+ updated_at = now()
92
+ RETURNING 1
93
+ )
94
+ SELECT count(*)::int FROM upserted;
95
+ $$;
96
+
97
+ CREATE OR REPLACE FUNCTION "{{SCHEMA}}".facts_store(
98
+ p_scope_key TEXT,
99
+ p_key TEXT,
100
+ p_value JSONB,
101
+ p_agent_id TEXT,
102
+ p_session_id TEXT,
103
+ p_shared BOOLEAN,
104
+ p_tags TEXT[]
105
+ ) RETURNS void
106
+ LANGUAGE sql AS $$
107
+ SELECT "{{SCHEMA}}".facts_store_batch(jsonb_build_array(jsonb_build_object(
108
+ 'scopeKey', p_scope_key,
109
+ 'key', p_key,
110
+ 'value', p_value,
111
+ 'agentId', p_agent_id,
112
+ 'sessionId', p_session_id,
113
+ 'shared', p_shared,
114
+ 'tags', coalesce(p_tags, '{}'::text[])
115
+ )));
116
+ $$;
117
+
118
+ CREATE OR REPLACE FUNCTION "{{SCHEMA}}".facts_delete_pattern(
119
+ p_key_pattern TEXT,
120
+ p_scope TEXT,
121
+ p_session_id TEXT,
122
+ p_unrestricted BOOLEAN DEFAULT FALSE
123
+ ) RETURNS INT
124
+ LANGUAGE plpgsql AS $$
125
+ DECLARE
126
+ deleted_count INT;
127
+ BEGIN
128
+ IF p_key_pattern IS NULL OR p_key_pattern = '' THEN
129
+ RAISE EXCEPTION 'facts_delete_pattern requires a key pattern';
130
+ END IF;
131
+ IF p_scope NOT IN ('session', 'shared', 'all') THEN
132
+ RAISE EXCEPTION 'facts_delete_pattern scope must be session, shared, or all';
133
+ END IF;
134
+ IF p_scope = 'all' AND p_unrestricted IS DISTINCT FROM TRUE THEN
135
+ RAISE EXCEPTION 'facts_delete_pattern scope=all requires unrestricted=true';
136
+ END IF;
137
+ IF p_scope = 'session' AND p_session_id IS NULL THEN
138
+ RAISE EXCEPTION 'facts_delete_pattern scope=session requires sessionId';
139
+ END IF;
140
+
141
+ DELETE FROM "{{SCHEMA}}".facts f
142
+ WHERE f.key LIKE p_key_pattern
143
+ AND (
144
+ (p_scope = 'shared' AND f.shared = TRUE)
145
+ OR (p_scope = 'session' AND f.shared = FALSE AND f.session_id = p_session_id)
146
+ OR (p_scope = 'all' AND p_unrestricted = TRUE)
147
+ );
148
+ GET DIAGNOSTICS deleted_count = ROW_COUNT;
149
+ RETURN deleted_count;
150
+ END;
151
+ $$;
152
+
153
+ DROP FUNCTION IF EXISTS "{{SCHEMA}}".facts_embedding_failures(TEXT, INT[], INT);
154
+
155
+ CREATE OR REPLACE FUNCTION "{{SCHEMA}}".embedder_batch_workflow(p_interval int, p_batch int)
156
+ RETURNS text
157
+ LANGUAGE plpgsql AS $fn$
158
+ DECLARE
159
+ v_url text;
160
+ v_model text;
161
+ v_key text;
162
+ v_keyhdr text;
163
+ v_bearer text;
164
+ v_inputfield text;
165
+ v_timeout int;
166
+ v_headers jsonb;
167
+ v_batch_sql text;
168
+ v_batch_too_large_sql text;
169
+ v_resp_ok_sql text;
170
+ v_batch_fail_sql text;
171
+ v_zip_sql text;
172
+ BEGIN
173
+ v_url := df.getvar('hz_{{SCHEMA}}_url');
174
+ v_model := df.getvar('hz_{{SCHEMA}}_model');
175
+ v_key := df.getvar('hz_{{SCHEMA}}_key');
176
+ v_keyhdr := coalesce(df.getvar('hz_{{SCHEMA}}_keyhdr'), 'api-key');
177
+ v_bearer := coalesce(df.getvar('hz_{{SCHEMA}}_bearer'), 'false');
178
+ v_inputfield := coalesce(df.getvar('hz_{{SCHEMA}}_inputfield'), 'input');
179
+ v_timeout := coalesce(df.getvar('hz_{{SCHEMA}}_timeout'), '30')::int;
180
+
181
+ IF v_url IS NULL OR v_model IS NULL THEN
182
+ RAISE EXCEPTION 'embedder not configured: call configureEmbedder first';
183
+ END IF;
184
+
185
+ v_headers := jsonb_build_object('content-type', 'application/json');
186
+ IF v_key IS NOT NULL AND v_key <> '' THEN
187
+ v_headers := v_headers || jsonb_build_object(
188
+ v_keyhdr,
189
+ CASE WHEN v_bearer = 'true' THEN 'Bearer ' || v_key ELSE v_key END
190
+ );
191
+ END IF;
192
+
193
+ v_batch_sql := format(
194
+ $q$SELECT jsonb_build_object(%L, jsonb_agg(t.txt ORDER BY t.id), 'model', %L)::text AS body,
195
+ array_agg(t.id ORDER BY t.id)::text AS ids,
196
+ array_agg(t.updated_at ORDER BY t.id)::text AS updated_ats,
197
+ bool_or(t.input_chars > 8000)::text AS has_oversized
198
+ FROM (SELECT id, updated_at,
199
+ coalesce(key, '') || E'\n' || coalesce(value::text, '') AS txt,
200
+ char_length(coalesce(key, '') || E'\n' || coalesce(value::text, '')) AS input_chars
201
+ FROM "{{SCHEMA}}".facts
202
+ WHERE last_embed_error IS NULL
203
+ AND (embedding IS NULL OR embedding_model IS DISTINCT FROM %L)
204
+ ORDER BY id
205
+ LIMIT %s) t
206
+ HAVING count(*) > 0$q$,
207
+ v_inputfield, v_model, v_model, greatest(p_batch, 1));
208
+
209
+ v_batch_too_large_sql := $q$SELECT 1 WHERE coalesce(($batch.has_oversized)::boolean, false)$q$;
210
+
211
+ v_resp_ok_sql := $q$SELECT 1
212
+ WHERE "{{SCHEMA}}".embed_response_data_count($resp::jsonb) = cardinality(($batch.ids)::bigint[])$q$;
213
+
214
+ v_batch_fail_sql := $q$WITH selected AS (
215
+ SELECT u.id, u.seen_updated_at
216
+ FROM unnest(($batch.ids)::bigint[], ($batch.updated_ats)::timestamptz[]) AS u(id, seen_updated_at)
217
+ )
218
+ UPDATE "{{SCHEMA}}".facts f
219
+ SET last_embed_error = -1,
220
+ embedding = NULL,
221
+ embedding_model = NULL
222
+ FROM selected s
223
+ WHERE f.id = s.id
224
+ AND f.updated_at IS NOT DISTINCT FROM s.seen_updated_at
225
+ AND f.last_embed_error IS NULL$q$;
226
+
227
+ v_zip_sql := format(
228
+ $q$WITH resp AS (SELECT ($resp::jsonb->>'body')::jsonb AS j),
229
+ emb AS (SELECT v.ord, v.e->'embedding' AS vec
230
+ FROM resp, jsonb_array_elements(resp.j->'data') WITH ORDINALITY AS v(e, ord)),
231
+ tgt AS (SELECT u.id, u.seen_updated_at, u.ord
232
+ FROM unnest(($batch.ids)::bigint[], ($batch.updated_ats)::timestamptz[]) WITH ORDINALITY AS u(id, seen_updated_at, ord)),
233
+ upd AS (
234
+ UPDATE "{{SCHEMA}}".facts f
235
+ SET embedding = (emb.vec)::text::vector,
236
+ embedding_model = %L,
237
+ last_embed_error = NULL
238
+ FROM emb JOIN tgt ON tgt.ord = emb.ord
239
+ WHERE f.id = tgt.id
240
+ AND f.updated_at IS NOT DISTINCT FROM tgt.seen_updated_at
241
+ RETURNING f.id)
242
+ SELECT count(*) FROM upd$q$,
243
+ v_model);
244
+
245
+ RETURN df.loop(
246
+ df.seq(
247
+ df.sleep(greatest(p_interval, 1)::bigint),
248
+ df.seq(
249
+ df.as(df.sql(v_batch_sql), 'batch'),
250
+ df.if_rows('batch',
251
+ df.seq(
252
+ df.as(df.sql(v_batch_too_large_sql), 'batch_too_large'),
253
+ df.if_rows('batch_too_large',
254
+ df.sql(v_batch_fail_sql),
255
+ df.seq(
256
+ df.as(df.http(v_url, 'POST', '$batch.body', v_headers, v_timeout), 'resp'),
257
+ df.seq(
258
+ df.as(df.sql(v_resp_ok_sql), 'resp_ok'),
259
+ df.if_rows('resp_ok', df.sql(v_zip_sql), df.sql(v_batch_fail_sql)))))),
260
+ df.sql('SELECT 1')))),
261
+ NULL);
262
+ END $fn$;
263
+
264
+ CREATE OR REPLACE FUNCTION "{{SCHEMA}}".embedder_retry_workflow(p_interval int)
265
+ RETURNS text
266
+ LANGUAGE plpgsql AS $fn$
267
+ DECLARE
268
+ v_url text;
269
+ v_model text;
270
+ v_key text;
271
+ v_keyhdr text;
272
+ v_bearer text;
273
+ v_inputfield text;
274
+ v_timeout int;
275
+ v_headers jsonb;
276
+ v_retry_sql text;
277
+ v_retry_too_large_sql text;
278
+ v_retry_resp_ok_sql text;
279
+ v_retry_zip_sql text;
280
+ v_retry_fail_sql text;
281
+ v_retry_oversized_fail_sql text;
282
+ BEGIN
283
+ v_url := df.getvar('hz_{{SCHEMA}}_url');
284
+ v_model := df.getvar('hz_{{SCHEMA}}_model');
285
+ v_key := df.getvar('hz_{{SCHEMA}}_key');
286
+ v_keyhdr := coalesce(df.getvar('hz_{{SCHEMA}}_keyhdr'), 'api-key');
287
+ v_bearer := coalesce(df.getvar('hz_{{SCHEMA}}_bearer'), 'false');
288
+ v_inputfield := coalesce(df.getvar('hz_{{SCHEMA}}_inputfield'), 'input');
289
+ v_timeout := coalesce(df.getvar('hz_{{SCHEMA}}_timeout'), '30')::int;
290
+
291
+ IF v_url IS NULL OR v_model IS NULL THEN
292
+ RAISE EXCEPTION 'embedder not configured: call configureEmbedder first';
293
+ END IF;
294
+
295
+ v_headers := jsonb_build_object('content-type', 'application/json');
296
+ IF v_key IS NOT NULL AND v_key <> '' THEN
297
+ v_headers := v_headers || jsonb_build_object(
298
+ v_keyhdr,
299
+ CASE WHEN v_bearer = 'true' THEN 'Bearer ' || v_key ELSE v_key END
300
+ );
301
+ END IF;
302
+
303
+ v_retry_sql := format(
304
+ $q$SELECT jsonb_build_object(%L, jsonb_agg(t.txt ORDER BY t.id), 'model', %L)::text AS body,
305
+ array_agg(t.id ORDER BY t.id)::text AS ids,
306
+ array_agg(t.updated_at ORDER BY t.id)::text AS updated_ats,
307
+ bool_or(t.input_chars > 8000)::text AS has_oversized
308
+ FROM (SELECT id, updated_at,
309
+ coalesce(key, '') || E'\n' || coalesce(value::text, '') AS txt,
310
+ char_length(coalesce(key, '') || E'\n' || coalesce(value::text, '')) AS input_chars
311
+ FROM "{{SCHEMA}}".facts
312
+ WHERE last_embed_error = -1
313
+ ORDER BY id
314
+ LIMIT 1) t
315
+ HAVING count(*) > 0$q$,
316
+ v_inputfield, v_model);
317
+
318
+ v_retry_too_large_sql := $q$SELECT 1 WHERE coalesce(($retry.has_oversized)::boolean, false)$q$;
319
+
320
+ v_retry_resp_ok_sql := $q$SELECT 1
321
+ WHERE "{{SCHEMA}}".embed_response_data_count($retry_resp::jsonb) = cardinality(($retry.ids)::bigint[])$q$;
322
+
323
+ v_retry_zip_sql := format(
324
+ $q$WITH resp AS (SELECT ($retry_resp::jsonb->>'body')::jsonb AS j),
325
+ emb AS (SELECT v.ord, v.e->'embedding' AS vec
326
+ FROM resp, jsonb_array_elements(resp.j->'data') WITH ORDINALITY AS v(e, ord)),
327
+ tgt AS (SELECT u.id, u.seen_updated_at, u.ord
328
+ FROM unnest(($retry.ids)::bigint[], ($retry.updated_ats)::timestamptz[]) WITH ORDINALITY AS u(id, seen_updated_at, ord)),
329
+ upd AS (
330
+ UPDATE "{{SCHEMA}}".facts f
331
+ SET embedding = (emb.vec)::text::vector,
332
+ embedding_model = %L,
333
+ last_embed_error = NULL
334
+ FROM emb JOIN tgt ON tgt.ord = emb.ord
335
+ WHERE f.id = tgt.id
336
+ AND f.updated_at IS NOT DISTINCT FROM tgt.seen_updated_at
337
+ RETURNING f.id)
338
+ SELECT count(*) FROM upd$q$,
339
+ v_model);
340
+
341
+ v_retry_fail_sql := $q$WITH resp AS (
342
+ SELECT $retry_resp::jsonb AS r
343
+ ), tgt AS (
344
+ SELECT u.id, u.seen_updated_at
345
+ FROM unnest(($retry.ids)::bigint[], ($retry.updated_ats)::timestamptz[]) AS u(id, seen_updated_at)
346
+ ), classified AS (
347
+ SELECT tgt.id, tgt.seen_updated_at,
348
+ "{{SCHEMA}}".embed_response_terminal_code(resp.r) AS code
349
+ FROM tgt, resp
350
+ ), upd AS (
351
+ UPDATE "{{SCHEMA}}".facts f
352
+ SET last_embed_error = classified.code,
353
+ embedding = NULL,
354
+ embedding_model = NULL
355
+ FROM classified
356
+ WHERE f.id = classified.id
357
+ AND f.updated_at IS NOT DISTINCT FROM classified.seen_updated_at
358
+ RETURNING f.id)
359
+ SELECT count(*) FROM upd$q$;
360
+
361
+ v_retry_oversized_fail_sql := $q$WITH tgt AS (
362
+ SELECT u.id, u.seen_updated_at
363
+ FROM unnest(($retry.ids)::bigint[], ($retry.updated_ats)::timestamptz[]) AS u(id, seen_updated_at)
364
+ ), upd AS (
365
+ UPDATE "{{SCHEMA}}".facts f
366
+ SET last_embed_error = 1001,
367
+ embedding = NULL,
368
+ embedding_model = NULL
369
+ FROM tgt
370
+ WHERE f.id = tgt.id
371
+ AND f.updated_at IS NOT DISTINCT FROM tgt.seen_updated_at
372
+ RETURNING f.id)
373
+ SELECT count(*) FROM upd$q$;
374
+
375
+ RETURN df.loop(
376
+ df.seq(
377
+ df.sleep(greatest(p_interval, 1)::bigint),
378
+ df.seq(
379
+ df.as(df.sql(v_retry_sql), 'retry'),
380
+ df.if_rows('retry',
381
+ df.seq(
382
+ df.as(df.sql(v_retry_too_large_sql), 'retry_too_large'),
383
+ df.if_rows('retry_too_large',
384
+ df.sql(v_retry_oversized_fail_sql),
385
+ df.seq(
386
+ df.as(df.http(v_url, 'POST', '$retry.body', v_headers, v_timeout), 'retry_resp'),
387
+ df.seq(
388
+ df.as(df.sql(v_retry_resp_ok_sql), 'retry_resp_ok'),
389
+ df.if_rows('retry_resp_ok', df.sql(v_retry_zip_sql), df.sql(v_retry_fail_sql)))))),
390
+ df.sql('SELECT 1')))),
391
+ NULL);
392
+ END $fn$;
@@ -0,0 +1,273 @@
1
+ -- 0011_unified_api_embedder_workflow — collapse public proc names and expose
2
+ -- one internal embedder workflow function started in two durable modes.
3
+ --
4
+ -- Public-ish provider procs after this migration:
5
+ -- facts_store(jsonb) -- single or batch input
6
+ -- facts_delete(text, boolean, text, text, boolean) -- exact or pattern
7
+ -- embedder_workflow(text, int, int) -- mode = batch | retry
8
+ --
9
+ -- The runtime still starts two durable loop instances so batch work and one-row
10
+ -- retries do not block each other. They are modes of one workflow, not two
11
+ -- separate schema concepts.
12
+
13
+ DROP FUNCTION IF EXISTS "{{SCHEMA}}".facts_store(JSONB);
14
+ CREATE OR REPLACE FUNCTION "{{SCHEMA}}".facts_store(p_facts JSONB)
15
+ RETURNS INT
16
+ LANGUAGE sql AS $$
17
+ WITH input AS (
18
+ SELECT
19
+ e->>'scopeKey' AS scope_key,
20
+ e->>'key' AS key,
21
+ e->'value' AS value,
22
+ e->>'agentId' AS agent_id,
23
+ e->>'sessionId' AS session_id,
24
+ coalesce((e->>'shared')::boolean, false) AS shared,
25
+ coalesce(ARRAY(SELECT jsonb_array_elements_text(e->'tags')), '{}'::text[]) AS tags
26
+ FROM jsonb_array_elements(p_facts) e
27
+ ), upserted AS (
28
+ INSERT INTO "{{SCHEMA}}".facts (scope_key, key, value, agent_id, session_id, shared, transient, tags, updated_at)
29
+ SELECT scope_key, key, value, agent_id, session_id, shared, NOT shared, tags, now()
30
+ FROM input
31
+ ON CONFLICT (scope_key) DO UPDATE SET
32
+ value = EXCLUDED.value,
33
+ agent_id = EXCLUDED.agent_id,
34
+ session_id = EXCLUDED.session_id,
35
+ shared = EXCLUDED.shared,
36
+ transient = EXCLUDED.transient,
37
+ tags = EXCLUDED.tags,
38
+ updated_at = now()
39
+ RETURNING 1
40
+ )
41
+ SELECT count(*)::int FROM upserted;
42
+ $$;
43
+
44
+ DROP FUNCTION IF EXISTS "{{SCHEMA}}".facts_store(TEXT, TEXT, JSONB, TEXT, TEXT, BOOLEAN, TEXT[]);
45
+ DROP FUNCTION IF EXISTS "{{SCHEMA}}".facts_store_batch(JSONB);
46
+
47
+ DROP FUNCTION IF EXISTS "{{SCHEMA}}".facts_delete(TEXT, BOOLEAN, TEXT, TEXT, BOOLEAN);
48
+ CREATE OR REPLACE FUNCTION "{{SCHEMA}}".facts_delete(
49
+ p_key_or_pattern TEXT,
50
+ p_pattern BOOLEAN DEFAULT FALSE,
51
+ p_scope TEXT DEFAULT NULL,
52
+ p_session_id TEXT DEFAULT NULL,
53
+ p_unrestricted BOOLEAN DEFAULT FALSE
54
+ ) RETURNS INT
55
+ LANGUAGE plpgsql AS $$
56
+ DECLARE
57
+ deleted_count INT;
58
+ v_scope TEXT;
59
+ BEGIN
60
+ IF p_pattern THEN
61
+ IF p_key_or_pattern IS NULL OR p_key_or_pattern = '' THEN
62
+ RAISE EXCEPTION 'facts_delete pattern mode requires key';
63
+ END IF;
64
+ v_scope := coalesce(p_scope, 'session');
65
+ IF v_scope NOT IN ('session', 'shared', 'all') THEN
66
+ RAISE EXCEPTION 'facts_delete scope must be session, shared, or all';
67
+ END IF;
68
+ IF v_scope = 'all' AND p_unrestricted IS DISTINCT FROM TRUE THEN
69
+ RAISE EXCEPTION 'facts_delete scope=all requires unrestricted=true';
70
+ END IF;
71
+ IF v_scope = 'session' AND p_session_id IS NULL THEN
72
+ RAISE EXCEPTION 'facts_delete scope=session requires sessionId';
73
+ END IF;
74
+
75
+ DELETE FROM "{{SCHEMA}}".facts f
76
+ WHERE f.key LIKE p_key_or_pattern
77
+ AND (
78
+ (v_scope = 'shared' AND f.shared = TRUE)
79
+ OR (v_scope = 'session' AND f.shared = FALSE AND f.session_id = p_session_id)
80
+ OR (v_scope = 'all' AND p_unrestricted = TRUE)
81
+ );
82
+ ELSE
83
+ DELETE FROM "{{SCHEMA}}".facts WHERE scope_key = p_key_or_pattern;
84
+ END IF;
85
+ GET DIAGNOSTICS deleted_count = ROW_COUNT;
86
+ RETURN deleted_count;
87
+ END;
88
+ $$;
89
+
90
+ DROP FUNCTION IF EXISTS "{{SCHEMA}}".facts_delete(TEXT);
91
+ DROP FUNCTION IF EXISTS "{{SCHEMA}}".facts_delete_pattern(TEXT, TEXT, TEXT, BOOLEAN);
92
+
93
+ DROP FUNCTION IF EXISTS "{{SCHEMA}}".embedder_workflow(TEXT, INT, INT);
94
+ CREATE OR REPLACE FUNCTION "{{SCHEMA}}".embedder_workflow(p_mode text, p_interval int, p_batch int)
95
+ RETURNS text
96
+ LANGUAGE plpgsql AS $fn$
97
+ DECLARE
98
+ v_url text;
99
+ v_model text;
100
+ v_key text;
101
+ v_keyhdr text;
102
+ v_bearer text;
103
+ v_inputfield text;
104
+ v_timeout int;
105
+ v_headers jsonb;
106
+ v_select_sql text;
107
+ v_too_large_sql text;
108
+ v_resp_ok_sql text;
109
+ v_batch_fail_sql text;
110
+ v_zip_sql text;
111
+ v_retry_fail_sql text;
112
+ v_oversized_fail_sql text;
113
+ v_alias text;
114
+ v_resp_alias text;
115
+ v_limit int;
116
+ BEGIN
117
+ IF p_mode NOT IN ('batch', 'retry') THEN
118
+ RAISE EXCEPTION 'embedder_workflow mode must be batch or retry';
119
+ END IF;
120
+
121
+ v_url := df.getvar('hz_{{SCHEMA}}_url');
122
+ v_model := df.getvar('hz_{{SCHEMA}}_model');
123
+ v_key := df.getvar('hz_{{SCHEMA}}_key');
124
+ v_keyhdr := coalesce(df.getvar('hz_{{SCHEMA}}_keyhdr'), 'api-key');
125
+ v_bearer := coalesce(df.getvar('hz_{{SCHEMA}}_bearer'), 'false');
126
+ v_inputfield := coalesce(df.getvar('hz_{{SCHEMA}}_inputfield'), 'input');
127
+ v_timeout := coalesce(df.getvar('hz_{{SCHEMA}}_timeout'), '30')::int;
128
+
129
+ IF v_url IS NULL OR v_model IS NULL THEN
130
+ RAISE EXCEPTION 'embedder not configured: call configureEmbedder first';
131
+ END IF;
132
+
133
+ v_headers := jsonb_build_object('content-type', 'application/json');
134
+ IF v_key IS NOT NULL AND v_key <> '' THEN
135
+ v_headers := v_headers || jsonb_build_object(
136
+ v_keyhdr,
137
+ CASE WHEN v_bearer = 'true' THEN 'Bearer ' || v_key ELSE v_key END
138
+ );
139
+ END IF;
140
+
141
+ v_alias := p_mode;
142
+ v_resp_alias := CASE WHEN p_mode = 'batch' THEN 'resp' ELSE 'retry_resp' END;
143
+ v_limit := CASE WHEN p_mode = 'batch' THEN greatest(p_batch, 1) ELSE 1 END;
144
+
145
+ v_select_sql := format(
146
+ $q$SELECT jsonb_build_object(%L, jsonb_agg(t.txt ORDER BY t.id), 'model', %L)::text AS body,
147
+ array_agg(t.id ORDER BY t.id)::text AS ids,
148
+ array_agg(t.updated_at ORDER BY t.id)::text AS updated_ats,
149
+ bool_or(t.input_chars > 8000)::text AS has_oversized
150
+ FROM (SELECT id, updated_at,
151
+ coalesce(key, '') || E'\n' || coalesce(value::text, '') AS txt,
152
+ char_length(coalesce(key, '') || E'\n' || coalesce(value::text, '')) AS input_chars
153
+ FROM "{{SCHEMA}}".facts
154
+ WHERE %s
155
+ ORDER BY id
156
+ LIMIT %s) t
157
+ HAVING count(*) > 0$q$,
158
+ v_inputfield,
159
+ v_model,
160
+ CASE WHEN p_mode = 'batch'
161
+ THEN format('last_embed_error IS NULL AND (embedding IS NULL OR embedding_model IS DISTINCT FROM %L)', v_model)
162
+ ELSE 'last_embed_error = -1'
163
+ END,
164
+ v_limit);
165
+
166
+ v_too_large_sql := format($q$SELECT 1 WHERE coalesce(($%s.has_oversized)::boolean, false)$q$, v_alias);
167
+
168
+ v_resp_ok_sql := format(
169
+ $q$SELECT 1 WHERE "{{SCHEMA}}".embed_response_data_count($%s::jsonb) = cardinality(($%s.ids)::bigint[])$q$,
170
+ v_resp_alias,
171
+ v_alias);
172
+
173
+ v_batch_fail_sql := format(
174
+ $q$WITH selected AS (
175
+ SELECT u.id, u.seen_updated_at
176
+ FROM unnest(($%s.ids)::bigint[], ($%s.updated_ats)::timestamptz[]) AS u(id, seen_updated_at)
177
+ )
178
+ UPDATE "{{SCHEMA}}".facts f
179
+ SET last_embed_error = -1,
180
+ embedding = NULL,
181
+ embedding_model = NULL
182
+ FROM selected s
183
+ WHERE f.id = s.id
184
+ AND f.updated_at IS NOT DISTINCT FROM s.seen_updated_at
185
+ AND f.last_embed_error IS NULL$q$,
186
+ v_alias,
187
+ v_alias);
188
+
189
+ v_zip_sql := format(
190
+ $q$WITH resp AS (SELECT ($%s::jsonb->>'body')::jsonb AS j),
191
+ emb AS (SELECT v.ord, v.e->'embedding' AS vec
192
+ FROM resp, jsonb_array_elements(resp.j->'data') WITH ORDINALITY AS v(e, ord)),
193
+ tgt AS (SELECT u.id, u.seen_updated_at, u.ord
194
+ FROM unnest(($%s.ids)::bigint[], ($%s.updated_ats)::timestamptz[]) WITH ORDINALITY AS u(id, seen_updated_at, ord)),
195
+ upd AS (
196
+ UPDATE "{{SCHEMA}}".facts f
197
+ SET embedding = (emb.vec)::text::vector,
198
+ embedding_model = %L,
199
+ last_embed_error = NULL
200
+ FROM emb JOIN tgt ON tgt.ord = emb.ord
201
+ WHERE f.id = tgt.id
202
+ AND f.updated_at IS NOT DISTINCT FROM tgt.seen_updated_at
203
+ RETURNING f.id)
204
+ SELECT count(*) FROM upd$q$,
205
+ v_resp_alias,
206
+ v_alias,
207
+ v_alias,
208
+ v_model);
209
+
210
+ v_retry_fail_sql := format(
211
+ $q$WITH resp AS (
212
+ SELECT $%s::jsonb AS r
213
+ ), tgt AS (
214
+ SELECT u.id, u.seen_updated_at
215
+ FROM unnest(($%s.ids)::bigint[], ($%s.updated_ats)::timestamptz[]) AS u(id, seen_updated_at)
216
+ ), classified AS (
217
+ SELECT tgt.id, tgt.seen_updated_at,
218
+ "{{SCHEMA}}".embed_response_terminal_code(resp.r) AS code
219
+ FROM tgt, resp
220
+ ), upd AS (
221
+ UPDATE "{{SCHEMA}}".facts f
222
+ SET last_embed_error = classified.code,
223
+ embedding = NULL,
224
+ embedding_model = NULL
225
+ FROM classified
226
+ WHERE f.id = classified.id
227
+ AND f.updated_at IS NOT DISTINCT FROM classified.seen_updated_at
228
+ RETURNING f.id)
229
+ SELECT count(*) FROM upd$q$,
230
+ v_resp_alias,
231
+ v_alias,
232
+ v_alias);
233
+
234
+ v_oversized_fail_sql := format(
235
+ $q$WITH tgt AS (
236
+ SELECT u.id, u.seen_updated_at
237
+ FROM unnest(($%s.ids)::bigint[], ($%s.updated_ats)::timestamptz[]) AS u(id, seen_updated_at)
238
+ ), upd AS (
239
+ UPDATE "{{SCHEMA}}".facts f
240
+ SET last_embed_error = 1001,
241
+ embedding = NULL,
242
+ embedding_model = NULL
243
+ FROM tgt
244
+ WHERE f.id = tgt.id
245
+ AND f.updated_at IS NOT DISTINCT FROM tgt.seen_updated_at
246
+ RETURNING f.id)
247
+ SELECT count(*) FROM upd$q$,
248
+ v_alias,
249
+ v_alias);
250
+
251
+ RETURN df.loop(
252
+ df.seq(
253
+ df.sleep(greatest(p_interval, 1)::bigint),
254
+ df.seq(
255
+ df.as(df.sql(v_select_sql), v_alias),
256
+ df.if_rows(v_alias,
257
+ df.seq(
258
+ df.as(df.sql(v_too_large_sql), v_alias || '_too_large'),
259
+ df.if_rows(v_alias || '_too_large',
260
+ CASE WHEN p_mode = 'batch' THEN df.sql(v_batch_fail_sql) ELSE df.sql(v_oversized_fail_sql) END,
261
+ df.seq(
262
+ df.as(df.http(v_url, 'POST', '$' || v_alias || '.body', v_headers, v_timeout), v_resp_alias),
263
+ df.seq(
264
+ df.as(df.sql(v_resp_ok_sql), v_resp_alias || '_ok'),
265
+ df.if_rows(v_resp_alias || '_ok',
266
+ df.sql(v_zip_sql),
267
+ CASE WHEN p_mode = 'batch' THEN df.sql(v_batch_fail_sql) ELSE df.sql(v_retry_fail_sql) END))))),
268
+ df.sql('SELECT 1')))),
269
+ NULL);
270
+ END $fn$;
271
+
272
+ DROP FUNCTION IF EXISTS "{{SCHEMA}}".embedder_batch_workflow(INT, INT);
273
+ DROP FUNCTION IF EXISTS "{{SCHEMA}}".embedder_retry_workflow(INT);