@retinue/agentkit 0.3.1 → 0.3.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.
@@ -7,22 +7,35 @@
7
7
  * the ones that must land in the same schema as everything else.
8
8
  *
9
9
  * **What it adds over `new Pool`.** `RETINUE_DATABASE_SCHEMA` names the schema the platform's tables
10
- * live in, and honouring it takes two things that must agree:
10
+ * live in, and honouring it takes three things:
11
11
  *
12
- * - `pool.on("connect")` sets `search_path` on **every** connection, because `createPgExecutor` runs
13
- * each query through `pool.query`, which takes a different connection per call. A pooled connection
14
- * carries whatever `search_path` its last user left, so setting it once at startup means the first
15
- * few statements land in the right schema and the rest land wherever. That failure is not loud: it
16
- * is a table created in `public` by a migration that reported success.
17
- * - `createPoolOpener(pool, …)` sets it again per checkout, which is what the transaction scope uses.
18
- * Redundant on paper and deliberately kept: the opener is the path that holds `SELECT … FOR UPDATE`
19
- * across statements, and it must not depend on a listener having fired.
12
+ * - `options: "-c search_path=…"`, a **startup parameter**, so every connection has it before it runs
13
+ * a single statement. This matters because `createPgExecutor` runs each query through `pool.query`,
14
+ * which takes a different connection per call and hands back one carrying whatever `search_path` its
15
+ * last borrower left.
16
+ *
17
+ * The first version of this did it with `pool.on("connect", (c) => c.query("SET search_path …"))`,
18
+ * node-postgres's documented idiom for session state. It worked, and pg deprecated it while this was
19
+ * being written: *"Calling client.query() when the client is already executing a query is deprecated
20
+ * and will be removed in pg@9.0"* — printed on every boot. A startup parameter needs no query at
21
+ * all, so the ordering question disappears rather than being answered.
22
+ *
23
+ * Verified against a real pooler before relying on it: Supabase's Supavisor forwards `options`
24
+ * on the session port, both in the config and in the URL's query string. That was the open
25
+ * question, since PgBouncer historically rejects unknown startup parameters.
26
+ * - `assertSearchPath`, one query at startup, because the mechanism above is the kind that fails
27
+ * silently. A pooler that swallowed `options` would leave every connection on the default path and
28
+ * every write in the wrong schema, with nothing to see. One round-trip turns that into a refusal.
29
+ * - `createPoolOpener(pool, …)` sets it per checkout as well, which is what the transaction scope
30
+ * uses. Belt and braces, deliberately: the opener is the path that holds `SELECT … FOR UPDATE`
31
+ * across statements, and it is worth its own guarantee.
20
32
  *
21
33
  * **`public` stays on the path** after the named schema. Two things need it: the `vector` type is
22
34
  * pinned to `public` so it resolves from any schema (see the note in `migrations.ts`), and ShareFlow's
23
35
  * adapters qualify all 70 of their queries as `public.`, which is what lets one pool serve a platform
24
36
  * schema and a product schema at once.
25
37
  */
38
+ import type { Pool } from "pg";
26
39
  import type { SqlExecutor } from "../adapters/postgres/sql.js";
27
40
  import type { ConnectionOpener } from "../adapters/postgres/transaction.js";
