Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions agentic/db-tools/__tests__/context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,59 @@ describe('resolveProjectContext sources', () => {
});
});

describe('resolveProjectContext schema resolution', () => {
const source = fromEnvironment({
[`${CONTEXT_ENV_PREFIX}ACCESS_TOKEN`]: 'project-key',
[`${CONTEXT_ENV_PREFIX}DATABASE_ID`]: 'db-1',
[`${CONTEXT_ENV_PREFIX}DATABASE_NAME`]: 'myapp',
});
const clientReturning = (result: unknown) => {
const findMany = jest.fn(() => ({ execute: async () => result }));
mockCreateClient.mockReturnValue({ schema: { findMany } });
return findMany;
};

it('queries the logical app_public schema, whatever its physical (hashed) name', async () => {
const findMany = clientReturning({
ok: true,
data: {
schemas: {
nodes: [{ id: 'schema-hashed', name: 'app_public', schemaName: 'myapp-1a2b3c4d-app-public' }],
},
},
});

const resolved = await resolveProjectContext(source);

expect(resolved.context?.schemaId).toBe('schema-hashed');
expect(findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { databaseId: { equalTo: 'db-1' }, name: { equalTo: 'app_public' } },
}),
);
});

it('fails hard when the database has no app_public schema, never picking another', async () => {
clientReturning({ ok: true, data: { schemas: { nodes: [] } } });

const resolved = await resolveProjectContext(source);

expect(resolved.context).toBeNull();
expect(resolved.code).toBe('schema-unresolved');
expect(resolved.reason).toMatch(/no app_public schema/);
});

it('fails hard with the GraphQL error when the schema query fails', async () => {
clientReturning({ ok: false, data: null, errors: [{ message: 'permission denied' }] });

const resolved = await resolveProjectContext(source);

expect(resolved.context).toBeNull();
expect(resolved.code).toBe('schema-unresolved');
expect(resolved.reason).toMatch(/permission denied/);
});
});

