@agentic-kit/db-tools 0.8.2 → 0.9.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.
package/context.js CHANGED
@@ -155,14 +155,11 @@ async function resolveProjectContext(input, options = {}) {
155
155
  const dataEndpoint = databaseName
156
156
  ? deriveSubdomainEndpoint(apiEndpoint, `api-${databaseName}`)
157
157
  : '';
158
- const schemaId = await resolveSchemaId(apiClient, databaseId);
159
- if (!schemaId) {
160
- return {
161
- context: null,
162
- reason: 'Could not resolve a schema (app_public/public) for this database. The database may not be fully provisioned yet.',
163
- code: 'schema-unresolved',
164
- };
158
+ const schema = await resolveSchemaId(apiClient, databaseId);
159
+ if ('reason' in schema) {
160
+ return { context: null, reason: schema.reason, code: 'schema-unresolved' };
165
161
  }
162
+ const schemaId = schema.id;
166
163
  return {
167
164
  context: {
168
165
  api: apiClient,
@@ -262,21 +259,24 @@ async function resolveOrgName(ownerId) {
262
259
  return undefined;
263
260
  }
264
261
  }
262
+ // `name` is the logical schema name, identical on every backend; the physical
263
+ // (possibly hashed) name is `schemaName`.
265
264
  async function resolveSchemaId(apiClient, databaseId) {
266
265
  const result = await apiClient.schema
267
266
  .findMany({
268
267
  select: { id: true, name: true },
269
- where: { databaseId: { equalTo: databaseId } },
268
+ where: { databaseId: { equalTo: databaseId }, name: { equalTo: 'app_public' } },
270
269
  })
271
270
  .execute();
272
- if (!result.ok)
273
- return undefined;
274
- const nodes = (result.data.schemas?.nodes ?? []).filter((s) => Boolean(s && s.id));
275
- const appPublic = nodes.find((s) => s.name === 'app_public');
276
- if (appPublic)
277
- return appPublic.id;
278
- const pub = nodes.find((s) => s.name === 'public');
279
- if (pub)
280
- return pub.id;
281
- return nodes[0]?.id;
271
+ if (!result.ok) {
272
+ const detail = result.errors?.[0]?.message ?? 'unknown error';
273
+ return { reason: `Could not query the app_public schema for database ${databaseId}: ${detail}` };
274
+ }
275
+ const appPublic = (result.data.schemas?.nodes ?? []).find((s) => s?.name === 'app_public');
276
+ if (!appPublic?.id) {
277
+ return {
278
+ reason: `Database ${databaseId} has no app_public schema. The database may not be fully provisioned yet.`,
279
+ };
280
+ }
281
+ return { id: appPublic.id };
282
282
  }
package/esm/context.js CHANGED
@@ -143,14 +143,11 @@ export async function resolveProjectContext(input, options = {}) {
143
143
  const dataEndpoint = databaseName
144
144
  ? deriveSubdomainEndpoint(apiEndpoint, `api-${databaseName}`)
145
145
  : '';
146
- const schemaId = await resolveSchemaId(apiClient, databaseId);
147
- if (!schemaId) {
148
- return {
149
- context: null,
150
- reason: 'Could not resolve a schema (app_public/public) for this database. The database may not be fully provisioned yet.',
151
- code: 'schema-unresolved',
152
- };
146
+ const schema = await resolveSchemaId(apiClient, databaseId);
147
+ if ('reason' in schema) {
148
+ return { context: null, reason: schema.reason, code: 'schema-unresolved' };
153
149
  }
150
+ const schemaId = schema.id;
154
151
  return {
155
152
  context: {
156
153
  api: apiClient,
@@ -250,21 +247,24 @@ export async function resolveOrgName(ownerId) {
250
247
  return undefined;
251
248
  }
252
249
  }
250
+ // `name` is the logical schema name, identical on every backend; the physical
251
+ // (possibly hashed) name is `schemaName`.
253
252
  async function resolveSchemaId(apiClient, databaseId) {
254
253
  const result = await apiClient.schema
255
254
  .findMany({
256
255
  select: { id: true, name: true },
257
- where: { databaseId: { equalTo: databaseId } },
256
+ where: { databaseId: { equalTo: databaseId }, name: { equalTo: 'app_public' } },
258
257
  })
259
258
  .execute();
260
- if (!result.ok)
261
- return undefined;
262
- const nodes = (result.data.schemas?.nodes ?? []).filter((s) => Boolean(s && s.id));
263
- const appPublic = nodes.find((s) => s.name === 'app_public');
264
- if (appPublic)
265
- return appPublic.id;
266
- const pub = nodes.find((s) => s.name === 'public');
267
- if (pub)
268
- return pub.id;
269
- return nodes[0]?.id;
259
+ if (!result.ok) {
260
+ const detail = result.errors?.[0]?.message ?? 'unknown error';
261
+ return { reason: `Could not query the app_public schema for database ${databaseId}: ${detail}` };
262
+ }
263
+ const appPublic = (result.data.schemas?.nodes ?? []).find((s) => s?.name === 'app_public');
264
+ if (!appPublic?.id) {
265
+ return {
266
+ reason: `Database ${databaseId} has no app_public schema. The database may not be fully provisioned yet.`,
267
+ };
268
+ }
269
+ return { id: appPublic.id };
270
270
  }
@@ -10,7 +10,6 @@ export type ProvisionDatabaseDetails = {
10
10
  databaseId?: string;
11
11
  databaseName?: string;
12
12
  ownerId?: string;
13
- fixupNote?: string;
14
13
  /** True when an existing live binding was kept — nothing changed. */
15
14
  skipped?: boolean;
16
15
  };
@@ -8,7 +8,6 @@ import { getHost } from '../host';
8
8
  import { selectProvisionCredential } from '../provision-database/credential';
9
9
  import { archiveBindingKeys, ARCHIVED_BINDING_KEYS, mergeEnv, provisionEnvVars, } from '../provision-database/env-file';
10
10
  import { loadProvisionManifest } from '../provision-database/manifest';
11
- import { applySqlFixups } from '../provision-database/pg-fixups';
12
11
  import { selectProvisionRequest } from '../provision-database/preset-match';
13
12
  import { requestDatabaseProvision } from '../provision-database/request-database';
14
13
  import { resolveProvisionModules } from '../provision-database/resolve';
@@ -60,7 +59,7 @@ function parseEnvKeys(source) {
60
59
  export const provisionDatabaseTool = {
61
60
  name: 'provision_database',
62
61
  label: 'Provision database',
63
- description: 'Bootstrap a new Constructive database for the project under your account: provision the standard module set, enable membership defaults, and write credentials (DATABASE_ID, ACCESS_TOKEN, etc.) to the project .env. Run this ONCE before any schema/record tools. If the project is already bound to a live database under the signed-in account it skips; if the bound database no longer exists on this backend (refreshed/deleted) or belongs to a different account, pass reprovision: true to mint a fresh one (old keys archived in .env, old database kept; rebuild the schema afterwards).',
62
+ description: 'Bootstrap a new Constructive database for the project under your account: provision the standard module set and write credentials (DATABASE_ID, ACCESS_TOKEN, etc.) to the project .env. Run this ONCE before any schema/record tools. If the project is already bound to a live database under the signed-in account it skips; if the bound database no longer exists on this backend (refreshed/deleted) or belongs to a different account, pass reprovision: true to mint a fresh one (old keys archived in .env, old database kept; rebuild the schema afterwards).',
64
63
  promptSnippet: 'provision_database: one-time bootstrap of the project database (owner + modules + .env). Run before describe_schema/provision_blueprint. Skips when the existing binding is live under the signed-in account; reprovision: true replaces a dead or foreign-account binding (archives old keys, never deletes the old db). Gated.',
65
64
  parameters: ProvisionDatabaseZod,
66
65
  async execute(params, ctx) {
@@ -191,7 +190,6 @@ export const provisionDatabaseTool = {
191
190
  catch (err) {
192
191
  return fail(`Cannot resolve provision modules: ${err instanceof Error ? err.message : String(err)}`);
193
192
  }
194
- const physicalDb = process.env.CONSTRUCTIVE_DB || 'constructive';
195
193
  // Provision on the API endpoint: requestDatabase claims a warm-pool
196
194
  // database when the resolved module set matches a cataloged preset
197
195
  // (near-instant) and cold-provisions asynchronously otherwise; the ticket
@@ -219,9 +217,6 @@ export const provisionDatabaseTool = {
219
217
  : '';
220
218
  return fail(`Database provisioning failed: ${detail}.${nameTakenHint}`);
221
219
  }
222
- // Enable membership defaults + naming settings at the SQL level. Best-effort:
223
- // provisioning already succeeded, so a fixup failure is a warning, not an error.
224
- const fixup = await applySqlFixups({ databaseName, physicalDb });
225
220
  // Persist the binding to the project .env (upsert, preserving other keys).
226
221
  const merged = mergeEnv(existing, provisionEnvVars({
227
222
  databaseId,
@@ -244,7 +239,7 @@ export const provisionDatabaseTool = {
244
239
  const reprovisionNote = hasBinding && params.reprovision
245
240
  ? ' Previous binding archived in .env (old database kept); rebuild the schema with provision_blueprint + run_codegen.'
246
241
  : '';
247
- const message = `Provisioned database "${databaseName}" (ID: ${databaseId}). Credentials written to .env. ${fixup.note}${prewarmNote}${reprovisionNote}`;
242
+ const message = `Provisioned database "${databaseName}" (ID: ${databaseId}). Credentials written to .env.${prewarmNote}${reprovisionNote}`;
248
243
  return {
249
244
  content: [{ type: 'text', text: message }],
250
245
  details: {
@@ -253,7 +248,6 @@ export const provisionDatabaseTool = {
253
248
  databaseId,
254
249
  databaseName,
255
250
  ownerId,
256
- fixupNote: fixup.note,
257
251
  },
258
252
  };
259
253
  },
@@ -58,8 +58,8 @@ declare const UpdateTemplateZod: z.ZodObject<{
58
58
  categories: z.ZodOptional<z.ZodArray<z.ZodString>>;
59
59
  tags: z.ZodOptional<z.ZodArray<z.ZodString>>;
60
60
  visibility: z.ZodOptional<z.ZodEnum<{
61
- public: "public";
62
61
  private: "private";
62
+ public: "public";
63
63
  }>>;
64
64
  }, z.core.$strip>;
65
65
  }, z.core.$strip>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentic-kit/db-tools",
3
- "version": "0.8.2",
3
+ "version": "0.9.0",
4
4
  "author": "Constructive <developers@constructive.io>",
5
5
  "description": "harness-neutral Constructive database tools — provisioning, schema, policies, records, templates and codegen as HarnessTools any adapter can bind",
6
6
  "main": "index.js",
@@ -31,9 +31,9 @@
31
31
  "dependencies": {
32
32
  "12factor-env": "^1.33.0",
33
33
  "@agentic-kit/harness": "^0.16.0",
34
- "@constructive-io/graphql-query": "^4.20.2",
35
- "@constructive-io/sdk": "^1.19.2",
36
- "node-type-registry": "^1.19.0",
34
+ "@constructive-io/graphql-query": "^4.20.4",
35
+ "@constructive-io/sdk": "^1.19.4",
36
+ "node-type-registry": "^1.19.1",
37
37
  "zod": "^4.4.3"
38
38
  },
39
39
  "devDependencies": {
@@ -45,5 +45,5 @@
45
45
  "database",
46
46
  "constructive"
47
47
  ],
48
- "gitHead": "e05686fb94c5e22c954436f18c25aa4439b9e83e"
48
+ "gitHead": "ee458dba426522b0d06e315f6ef010938ed71e0e"
49
49
  }
@@ -10,7 +10,6 @@ export type ProvisionDatabaseDetails = {
10
10
  databaseId?: string;
11
11
  databaseName?: string;
12
12
  ownerId?: string;
13
- fixupNote?: string;
14
13
  /** True when an existing live binding was kept — nothing changed. */
15
14
  skipped?: boolean;
16
15
  };
@@ -14,7 +14,6 @@ const host_1 = require("../host");
14
14
  const credential_1 = require("../provision-database/credential");
15
15
  const env_file_1 = require("../provision-database/env-file");
16
16
  const manifest_1 = require("../provision-database/manifest");
17
- const pg_fixups_1 = require("../provision-database/pg-fixups");
18
17
  const preset_match_1 = require("../provision-database/preset-match");
19
18
  const request_database_1 = require("../provision-database/request-database");
20
19
  const resolve_1 = require("../provision-database/resolve");
@@ -66,7 +65,7 @@ function parseEnvKeys(source) {
66
65
  exports.provisionDatabaseTool = {
67
66
  name: 'provision_database',
68
67
  label: 'Provision database',
69
- description: 'Bootstrap a new Constructive database for the project under your account: provision the standard module set, enable membership defaults, and write credentials (DATABASE_ID, ACCESS_TOKEN, etc.) to the project .env. Run this ONCE before any schema/record tools. If the project is already bound to a live database under the signed-in account it skips; if the bound database no longer exists on this backend (refreshed/deleted) or belongs to a different account, pass reprovision: true to mint a fresh one (old keys archived in .env, old database kept; rebuild the schema afterwards).',
68
+ description: 'Bootstrap a new Constructive database for the project under your account: provision the standard module set and write credentials (DATABASE_ID, ACCESS_TOKEN, etc.) to the project .env. Run this ONCE before any schema/record tools. If the project is already bound to a live database under the signed-in account it skips; if the bound database no longer exists on this backend (refreshed/deleted) or belongs to a different account, pass reprovision: true to mint a fresh one (old keys archived in .env, old database kept; rebuild the schema afterwards).',
70
69
  promptSnippet: 'provision_database: one-time bootstrap of the project database (owner + modules + .env). Run before describe_schema/provision_blueprint. Skips when the existing binding is live under the signed-in account; reprovision: true replaces a dead or foreign-account binding (archives old keys, never deletes the old db). Gated.',
71
70
  parameters: ProvisionDatabaseZod,
72
71
  async execute(params, ctx) {
@@ -197,7 +196,6 @@ exports.provisionDatabaseTool = {
197
196
  catch (err) {
198
197
  return fail(`Cannot resolve provision modules: ${err instanceof Error ? err.message : String(err)}`);
199
198
  }
200
- const physicalDb = process.env.CONSTRUCTIVE_DB || 'constructive';
201
199
  // Provision on the API endpoint: requestDatabase claims a warm-pool
202
200
  // database when the resolved module set matches a cataloged preset
203
201
  // (near-instant) and cold-provisions asynchronously otherwise; the ticket
@@ -225,9 +223,6 @@ exports.provisionDatabaseTool = {
225
223
  : '';
226
224
  return fail(`Database provisioning failed: ${detail}.${nameTakenHint}`);
227
225
  }
228
- // Enable membership defaults + naming settings at the SQL level. Best-effort:
229
- // provisioning already succeeded, so a fixup failure is a warning, not an error.
230
- const fixup = await (0, pg_fixups_1.applySqlFixups)({ databaseName, physicalDb });
231
226
  // Persist the binding to the project .env (upsert, preserving other keys).
232
227
  const merged = (0, env_file_1.mergeEnv)(existing, (0, env_file_1.provisionEnvVars)({
233
228
  databaseId,
@@ -250,7 +245,7 @@ exports.provisionDatabaseTool = {
250
245
  const reprovisionNote = hasBinding && params.reprovision
251
246
  ? ' Previous binding archived in .env (old database kept); rebuild the schema with provision_blueprint + run_codegen.'
252
247
  : '';
253
- const message = `Provisioned database "${databaseName}" (ID: ${databaseId}). Credentials written to .env. ${fixup.note}${prewarmNote}${reprovisionNote}`;
248
+ const message = `Provisioned database "${databaseName}" (ID: ${databaseId}). Credentials written to .env.${prewarmNote}${reprovisionNote}`;
254
249
  return {
255
250
  content: [{ type: 'text', text: message }],
256
251
  details: {
@@ -259,7 +254,6 @@ exports.provisionDatabaseTool = {
259
254
  databaseId,
260
255
  databaseName,
261
256
  ownerId,
262
- fixupNote: fixup.note,
263
257
  },
264
258
  };
265
259
  },
@@ -58,8 +58,8 @@ declare const UpdateTemplateZod: z.ZodObject<{
58
58
  categories: z.ZodOptional<z.ZodArray<z.ZodString>>;
59
59
  tags: z.ZodOptional<z.ZodArray<z.ZodString>>;
60
60
  visibility: z.ZodOptional<z.ZodEnum<{
61
- public: "public";
62
61
  private: "private";
62
+ public: "public";
63
63
  }>>;
64
64
  }, z.core.$strip>;
65
65
  }, z.core.$strip>;
@@ -1,16 +0,0 @@
1
- export type FixupResult = {
2
- applied: boolean;
3
- note: string;
4
- };
5
- export type PgEnv = {
6
- host?: string;
7
- port?: string;
8
- user?: string;
9
- password?: string;
10
- };
11
- export declare function parsePgpmEnv(stdout: string): PgEnv;
12
- export declare function resolvePgEnv(): Promise<PgEnv | null>;
13
- export declare function applySqlFixups(args: {
14
- databaseName: string;
15
- physicalDb: string;
16
- }): Promise<FixupResult>;
@@ -1,132 +0,0 @@
1
- // Post-provision SQL fixups for a freshly provisioned database.
2
- //
3
- // Two things must happen at the Postgres level that the provisioning GraphQL API
4
- // does not do for us:
5
- //
6
- // 1. Naming settings — `constructive.simple_schema_names` and
7
- // `schema_use_underscores` make later `provision_blueprint` calls create an
8
- // `app_public` schema (which resolveSchemaId looks for) instead of a
9
- // hyphenated `<db>-<hex>-app-public` one. These ALTER DATABASE settings
10
- // PERSIST, so we set them once here, before any blueprint construction.
11
- //
12
- // 2. Membership defaults — new sign-ups otherwise land with
13
- // is_approved=false/is_verified=false, the AuthzEntityMembership policy then
14
- // denies every insert/select, and CRUD rows silently never persist
15
- // (BLUEPRINT-PENDING-001). We flip the defaults (future sign-ups) AND any
16
- // memberships already created during provisioning.
17
- //
18
- // All SQL targets the PHYSICAL control-plane database (`constructive`), NOT the
19
- // app's database name — that name is only a schema prefix (MEMBERSHIP-DB-001).
20
- //
21
- // Connection creds come from the ambient PG* env vars, falling back to sourcing
22
- // `pgpm env` (the same mechanism the old `eval "$(pgpm env)"` shell step used).
23
- // If neither yields a connection, provisioning still succeeded — we return a
24
- // note so the tool can warn the user that sign-in/CRUD may need a manual fixup.
25
- import { execFile } from 'node:child_process';
26
- import { promisify } from 'node:util';
27
- const execFileAsync = promisify(execFile);
28
- // Parse `pgpm env` output. It prints shell assignments (optionally `export`-
29
- // prefixed), one per line: `export PGHOST=localhost`. Quotes are stripped.
30
- export function parsePgpmEnv(stdout) {
31
- const env = {};
32
- for (const rawLine of stdout.split('\n')) {
33
- const line = rawLine.trim().replace(/^export\s+/, '');
34
- const eq = line.indexOf('=');
35
- if (eq === -1)
36
- continue;
37
- const key = line.slice(0, eq).trim();
38
- let value = line.slice(eq + 1).trim();
39
- if ((value.startsWith('"') && value.endsWith('"')) ||
40
- (value.startsWith("'") && value.endsWith("'"))) {
41
- value = value.slice(1, -1);
42
- }
43
- env[key] = value;
44
- }
45
- return {
46
- host: env.PGHOST,
47
- port: env.PGPORT,
48
- user: env.PGUSER,
49
- password: env.PGPASSWORD,
50
- };
51
- }
52
- export async function resolvePgEnv() {
53
- if (process.env.PGHOST) {
54
- return {
55
- host: process.env.PGHOST,
56
- port: process.env.PGPORT,
57
- user: process.env.PGUSER,
58
- password: process.env.PGPASSWORD,
59
- };
60
- }
61
- try {
62
- const { stdout } = await execFileAsync('pgpm', ['env'], { timeout: 15_000 });
63
- const parsed = parsePgpmEnv(stdout);
64
- return parsed.host ? parsed : null;
65
- }
66
- catch {
67
- return null;
68
- }
69
- }
70
- // Find the memberships schema for this app. Schema names use hyphens locally
71
- // (`<db>-<hex>-memberships-public`) but may use underscores when the naming
72
- // settings are active — match both, scoped to this app's name prefix.
73
- const MEMBERSHIPS_SCHEMA_QUERY = `
74
- SELECT schema_name FROM information_schema.schemata
75
- WHERE (schema_name LIKE '%memberships-public' OR schema_name LIKE '%memberships_public')
76
- AND (schema_name LIKE $1 OR schema_name LIKE $2)
77
- ORDER BY schema_name DESC LIMIT 1`;
78
- export async function applySqlFixups(args) {
79
- const pgEnv = await resolvePgEnv();
80
- if (!pgEnv) {
81
- return {
82
- applied: false,
83
- note: 'Could not reach Postgres (no PGHOST and `pgpm env` unavailable). Database provisioned, but membership defaults were not enabled — sign-in/CRUD may fail until the constructive DB is fixed up manually.',
84
- };
85
- }
86
- // pg is dynamically imported so the main bundle never loads it unless a
87
- // provision actually runs with Postgres access. The import lives inside the
88
- // try: fixups are best-effort, so even a broken pg module must degrade to a
89
- // note instead of failing the tool after the database is already provisioned.
90
- let pool;
91
- try {
92
- const { Pool } = await import('pg');
93
- pool = new Pool({
94
- host: pgEnv.host,
95
- port: pgEnv.port ? Number(pgEnv.port) : undefined,
96
- user: pgEnv.user,
97
- password: pgEnv.password,
98
- database: args.physicalDb,
99
- });
100
- const db = args.physicalDb;
101
- // Persistent naming settings — so future provision_blueprint calls land an
102
- // `app_public` schema resolveSchemaId can find.
103
- await pool.query(`ALTER DATABASE "${db}" SET constructive.simple_schema_names = 'true'`);
104
- await pool.query(`ALTER DATABASE "${db}" SET constructive.schema_use_underscores = 'true'`);
105
- const schemaResult = await pool.query(MEMBERSHIPS_SCHEMA_QUERY, [
106
- `${args.databaseName}-%`,
107
- `${args.databaseName}\\_%`,
108
- ]);
109
- if (schemaResult.rows.length === 0) {
110
- return {
111
- applied: true,
112
- note: `Naming settings applied, but no memberships schema was found for "${args.databaseName}" — sign-in may need a manual membership fixup.`,
113
- };
114
- }
115
- const membershipsSchema = schemaResult.rows[0].schema_name;
116
- await pool.query(`UPDATE "${membershipsSchema}".app_membership_defaults SET is_approved = TRUE, is_verified = TRUE`);
117
- await pool.query(`UPDATE "${membershipsSchema}".app_memberships
118
- SET is_approved = TRUE, is_verified = TRUE
119
- WHERE is_approved = FALSE OR is_verified = FALSE`);
120
- return { applied: true, note: `Membership defaults enabled (schema: ${membershipsSchema}).` };
121
- }
122
- catch (err) {
123
- const message = err instanceof Error ? err.message : String(err);
124
- return {
125
- applied: false,
126
- note: `Database provisioned, but SQL fixups failed (${message}). Sign-in/CRUD may fail until membership defaults are enabled manually.`,
127
- };
128
- }
129
- finally {
130
- await pool?.end().catch(() => { });
131
- }
132
- }
@@ -1,16 +0,0 @@
1
- export type FixupResult = {
2
- applied: boolean;
3
- note: string;
4
- };
5
- export type PgEnv = {
6
- host?: string;
7
- port?: string;
8
- user?: string;
9
- password?: string;
10
- };
11
- export declare function parsePgpmEnv(stdout: string): PgEnv;
12
- export declare function resolvePgEnv(): Promise<PgEnv | null>;
13
- export declare function applySqlFixups(args: {
14
- databaseName: string;
15
- physicalDb: string;
16
- }): Promise<FixupResult>;
@@ -1,137 +0,0 @@
1
- "use strict";
2
- // Post-provision SQL fixups for a freshly provisioned database.
3
- //
4
- // Two things must happen at the Postgres level that the provisioning GraphQL API
5
- // does not do for us:
6
- //
7
- // 1. Naming settings — `constructive.simple_schema_names` and
8
- // `schema_use_underscores` make later `provision_blueprint` calls create an
9
- // `app_public` schema (which resolveSchemaId looks for) instead of a
10
- // hyphenated `<db>-<hex>-app-public` one. These ALTER DATABASE settings
11
- // PERSIST, so we set them once here, before any blueprint construction.
12
- //
13
- // 2. Membership defaults — new sign-ups otherwise land with
14
- // is_approved=false/is_verified=false, the AuthzEntityMembership policy then
15
- // denies every insert/select, and CRUD rows silently never persist
16
- // (BLUEPRINT-PENDING-001). We flip the defaults (future sign-ups) AND any
17
- // memberships already created during provisioning.
18
- //
19
- // All SQL targets the PHYSICAL control-plane database (`constructive`), NOT the
20
- // app's database name — that name is only a schema prefix (MEMBERSHIP-DB-001).
21
- //
22
- // Connection creds come from the ambient PG* env vars, falling back to sourcing
23
- // `pgpm env` (the same mechanism the old `eval "$(pgpm env)"` shell step used).
24
- // If neither yields a connection, provisioning still succeeded — we return a
25
- // note so the tool can warn the user that sign-in/CRUD may need a manual fixup.
26
- Object.defineProperty(exports, "__esModule", { value: true });
27
- exports.parsePgpmEnv = parsePgpmEnv;
28
- exports.resolvePgEnv = resolvePgEnv;
29
- exports.applySqlFixups = applySqlFixups;
30
- const node_child_process_1 = require("node:child_process");
31
- const node_util_1 = require("node:util");
32
- const execFileAsync = (0, node_util_1.promisify)(node_child_process_1.execFile);
33
- // Parse `pgpm env` output. It prints shell assignments (optionally `export`-
34
- // prefixed), one per line: `export PGHOST=localhost`. Quotes are stripped.
35
- function parsePgpmEnv(stdout) {
36
- const env = {};
37
- for (const rawLine of stdout.split('\n')) {
38
- const line = rawLine.trim().replace(/^export\s+/, '');
39
- const eq = line.indexOf('=');
40
- if (eq === -1)
41
- continue;
42
- const key = line.slice(0, eq).trim();
43
- let value = line.slice(eq + 1).trim();
44
- if ((value.startsWith('"') && value.endsWith('"')) ||
45
- (value.startsWith("'") && value.endsWith("'"))) {
46
- value = value.slice(1, -1);
47
- }
48
- env[key] = value;
49
- }
50
- return {
51
- host: env.PGHOST,
52
- port: env.PGPORT,
53
- user: env.PGUSER,
54
- password: env.PGPASSWORD,
55
- };
56
- }
57
- async function resolvePgEnv() {
58
- if (process.env.PGHOST) {
59
- return {
60
- host: process.env.PGHOST,
61
- port: process.env.PGPORT,
62
- user: process.env.PGUSER,
63
- password: process.env.PGPASSWORD,
64
- };
65
- }
66
- try {
67
- const { stdout } = await execFileAsync('pgpm', ['env'], { timeout: 15_000 });
68
- const parsed = parsePgpmEnv(stdout);
69
- return parsed.host ? parsed : null;
70
- }
71
- catch {
72
- return null;
73
- }
74
- }
75
- // Find the memberships schema for this app. Schema names use hyphens locally
76
- // (`<db>-<hex>-memberships-public`) but may use underscores when the naming
77
- // settings are active — match both, scoped to this app's name prefix.
78
- const MEMBERSHIPS_SCHEMA_QUERY = `
79
- SELECT schema_name FROM information_schema.schemata
80
- WHERE (schema_name LIKE '%memberships-public' OR schema_name LIKE '%memberships_public')
81
- AND (schema_name LIKE $1 OR schema_name LIKE $2)
82
- ORDER BY schema_name DESC LIMIT 1`;
83
- async function applySqlFixups(args) {
84
- const pgEnv = await resolvePgEnv();
85
- if (!pgEnv) {
86
- return {
87
- applied: false,
88
- note: 'Could not reach Postgres (no PGHOST and `pgpm env` unavailable). Database provisioned, but membership defaults were not enabled — sign-in/CRUD may fail until the constructive DB is fixed up manually.',
89
- };
90
- }
91
- // pg is dynamically imported so the main bundle never loads it unless a
92
- // provision actually runs with Postgres access. The import lives inside the
93
- // try: fixups are best-effort, so even a broken pg module must degrade to a
94
- // note instead of failing the tool after the database is already provisioned.
95
- let pool;
96
- try {
97
- const { Pool } = await import('pg');
98
- pool = new Pool({
99
- host: pgEnv.host,
100
- port: pgEnv.port ? Number(pgEnv.port) : undefined,
101
- user: pgEnv.user,
102
- password: pgEnv.password,
103
- database: args.physicalDb,
104
- });
105
- const db = args.physicalDb;
106
- // Persistent naming settings — so future provision_blueprint calls land an
107
- // `app_public` schema resolveSchemaId can find.
108
- await pool.query(`ALTER DATABASE "${db}" SET constructive.simple_schema_names = 'true'`);
109
- await pool.query(`ALTER DATABASE "${db}" SET constructive.schema_use_underscores = 'true'`);
110
- const schemaResult = await pool.query(MEMBERSHIPS_SCHEMA_QUERY, [
111
- `${args.databaseName}-%`,
112
- `${args.databaseName}\\_%`,
113
- ]);
114
- if (schemaResult.rows.length === 0) {
115
- return {
116
- applied: true,
117
- note: `Naming settings applied, but no memberships schema was found for "${args.databaseName}" — sign-in may need a manual membership fixup.`,
118
- };
119
- }
120
- const membershipsSchema = schemaResult.rows[0].schema_name;
121
- await pool.query(`UPDATE "${membershipsSchema}".app_membership_defaults SET is_approved = TRUE, is_verified = TRUE`);
122
- await pool.query(`UPDATE "${membershipsSchema}".app_memberships
123
- SET is_approved = TRUE, is_verified = TRUE
124
- WHERE is_approved = FALSE OR is_verified = FALSE`);
125
- return { applied: true, note: `Membership defaults enabled (schema: ${membershipsSchema}).` };
126
- }
127
- catch (err) {
128
- const message = err instanceof Error ? err.message : String(err);
129
- return {
130
- applied: false,
131
- note: `Database provisioned, but SQL fixups failed (${message}). Sign-in/CRUD may fail until membership defaults are enabled manually.`,
132
- };
133
- }
134
- finally {
135
- await pool?.end().catch(() => { });
136
- }
137
- }