28
41
  export type PostgresConnection = {
@@ -43,5 +56,18 @@ export type PoolSettings = {
43
56
  * cheaper than reading two call sites to find out what a deployment actually gets.
44
57
  */
45
58
  export declare const searchPathFor: (schema: string | undefined) => string | undefined;
59
+ /**
60
+ * One query, to prove the startup parameter actually took effect.
61
+ *
62
+ * Without it the mechanism is silent when it fails. A pooler that dropped `options` — PgBouncer
63
+ * rejects unknown startup parameters by default, and a managed pooler can change behaviour under you —
64
+ * would leave every connection on the default `search_path`, and every table the platform creates
65
+ * would land in whatever schema comes first there. No error, no log line, and the symptom is two
66
+ * projects quietly sharing a namespace.
67
+ *
68
+ * Compared as a set rather than as a string: Postgres echoes what it was given, and `retinue, public`
69
+ * is the same path as `retinue,public` while being a different string.
70
+ */
71
+ export declare const assertSearchPath: (pool: Pool, expected: string) => Promise<void>;
46
72
  export declare const openPostgres: (settings: PoolSettings) => Promise<PostgresConnection>;
47
73
  //# sourceMappingURL=pool.d.ts.map
@@ -7,16 +7,28 @@
7
7
  * the ones that must land in the same schema as everything else.
8
8
  *
9
9
  * **What it adds over `new Pool`.** `RETINUE_DATABASE_SCHEMA` names the schema the platform's tables
10
- * live in, and honouring it takes two things that must agree:
10
+ * live in, and honouring it takes three things:
11
11
  *
12
- * - `pool.on("connect")` sets `search_path` on **every** connection, because `createPgExecutor` runs
13
- * each query through `pool.query`, which takes a different connection per call. A pooled connection
14
- * carries whatever `search_path` its last user left, so setting it once at startup means the first
15
- * few statements land in the right schema and the rest land wherever. That failure is not loud: it
16
- * is a table created in `public` by a migration that reported success.
17
- * - `createPoolOpener(pool, …)` sets it again per checkout, which is what the transaction scope uses.
18
- * Redundant on paper and deliberately kept: the opener is the path that holds `SELECT … FOR UPDATE`
19
- * across statements, and it must not depend on a listener having fired.
12
+ * - `options: "-c search_path=…"`, a **startup parameter**, so every connection has it before it runs
13
+ * a single statement. This matters because `createPgExecutor` runs each query through `pool.query`,
14
+ * which takes a different connection per call and hands back one carrying whatever `search_path` its
15
+ * last borrower left.
16
+ *
17
+ * The first version of this did it with `pool.on("connect", (c) => c.query("SET search_path …"))`,
18
+ * node-postgres's documented idiom for session state. It worked, and pg deprecated it while this was
19
+ * being written: *"Calling client.query() when the client is already executing a query is deprecated
20
+ * and will be removed in pg@9.0"* — printed on every boot. A startup parameter needs no query at
21
+ * all, so the ordering question disappears rather than being answered.
22
+ *
23
+ * Verified against a real pooler before relying on it: Supabase's Supavisor forwards `options`
24
+ * on the session port, both in the config and in the URL's query string. That was the open
25
+ * question, since PgBouncer historically rejects unknown startup parameters.
26
+ * - `assertSearchPath`, one query at startup, because the mechanism above is the kind that fails
27
+ * silently. A pooler that swallowed `options` would leave every connection on the default path and
28
+ * every write in the wrong schema, with nothing to see. One round-trip turns that into a refusal.
29
+ * - `createPoolOpener(pool, …)` sets it per checkout as well, which is what the transaction scope
30
+ * uses. Belt and braces, deliberately: the opener is the path that holds `SELECT … FOR UPDATE`
31
+ * across statements, and it is worth its own guarantee.
20
32
  *
21
33
  * **`public` stays on the path** after the named schema. Two things need it: the `vector` type is
22
34
  * pinned to `public` so it resolves from any schema (see the note in `migrations.ts`), and ShareFlow's
@@ -29,41 +41,58 @@
29
41
  * Exported because it is the only string in this file that ends up in SQL, and a test that pins it is
30
42
  * cheaper than reading two call sites to find out what a deployment actually gets.
31
43
  */
32
- export const searchPathFor = (schema) => schema === undefined || schema === "" ? undefined : `${schema}, public`;
44
+ export const searchPathFor = (schema) =>
45
+ // **No space after the comma**, and that is not a style choice. This string is passed as libpq's
46
+ // `-c search_path=…`, where a space separates one option from the next: `-c search_path=retinue,
47
+ // public` reaches Postgres as `search_path` = `retinue,` and a stray `public`, and the server
48
+ // refuses it outright — `invalid value for parameter "search_path": "retinue,"`. Found by running
49
+ // `migrate` against a real database, not by reading the code. `SET search_path TO retinue,public` is
50
+ // equally valid, so one representation serves both uses.
51
+ schema === undefined || schema === "" ? undefined : `${schema},public`;
52
+ /**
53
+ * One query, to prove the startup parameter actually took effect.
54
+ *
55
+ * Without it the mechanism is silent when it fails. A pooler that dropped `options` — PgBouncer
56
+ * rejects unknown startup parameters by default, and a managed pooler can change behaviour under you —
57
+ * would leave every connection on the default `search_path`, and every table the platform creates
58
+ * would land in whatever schema comes first there. No error, no log line, and the symptom is two
59
+ * projects quietly sharing a namespace.
60
+ *
61
+ * Compared as a set rather than as a string: Postgres echoes what it was given, and `retinue, public`
62
+ * is the same path as `retinue,public` while being a different string.
63
+ */
64
+ export const assertSearchPath = async (pool, expected) => {
65
+ const normalise = (value) => value
66
+ .split(",")
67
+ .map((part) => part.trim().replace(/^"|"$/g, ""))
68
+ .filter((part) => part !== "");
69
+ const rows = await pool.query("show search_path");
70
+ const actual = normalise(rows.rows[0]?.search_path ?? "");
71
+ const wanted = normalise(expected);
72
+ const matches = wanted.length === actual.length && wanted.every((part, index) => part === actual[index]);
73
+ if (!matches) {
74
+ await pool.end().catch(() => undefined);
75
+ throw new Error(`RETINUE_DATABASE_SCHEMA asked for search_path "${expected}" but this connection reports ` +
76
+ `"${rows.rows[0]?.search_path ?? "(nothing)"}". The connection options were not applied — a ` +
77
+ `pooler in front of Postgres may be dropping them. Refusing to start, because every table this ` +
78
+ `process creates would otherwise land in the wrong schema with no error.`);
79
+ }
80
+ };
33
81
  export const openPostgres = async (settings) => {
34
82
  const { Pool } = await import("pg");
35
83
  const { createPgExecutor, createPoolOpener } = await import("../entries/adapters-postgres.js");
36
84
  const searchPath = searchPathFor(settings.databaseSchema);
37
85
  const pool = new Pool({
38
86
  connectionString: settings.databaseUrl,
87
+ // The startup parameter, applied by the server before the connection is usable. `-c key=value` is
88
+ // libpq's form and node-postgres passes it straight through.
89
+ ...(searchPath === undefined ? {} : { options: `-c search_path=${searchPath}` }),
39
90
  ...(settings.connectionTimeoutMillis === undefined
40
91
  ? {}
41
92
  : { connectionTimeoutMillis: settings.connectionTimeoutMillis }),
42
93
  });
43
- if (searchPath !== undefined) {
44
- /**
45
- * Queued on the client, not awaited — which is what makes it correct rather than racy.
46
- *
47
- * node-postgres queues queries per client in order, so this `SET` is ahead of whatever the borrower
48
- * runs next on that same connection. An `await` here would have nothing to attach to: `connect` is
49
- * an event, and the pool hands the client out regardless of what a listener is still doing.
50
- *
51
- * A failure is surfaced rather than swallowed, and it is worth being precise about what it can and
52
- * cannot catch. It means the role may not *use* the schema. It does **not** mean the schema is
53
- * missing: `SET search_path TO retinue, public` succeeds when `retinue` does not exist, because a
54
- * missing entry is skipped rather than rejected, and every write then lands in `public` with no
55
- * error anywhere. Nothing at this layer can see that — which is why `retinue migrate` creates the
56
- * schema before anything connects, and why this listener is not the guard against it.
57
- */
58
- pool.on("connect", (client) => {
59
- void client.query(`SET search_path TO ${searchPath}`).catch((error) => {
60
- client.end();
61
- pool.emit("error", error instanceof Error
62
- ? new Error(`could not SET search_path TO ${searchPath}: ${error.message}`, { cause: error })
63
- : new Error(`could not SET search_path TO ${searchPath}`), client);
64
- });
65
- });
66
- }
94
+ if (searchPath !== undefined)
95
+ await assertSearchPath(pool, searchPath);
67
96
  return {
68
97
  sql: createPgExecutor(pool),
69
98
  open: createPoolOpener(pool, searchPath),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@retinue/agentkit",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
4
4
  "description": "A provider-neutral, durable AI agent runtime for TypeScript: agents, tools, approvals, context, knowledge and persistence behind ports.",
5
5
  "license": "MIT",
6
6
  "type": "module",