pgkiln 0.68.0 → 0.70.0

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 (65) hide show
  1. package/db/migrations/135_fulltext_search.sql +60 -0
  2. package/db/migrations/136_vector_search.sql +252 -0
  3. package/docs/apex-feature-parity.md +5 -4
  4. package/docs/guide/03-builder.md +76 -0
  5. package/docs/guide/04-pages-and-regions.md +86 -1
  6. package/docs/guide/09-reference.md +19 -0
  7. package/docs/guide/12-development.md +41 -2
  8. package/docs/guide/15-extensions.md +1 -1
  9. package/examples/hr/README.md +2 -0
  10. package/examples/hr/hr_71_fulltext_search.sql +43 -0
  11. package/examples/hr/hr_72_vector_search.sql +158 -0
  12. package/package.json +1 -1
  13. package/public/builder.css +3 -0
  14. package/src/ai/embeddings.ts +197 -0
  15. package/src/ai/service.ts +13 -9
  16. package/src/app.ts +3 -0
  17. package/src/builder/advisor.ts +79 -3
  18. package/src/builder/ai-agents.ts +13 -5
  19. package/src/builder/components.ts +32 -1
  20. package/src/builder/fulltext.ts +160 -0
  21. package/src/builder/home.ts +1 -0
  22. package/src/builder/i18n/de.ts +54 -0
  23. package/src/builder/i18n/en.ts +55 -0
  24. package/src/builder/i18n/es.ts +54 -0
  25. package/src/builder/i18n/fr.ts +54 -0
  26. package/src/builder/i18n/it.ts +54 -0
  27. package/src/builder/i18n/ja.ts +54 -0
  28. package/src/builder/i18n/ko.ts +54 -0
  29. package/src/builder/i18n/nl.ts +54 -0
  30. package/src/builder/i18n/pt.ts +54 -0
  31. package/src/builder/i18n/zh-cn.ts +54 -0
  32. package/src/builder/i18n/zh-tw.ts +54 -0
  33. package/src/builder/object-actions.ts +2 -0
  34. package/src/builder/shared.ts +1 -1
  35. package/src/builder/sql.ts +6 -0
  36. package/src/builder/table-editor.ts +2 -0
  37. package/src/builder/vector-column.ts +229 -0
  38. package/src/builder/vector-providers.ts +286 -0
  39. package/src/cli/replace.ts +3 -1
  40. package/src/i18n/ar.ts +1 -0
  41. package/src/i18n/cs.ts +1 -0
  42. package/src/i18n/da.ts +1 -0
  43. package/src/i18n/de.ts +1 -0
  44. package/src/i18n/el.ts +1 -0
  45. package/src/i18n/es.ts +1 -0
  46. package/src/i18n/fi.ts +1 -0
  47. package/src/i18n/fr.ts +1 -0
  48. package/src/i18n/he.ts +1 -0
  49. package/src/i18n/it.ts +1 -0
  50. package/src/i18n/ja.ts +1 -0
  51. package/src/i18n/ko.ts +1 -0
  52. package/src/i18n/nb.ts +1 -0
  53. package/src/i18n/pl.ts +1 -0
  54. package/src/i18n/pt.ts +1 -0
  55. package/src/i18n/ru.ts +1 -0
  56. package/src/i18n/sv.ts +1 -0
  57. package/src/i18n/tr.ts +1 -0
  58. package/src/i18n/uk.ts +1 -0
  59. package/src/i18n/zh.ts +1 -0
  60. package/src/i18n.ts +2 -0
  61. package/src/runtime/ai-agents.ts +53 -2
  62. package/src/runtime/report.ts +26 -2
  63. package/src/runtime/search.ts +290 -23
  64. package/src/server.ts +3 -0
  65. package/src/vector.ts +288 -0
