CI / verify (pull_request) Successful in 1m10s
CI / infrastructure-smoke (pull_request) Successful in 34s
CI / package-release (pull_request) Successful in 50s
CI / migration-smoke (pull_request) Successful in 2m26s
CI / image-build (pull_request) Successful in 1m12s
CI / deploy-smoke (same-origin) (pull_request) Successful in 42s
CI / package-publication (pull_request) Successful in 54s
CI / deploy-smoke (split) (pull_request) Successful in 3m17s
Stage 1 PR 6 (ADR 0062). /account shows who is signed in; /account/appearance chooses System, Light, or Dark and reduced motion (System, Reduce, Allow). - Core: ekklesia_core_user_preference, one typed, CHECK-constrained, nullable column per preference (null = never chosen), migration 0008_user_preferences (additive), @ekklesia/core/account store, and the authenticated-baseline permission core.account.self.manage. - API: GET /api/v1/account (name, email, preferences) and partial PATCH /api/v1/account/preferences, self-only by construction: no user id is accepted anywhere; trusted Origin and body limit on the mutation. - Shell: the account control shows name, email, and initials (never the unvetted image URL) and offers Account, Appearance, Sign out; native radio groups apply a choice at once through the ADR 0060 controller and save it; the server's answer wins and a failed save reverts. - First paint: a classic, same-origin, render-blocking appearance.js in <head> applies a two-word localStorage cache before paint (no inline script, CSP unchanged). The account replaces the cache on every load, sign-out clears it, and other tabs follow via the storage event. - Deploy smoke: real-Chromium checks for light/dark, OS emulation, overrides, reload persistence, pre-paint, Shadow DOM modules, reduced motion, the display staying light, and phone width; the browser container gets a 512 MiB /dev/shm so a long session no longer fails requests with ERR_INSUFFICIENT_RESOURCES. Co-Authored-By: Claude Opus 5.5 <[email protected]>
94 lines
4.6 KiB
TypeScript
94 lines
4.6 KiB
TypeScript
import type { ModuleDatabaseExecutor, ModuleId } from "@ekklesia/contracts";
|
|
import { type AccountDrizzleDatabase, createAccountDrizzleView } from "@ekklesia/core/account";
|
|
import {
|
|
type AuthorizationDrizzleDatabase,
|
|
createAuthorizationDrizzleView,
|
|
} from "@ekklesia/core/authorization";
|
|
import {
|
|
createDashboardDrizzleView,
|
|
type DashboardDrizzleDatabase,
|
|
} from "@ekklesia/core/dashboard";
|
|
import { createValidatedPostgresPool, type PostgresPoolOptions } from "@ekklesia/core/database";
|
|
import { createDeviceDrizzleView, type DeviceDrizzleDatabase } from "@ekklesia/core/devices";
|
|
import { createIdentityDrizzleView, type IdentityDrizzleDatabase } from "@ekklesia/core/identity";
|
|
import {
|
|
createModuleConfigurationView,
|
|
createModuleDatabaseExecutor,
|
|
createModuleDatabaseView,
|
|
createModuleLifecycleView,
|
|
type ModuleConfigurationDrizzleDatabase,
|
|
type ModuleDatabase,
|
|
type ModuleLifecycleDrizzleDatabase,
|
|
} from "@ekklesia/core/modules";
|
|
|
|
export interface ApiDatabaseHandle {
|
|
readonly account: AccountDrizzleDatabase;
|
|
readonly authorization: AuthorizationDrizzleDatabase;
|
|
readonly devices: DeviceDrizzleDatabase;
|
|
readonly dashboard: DashboardDrizzleDatabase;
|
|
readonly identity: IdentityDrizzleDatabase;
|
|
readonly module: ModuleDatabase;
|
|
/** Builds one ABI-v1 executor attributed to exactly one module, over this handle's shared pool. */
|
|
readonly moduleExecutor: (moduleId: ModuleId) => ModuleDatabaseExecutor;
|
|
readonly moduleConfiguration: ModuleConfigurationDrizzleDatabase;
|
|
readonly moduleLifecycle: ModuleLifecycleDrizzleDatabase;
|
|
/**
|
|
* One trivial round trip on the shared pool, for readiness (`./health-routes.js`). The `SELECT 1`
|
|
* runs in a read-only transaction carrying its own `SET LOCAL statement_timeout`, so PostgreSQL
|
|
* cancels a stalled statement itself rather than the caller merely abandoning it; the pool's
|
|
* session-wide `statement_timeout`/`query_timeout` remain the outer bound. The four statements
|
|
* travel as one simple-protocol message, so the check costs a single round trip. On any failure
|
|
* the pool discards that connection instead of returning it, so a transaction left open by a
|
|
* cancelled statement never reaches another caller. Rejects with whatever the driver raised; no
|
|
* caller may surface it.
|
|
*/
|
|
readonly ping: (statementTimeoutMs: number) => Promise<void>;
|
|
/** Closes the one shared pool exactly once, regardless of how many typed views were created
|
|
* over it. Safe to call from both a startup-failure cleanup path and normal shutdown. */
|
|
readonly close: () => Promise<void>;
|
|
}
|
|
|
|
function pingStatement(statementTimeoutMs: number): string {
|
|
// Interpolated rather than bound: neither SET nor a multi-statement simple query accepts
|
|
// parameters. Only a positive safe integer can ever reach the SQL text.
|
|
if (!Number.isSafeInteger(statementTimeoutMs) || statementTimeoutMs <= 0) {
|
|
throw new RangeError("The ping statement timeout must be a positive integer.");
|
|
}
|
|
return `BEGIN READ ONLY; SET LOCAL statement_timeout = ${statementTimeoutMs}; SELECT 1; COMMIT`;
|
|
}
|
|
|
|
/**
|
|
* Composes the one physical PostgreSQL pool this process uses for Core's identity and
|
|
* authorization schemas plus the module database facade: one validated, timeout-bounded
|
|
* `pg.Pool` (`createValidatedPostgresPool`, `@ekklesia/core/database`), wrapped by independent
|
|
* typed Drizzle views (`createIdentityDrizzleView`, `createAuthorizationDrizzleView`,
|
|
* `createDashboardDrizzleView`, `createModuleLifecycleView`, `createModuleConfigurationView`,
|
|
* `createModuleDatabaseView`). No domain
|
|
* owns or closes the pool in this composition — `close()`
|
|
* here is the only thing that ever calls `pool.end()`. `createIdentityDrizzleDatabase`/
|
|
* `createAuthorizationDrizzleDatabase` remain available, unaffected, and independently testable
|
|
* for callers that only need one domain.
|
|
*/
|
|
export function createApiDatabase(
|
|
databaseUrl: string,
|
|
options: PostgresPoolOptions = {},
|
|
): ApiDatabaseHandle {
|
|
const { close, pool } = createValidatedPostgresPool(databaseUrl, options);
|
|
|
|
return Object.freeze({
|
|
account: createAccountDrizzleView(pool),
|
|
authorization: createAuthorizationDrizzleView(pool),
|
|
close,
|
|
devices: createDeviceDrizzleView(pool),
|
|
dashboard: createDashboardDrizzleView(pool),
|
|
identity: createIdentityDrizzleView(pool),
|
|
module: createModuleDatabaseView(pool),
|
|
moduleExecutor: (moduleId: ModuleId) => createModuleDatabaseExecutor(pool, { moduleId }),
|
|
moduleConfiguration: createModuleConfigurationView(pool),
|
|
moduleLifecycle: createModuleLifecycleView(pool),
|
|
ping: async (statementTimeoutMs: number) => {
|
|
await pool.query(pingStatement(statementTimeoutMs));
|
|
},
|
|
});
|
|
}
|