describe('fromEnvFile', () => {
it('parses the project .env, quotes and comments included', async () => {
writeFileSync(path.join(dir, '.env'), '# comment\nACCESS_TOKEN="quoted key"\nDATABASE_ID=db-1\n');
Expand Down
36 changes: 19 additions & 17 deletions agentic/db-tools/src/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,15 +223,11 @@ export async function resolveProjectContext(
? deriveSubdomainEndpoint(apiEndpoint, `api-${databaseName}`)
: '';

const schemaId = await resolveSchemaId(apiClient, databaseId);
if (!schemaId) {
return {
context: null,
reason:
'Could not resolve a schema (app_public/public) for this database. The database may not be fully provisioned yet.',
code: 'schema-unresolved',
};
const schema = await resolveSchemaId(apiClient, databaseId);
if ('reason' in schema) {
return { context: null, reason: schema.reason, code: 'schema-unresolved' };
}
const schemaId = schema.id;

return {
context: {
Expand Down Expand Up @@ -340,23 +336,29 @@ export async function resolveOrgName(ownerId: string): Promise<string | undefine
}
}

// `name` is the logical schema name, identical on every backend; the physical
// (possibly hashed) name is `schemaName`.
async function resolveSchemaId(
apiClient: ApiClient,
databaseId: string,
): Promise<string | undefined> {
): Promise<{ id: string } | { reason: string }> {
const result = await apiClient.schema
.findMany({
select: { id: true, name: true },
where: { databaseId: { equalTo: databaseId } },
where: { databaseId: { equalTo: databaseId }, name: { equalTo: 'app_public' } },
})
.execute();

if (!result.ok) return undefined;
if (!result.ok) {
const detail = result.errors?.[0]?.message ?? 'unknown error';
return { reason: `Could not query the app_public schema for database ${databaseId}: ${detail}` };
}

const nodes = (result.data.schemas?.nodes ?? []).filter((s) => Boolean(s && s.id));
const appPublic = nodes.find((s) => s.name === 'app_public');
if (appPublic) return appPublic.id;
const pub = nodes.find((s) => s.name === 'public');
if (pub) return pub.id;
return nodes[0]?.id;
const appPublic = (result.data.schemas?.nodes ?? []).find((s) => s?.name === 'app_public');
if (!appPublic?.id) {
return {
reason: `Database ${databaseId} has no app_public schema. The database may not be fully provisioned yet.`,
};
}
Comment on lines +348 to +362

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 bug · high

Strict app_public lookup loses its naming-settings guarantee

resolveSchemaId now returns schema-unresolved unless a logical schema named app_public exists (agentic/db-tools/src/context.ts:348), but this PR also deleted pg-fixups.ts, the only code that set constructive.simple_schema_names/schema_use_underscores — which, per that file's own comment, is what makes provision_blueprint create app_public instead of a hyphenated <db>-<hex>-app-public schema. No replacement exists in the provision request path (request-database.ts sends only a preset/module list). If the backend still applies the old naming default, provisioning succeeds but every tool gated on resolveProjectContext fails with schema-unresolved; previously the resolver fell back to public/any first schema and kept working. Legacy databases kept by the reprovision path (old database preserved) cannot resolve at all under the strict filter.

📋 Prompt for AI Agents

In agentic/db-tools, reconcile the strict app_public requirement in resolveSchemaId (agentic/db-tools/src/context.ts lines 341-364) with the removal of provision-database/pg-fixups.ts. The deleted fixup set constructive.simple_schema_names and constructive.schema_use_underscores so provision_blueprint created an app_public schema. Either move that guarantee into the provisioning request path (agentic/db-tools/src/provision-database/request-database.ts or a preset flag in presets.ts) or add a documented fallback in resolveSchemaId when app_public is absent (with a legacy-naming reason pointing to reprovision). Update agentic/db-tools/tests/context.test.ts to cover the chosen behavior.

return { id: appPublic.id };
}
158 changes: 0 additions & 158 deletions agentic/db-tools/src/provision-database/pg-fixups.ts

This file was deleted.

13 changes: 2 additions & 11 deletions agentic/db-tools/src/tools/provision-database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ import {
provisionEnvVars,
} from '../provision-database/env-file';
import { loadProvisionManifest } from '../provision-database/manifest';
import { applySqlFixups } from '../provision-database/pg-fixups';
import { selectProvisionRequest } from '../provision-database/preset-match';
import { requestDatabaseProvision } from '../provision-database/request-database';
import { type ProvisionOverlay, resolveProvisionModules } from '../provision-database/resolve';
Expand Down Expand Up @@ -62,7 +61,6 @@ export type ProvisionDatabaseDetails = {
databaseId?: string;
databaseName?: string;
ownerId?: string;
fixupNote?: string;
/** True when an existing live binding was kept — nothing changed. */
skipped?: boolean;
};
Expand Down Expand Up @@ -93,7 +91,7 @@ export const provisionDatabaseTool: HarnessTool<
name: 'provision_database',
label: 'Provision database',
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).',
'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).',
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.',
parameters: ProvisionDatabaseZod,
Expand Down Expand Up @@ -239,8 +237,6 @@ export const provisionDatabaseTool: HarnessTool<
);
}

const physicalDb = process.env.CONSTRUCTIVE_DB || 'constructive';

// Provision on the API endpoint: requestDatabase claims a warm-pool
// database when the resolved module set matches a cataloged preset
// (near-instant) and cold-provisions asynchronously otherwise; the ticket
Expand Down Expand Up @@ -268,10 +264,6 @@ export const provisionDatabaseTool: HarnessTool<
return fail(`Database provisioning failed: ${detail}.${nameTakenHint}`);
}

// Enable membership defaults + naming settings at the SQL level. Best-effort:
// provisioning already succeeded, so a fixup failure is a warning, not an error.
const fixup = await applySqlFixups({ databaseName, physicalDb });

// Persist the binding to the project .env (upsert, preserving other keys).

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 bug · high

Membership-defaults fixup deleted without replacement

Deleting pg-fixups.ts removes the fixup that flipped app_membership_defaults/app_memberships to is_approved=true/is_verified=true, and nothing in the provision flow replaces it — provision-database.ts now goes from requestDatabaseProvision straight to .env persistence (agentic/db-tools/src/tools/provision-database.ts:267). The deleted file's own header documented the consequence: new sign-ups land unapproved/unverified and the AuthzEntityMembership policy silently denies every insert/select, so CRUD rows never persist. The tool also dropped its fixupNote success-message feedback and its 'enable membership defaults' description wording, so users get no signal that the guarantee is gone.

📋 Prompt for AI Agents

Restore the guarantee that agentic/db-tools/src/provision-database/pg-fixups.ts previously provided: after provisioning, app_membership_defaults and app_memberships must have is_approved=TRUE and is_verified=TRUE. Implement it in the provisioning backend or in the request payload built by agentic/db-tools/src/provision-database/request-database.ts (used from agentic/db-tools/src/tools/provision-database.ts around lines 249-296), since the CLI no longer connects to Postgres directly. If the backend cannot be confirmed to apply it, surface a warning note in the tool's success message/details so users know sign-in/CRUD may need a manual fixup.

const merged = mergeEnv(
existing,
Expand Down Expand Up @@ -301,7 +293,7 @@ export const provisionDatabaseTool: HarnessTool<
hasBinding && params.reprovision
? ' Previous binding archived in .env (old database kept); rebuild the schema with provision_blueprint + run_codegen.'
: '';
const message = `Provisioned database "${databaseName}" (ID: ${databaseId}). Credentials written to .env. ${fixup.note}${prewarmNote}${reprovisionNote}`;
const message = `Provisioned database "${databaseName}" (ID: ${databaseId}). Credentials written to .env.${prewarmNote}${reprovisionNote}`;
return {
content: [{ type: 'text', text: message }],
details: {
Expand All @@ -310,7 +302,6 @@ export const provisionDatabaseTool: HarnessTool<
databaseId,
databaseName,
ownerId,
fixupNote: fixup.note,
},
};
},
Expand Down
Loading