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
5 changes: 3 additions & 2 deletions docs/adr/0017-simplified-managed-stack-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,8 +72,9 @@ and previously persisted sticky automatic ports remain hard failures and are
never silently moved.

Every managed document records one concrete runtime selection. Native and
container runtimes never mix. Omission defaults to Docker; callers may
explicitly select Docker or Podman. There is no probing or auto-detection;
container runtimes never mix. An omitted runtime selects native; when a
container runtime is selected, an omitted engine defaults to Docker. Callers
may explicitly select Docker or Podman. There is no probing or auto-detection;
Podman is supported only on local Linux hosts. Persisted state records the
resolved exact engine. Capability releases and
workload artifacts are persisted as exact version pins (including their
Expand Down
4 changes: 3 additions & 1 deletion packages/stack/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,4 +41,6 @@ non-PostgreSQL capability lazy. `followLogs(...)` provides filterable live entri
stateless client-polled cursor.

Database reset is intentionally outside the current API. Applying migrations, declarative schemas,
and seeds remains the caller's responsibility.
and seeds remains the caller's responsibility. The runtime bootstrap only reconciles the `_realtime`
schema owner, closed database role passwords, and JWT settings in one transaction; the slim database
artifact owns its initialization and migrations.
5 changes: 2 additions & 3 deletions packages/stack/src/model/Compiler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -459,9 +459,8 @@ const releaseFor = <T>(

const enabledSettings = (
name: CapabilityName,
capabilities: Readonly<Record<string, unknown>>,
raw: unknown,
): { enabled: boolean; activation: "eager" | "lazy"; settings: unknown; raw: unknown } => {
const raw = capabilities[name];
if (name === "database")
return { enabled: true, activation: "eager", settings: extract(raw, "settings") ?? {}, raw };
if (raw === undefined || raw === null) {
Expand Down Expand Up @@ -505,7 +504,7 @@ const materializeCapability = <T>(
InvalidStackConfigError | StackVersionUnsupportedError,
Path.Path
> => {
const selected = enabledSettings(module.name, { [module.name]: raw });
const selected = enabledSettings(module.name, raw);
const mergedInput = merge(module.defaultSettings, selected.settings);
const materialized = module.materialize?.(mergedInput, projectRoot) ?? mergedInput;
const normalized = normalizeFunctions
Expand Down
236 changes: 61 additions & 175 deletions packages/stack/src/model/DatabaseBootstrap.ts
Original file line number Diff line number Diff line change
@@ -1,30 +1,19 @@
import { Data, Effect, Redacted, Schema } from "effect";
import { Data, Effect, Redacted } from "effect";

/** A small runtime-neutral SQL boundary used by the internal database bootstrap. */
export type DatabaseSqlValue = string | number | boolean | null;

interface DatabaseRow {
readonly [column: string]: unknown;
}

/** Login roles provisioned by the managed database template. */
type DatabaseBootstrapRole =
| "postgres"
| "authenticator"
| "pgbouncer"
| "supabase_auth_admin"
| "supabase_storage_admin"
| "supabase_replication_admin"
| "supabase_read_only_user";
const DATABASE_BOOTSTRAP_ROLES: ReadonlyArray<DatabaseBootstrapRole> = [
const DATABASE_BOOTSTRAP_ROLES = [
"postgres",
"authenticator",
"pgbouncer",
"supabase_auth_admin",
"supabase_storage_admin",
"supabase_replication_admin",
"supabase_read_only_user",
];
] as const;
type DatabaseBootstrapRole = (typeof DATABASE_BOOTSTRAP_ROLES)[number];

export type DatabaseBootstrapSetting =
| {
Expand All @@ -36,28 +25,17 @@ export type DatabaseBootstrapSetting =
readonly value: number;
};

/** Values come from resolved managed secret slots and never become SQL text. */
export interface DatabaseBootstrapCredentials {
readonly roles?: Readonly<Partial<Record<DatabaseBootstrapRole, Redacted.Redacted<string>>>>;
}

interface DatabaseBootstrapSettings {
export interface DatabaseBootstrapOptions {
/** One managed password shared by the closed login roles. */
readonly databasePassword: Redacted.Redacted<string>;
/** Managed JWT material applied to the database settings on each invocation. */
readonly jwtSecret: Redacted.Redacted<string>;
readonly jwtExpiry: number;
}

export interface DatabaseBootstrapOptions {
/** Ordered plan resolved for the pinned database release. */
readonly revisions: ReadonlyArray<DatabaseBootstrapRevision>;
readonly credentials?: DatabaseBootstrapCredentials;
/** Configuration values are reconciled on every invocation, like role passwords. */
readonly settings?: DatabaseBootstrapSettings;
}

export class DatabaseBootstrapError extends Data.TaggedError("DatabaseBootstrapError")<{
readonly message: string;
readonly statement?: string;
readonly revision?: string;
/** Whether retrying the database operation may succeed once the server settles. */
readonly retryable?: boolean;
readonly cause?: unknown;
Expand All @@ -80,10 +58,6 @@ export interface DatabaseTransaction {
readonly setDatabaseSetting: (
setting: DatabaseBootstrapSetting,
) => Effect.Effect<void, DatabaseBootstrapError>;
readonly query: (
statement: string,
parameters?: ReadonlyArray<DatabaseSqlValue>,
) => Effect.Effect<ReadonlyArray<DatabaseRow>, DatabaseBootstrapError>;
}

export interface DatabaseSession {
Expand All @@ -97,157 +71,69 @@ export interface DatabaseSession {
) => Effect.Effect<void, DatabaseBootstrapError>;
}

interface DatabaseBootstrapRevision {
readonly id: string;
readonly statement: string;
}

const TRACKING_SCHEMA = "supabase_internal";
const TRACKING_TABLE = `${TRACKING_SCHEMA}.bootstrap_revisions`;

const TRACKING_SCHEMA_STATEMENT = `CREATE SCHEMA IF NOT EXISTS ${TRACKING_SCHEMA};`;
const TRACKING_TABLE_STATEMENT = `CREATE TABLE IF NOT EXISTS ${TRACKING_TABLE} (
revision text PRIMARY KEY,
applied_at timestamptz NOT NULL DEFAULT now()
);`;
const APPLIED_REVISIONS_STATEMENT = `
SELECT revision FROM ${TRACKING_TABLE} ORDER BY revision;
`;
const RECORD_REVISION_STATEMENT = `
INSERT INTO ${TRACKING_TABLE} (revision) VALUES ($1)
ON CONFLICT (revision) DO NOTHING;
`;
const REALTIME_SCHEMA_STATEMENT =
"CREATE SCHEMA IF NOT EXISTS _realtime;\nALTER SCHEMA _realtime OWNER TO postgres;";
const ADVISORY_LOCK_STATEMENT = `SELECT pg_advisory_xact_lock(hashtext('supabase_internal.bootstrap'));`;

const RevisionRowSchema = Schema.Struct({ revision: Schema.String });

const statementError = (error: DatabaseBootstrapError, statement: string, revision?: string) =>
const statementError = (error: DatabaseBootstrapError, statement: string) =>
new DatabaseBootstrapError({
message: error.message,
statement,
...(revision === undefined ? {} : { revision }),
...(error.retryable === undefined ? {} : { retryable: error.retryable }),
...(error.cause === undefined ? {} : { cause: error.cause }),
});

/** Runs all unapplied internal revisions, recording each only after it succeeds. */
/** Reconciles the runtime-owned schema, roles, and settings in one transaction. */
export const runDatabaseBootstrap = (
session: DatabaseSession,
options: DatabaseBootstrapOptions,
): Effect.Effect<void, DatabaseBootstrapError> =>
Effect.gen(function* () {
const ids = new Set<string>();
for (const revision of options.revisions) {
if (revision.id.trim().length === 0 || ids.has(revision.id))
return yield* new DatabaseBootstrapError({
message: "Database bootstrap revision ids must be non-empty and unique",
revision: revision.id,
});
ids.add(revision.id);
}
yield* session.transaction((transaction) =>
Effect.gen(function* () {
yield* transaction
.execute(ADVISORY_LOCK_STATEMENT)
.pipe(Effect.mapError((error) => statementError(error, ADVISORY_LOCK_STATEMENT)));
yield* transaction
.execute(TRACKING_SCHEMA_STATEMENT)
.pipe(Effect.mapError((error) => statementError(error, TRACKING_SCHEMA_STATEMENT)));
yield* transaction
.execute(TRACKING_TABLE_STATEMENT)
.pipe(Effect.mapError((error) => statementError(error, TRACKING_TABLE_STATEMENT)));
}),
);
for (const revision of options.revisions) {
yield* session.transaction((transaction) =>
Effect.gen(function* () {
// Re-check under a transaction-scoped advisory lock. This prevents
// two owners from both applying a non-idempotent revision after
// observing the same pre-lock snapshot.
yield* transaction
.execute(ADVISORY_LOCK_STATEMENT)
.pipe(Effect.mapError((error) => statementError(error, ADVISORY_LOCK_STATEMENT)));
const rows = yield* transaction
.query(APPLIED_REVISIONS_STATEMENT)
.pipe(Effect.mapError((error) => statementError(error, APPLIED_REVISIONS_STATEMENT)));
const applied = new Set<string>();
for (const row of rows) {
const decoded = yield* Schema.decodeUnknownEffect(RevisionRowSchema)(row).pipe(
Effect.mapError(
(cause) =>
new DatabaseBootstrapError({
message: `Bootstrap tracking row is malformed: ${String(cause)}`,
statement: APPLIED_REVISIONS_STATEMENT,
}),
),
);
applied.add(decoded.revision);
}
if (applied.has(revision.id)) return;
yield* transaction
.execute(revision.statement)
.pipe(
Effect.mapError((error) => statementError(error, revision.statement, revision.id)),
);
yield* transaction
.execute(RECORD_REVISION_STATEMENT, [revision.id])
.pipe(
Effect.mapError((error) =>
statementError(error, RECORD_REVISION_STATEMENT, revision.id),
),
);
}),
);
}
if (options.credentials?.roles !== undefined || options.settings !== undefined) {
yield* session.transaction((transaction) =>
Effect.gen(function* () {
yield* transaction
.execute(ADVISORY_LOCK_STATEMENT)
.pipe(Effect.mapError((error) => statementError(error, ADVISORY_LOCK_STATEMENT)));
if (options.credentials?.roles !== undefined) {
for (const role of DATABASE_BOOTSTRAP_ROLES) {
const password = options.credentials.roles[role];
if (password === undefined) continue;
yield* transaction.setRolePassword(role, password).pipe(
Effect.mapError(
() =>
new DatabaseBootstrapError({
message: `Unable to configure internal database role ${role}`,
}),
),
);
}
}
if (options.settings !== undefined) {
yield* transaction
.setDatabaseSetting({
name: "app.settings.jwt_secret",
value: options.settings.jwtSecret,
})
.pipe(
Effect.mapError(
() =>
new DatabaseBootstrapError({
message: "Unable to configure database JWT secret",
}),
),
);
yield* transaction
.setDatabaseSetting({
name: "app.settings.jwt_exp",
value: options.settings.jwtExpiry,
})
.pipe(
Effect.mapError(
() =>
new DatabaseBootstrapError({
message: "Unable to configure database JWT expiry",
}),
),
);
}
}),
);
}
});
session.transaction((transaction) =>
Effect.gen(function* () {
yield* transaction
.execute(ADVISORY_LOCK_STATEMENT)
.pipe(Effect.mapError((error) => statementError(error, ADVISORY_LOCK_STATEMENT)));
yield* transaction
.execute(REALTIME_SCHEMA_STATEMENT)
.pipe(Effect.mapError((error) => statementError(error, REALTIME_SCHEMA_STATEMENT)));
for (const role of DATABASE_BOOTSTRAP_ROLES) {
yield* transaction.setRolePassword(role, options.databasePassword).pipe(
Effect.mapError(
(error) =>
new DatabaseBootstrapError({
message: `Unable to configure internal database role ${role}`,
...(error.retryable === undefined ? {} : { retryable: error.retryable }),
}),
),
);
}
yield* transaction
.setDatabaseSetting({
name: "app.settings.jwt_secret",
value: options.jwtSecret,
})
.pipe(
Effect.mapError(
(error) =>
new DatabaseBootstrapError({
message: "Unable to configure database JWT secret",
...(error.retryable === undefined ? {} : { retryable: error.retryable }),
}),
),
);
yield* transaction
.setDatabaseSetting({
name: "app.settings.jwt_exp",
value: options.jwtExpiry,
})
.pipe(
Effect.mapError(
(error) =>
new DatabaseBootstrapError({
message: "Unable to configure database JWT expiry",
...(error.retryable === undefined ? {} : { retryable: error.retryable }),
}),
),
);
}),
);
Loading
Loading