package/src/vector.ts ADDED
@@ -0,0 +1,288 @@
1
+ import pg from 'pg';
2
+ import { embedTexts, loadVectorProviders, vectorLiteral, vectorProviderProblems, DEFAULT_BATCH, DEFAULT_PER_MINUTE, type VectorProvider } from './ai/embeddings.ts';
3
+ import { isAiError } from './ai/types.ts';
4
+ import { owner } from './db.ts';
5
+ import { devDbForApp } from './workspace-db.ts';
6
+
7
+ // (sprint 136, AP-1) pgvector, and keeping vector columns current.
8
+ //
9
+ // pgvector is an extension that may be missing: stock postgres images don't have it. pgvectorStatus() says what
10
+ // there is (as the SQL Workshop does for pg_cron), vectorType() the schema-qualified type name to use; nothing in
11
+ // meta needs the extension.
12
+ //
13
+ // The embedding queue (migration 136): a vector column kept by a queue has an AFTER INSERT OR UPDATE OF … trigger
14
+ // (meta.embedding_enqueue) that puts the row's key in meta.embedding_queue; Backfill queues the rows without an
15
+ // embedding. embeddingTick() takes queued rows of one table and provider (FOR UPDATE SKIP LOCKED, so several
16
+ // servers share the queue), reads their source columns, embeds them in batches of the provider's batch_size
17
+ // within its requests per minute (and the application's daily AI limits), and writes the vectors. It reads and
18
+ // writes the table with the developer connection of the application's workspace (the owner, or an isolated
19
+ // workspace's developer login), as the SQL Workshop that created the column does. A failed batch is retried later
20
+ // (1, 2, 4 … minutes; at most 6 attempts), its message kept for the builder.
21
+
22
+ type Queryable = { query: (sql: string, params?: unknown[]) => Promise<{ rows: any[] }> };
23
+
24
+ export interface PgvectorStatus {
25
+ /** the extension is created in this database */
26
+ installed: boolean;
27
+ version: string | null;
28
+ schema: string | null;
29
+ /** the server has the package (pg_available_extensions) */
30
+ available: boolean;
31
+ availableVersion: string | null;
32
+ /** the connection may create it (a superuser; pgvector is not a trusted extension) */
33
+ canCreate: boolean;
34
+ }
35
+
36
+ export async function pgvectorStatus(db: Queryable): Promise<PgvectorStatus> {
37
+ const r = (await db.query(
38
+ `select e.extversion as version, n.nspname as schema, a.default_version as available_version,
39
+ (select rolsuper from pg_catalog.pg_roles where rolname = current_user) as superuser
40
+ from (select 1) x
41
+ left join pg_catalog.pg_extension e on e.extname = 'vector'
42
+ left join pg_catalog.pg_namespace n on n.oid = e.extnamespace
43
+ left join pg_catalog.pg_available_extensions a on a.name = 'vector'`,
44
+ )).rows[0];
45
+ return {
46
+ installed: !!r.version, version: r.version ?? null, schema: r.schema ?? null,
47
+ available: !!r.available_version, availableVersion: r.available_version ?? null, canCreate: !!r.superuser,
48
+ };
49
+ }
50
+
51
+ /** The schema of pgvector when it is installed (its type and operators are used schema-qualified), else null. */
52
+ export async function vectorSchema(db: Queryable): Promise<string | null> {
53
+ return (await db.query(`select n.nspname as s from pg_catalog.pg_extension e join pg_catalog.pg_namespace n on n.oid = e.extnamespace where e.extname = 'vector'`)).rows[0]?.s ?? null;
54
+ }
55
+
56
+ /** What to say when pgvector is missing (builder and runtime messages). */
57
+ export const PGVECTOR_MISSING = 'The pgvector extension is not installed in this database: an administrator installs the package on the database server (e.g. apt install postgresql-17-pgvector, or the pgvector/pgvector Docker image) and creates it (Workspace utilities → Vector providers, or create extension vector as a superuser).';
58
+
59
+ /** The distances of pgvector: operator, HNSW operator class, and the similarity (higher is closer) as SQL of `d`. */
60
+ export const DISTANCES = {
61
+ cosine: { op: '<=>', opclass: 'vector_cosine_ops', similarity: (d: string) => `(1 - ${d})` },
62
+ l2: { op: '<->', opclass: 'vector_l2_ops', similarity: (d: string) => `(1 / (1 + ${d}))` },
63
+ inner_product: { op: '<#>', opclass: 'vector_ip_ops', similarity: (d: string) => `(-${d})` },
64
+ } as const;
65
+ export type Distance = keyof typeof DISTANCES;
66
+ export const isDistance = (d: unknown): d is Distance => typeof d === 'string' && Object.hasOwn(DISTANCES, d);
67
+
68
+ /** `a <=> b` with pgvector's operator schema-qualified (an application role's search_path may not have it). */
69
+ export const distanceSql = (schema: string, d: Distance, a: string, b: string) => `${a} OPERATOR(${pg.escapeIdentifier(schema)}.${DISTANCES[d].op}) ${b}`;
70
+
71
+ /** The dimensions of a vector column (its typmod), or null when it is no vector(n) column. */
72
+ export async function vectorColumnDims(db: Queryable, table: string, column: string): Promise<number | null | undefined> {
73
+ const r = (await db.query(
74
+ `select t.typname = 'vector' as is_vector, a.atttypmod as dims
75
+ from pg_catalog.pg_attribute a join pg_catalog.pg_type t on t.oid = a.atttypid
76
+ where a.attrelid = $1::regclass and a.attname = $2 and a.attnum > 0 and not a.attisdropped`, [table, column])).rows[0];
77
+ if (!r) return undefined;
78
+ return r.is_vector && r.dims > 0 ? r.dims : null;
79
+ }
80
+
81
+ // ---------------------------------------------------------------- the queue
82
+
83
+ const COLUMN = /^[a-z_][a-z0-9_$]{0,62}$/;
84
+ const MAX_ATTEMPTS = 6;
85
+ /** Requests per provider in the last minute (this process). */
86
+ const sent = new Map<string, number[]>();
87
+
88
+ function mayRequest(key: string, perMinute: number) {
89
+ const now = Date.now();
90
+ const times = (sent.get(key) ?? []).filter((t) => now - t < 60_000);
91
+ sent.set(key, times);
92
+ if (times.length >= perMinute) return false;
93
+ times.push(now);
94
+ return true;
95
+ }
96
+
97
+ interface Group { app_id: number; provider: string; tbl: number; vector_column: string; key_column: string; source_columns: string[] }
98
+
99
+ /** Embed one batch of a group; returns the number of rows written (0: nothing to do or wait). */
100
+ async function runBatch(g: Group, p: VectorProvider): Promise<number> {
101
+ const batch = p.type === 'ai_service' ? (p.batch_size ?? DEFAULT_BATCH) : 100;
102
+ if (p.type === 'ai_service' && !mayRequest(`${g.app_id}:${p.name}`, p.per_minute ?? DEFAULT_PER_MINUTE)) return 0;
103
+ return owner.tx(async (q) => {
104
+ const rows = (await q.query<{ id: string; key_value: string; queued_at: string }>(
105
+ `select id, key_value, queued_at::text from meta.embedding_queue
106
+ where app_id = $1 and provider = $2 and tbl = $3 and vector_column = $4 and key_column = $5 and source_columns = $6 and next_at <= now()
107
+ order by id limit $7 for update skip locked`,
108
+ [g.app_id, g.provider, g.tbl, g.vector_column, g.key_column, g.source_columns, batch])).rows;
109
+ if (!rows.length) return 0;
110
+ const ids = rows.map((r) => r.id);
111
+ const fail = async (message: string) => {
112
+ await q.query(
113
+ `update meta.embedding_queue set attempts = attempts + 1, last_error = left($2, 500),
114
+ next_at = now() + make_interval(mins => power(2, least(attempts, 6))::int)
115
+ where id = any($1::bigint[])`, [ids, message]);
116
+ return 0;
117
+ };
118
+ const db = await devDbForApp(g.app_id);
119
+ const table = (await db.query<{ t: string | null }>('select $1::oid::regclass::text as t from pg_catalog.pg_class where oid = $1', [g.tbl])).rows[0]?.t;
120
+ if (!table) {
121
+ // the table is gone
122
+ await q.query('delete from meta.embedding_queue where id = any($1::bigint[])', [ids]);
123
+ return 0;
124
+ }
125
+ if (![g.vector_column, g.key_column, ...g.source_columns].every((c) => COLUMN.test(c))) return fail('The trigger names a column that is not a plain name.');
126
+ const id = pg.escapeIdentifier;
127
+ const text = `meta.embedding_text(to_jsonb(t), $2::text[])`;
128
+ try {
129
+ let written = 0;
130
+ if (p.type === 'function') {
131
+ // a function provider: embedded in the database, as the developer connection (the trigger's own function)
132
+ await db.query(
133
+ `update ${table} t set ${id(g.vector_column)} = translate(meta.embed(${text}, $3, $4)::text, '{}', '[]')::text::${await vectorTypeOf(db)}
134
+ where t.${id(g.key_column)}::text = any($1::text[])`, [rows.map((r) => r.key_value), g.source_columns, p.name, g.app_id]);
135
+ written = rows.length;
136
+ } else {
137
+ const found = (await db.query<{ k: string; t: string | null }>(
138
+ `select t.${id(g.key_column)}::text as k, ${text} as t from ${table} t where t.${id(g.key_column)}::text = any($1::text[])`,
139
+ [rows.map((r) => r.key_value), g.source_columns])).rows;
140
+ const withText = found.filter((r) => r.t);
141
+ const vectors = withText.length ? await embedTexts(p, withText.map((r) => r.t!), { appId: g.app_id, user: null, pageNo: null }) : [];
142
+ const type = await vectorTypeOf(db);
143
+ // rows without text get no embedding; rows deleted meanwhile are simply gone
144
+ await db.query(
145
+ `update ${table} t set ${id(g.vector_column)} = x.v::${type}
146
+ from unnest($1::text[], $2::text[]) x(k, v) where t.${id(g.key_column)}::text = x.k`,
147
+ [[...withText.map((r) => r.k), ...found.filter((r) => !r.t).map((r) => r.k)], [...vectors.map(vectorLiteral), ...found.filter((r) => !r.t).map(() => null)]]);
148
+ written = found.length;
149
+ }
150
+ // done, unless the row was queued again meanwhile (the trigger moved queued_at)
151
+ await q.query(
152
+ `delete from meta.embedding_queue e using unnest($1::bigint[], $2::text[]) x(id, at) where e.id = x.id and e.queued_at::text = x.at`,
153
+ [ids, rows.map((r) => r.queued_at)]);
154
+ return Math.max(written, 1);
155
+ } catch (e) {
156
+ const message = isAiError(e) ? e.message : (e as Error).message;
157
+ return fail(message);
158
+ }
159
+ });
160
+ }
161
+
162
+ /** pgvector's type, schema-qualified, for casts. */
163
+ async function vectorTypeOf(db: Queryable) {
164
+ const s = await vectorSchema(db);
165
+ if (!s) throw new Error(PGVECTOR_MISSING);
166
+ return `${pg.escapeIdentifier(s)}.vector`;
167
+ }
168
+
169
+ let lastPurge = 0;
170
+
171
+ /** One round of the queue: a batch of each group with work (call again until it returns 0). */
172
+ export async function embeddingTick(): Promise<number> {
173
+ if (Date.now() - lastPurge > 3_600_000) {
174
+ lastPurge = Date.now();
175
+ await owner.query(`delete from meta.embedding_queue where attempts >= $1 and next_at < now() - interval '7 days'`, [MAX_ATTEMPTS]);
176
+ }
177
+ const groups = (await owner.query<Group>(
178
+ `select distinct app_id, provider, tbl, vector_column, key_column, source_columns from meta.embedding_queue
179
+ where next_at <= now() and attempts < $1 limit 20`, [MAX_ATTEMPTS])).rows;
180
+ let done = 0;
181
+ const providers = new Map<number, VectorProvider[]>();
182
+ for (const g of groups) {
183
+ if (!providers.has(g.app_id)) providers.set(g.app_id, await loadVectorProviders(g.app_id));
184
+ const p = providers.get(g.app_id)!.find((x) => x.name === g.provider);
185
+ if (!p || vectorProviderProblems(p).length) {
186
+ await owner.query(
187
+ `update meta.embedding_queue set attempts = $5, last_error = $6, next_at = now() + interval '10 minutes'
188
+ where app_id = $1 and provider = $2 and tbl = $3 and vector_column = $4`,
189
+ [g.app_id, g.provider, g.tbl, g.vector_column, MAX_ATTEMPTS, `There is no valid vector provider ${g.provider} in application ${g.app_id}.`]);
190
+ continue;
191
+ }
192
+ try {
193
+ done += await runBatch(g, p);
194
+ } catch (e) {
195
+ console.error('embeddings:', (e as Error).message);
196
+ }
197
+ }
198
+ return done;
199
+ }
200
+
201
+ /** Work the queue until it is empty or waiting (tests, Backfill); at most `rounds` rounds. */
202
+ export async function drainEmbeddings(rounds = 50) {
203
+ let total = 0;
204
+ for (let i = 0; i < rounds; i++) {
205
+ const n = await embeddingTick();
206
+ if (!n) break;
207
+ total += n;
208
+ }
209
+ return total;
210
+ }
211
+
212
+ let timer: NodeJS.Timeout | null = null;
213
+ let busy = false;
214
+
215
+ /** Kick the queue now (after a Backfill), without waiting for it (not with EMBEDDINGS=off). */
216
+ export function kickEmbeddings() {
217
+ if (busy || process.env.EMBEDDINGS === 'off') return;
218
+ busy = true;
219
+ drainEmbeddings(20).catch((e) => console.error('embeddings:', (e as Error).message)).finally(() => (busy = false));
220
+ }
221
+
222
+ /** Started by server.ts; EMBEDDINGS=off on servers that should not embed, EMBEDDING_INTERVAL_S (default 5). */
223
+ export function startEmbeddingRunner() {
224
+ if (process.env.EMBEDDINGS === 'off' || timer) return;
225
+ const every = Math.max(1, Number(process.env.EMBEDDING_INTERVAL_S ?? 5)) * 1000;
226
+ timer = setInterval(() => kickEmbeddings(), every);
227
+ timer.unref();
228
+ }
229
+
230
+ /** The queue of a vector column: waiting rows, failing rows and the last message. */
231
+ export async function queueState(tbl: number, column: string) {
232
+ return (await owner.one<{ queued: number; failing: number; message: string | null }>(
233
+ `select count(*)::int as queued, count(*) filter (where attempts > 0)::int as failing,
234
+ (select last_error from meta.embedding_queue where tbl = $1 and vector_column = $2 and last_error is not null order by id desc limit 1) as message
235
+ from meta.embedding_queue where tbl = $1 and vector_column = $2`, [tbl, column]))!;
236
+ }
237
+
238
+ // ---------------------------------------------------------------- the triggers that keep vector columns
239
+
240
+ export interface VectorTrigger {
241
+ table: string;
242
+ tbl: number;
243
+ trigger: string;
244
+ /** set: a function provider's embedding at once (BEFORE); queue: queued for the server (AFTER) */
245
+ mode: 'set' | 'queue';
246
+ appId: number;
247
+ provider: string;
248
+ vectorColumn: string;
249
+ keyColumn: string;
250
+ sourceColumns: string[];
251
+ }
252
+
253
+ /** The meta.embedding_set / meta.embedding_enqueue triggers (of one table, or of every table), with their arguments. */
254
+ export async function vectorTriggers(db: Queryable, table?: string): Promise<VectorTrigger[]> {
255
+ const rows = (await db.query(
256
+ `select t.tgrelid::regclass::text as tbl_name, t.tgrelid::int as tbl, t.tgname as trigger, p.proname as fn, pg_catalog.pg_get_triggerdef(t.oid) as def
257
+ from pg_catalog.pg_trigger t join pg_catalog.pg_proc p on p.oid = t.tgfoid join pg_catalog.pg_namespace n on n.oid = p.pronamespace
258
+ where n.nspname = 'meta' and p.proname in ('embedding_set', 'embedding_enqueue') and not t.tgisinternal
259
+ and ($1::text is null or t.tgrelid = $1::regclass)
260
+ order by 1, 3`, [table ?? null])).rows;
261
+ const out: VectorTrigger[] = [];
262
+ for (const r of rows) {
263
+ const args = [...String(/EXECUTE FUNCTION [^(]+\((.*)\)\s*$/i.exec(r.def)?.[1] ?? '').matchAll(/'((?:[^']|'')*)'/g)].map((m) => m[1].replace(/''/g, "'"));
264
+ if (args.length < 5 || !/^\d+$/.test(args[0])) continue;
265
+ out.push({
266
+ table: r.tbl_name, tbl: r.tbl, trigger: r.trigger, mode: r.fn === 'embedding_set' ? 'set' : 'queue', appId: Number(args[0]),
267
+ provider: args[1].toUpperCase(), vectorColumn: args[2], keyColumn: args[3], sourceColumns: args.slice(4),
268
+ });
269
+ }
270
+ return out;
271
+ }
272
+
273
+ /** Queue rows of a vector column for the server: those without an embedding, or all of them. Returns how many. */
274
+ export async function backfill(db: Queryable, t: VectorTrigger, all: boolean, max = 100_000): Promise<number> {
275
+ const id = pg.escapeIdentifier;
276
+ const keys = (await db.query(
277
+ `select t.${id(t.keyColumn)}::text as k from ${t.table} t ${all ? '' : `where t.${id(t.vectorColumn)} is null`} order by 1 limit ${max}`)).rows.map((r: { k: string }) => r.k);
278
+ for (let i = 0; i < keys.length; i += 5000) {
279
+ await owner.query(
280
+ `insert into meta.embedding_queue (app_id, provider, tbl, vector_column, key_column, source_columns, key_value)
281
+ select $1, $2, $3, $4, $5, $6, k from unnest($7::text[]) k
282
+ on conflict (tbl, vector_column, key_value) do update
283
+ set app_id = excluded.app_id, provider = excluded.provider, key_column = excluded.key_column, source_columns = excluded.source_columns,
284
+ queued_at = now(), next_at = now(), attempts = 0, last_error = null`,
285
+ [t.appId, t.provider, t.tbl, t.vectorColumn, t.keyColumn, t.sourceColumns, keys.slice(i, i + 5000)]);
286
+ }
287
+ return keys.length;
288
+ }