@retinue/agentkit 0.3.0 → 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.
- package/dist/server/bin.d.ts +53 -0
- package/dist/server/bin.js +91 -13
- package/dist/server/cli-worker.js +6 -3
- package/dist/server/cli.js +6 -4
- package/dist/server/config.d.ts +11 -1
- package/dist/server/config.js +18 -0
- package/dist/server/doctor.d.ts +13 -2
- package/dist/server/doctor.js +1 -1
- package/dist/server/pool.d.ts +73 -0
- package/dist/server/pool.js +102 -0
- package/package.json +1 -1
package/dist/server/bin.d.ts
CHANGED
|
@@ -15,5 +15,58 @@
|
|
|
15
15
|
* `migrate` and `doctor` deliberately need **no** app module: a database is provisioned before an application
|
|
16
16
|
* exists, and a diagnostic that cannot run until everything else is configured is a diagnostic nobody can use.
|
|
17
17
|
*/
|
|
18
|
+
/**
|
|
19
|
+
* The advisory-lock key comes from `schema.ts` — #252's AC-2, and #266's AC-4.
|
|
20
|
+
*
|
|
21
|
+
* It used to be defined here. That made "the CLI and `auto` mode use the same key" a property of two constants
|
|
22
|
+
* happening to be equal, and a copy that drifted would produce two locks, no serialisation, and the original
|
|
23
|
+
* crash returning with the fix apparently in place. Two constants that must be equal are one constant.
|
|
24
|
+
*/
|
|
25
|
+
/**
|
|
26
|
+
* Create the configured schema if it is not there, before anything tries to use it.
|
|
27
|
+
*
|
|
28
|
+
* `migrate` is the command that owns provisioning, so the namespace it was told to provision into is
|
|
29
|
+
* its job too — and the reason is worse than a missing-schema error. **Postgres does not error.**
|
|
30
|
+
* `SET search_path TO retinue, public` succeeds when `retinue` does not exist: a missing entry is
|
|
31
|
+
* skipped, not rejected. `CREATE TABLE conversations` then lands in the first schema that *does*
|
|
32
|
+
* exist, which is `public` — so all 34 platform migrations would silently be created alongside the
|
|
33
|
+
* product's tables, reporting success the whole way. Verified against a real Postgres 17: the SET
|
|
34
|
+
* returns, the CREATE returns, and `information_schema` says `public`.
|
|
35
|
+
*
|
|
36
|
+
* That is the failure this function exists to prevent, and it is why it runs before the pool rather
|
|
37
|
+
* than relying on a connection error that never comes.
|
|
38
|
+
*
|
|
39
|
+
* On its own connection with the **default** search path, which is the part that is easy to get wrong.
|
|
40
|
+
* A connection configured for `retinue` cannot be the one that creates `retinue` — `openPostgres`
|
|
41
|
+
* destroys it during setup, before a statement of ours runs.
|
|
42
|
+
*
|
|
43
|
+
* `IF NOT EXISTS` and nothing else: no owner, no grants, no drop. Provisioning a namespace is additive;
|
|
44
|
+
* deciding who may use it is a deployment's decision and not a migration's.
|
|
45
|
+
*/
|
|
46
|
+
/**
|
|
47
|
+
* Which `migrate` invocations must change nothing.
|
|
48
|
+
*
|
|
49
|
+
* Its own function because it is the link between a flag and a side effect, and that link is invisible
|
|
50
|
+
* to a test of either end: sabotaging it to `false` — so `--dry-run` provisions a schema — broke
|
|
51
|
+
* nothing, while every assertion about `ensureSchema` itself stayed green. The list is also the same
|
|
52
|
+
* one the read-only branch below uses, so a third flag added to one and not the other cannot silently
|
|
53
|
+
* become a writing dry run.
|
|
54
|
+
*/
|
|
55
|
+
export declare const READ_ONLY_FLAGS: readonly ["--status", "--dry-run"];
|
|
56
|
+
export declare const isReadOnly: (flags: ReadonlySet<string>) => boolean;
|
|
57
|
+
export declare const ensureSchema: (config: {
|
|
58
|
+
readonly databaseUrl: string;
|
|
59
|
+
readonly databaseSchema?: string;
|
|
60
|
+
}, { create, connect, }: {
|
|
61
|
+
readonly create: boolean;
|
|
62
|
+
readonly connect?: (settings: {
|
|
63
|
+
readonly databaseUrl: string;
|
|
64
|
+
}) => Promise<{
|
|
65
|
+
readonly sql: {
|
|
66
|
+
query<Row>(text: string, params?: readonly unknown[]): Promise<Row[]>;
|
|
67
|
+
};
|
|
68
|
+
readonly end: () => Promise<void>;
|
|
69
|
+
}>;
|
|
70
|
+
}) => Promise<boolean>;
|
|
18
71
|
export declare const main: (argv: readonly string[], env?: NodeJS.ProcessEnv) => Promise<number>;
|
|
19
72
|
//# sourceMappingURL=bin.d.ts.map
|
package/dist/server/bin.js
CHANGED
|
@@ -29,15 +29,20 @@ const USAGE = `retinue <command>
|
|
|
29
29
|
doctor Check configuration, database, schema and Redis. Reports every failure.
|
|
30
30
|
|
|
31
31
|
Configuration comes from the environment; see .env.example.`;
|
|
32
|
-
/**
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
32
|
+
/**
|
|
33
|
+
* Opened lazily and per command, so `doctor` and `migrate` never load a driver they do not use.
|
|
34
|
+
*
|
|
35
|
+
* The pool itself comes from `pool.ts`, which is also what the API host and the worker use — the schema
|
|
36
|
+
* a deployment configures has to be the same one migrations run against, and that is only true while
|
|
37
|
+
* there is one place that decides it.
|
|
38
|
+
*
|
|
39
|
+
* A connect timeout, because the default is *none*: `doctor` against a refused port sat silently instead
|
|
40
|
+
* of reporting the failure it exists to report. Short, since every command here either connects
|
|
41
|
+
* immediately or is misconfigured.
|
|
42
|
+
*/
|
|
43
|
+
const postgres = async (config) => {
|
|
44
|
+
const { openPostgres } = await import("./pool.js");
|
|
45
|
+
return openPostgres({ ...config, connectionTimeoutMillis: 5_000 });
|
|
41
46
|
};
|
|
42
47
|
/**
|
|
43
48
|
* The advisory-lock key comes from `schema.ts` — #252's AC-2, and #266's AC-4.
|
|
@@ -46,12 +51,85 @@ const postgres = async (url) => {
|
|
|
46
51
|
* happening to be equal, and a copy that drifted would produce two locks, no serialisation, and the original
|
|
47
52
|
* crash returning with the fix apparently in place. Two constants that must be equal are one constant.
|
|
48
53
|
*/
|
|
54
|
+
/**
|
|
55
|
+
* Create the configured schema if it is not there, before anything tries to use it.
|
|
56
|
+
*
|
|
57
|
+
* `migrate` is the command that owns provisioning, so the namespace it was told to provision into is
|
|
58
|
+
* its job too — and the reason is worse than a missing-schema error. **Postgres does not error.**
|
|
59
|
+
* `SET search_path TO retinue, public` succeeds when `retinue` does not exist: a missing entry is
|
|
60
|
+
* skipped, not rejected. `CREATE TABLE conversations` then lands in the first schema that *does*
|
|
61
|
+
* exist, which is `public` — so all 34 platform migrations would silently be created alongside the
|
|
62
|
+
* product's tables, reporting success the whole way. Verified against a real Postgres 17: the SET
|
|
63
|
+
* returns, the CREATE returns, and `information_schema` says `public`.
|
|
64
|
+
*
|
|
65
|
+
* That is the failure this function exists to prevent, and it is why it runs before the pool rather
|
|
66
|
+
* than relying on a connection error that never comes.
|
|
67
|
+
*
|
|
68
|
+
* On its own connection with the **default** search path, which is the part that is easy to get wrong.
|
|
69
|
+
* A connection configured for `retinue` cannot be the one that creates `retinue` — `openPostgres`
|
|
70
|
+
* destroys it during setup, before a statement of ours runs.
|
|
71
|
+
*
|
|
72
|
+
* `IF NOT EXISTS` and nothing else: no owner, no grants, no drop. Provisioning a namespace is additive;
|
|
73
|
+
* deciding who may use it is a deployment's decision and not a migration's.
|
|
74
|
+
*/
|
|
75
|
+
/**
|
|
76
|
+
* Which `migrate` invocations must change nothing.
|
|
77
|
+
*
|
|
78
|
+
* Its own function because it is the link between a flag and a side effect, and that link is invisible
|
|
79
|
+
* to a test of either end: sabotaging it to `false` — so `--dry-run` provisions a schema — broke
|
|
80
|
+
* nothing, while every assertion about `ensureSchema` itself stayed green. The list is also the same
|
|
81
|
+
* one the read-only branch below uses, so a third flag added to one and not the other cannot silently
|
|
82
|
+
* become a writing dry run.
|
|
83
|
+
*/
|
|
84
|
+
export const READ_ONLY_FLAGS = ["--status", "--dry-run"];
|
|
85
|
+
export const isReadOnly = (flags) => READ_ONLY_FLAGS.some((flag) => flags.has(flag));
|
|
86
|
+
export const ensureSchema = async (config, { create,
|
|
87
|
+
// Injectable so a test can run this against a real Postgres — PGlite — without a live server. The
|
|
88
|
+
// default is the same `postgres` every command here uses.
|
|
89
|
+
connect = (settings) => postgres(settings), }) => {
|
|
90
|
+
if (config.databaseSchema === undefined)
|
|
91
|
+
return true;
|
|
92
|
+
const { sql, end } = await connect({ databaseUrl: config.databaseUrl });
|
|
93
|
+
try {
|
|
94
|
+
if (create) {
|
|
95
|
+
// Validated as an unquoted identifier by `loadConfig` — the only reason this concatenation is safe,
|
|
96
|
+
// and the reason that check refuses anything Postgres would need quoted.
|
|
97
|
+
await sql.query(`CREATE SCHEMA IF NOT EXISTS ${config.databaseSchema}`);
|
|
98
|
+
console.log(`schema: ${config.databaseSchema} ready`);
|
|
99
|
+
return true;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* The read-only paths report instead of creating, and that is not fussiness.
|
|
103
|
+
*
|
|
104
|
+
* `--dry-run` and `--status` are documented a few lines below as side-effect free — the note says
|
|
105
|
+
* `public` has 0 tables after a dry run against a fresh database — and a reader deciding whether to
|
|
106
|
+
* trust a dry run is exactly the reader who must not find it provisioned a schema. Creating one is
|
|
107
|
+
* a small side effect and a large broken promise.
|
|
108
|
+
*/
|
|
109
|
+
const rows = await sql.query(`select exists (select 1 from pg_namespace where nspname = $1) as exists`, [config.databaseSchema]);
|
|
110
|
+
if (rows[0]?.exists === true)
|
|
111
|
+
return true;
|
|
112
|
+
console.error(`schema: ${config.databaseSchema} does not exist. Postgres will not complain — it skips a missing ` +
|
|
113
|
+
`entry in search_path — so tables would be created in the next schema on the path instead, ` +
|
|
114
|
+
`silently. Run \`retinue migrate\`, which creates it, or unset RETINUE_DATABASE_SCHEMA to use the ` +
|
|
115
|
+
`connection's own schema deliberately.`);
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
finally {
|
|
119
|
+
await end();
|
|
120
|
+
}
|
|
121
|
+
};
|
|
49
122
|
const migrate = async (flags, env = process.env) => {
|
|
50
123
|
const config = loadConfig(env);
|
|
51
|
-
|
|
124
|
+
// Before the pool, because a missing schema does not fail: `SET search_path` skips an entry that does
|
|
125
|
+
// not exist and every CREATE lands in `public` instead, silently. The read-only flags report rather
|
|
126
|
+
// than create — see `ensureSchema`.
|
|
127
|
+
if (!(await ensureSchema(config, { create: !isReadOnly(flags) })))
|
|
128
|
+
return 1;
|
|
129
|
+
const { sql, open, end } = await postgres(config);
|
|
52
130
|
try {
|
|
53
131
|
const { createSchemaManager, MIGRATION_LOCK } = await import("../entries/adapters-postgres.js");
|
|
54
|
-
if (
|
|
132
|
+
if (isReadOnly(flags)) {
|
|
55
133
|
// Read-only paths take no lock. `plan()` and `currentVersion()` are documented as side-effect free — only
|
|
56
134
|
// `apply()` creates the ledger table — which is what makes a dry run honest rather than a dry run that
|
|
57
135
|
// provisions one table. Verified: after `--dry-run` against a fresh database, `public` has 0 tables.
|
|
@@ -116,8 +194,8 @@ const migrate = async (flags, env = process.env) => {
|
|
|
116
194
|
const doctor = async (env = process.env) => {
|
|
117
195
|
const results = await runChecks({
|
|
118
196
|
env,
|
|
119
|
-
connectPostgres: async (
|
|
120
|
-
const { sql, end } = await postgres(
|
|
197
|
+
connectPostgres: async (settings) => {
|
|
198
|
+
const { sql, end } = await postgres(settings);
|
|
121
199
|
return { query: (text, params) => sql.query(text, params), end };
|
|
122
200
|
},
|
|
123
201
|
connectRedis: async (url) => {
|
|
@@ -25,9 +25,12 @@ export const runWorker = async (env = process.env) => {
|
|
|
25
25
|
const { config, sql } = await boot({
|
|
26
26
|
env,
|
|
27
27
|
connect: async (loaded) => {
|
|
28
|
-
const {
|
|
29
|
-
|
|
30
|
-
|
|
28
|
+
const { openPostgres } = await import("./pool.js");
|
|
29
|
+
// Through the shared pool for the schema, which matters most here: the worker is the process whose
|
|
30
|
+
// writes nobody watches, so a worker in `public` while the host is in `retinue` is a split brain
|
|
31
|
+
// that shows up as runs that vanish rather than as an error.
|
|
32
|
+
const { sql } = await openPostgres(loaded);
|
|
33
|
+
return { sql };
|
|
31
34
|
},
|
|
32
35
|
});
|
|
33
36
|
/**
|
package/dist/server/cli.js
CHANGED
|
@@ -38,12 +38,14 @@ export const runApiHost = async (env = process.env) => {
|
|
|
38
38
|
const { config, sql, runner } = await boot({
|
|
39
39
|
env,
|
|
40
40
|
connect: async (loaded) => {
|
|
41
|
-
const {
|
|
42
|
-
const { createPgExecutor, createPoolOpener } = await import("../entries/adapters-postgres.js");
|
|
43
|
-
const pool = new Pool({ connectionString: loaded.databaseUrl });
|
|
41
|
+
const { openPostgres } = await import("./pool.js");
|
|
44
42
|
// `open` is what lets `boot` build a transaction scope. Without it the API host had no runner and
|
|
45
43
|
// `sendMessage` could not claim a conversation — #254.
|
|
46
|
-
|
|
44
|
+
//
|
|
45
|
+
// The whole config is passed, not just the URL: `databaseSchema` has to reach the pool or the host
|
|
46
|
+
// reads and writes `public` while migrations ran somewhere else.
|
|
47
|
+
const { sql, open } = await openPostgres(loaded);
|
|
48
|
+
return { sql, open };
|
|
47
49
|
},
|
|
48
50
|
});
|
|
49
51
|
const deps = await app.deps({ config, sql, ...(runner === undefined ? {} : { runner }) });
|
package/dist/server/config.d.ts
CHANGED
|
@@ -4,6 +4,16 @@ export type RetinueConfig = {
|
|
|
4
4
|
readonly redisUrl: string;
|
|
5
5
|
/** How the schema is provisioned at boot. `off` in production, so managed migrations stay in control. */
|
|
6
6
|
readonly schemaMode: SchemaMode;
|
|
7
|
+
/**
|
|
8
|
+
* The Postgres schema the platform's own tables live in, or `undefined` for the connection's default.
|
|
9
|
+
*
|
|
10
|
+
* Exists because a deployment may be sharing a database with a product that owns `public` — which is
|
|
11
|
+
* exactly the ShareFlow case: its adapters qualify every one of their queries as `public.`, so with this
|
|
12
|
+
* set to `retinue` one pool serves both, platform tables in one schema and product tables in the other.
|
|
13
|
+
* Without it the platform's 35 migrations land in `public` alongside the product's, and the first name
|
|
14
|
+
* they share is a migration that fails or, worse, one that succeeds against the wrong table.
|
|
15
|
+
*/
|
|
16
|
+
readonly databaseSchema?: string;
|
|
7
17
|
readonly port: number;
|
|
8
18
|
readonly workerConcurrency: number;
|
|
9
19
|
readonly logLevel: "debug" | "info" | "warn" | "error";
|
|
@@ -38,5 +48,5 @@ export declare const loadConfig: (env: Env) => RetinueConfig;
|
|
|
38
48
|
* test that asserts the error names every missing variable is what found it.
|
|
39
49
|
*/
|
|
40
50
|
export declare const REQUIRED_VARIABLES: readonly ["RETINUE_DATABASE_URL", "RETINUE_REDIS_URL"];
|
|
41
|
-
export declare const OPTIONAL_VARIABLES: readonly ["SCHEMA_MODE", "WORKER_CONCURRENCY", "LOG_LEVEL", "PORT"];
|
|
51
|
+
export declare const OPTIONAL_VARIABLES: readonly ["SCHEMA_MODE", "DATABASE_SCHEMA", "WORKER_CONCURRENCY", "LOG_LEVEL", "PORT"];
|
|
42
52
|
//# sourceMappingURL=config.d.ts.map
|
package/dist/server/config.js
CHANGED
|
@@ -76,6 +76,20 @@ export const loadConfig = (env) => {
|
|
|
76
76
|
if (!SCHEMA_MODES.includes(rawSchemaMode)) {
|
|
77
77
|
fail(named("SCHEMA_MODE"), `must be one of ${SCHEMA_MODES.join(", ")}, got "${rawSchemaMode}"`);
|
|
78
78
|
}
|
|
79
|
+
/**
|
|
80
|
+
* A schema name, validated as an identifier rather than quoted.
|
|
81
|
+
*
|
|
82
|
+
* `SET search_path` takes no parameters — it is not a value position, so there is no placeholder to bind
|
|
83
|
+
* and the name is concatenated into SQL. That makes this the one config value where a lax check is a SQL
|
|
84
|
+
* injection with the deployment's own credentials, so the pattern is deliberately narrow: what Postgres
|
|
85
|
+
* accepts unquoted, and nothing else. A rejected name fails boot, where it is one line to fix.
|
|
86
|
+
*/
|
|
87
|
+
const rawSchema = lookup("DATABASE_SCHEMA");
|
|
88
|
+
const databaseSchema = rawSchema === undefined || rawSchema.trim() === "" ? undefined : rawSchema.trim();
|
|
89
|
+
if (databaseSchema !== undefined && !/^[A-Za-z_][A-Za-z0-9_$]*$/.test(databaseSchema)) {
|
|
90
|
+
fail(named("DATABASE_SCHEMA"), `must be an unquoted Postgres identifier — letters, digits, underscore and $, not starting with a ` +
|
|
91
|
+
`digit — got "${databaseSchema.slice(0, 24)}"`);
|
|
92
|
+
}
|
|
79
93
|
const positiveInt = (suffix, fallback) => {
|
|
80
94
|
// `PORT` has no prefix — it is the conventional name and always has been, so it is read directly.
|
|
81
95
|
const variable = suffix === "PORT" ? "PORT" : named(suffix);
|
|
@@ -103,6 +117,9 @@ export const loadConfig = (env) => {
|
|
|
103
117
|
databaseUrl,
|
|
104
118
|
redisUrl,
|
|
105
119
|
schemaMode: rawSchemaMode,
|
|
120
|
+
// Spread, not `databaseSchema: undefined` — `exactOptionalPropertyTypes` is on, so an explicit
|
|
121
|
+
// undefined is a different type from an absent key.
|
|
122
|
+
...(databaseSchema === undefined ? {} : { databaseSchema }),
|
|
106
123
|
port,
|
|
107
124
|
workerConcurrency,
|
|
108
125
|
logLevel: rawLogLevel,
|
|
@@ -120,6 +137,7 @@ export const loadConfig = (env) => {
|
|
|
120
137
|
export const REQUIRED_VARIABLES = ["RETINUE_DATABASE_URL", "RETINUE_REDIS_URL"];
|
|
121
138
|
export const OPTIONAL_VARIABLES = [
|
|
122
139
|
"SCHEMA_MODE",
|
|
140
|
+
"DATABASE_SCHEMA",
|
|
123
141
|
"WORKER_CONCURRENCY",
|
|
124
142
|
"LOG_LEVEL",
|
|
125
143
|
"PORT",
|
package/dist/server/doctor.d.ts
CHANGED
|
@@ -39,8 +39,19 @@ export declare const describeUrl: (value: string) => string;
|
|
|
39
39
|
export declare const scrub: (message: string) => string;
|
|
40
40
|
export type DoctorDeps = {
|
|
41
41
|
readonly env?: Readonly<Record<string, string | undefined>>;
|
|
42
|
-
/**
|
|
43
|
-
|
|
42
|
+
/**
|
|
43
|
+
* Injected so the checks are testable without a database or a Redis.
|
|
44
|
+
*
|
|
45
|
+
* Takes the whole connection setting rather than a URL, because `databaseSchema` changes the answer:
|
|
46
|
+
* the schema probe counts applied migrations, and reading `public` when the deployment configured
|
|
47
|
+
* `retinue` reports "0 of 35 applied → run migrate" about a schema that is fully migrated. The
|
|
48
|
+
* comment below already names that class of bug — a diagnostic sending an operator to fix the wrong
|
|
49
|
+
* thing — and a URL-only signature is how this one would have got in.
|
|
50
|
+
*/
|
|
51
|
+
readonly connectPostgres?: (settings: {
|
|
52
|
+
readonly databaseUrl: string;
|
|
53
|
+
readonly databaseSchema?: string;
|
|
54
|
+
}) => Promise<{
|
|
44
55
|
query(text: string, params?: readonly unknown[]): Promise<unknown>;
|
|
45
56
|
end(): Promise<void>;
|
|
46
57
|
}>;
|
package/dist/server/doctor.js
CHANGED
|
@@ -179,7 +179,7 @@ export const runChecks = async (deps = {}) => {
|
|
|
179
179
|
*/
|
|
180
180
|
let reachable = false;
|
|
181
181
|
try {
|
|
182
|
-
sql = await withTimeout("postgres", connectPostgres(config
|
|
182
|
+
sql = await withTimeout("postgres", connectPostgres(config));
|
|
183
183
|
await withTimeout("postgres", sql.query("select 1"));
|
|
184
184
|
reachable = true;
|
|
185
185
|
results.push({ name: "postgres", ok: true, detail: `reachable at ${describeUrl(config.databaseUrl)}` });
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one place a `pg.Pool` is built for this host — REQ-041 (#190).
|
|
3
|
+
*
|
|
4
|
+
* There were four: `bin.ts` for `migrate`/`doctor`, `cli.ts` for the API host, `cli-worker.ts` for the
|
|
5
|
+
* worker, and the driver `doctor` is handed. Four copies of "how this deployment connects" is three
|
|
6
|
+
* copies too many, and the one that drifts is the one nobody exercises — the worker, whose writes are
|
|
7
|
+
* the ones that must land in the same schema as everything else.
|
|
8
|
+
*
|
|
9
|
+
* **What it adds over `new Pool`.** `RETINUE_DATABASE_SCHEMA` names the schema the platform's tables
|
|
10
|
+
* live in, and honouring it takes three things:
|
|
11
|
+
*
|
|
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.
|
|
32
|
+
*
|
|
33
|
+
* **`public` stays on the path** after the named schema. Two things need it: the `vector` type is
|
|
34
|
+
* pinned to `public` so it resolves from any schema (see the note in `migrations.ts`), and ShareFlow's
|
|
35
|
+
* adapters qualify all 70 of their queries as `public.`, which is what lets one pool serve a platform
|
|
36
|
+
* schema and a product schema at once.
|
|
37
|
+
*/
|
|
38
|
+
import type { Pool } from "pg";
|
|
39
|
+
import type { SqlExecutor } from "../adapters/postgres/sql.js";
|
|
40
|
+
import type { ConnectionOpener } from "../adapters/postgres/transaction.js";
|
|
41
|
+
export type PostgresConnection = {
|
|
42
|
+
readonly sql: SqlExecutor;
|
|
43
|
+
readonly open: ConnectionOpener;
|
|
44
|
+
readonly end: () => Promise<void>;
|
|
45
|
+
};
|
|
46
|
+
export type PoolSettings = {
|
|
47
|
+
readonly databaseUrl: string;
|
|
48
|
+
readonly databaseSchema?: string;
|
|
49
|
+
/** Left unset by the host and the worker, which is node-postgres's default of no timeout. */
|
|
50
|
+
readonly connectionTimeoutMillis?: number;
|
|
51
|
+
};
|
|
52
|
+
/**
|
|
53
|
+
* The `search_path` for a named schema, or `undefined` to leave the connection's own.
|
|
54
|
+
*
|
|
55
|
+
* Exported because it is the only string in this file that ends up in SQL, and a test that pins it is
|
|
56
|
+
* cheaper than reading two call sites to find out what a deployment actually gets.
|
|
57
|
+
*/
|
|
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>;
|
|
72
|
+
export declare const openPostgres: (settings: PoolSettings) => Promise<PostgresConnection>;
|
|
73
|
+
//# sourceMappingURL=pool.d.ts.map
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one place a `pg.Pool` is built for this host — REQ-041 (#190).
|
|
3
|
+
*
|
|
4
|
+
* There were four: `bin.ts` for `migrate`/`doctor`, `cli.ts` for the API host, `cli-worker.ts` for the
|
|
5
|
+
* worker, and the driver `doctor` is handed. Four copies of "how this deployment connects" is three
|
|
6
|
+
* copies too many, and the one that drifts is the one nobody exercises — the worker, whose writes are
|
|
7
|
+
* the ones that must land in the same schema as everything else.
|
|
8
|
+
*
|
|
9
|
+
* **What it adds over `new Pool`.** `RETINUE_DATABASE_SCHEMA` names the schema the platform's tables
|
|
10
|
+
* live in, and honouring it takes three things:
|
|
11
|
+
*
|
|
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.
|
|
32
|
+
*
|
|
33
|
+
* **`public` stays on the path** after the named schema. Two things need it: the `vector` type is
|
|
34
|
+
* pinned to `public` so it resolves from any schema (see the note in `migrations.ts`), and ShareFlow's
|
|
35
|
+
* adapters qualify all 70 of their queries as `public.`, which is what lets one pool serve a platform
|
|
36
|
+
* schema and a product schema at once.
|
|
37
|
+
*/
|
|
38
|
+
/**
|
|
39
|
+
* The `search_path` for a named schema, or `undefined` to leave the connection's own.
|
|
40
|
+
*
|
|
41
|
+
* Exported because it is the only string in this file that ends up in SQL, and a test that pins it is
|
|
42
|
+
* cheaper than reading two call sites to find out what a deployment actually gets.
|
|
43
|
+
*/
|
|
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
|
+
};
|
|
81
|
+
export const openPostgres = async (settings) => {
|
|
82
|
+
const { Pool } = await import("pg");
|
|
83
|
+
const { createPgExecutor, createPoolOpener } = await import("../entries/adapters-postgres.js");
|
|
84
|
+
const searchPath = searchPathFor(settings.databaseSchema);
|
|
85
|
+
const pool = new Pool({
|
|
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}` }),
|
|
90
|
+
...(settings.connectionTimeoutMillis === undefined
|
|
91
|
+
? {}
|
|
92
|
+
: { connectionTimeoutMillis: settings.connectionTimeoutMillis }),
|
|
93
|
+
});
|
|
94
|
+
if (searchPath !== undefined)
|
|
95
|
+
await assertSearchPath(pool, searchPath);
|
|
96
|
+
return {
|
|
97
|
+
sql: createPgExecutor(pool),
|
|
98
|
+
open: createPoolOpener(pool, searchPath),
|
|
99
|
+
end: () => pool.end(),
|
|
100
|
+
};
|
|
101
|
+
};
|
|
102
|
+
//# sourceMappingURL=pool.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@retinue/agentkit",
|
|
3
|
-
"version": "0.3.
|
|
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",
|