feat: add module database ABI v1 adapters
CI / verify (pull_request) Successful in 58s
CI / infrastructure-smoke (pull_request) Successful in 35s
CI / migration-smoke (pull_request) Successful in 1m53s
CI / image-build (pull_request) Successful in 29s
CI / deploy-smoke (same-origin) (pull_request) Successful in 49s
CI / deploy-smoke (split) (pull_request) Successful in 1m31s
CI / verify (pull_request) Successful in 58s
CI / infrastructure-smoke (pull_request) Successful in 35s
CI / migration-smoke (pull_request) Successful in 1m53s
CI / image-build (pull_request) Successful in 29s
CI / deploy-smoke (same-origin) (pull_request) Successful in 49s
CI / deploy-smoke (split) (pull_request) Successful in 1m31s
This commit is contained in:
@@ -136,11 +136,14 @@ These rules map platform ownership to filesystem and package boundaries. The exe
|
|||||||
declared by the consumer.
|
declared by the consumer.
|
||||||
- Code a workspace package ships (its `src/` and `bin/`, excluding tests and declaration files)
|
- Code a workspace package ships (its `src/` and `bin/`, excluding tests and declaration files)
|
||||||
declares every runtime import in `dependencies` or `optionalDependencies`. A peer declaration may
|
declares every runtime import in `dependencies` or `optionalDependencies`. A peer declaration may
|
||||||
stay beside it, but never replaces it: a workspace package is linked into its consumers as a
|
stay beside it, but ordinarily never replaces it: a workspace package is linked into its consumers
|
||||||
directory, so its imports resolve from its own `node_modules`, where a consumer never satisfies a
|
as a directory, so its imports resolve from its own `node_modules`, where a consumer never
|
||||||
peer, and a production install (`pnpm install --prod`, as the production image runs) omits
|
satisfies a peer, and a production install (`pnpm install --prod`, as the production image runs)
|
||||||
development dependencies. Type-only imports are exempt; a `.tsx`/`.jsx` file counts as importing
|
omits development dependencies. The one narrow exception is
|
||||||
`react`, because the automatic JSX runtime imports `react/jsx-runtime` implicitly
|
`@ekklesia/module-database`'s `drizzle-orm` peer: modules choose that compatible runtime and the
|
||||||
|
Module Package v1 bundler incorporates it into their self-contained backend; Core never loads the
|
||||||
|
adapter package. Type-only imports are exempt; a `.tsx`/`.jsx` file counts as importing `react`,
|
||||||
|
because the automatic JSX runtime imports `react/jsx-runtime` implicitly
|
||||||
(`runtime-imports-are-production-dependencies`).
|
(`runtime-imports-are-production-dependencies`).
|
||||||
- Workspace aliases are forbidden because they obscure package ownership in the dependency graph.
|
- Workspace aliases are forbidden because they obscure package ownership in the dependency graph.
|
||||||
- Workspace package names are unique and shared packages declare a recognized architectural role.
|
- Workspace package names are unique and shared packages declare a recognized architectural role.
|
||||||
|
|||||||
@@ -145,6 +145,12 @@ copies interoperate only when the copies are interchangeable, so the current imp
|
|||||||
package records it under `transitional`, and an installation refuses a package whose Drizzle differs.
|
package records it under `transitional`, and an installation refuses a package whose Drizzle differs.
|
||||||
This is a constraint of today's implementation, not a permanent contract.
|
This is a constraint of today's implementation, not a permanent contract.
|
||||||
|
|
||||||
|
`@ekklesia/module-database` now contains the module-side ABI v1 adapters that remove that coupling,
|
||||||
|
but deployed modules cannot receive an ABI v1 executor yet. Manifest v8 and per-module runtime
|
||||||
|
service delivery are separate later work. Until those land, the current transitional package rule
|
||||||
|
and database service above remain authoritative; neither Module Package v1 nor the template changed
|
||||||
|
with the adapter package.
|
||||||
|
|
||||||
#### Verifying a package
|
#### Verifying a package
|
||||||
|
|
||||||
`node core/dist/cli/verify-module-package.js <archive> --sha256-file <archive>.sha256`, run from an
|
`node core/dist/cli/verify-module-package.js <archive> --sha256-file <archive>.sha256`, run from an
|
||||||
@@ -1485,6 +1491,75 @@ destructive behavior. Review those properties explicitly. Core is the only migra
|
|||||||
follows Drizzle's exact `--> statement-breakpoint` delimiter and never naively splits semicolons or
|
follows Drizzle's exact `--> statement-breakpoint` delimiter and never naively splits semicolons or
|
||||||
falls back to non-transactional execution.
|
falls back to non-transactional execution.
|
||||||
|
|
||||||
|
## Module Database ABI v1 adapters (not yet delivered at runtime)
|
||||||
|
|
||||||
|
The stable database boundary is Module Database ABI v1: SQL text, ordered bound values, an explicit
|
||||||
|
row mode, low-level PostgreSQL result values, and callback-scoped transactions. ORM objects never
|
||||||
|
cross it. `@ekklesia/module-database` is module-side code for consuming that boundary once a later
|
||||||
|
manifest/runtime slice delivers a `ModuleDatabaseExecutor`; it is **not usable by deployed modules
|
||||||
|
yet**.
|
||||||
|
|
||||||
|
A Drizzle module imports the optional adapter subpath and declares its own exact compatible
|
||||||
|
`drizzle-orm` dependency. The adapter's peer range (`>=0.45.2 <0.46.0`) describes adapter API
|
||||||
|
compatibility; it is not a Core or platform compatibility range:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { moduleDrizzle } from "@ekklesia/module-database/drizzle";
|
||||||
|
import * as schema from "./schema/tables.js";
|
||||||
|
|
||||||
|
// `executor` will be supplied by later ABI service delivery work.
|
||||||
|
const db = moduleDrizzle(executor, { schema });
|
||||||
|
const events = await db.select().from(schema.events);
|
||||||
|
```
|
||||||
|
|
||||||
|
Drizzle compiles SQL and parameters before calling the ABI. The adapter always requests positional
|
||||||
|
(`rowMode: "array"`) results, gives those rows back to Drizzle in column order, and Drizzle applies
|
||||||
|
the module's own column decoders for bigint, numeric, dates/timestamps, JSON, byte arrays, nulls, and
|
||||||
|
supported arrays. This also makes joins with duplicate PostgreSQL column names unambiguous. Core
|
||||||
|
never imports this adapter and has no dependency on the module's Drizzle version. The adapter surface
|
||||||
|
is deliberately limited to `select`, `insert`, `update`, `delete`, and `transaction`; returning,
|
||||||
|
joins, and SQL expressions come from those normal Drizzle builders.
|
||||||
|
|
||||||
|
`db.transaction(callback)` maps to the ABI transaction callback. The callback receives another
|
||||||
|
Drizzle-facing database backed by the scoped ABI executor; a nested `.transaction()` maps to the
|
||||||
|
ABI's `savepoint` callback. Callback values and errors propagate, Core remains authoritative for
|
||||||
|
commit/rollback and escaped-scope behavior, and the adapter adds no retries or transaction-control
|
||||||
|
SQL.
|
||||||
|
|
||||||
|
Drizzle wraps execution failures in `DrizzleQueryError`. Recover the stable sanitized ABI error by
|
||||||
|
shape instead of using a PostgreSQL error instance, parsing a message, or reading `.cause.code`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { findEkklesiaDatabaseError } from "@ekklesia/module-database";
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db.insert(schema.events).values(event);
|
||||||
|
} catch (error) {
|
||||||
|
const databaseError = findEkklesiaDatabaseError(error);
|
||||||
|
if (databaseError?.sqlState === "23505") {
|
||||||
|
// Handle a uniqueness conflict.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Modules that do not want Drizzle use the package's ORM-neutral tag. Every interpolation is one bound
|
||||||
|
value; v1 deliberately provides no dynamic-identifier or trusted-fragment escape hatch:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { sql } from "@ekklesia/module-database";
|
||||||
|
|
||||||
|
const statement = sql`
|
||||||
|
SELECT id, title
|
||||||
|
FROM calendar_events
|
||||||
|
WHERE id = ${eventId}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await executor.query({ ...statement, rowMode: "object" });
|
||||||
|
```
|
||||||
|
|
||||||
|
The tag does not stringify values. Unsupported values pass through unchanged and the ABI executor
|
||||||
|
rejects them; strings containing quotes or SQL syntax always remain parameters.
|
||||||
|
|
||||||
## Database access
|
## Database access
|
||||||
|
|
||||||
`context.services.database` — available in `start`, `stop`, and every authorized route handler,
|
`context.services.database` — available in `start`, `stop`, and every authorized route handler,
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
{
|
||||||
|
"name": "@ekklesia/module-database",
|
||||||
|
"version": "0.1.2",
|
||||||
|
"license": "AGPL-3.0-or-later",
|
||||||
|
"type": "module",
|
||||||
|
"files": [
|
||||||
|
"dist"
|
||||||
|
],
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"import": "./dist/index.js"
|
||||||
|
},
|
||||||
|
"./drizzle": {
|
||||||
|
"types": "./dist/drizzle.d.ts",
|
||||||
|
"import": "./dist/drizzle.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc --project tsconfig.build.json",
|
||||||
|
"lint": "biome lint .",
|
||||||
|
"prepack": "pnpm --workspace-root exec turbo run build --filter=@ekklesia/module-database",
|
||||||
|
"test": "vitest run",
|
||||||
|
"typecheck": "tsc --noEmit --project tsconfig.json"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@ekklesia/contracts": "workspace:*"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@biomejs/biome": "2.5.3",
|
||||||
|
"@types/node": "24.13.3",
|
||||||
|
"drizzle-orm": "0.45.2",
|
||||||
|
"typescript": "7.0.2",
|
||||||
|
"vitest": "4.1.10"
|
||||||
|
},
|
||||||
|
"ekklesia": {
|
||||||
|
"layer": "module-sdk"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"drizzle-orm": ">=0.45.2 <0.46.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"drizzle-orm": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import type {
|
||||||
|
ModuleDatabaseExecutor,
|
||||||
|
ModuleDatabaseParameter,
|
||||||
|
ModuleDatabaseQueryable,
|
||||||
|
ModuleDatabaseTransaction,
|
||||||
|
} from "@ekklesia/contracts";
|
||||||
|
import { drizzle, type PgRemoteDatabase } from "drizzle-orm/pg-proxy";
|
||||||
|
import type { DrizzleConfig } from "drizzle-orm/utils";
|
||||||
|
|
||||||
|
type MaybePromise<T> = Promise<T> | T;
|
||||||
|
type DrizzleSurface<TSchema extends Record<string, unknown>> = Pick<
|
||||||
|
PgRemoteDatabase<TSchema>,
|
||||||
|
"delete" | "insert" | "select" | "update"
|
||||||
|
>;
|
||||||
|
|
||||||
|
export type ModuleDrizzleTransaction<
|
||||||
|
TSchema extends Record<string, unknown> = Record<string, never>,
|
||||||
|
> = DrizzleSurface<TSchema> & {
|
||||||
|
transaction<T>(
|
||||||
|
callback: (transaction: ModuleDrizzleTransaction<TSchema>) => MaybePromise<T>,
|
||||||
|
): Promise<T>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ModuleDrizzleDatabase<TSchema extends Record<string, unknown> = Record<string, never>> =
|
||||||
|
DrizzleSurface<TSchema> & {
|
||||||
|
transaction<T>(
|
||||||
|
callback: (transaction: ModuleDrizzleTransaction<TSchema>) => MaybePromise<T>,
|
||||||
|
): Promise<T>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TransactionRunner<TSchema extends Record<string, unknown>> = <T>(
|
||||||
|
callback: (transaction: ModuleDrizzleTransaction<TSchema>) => MaybePromise<T>,
|
||||||
|
) => Promise<T>;
|
||||||
|
|
||||||
|
function remoteDatabase<TSchema extends Record<string, unknown>>(
|
||||||
|
executor: ModuleDatabaseQueryable,
|
||||||
|
config: DrizzleConfig<TSchema>,
|
||||||
|
runTransaction: TransactionRunner<TSchema>,
|
||||||
|
): ModuleDrizzleDatabase<TSchema> {
|
||||||
|
const database = drizzle<TSchema>(async (text, parameters) => {
|
||||||
|
const result = await executor.query({
|
||||||
|
text,
|
||||||
|
// Drizzle performs column-specific parameter encoding before this callback. Values outside
|
||||||
|
// ABI v1 remain unchanged so the ABI executor, not the adapter, rejects them.
|
||||||
|
values: parameters as readonly ModuleDatabaseParameter[],
|
||||||
|
// Drizzle maps selected fields by position. Array rows also remain correct when a join returns
|
||||||
|
// duplicate PostgreSQL column names.
|
||||||
|
rowMode: "array",
|
||||||
|
});
|
||||||
|
return { rows: result.rows as unknown as unknown[][] };
|
||||||
|
}, config);
|
||||||
|
|
||||||
|
return Object.freeze({
|
||||||
|
delete: database.delete.bind(database),
|
||||||
|
insert: database.insert.bind(database),
|
||||||
|
select: database.select.bind(database),
|
||||||
|
transaction: runTransaction,
|
||||||
|
update: database.update.bind(database),
|
||||||
|
}) as ModuleDrizzleDatabase<TSchema>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function transactionDatabase<TSchema extends Record<string, unknown>>(
|
||||||
|
transaction: ModuleDatabaseTransaction,
|
||||||
|
config: DrizzleConfig<TSchema>,
|
||||||
|
): ModuleDrizzleTransaction<TSchema> {
|
||||||
|
return remoteDatabase(transaction, config, (callback) =>
|
||||||
|
transaction.savepoint((savepoint) => callback(transactionDatabase(savepoint, config))),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a module-owned Drizzle database backed exclusively by Module Database ABI v1 calls.
|
||||||
|
*
|
||||||
|
* Drizzle compiles and decodes on the module side. Core receives only SQL text, ordered parameter
|
||||||
|
* values, and `rowMode: "array"`. Top-level transactions use the ABI transaction callback; nested
|
||||||
|
* Drizzle transactions use ABI savepoints.
|
||||||
|
*/
|
||||||
|
export function moduleDrizzle<TSchema extends Record<string, unknown> = Record<string, never>>(
|
||||||
|
executor: ModuleDatabaseExecutor,
|
||||||
|
config: DrizzleConfig<TSchema> = {},
|
||||||
|
): ModuleDrizzleDatabase<TSchema> {
|
||||||
|
return remoteDatabase(executor, config, (callback) =>
|
||||||
|
executor.transaction((transaction) => callback(transactionDatabase(transaction, config))),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import {
|
||||||
|
isModuleDatabaseError,
|
||||||
|
type ModuleDatabaseError,
|
||||||
|
type ModuleDatabaseParameter,
|
||||||
|
type ModuleDatabaseQuery,
|
||||||
|
} from "@ekklesia/contracts";
|
||||||
|
|
||||||
|
/** SQL text and values ready to pass directly to a Module Database ABI v1 executor. */
|
||||||
|
export type ModuleSqlQuery = Readonly<Required<Pick<ModuleDatabaseQuery, "text" | "values">>>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds one PostgreSQL statement without ever interpolating a value into its text.
|
||||||
|
*
|
||||||
|
* Every interpolation becomes the next positional parameter. Dynamic identifiers and trusted SQL
|
||||||
|
* fragments are deliberately unsupported in v1: keep identifiers in the static template text.
|
||||||
|
*/
|
||||||
|
export function sql(
|
||||||
|
strings: TemplateStringsArray,
|
||||||
|
...values: readonly ModuleDatabaseParameter[]
|
||||||
|
): ModuleSqlQuery {
|
||||||
|
let text = strings[0] ?? "";
|
||||||
|
for (let index = 0; index < values.length; index += 1) {
|
||||||
|
text += `$${index + 1}${strings[index + 1] ?? ""}`;
|
||||||
|
}
|
||||||
|
return Object.freeze({ text, values: Object.freeze([...values]) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EkklesiaDatabaseError = Pick<
|
||||||
|
ModuleDatabaseError,
|
||||||
|
"code" | "message" | "name" | "sqlState"
|
||||||
|
>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds a stable Ekklesia database error through wrappers such as DrizzleQueryError.
|
||||||
|
*
|
||||||
|
* Callers inspect the returned `code` and `sqlState`; they never need a PostgreSQL driver error,
|
||||||
|
* `.cause.code`, or message parsing.
|
||||||
|
*/
|
||||||
|
export function findEkklesiaDatabaseError(value: unknown): EkklesiaDatabaseError | undefined {
|
||||||
|
const visited = new Set<unknown>();
|
||||||
|
let current = value;
|
||||||
|
while (current instanceof Error && !visited.has(current)) {
|
||||||
|
if (isModuleDatabaseError(current)) {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
visited.add(current);
|
||||||
|
current = current.cause;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||||
|
const workspaceRoot = path.resolve(packageRoot, "..", "..");
|
||||||
|
|
||||||
|
async function text(relative: string): Promise<string> {
|
||||||
|
return readFile(path.resolve(workspaceRoot, relative), "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("the module-side database architecture", () => {
|
||||||
|
it("keeps the Drizzle adapter free of Core, pg, and Core-owned Drizzle types", async () => {
|
||||||
|
const source = await text("packages/module-database/src/drizzle.ts");
|
||||||
|
expect(source).not.toMatch(/@ekklesia\/core|from ["']pg["']|node-postgres/u);
|
||||||
|
expect(source).not.toMatch(/NodePgDatabase|NodePgTransaction/u);
|
||||||
|
expect(source).toContain("@ekklesia/contracts");
|
||||||
|
expect(source).toContain("drizzle-orm/pg-proxy");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps ABI v1 contracts free of ORM imports", async () => {
|
||||||
|
const source = await text("packages/contracts/src/module-database-abi.ts");
|
||||||
|
expect(source).not.toMatch(/(?:import|export).*drizzle-orm/u);
|
||||||
|
expect(source).not.toMatch(/(?:import|export).*kysely/u);
|
||||||
|
expect(source).not.toMatch(/(?:import|export).*\bpg\b/u);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("makes Drizzle an optional ranged peer and keeps raw SQL independent from it", async () => {
|
||||||
|
const manifest = JSON.parse(await text("packages/module-database/package.json")) as Record<
|
||||||
|
string,
|
||||||
|
Record<string, string>
|
||||||
|
>;
|
||||||
|
expect(manifest.dependencies).not.toHaveProperty("drizzle-orm");
|
||||||
|
expect(manifest.peerDependencies?.["drizzle-orm"]).toBe(">=0.45.2 <0.46.0");
|
||||||
|
expect(manifest.peerDependenciesMeta?.["drizzle-orm"]).toEqual({ optional: true });
|
||||||
|
|
||||||
|
const rawSource = await text("packages/module-database/src/index.ts");
|
||||||
|
expect(rawSource).not.toContain("drizzle-orm");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not introduce an adapter dependency into Core", async () => {
|
||||||
|
const manifest = await text("core/package.json");
|
||||||
|
expect(manifest).not.toContain("@ekklesia/module-database");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,415 @@
|
|||||||
|
import {
|
||||||
|
type ModuleDatabaseArrayRow,
|
||||||
|
ModuleDatabaseError,
|
||||||
|
type ModuleDatabaseExecutor,
|
||||||
|
type ModuleDatabaseField,
|
||||||
|
type ModuleDatabaseObjectRow,
|
||||||
|
type ModuleDatabaseQuery,
|
||||||
|
type ModuleDatabaseQueryResult,
|
||||||
|
type ModuleDatabaseTransaction,
|
||||||
|
} from "@ekklesia/contracts";
|
||||||
|
import { and, eq, sql as drizzleSql } from "drizzle-orm";
|
||||||
|
import {
|
||||||
|
bigint,
|
||||||
|
customType,
|
||||||
|
date,
|
||||||
|
integer,
|
||||||
|
json,
|
||||||
|
jsonb,
|
||||||
|
numeric,
|
||||||
|
pgTable,
|
||||||
|
text,
|
||||||
|
timestamp,
|
||||||
|
} from "drizzle-orm/pg-core";
|
||||||
|
import { describe, expect, expectTypeOf, it } from "vitest";
|
||||||
|
import { moduleDrizzle, type ModuleDrizzleDatabase } from "../src/drizzle.js";
|
||||||
|
import { findEkklesiaDatabaseError } from "../src/index.js";
|
||||||
|
|
||||||
|
const bytea = customType<{ data: Uint8Array; driverData: Uint8Array }>({
|
||||||
|
dataType: () => "bytea",
|
||||||
|
});
|
||||||
|
|
||||||
|
const records = pgTable("calendar_records", {
|
||||||
|
amount: numeric("amount"),
|
||||||
|
bigId: bigint("big_id", { mode: "bigint" }).notNull(),
|
||||||
|
bytes: bytea("bytes"),
|
||||||
|
count: integer("count").notNull(),
|
||||||
|
createdAt: timestamp("created_at", { mode: "date", withTimezone: true }).notNull(),
|
||||||
|
day: date("day", { mode: "date" }).notNull(),
|
||||||
|
id: integer("id").primaryKey(),
|
||||||
|
localAt: timestamp("local_at", { mode: "date" }).notNull(),
|
||||||
|
metadata: jsonb("metadata"),
|
||||||
|
nullable: text("nullable"),
|
||||||
|
payload: json("payload"),
|
||||||
|
scores: bigint("scores", { mode: "bigint" }).array(),
|
||||||
|
tags: text("tags").array(),
|
||||||
|
title: text("title").notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const categories = pgTable("calendar_categories", {
|
||||||
|
id: integer("id").primaryKey(),
|
||||||
|
title: text("title").notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
type Outcome = Error | ModuleDatabaseQueryResult<ModuleDatabaseArrayRow | ModuleDatabaseObjectRow>;
|
||||||
|
|
||||||
|
interface RecordedCall {
|
||||||
|
readonly query: ModuleDatabaseQuery;
|
||||||
|
readonly scope: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const noFields: readonly ModuleDatabaseField[] = [];
|
||||||
|
|
||||||
|
function result(
|
||||||
|
rows: readonly ModuleDatabaseArrayRow[],
|
||||||
|
command = "SELECT",
|
||||||
|
): ModuleDatabaseQueryResult<ModuleDatabaseArrayRow> {
|
||||||
|
return { command, fields: noFields, rowCount: rows.length, rows };
|
||||||
|
}
|
||||||
|
|
||||||
|
class FakeState {
|
||||||
|
readonly calls: RecordedCall[] = [];
|
||||||
|
readonly events: string[] = [];
|
||||||
|
readonly outcomes: Outcome[] = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
class FakeTransaction implements ModuleDatabaseTransaction {
|
||||||
|
readonly abiVersion = 1;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly state: FakeState,
|
||||||
|
private readonly scope: string,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
query(
|
||||||
|
query: ModuleDatabaseQuery & { readonly rowMode: "array" },
|
||||||
|
): Promise<ModuleDatabaseQueryResult<ModuleDatabaseArrayRow>>;
|
||||||
|
query(
|
||||||
|
query: ModuleDatabaseQuery & { readonly rowMode?: "object" },
|
||||||
|
): Promise<ModuleDatabaseQueryResult<ModuleDatabaseObjectRow>>;
|
||||||
|
query(
|
||||||
|
query: ModuleDatabaseQuery,
|
||||||
|
): Promise<ModuleDatabaseQueryResult<ModuleDatabaseObjectRow | ModuleDatabaseArrayRow>>;
|
||||||
|
async query(
|
||||||
|
query: ModuleDatabaseQuery,
|
||||||
|
): Promise<ModuleDatabaseQueryResult<ModuleDatabaseObjectRow | ModuleDatabaseArrayRow>> {
|
||||||
|
this.state.calls.push({ query, scope: this.scope });
|
||||||
|
this.state.events.push(`${this.scope}:query`);
|
||||||
|
const outcome = this.state.outcomes.shift() ?? result([]);
|
||||||
|
if (outcome instanceof Error) {
|
||||||
|
throw outcome;
|
||||||
|
}
|
||||||
|
return outcome;
|
||||||
|
}
|
||||||
|
|
||||||
|
async savepoint<T>(
|
||||||
|
callback: (savepoint: ModuleDatabaseTransaction) => Promise<T> | T,
|
||||||
|
): Promise<T> {
|
||||||
|
const scope = `${this.scope}.savepoint`;
|
||||||
|
this.state.events.push(`${scope}:begin`);
|
||||||
|
try {
|
||||||
|
const value = await callback(new FakeTransaction(this.state, scope));
|
||||||
|
this.state.events.push(`${scope}:release`);
|
||||||
|
return value;
|
||||||
|
} catch (error) {
|
||||||
|
this.state.events.push(`${scope}:rollback`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class FakeExecutor implements ModuleDatabaseExecutor {
|
||||||
|
readonly abiVersion = 1;
|
||||||
|
readonly state = new FakeState();
|
||||||
|
|
||||||
|
query(
|
||||||
|
query: ModuleDatabaseQuery & { readonly rowMode: "array" },
|
||||||
|
): Promise<ModuleDatabaseQueryResult<ModuleDatabaseArrayRow>>;
|
||||||
|
query(
|
||||||
|
query: ModuleDatabaseQuery & { readonly rowMode?: "object" },
|
||||||
|
): Promise<ModuleDatabaseQueryResult<ModuleDatabaseObjectRow>>;
|
||||||
|
query(
|
||||||
|
query: ModuleDatabaseQuery,
|
||||||
|
): Promise<ModuleDatabaseQueryResult<ModuleDatabaseObjectRow | ModuleDatabaseArrayRow>>;
|
||||||
|
async query(
|
||||||
|
query: ModuleDatabaseQuery,
|
||||||
|
): Promise<ModuleDatabaseQueryResult<ModuleDatabaseObjectRow | ModuleDatabaseArrayRow>> {
|
||||||
|
return new FakeTransaction(this.state, "executor").query(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
async transaction<T>(
|
||||||
|
callback: (transaction: ModuleDatabaseTransaction) => Promise<T> | T,
|
||||||
|
): Promise<T> {
|
||||||
|
this.state.events.push("transaction:begin");
|
||||||
|
try {
|
||||||
|
const value = await callback(new FakeTransaction(this.state, "transaction"));
|
||||||
|
this.state.events.push("transaction:commit");
|
||||||
|
return value;
|
||||||
|
} catch (error) {
|
||||||
|
this.state.events.push("transaction:rollback");
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setup(...outcomes: Outcome[]) {
|
||||||
|
const executor = new FakeExecutor();
|
||||||
|
executor.state.outcomes.push(...outcomes);
|
||||||
|
return { db: moduleDrizzle(executor), executor };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("moduleDrizzle query compilation and ABI mapping", () => {
|
||||||
|
it("compiles a select and decodes its positional result on the module side", async () => {
|
||||||
|
const { db, executor } = setup(result([[7, "Launch"]]));
|
||||||
|
await expect(
|
||||||
|
db.select({ id: records.id, title: records.title }).from(records).where(eq(records.id, 7)),
|
||||||
|
).resolves.toEqual([{ id: 7, title: "Launch" }]);
|
||||||
|
expect(executor.state.calls).toEqual([
|
||||||
|
{
|
||||||
|
query: {
|
||||||
|
text: 'select "id", "title" from "calendar_records" where "calendar_records"."id" = $1',
|
||||||
|
values: [7],
|
||||||
|
rowMode: "array",
|
||||||
|
},
|
||||||
|
scope: "executor",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("compiles insert returning through the array-row ABI", async () => {
|
||||||
|
const { db, executor } = setup(result([[8, "New"]], "INSERT"));
|
||||||
|
await expect(
|
||||||
|
db
|
||||||
|
.insert(records)
|
||||||
|
.values({
|
||||||
|
bigId: 8n,
|
||||||
|
count: 1,
|
||||||
|
createdAt: new Date("2026-01-02T03:04:05.000Z"),
|
||||||
|
day: new Date("2026-01-02T00:00:00.000Z"),
|
||||||
|
id: 8,
|
||||||
|
localAt: new Date("2026-01-02T03:04:05.000Z"),
|
||||||
|
title: "New",
|
||||||
|
})
|
||||||
|
.returning({ id: records.id, title: records.title }),
|
||||||
|
).resolves.toEqual([{ id: 8, title: "New" }]);
|
||||||
|
expect(executor.state.calls[0]?.query).toMatchObject({
|
||||||
|
rowMode: "array",
|
||||||
|
values: [
|
||||||
|
8n,
|
||||||
|
1,
|
||||||
|
"2026-01-02T03:04:05.000Z",
|
||||||
|
"2026-01-02T00:00:00.000Z",
|
||||||
|
8,
|
||||||
|
"2026-01-02T03:04:05.000Z",
|
||||||
|
"New",
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(executor.state.calls[0]?.query.text).toContain('insert into "calendar_records"');
|
||||||
|
expect(executor.state.calls[0]?.query.text).toContain('returning "id", "title"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("compiles an update returning with a SQL expression", async () => {
|
||||||
|
const { db, executor } = setup(result([[7, 4]], "UPDATE"));
|
||||||
|
await expect(
|
||||||
|
db
|
||||||
|
.update(records)
|
||||||
|
.set({ count: drizzleSql`${records.count} + 1` })
|
||||||
|
.where(and(eq(records.id, 7), eq(records.title, "Launch")))
|
||||||
|
.returning({ count: records.count, id: records.id }),
|
||||||
|
).resolves.toEqual([{ count: 7, id: 4 }]);
|
||||||
|
expect(executor.state.calls[0]?.query).toEqual({
|
||||||
|
text: 'update "calendar_records" set "count" = "calendar_records"."count" + 1 where ("calendar_records"."id" = $1 and "calendar_records"."title" = $2) returning "count", "id"',
|
||||||
|
values: [7, "Launch"],
|
||||||
|
rowMode: "array",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("compiles delete returning", async () => {
|
||||||
|
const { db, executor } = setup(result([[7, "Old"]], "DELETE"));
|
||||||
|
await expect(
|
||||||
|
db
|
||||||
|
.delete(records)
|
||||||
|
.where(eq(records.id, 7))
|
||||||
|
.returning({ id: records.id, title: records.title }),
|
||||||
|
).resolves.toEqual([{ id: 7, title: "Old" }]);
|
||||||
|
expect(executor.state.calls[0]?.query).toEqual({
|
||||||
|
text: 'delete from "calendar_records" where "calendar_records"."id" = $1 returning "id", "title"',
|
||||||
|
values: [7],
|
||||||
|
rowMode: "array",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses positions rather than duplicate database column names for joins", async () => {
|
||||||
|
const { db, executor } = setup(result([[9, "Category", 3, "Record"]]));
|
||||||
|
await expect(
|
||||||
|
db
|
||||||
|
.select({
|
||||||
|
categoryId: categories.id,
|
||||||
|
categoryTitle: categories.title,
|
||||||
|
recordId: records.id,
|
||||||
|
recordTitle: records.title,
|
||||||
|
})
|
||||||
|
.from(records)
|
||||||
|
.innerJoin(categories, eq(records.id, categories.id)),
|
||||||
|
).resolves.toEqual([
|
||||||
|
{ categoryId: 9, categoryTitle: "Category", recordId: 3, recordTitle: "Record" },
|
||||||
|
]);
|
||||||
|
expect(executor.state.calls[0]?.query.rowMode).toBe("array");
|
||||||
|
expect(executor.state.calls[0]?.query.text).toContain(
|
||||||
|
'select "calendar_categories"."id", "calendar_categories"."title", "calendar_records"."id", "calendar_records"."title"',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("moduleDrizzle result decoding", () => {
|
||||||
|
it("lets Drizzle decode every stable low-level ABI representation", async () => {
|
||||||
|
const bytes = new Uint8Array([0, 127, 255]);
|
||||||
|
const { db, executor } = setup(
|
||||||
|
result([
|
||||||
|
[
|
||||||
|
"1234567890.0123456789",
|
||||||
|
"12345678901234567890",
|
||||||
|
bytes,
|
||||||
|
"2026-01-02 03:04:05+00",
|
||||||
|
"2026-01-02",
|
||||||
|
"2026-01-02 03:04:05",
|
||||||
|
'{"kind":"jsonb"}',
|
||||||
|
null,
|
||||||
|
'{"kind":"json"}',
|
||||||
|
["3", "9007199254740993"],
|
||||||
|
["one", "two"],
|
||||||
|
],
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
amount: records.amount,
|
||||||
|
bigId: records.bigId,
|
||||||
|
bytes: records.bytes,
|
||||||
|
createdAt: records.createdAt,
|
||||||
|
day: records.day,
|
||||||
|
localAt: records.localAt,
|
||||||
|
metadata: records.metadata,
|
||||||
|
nullable: records.nullable,
|
||||||
|
payload: records.payload,
|
||||||
|
scores: records.scores,
|
||||||
|
tags: records.tags,
|
||||||
|
})
|
||||||
|
.from(records);
|
||||||
|
|
||||||
|
expect(rows).toEqual([
|
||||||
|
{
|
||||||
|
amount: "1234567890.0123456789",
|
||||||
|
bigId: 12_345_678_901_234_567_890n,
|
||||||
|
bytes,
|
||||||
|
createdAt: new Date("2026-01-02T03:04:05.000Z"),
|
||||||
|
day: new Date("2026-01-02T00:00:00.000Z"),
|
||||||
|
localAt: new Date("2026-01-02T03:04:05.000Z"),
|
||||||
|
metadata: { kind: "jsonb" },
|
||||||
|
nullable: null,
|
||||||
|
payload: { kind: "json" },
|
||||||
|
scores: [3n, 9_007_199_254_740_993n],
|
||||||
|
tags: ["one", "two"],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(rows[0]?.bytes).toBe(bytes);
|
||||||
|
expect(executor.state.calls[0]?.query.rowMode).toBe("array");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("moduleDrizzle transactions", () => {
|
||||||
|
it("maps top-level and nested transactions to ABI callbacks and savepoints", async () => {
|
||||||
|
const { db, executor } = setup(result([[1]]), result([[2]]), result([[3]]));
|
||||||
|
const value = await db.transaction(async (transaction) => {
|
||||||
|
await transaction.select({ id: records.id }).from(records);
|
||||||
|
const nested = await transaction.transaction(async (savepoint) => {
|
||||||
|
await savepoint.select({ id: records.id }).from(records);
|
||||||
|
return "nested";
|
||||||
|
});
|
||||||
|
await transaction.select({ id: records.id }).from(records);
|
||||||
|
return `${nested}-result`;
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(value).toBe("nested-result");
|
||||||
|
expect(executor.state.events).toEqual([
|
||||||
|
"transaction:begin",
|
||||||
|
"transaction:query",
|
||||||
|
"transaction.savepoint:begin",
|
||||||
|
"transaction.savepoint:query",
|
||||||
|
"transaction.savepoint:release",
|
||||||
|
"transaction:query",
|
||||||
|
"transaction:commit",
|
||||||
|
]);
|
||||||
|
expect(executor.state.calls.map((call) => call.scope)).toEqual([
|
||||||
|
"transaction",
|
||||||
|
"transaction.savepoint",
|
||||||
|
"transaction",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("propagates callback errors unchanged and leaves rollback to the ABI", async () => {
|
||||||
|
const { db, executor } = setup();
|
||||||
|
const failure = new Error("module callback failed");
|
||||||
|
await expect(
|
||||||
|
db.transaction(() => {
|
||||||
|
throw failure;
|
||||||
|
}),
|
||||||
|
).rejects.toBe(failure);
|
||||||
|
expect(executor.state.events).toEqual(["transaction:begin", "transaction:rollback"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rolls a rejected nested callback back to its ABI savepoint", async () => {
|
||||||
|
const { db, executor } = setup();
|
||||||
|
const failure = new Error("nested callback failed");
|
||||||
|
await expect(
|
||||||
|
db.transaction((transaction) => transaction.transaction(() => Promise.reject(failure))),
|
||||||
|
).rejects.toBe(failure);
|
||||||
|
expect(executor.state.events).toEqual([
|
||||||
|
"transaction:begin",
|
||||||
|
"transaction.savepoint:begin",
|
||||||
|
"transaction.savepoint:rollback",
|
||||||
|
"transaction:rollback",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("moduleDrizzle error and type boundaries", () => {
|
||||||
|
it("recovers the stable ABI code and SQLSTATE from Drizzle's wrapper", async () => {
|
||||||
|
const databaseError = new ModuleDatabaseError("MODULE_DATABASE_QUERY_FAILED", {
|
||||||
|
sqlState: "23505",
|
||||||
|
});
|
||||||
|
const { db } = setup(databaseError);
|
||||||
|
let thrown: unknown;
|
||||||
|
try {
|
||||||
|
await db.select({ id: records.id }).from(records);
|
||||||
|
} catch (error) {
|
||||||
|
thrown = error;
|
||||||
|
}
|
||||||
|
expect(thrown).toBeInstanceOf(Error);
|
||||||
|
expect(findEkklesiaDatabaseError(thrown)).toBe(databaseError);
|
||||||
|
expect(findEkklesiaDatabaseError(thrown)).toMatchObject({
|
||||||
|
code: "MODULE_DATABASE_QUERY_FAILED",
|
||||||
|
sqlState: "23505",
|
||||||
|
});
|
||||||
|
expect(databaseError).not.toHaveProperty("query");
|
||||||
|
expect(databaseError).not.toHaveProperty("params");
|
||||||
|
expect(databaseError).not.toHaveProperty("detail");
|
||||||
|
expect(databaseError).not.toHaveProperty("hint");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retains the module's schema inference without Core or pg database types", () => {
|
||||||
|
const executor = new FakeExecutor();
|
||||||
|
const schema = { records };
|
||||||
|
const db = moduleDrizzle(executor, { schema });
|
||||||
|
expectTypeOf(db).toMatchTypeOf<ModuleDrizzleDatabase<typeof schema>>();
|
||||||
|
expectTypeOf(db.select().from(records)).resolves.toEqualTypeOf<
|
||||||
|
(typeof records.$inferSelect)[]
|
||||||
|
>();
|
||||||
|
expect(Object.keys(db).sort()).toEqual(["delete", "insert", "select", "transaction", "update"]);
|
||||||
|
expect(db).not.toHaveProperty("_");
|
||||||
|
expect(db).not.toHaveProperty("$client");
|
||||||
|
expect(db).not.toHaveProperty("execute");
|
||||||
|
expect(db).not.toHaveProperty("query");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import {
|
||||||
|
ModuleDatabaseError,
|
||||||
|
type ModuleDatabaseExecutor,
|
||||||
|
type ModuleDatabaseParameter,
|
||||||
|
} from "@ekklesia/contracts";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { findEkklesiaDatabaseError, sql } from "../src/index.js";
|
||||||
|
|
||||||
|
describe("the raw Module Database ABI v1 SQL tag", () => {
|
||||||
|
it("keeps static SQL unchanged when there are no interpolations", () => {
|
||||||
|
expect(sql`SELECT id FROM calendar_events ORDER BY id`).toEqual({
|
||||||
|
text: "SELECT id FROM calendar_events ORDER BY id",
|
||||||
|
values: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("turns every interpolation into an ordered PostgreSQL parameter", () => {
|
||||||
|
const id = "event-1";
|
||||||
|
const active = true;
|
||||||
|
expect(sql`SELECT * FROM calendar_events WHERE id = ${id} AND active = ${active}`).toEqual({
|
||||||
|
text: "SELECT * FROM calendar_events WHERE id = $1 AND active = $2",
|
||||||
|
values: [id, active],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gives repeated values separate positional parameters", () => {
|
||||||
|
const value = "same";
|
||||||
|
expect(sql`SELECT ${value}, ${value}, ${value}`).toEqual({
|
||||||
|
text: "SELECT $1, $2, $3",
|
||||||
|
values: [value, value, value],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps SQL-injection-sensitive strings bound instead of joining them into SQL", () => {
|
||||||
|
const attack = "x'; DROP TABLE calendar_events; --";
|
||||||
|
const query = sql`SELECT * FROM calendar_events WHERE title = ${attack}`;
|
||||||
|
expect(query.text).toBe("SELECT * FROM calendar_events WHERE title = $1");
|
||||||
|
expect(query.text).not.toContain(attack);
|
||||||
|
expect(query.values).toEqual([attack]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves bigint, byte arrays, and PostgreSQL arrays as ABI values", () => {
|
||||||
|
const bytes = new Uint8Array([0, 127, 255]);
|
||||||
|
const identifiers = [1n, 2n, null] as const;
|
||||||
|
const query = sql`SELECT ${9_007_199_254_740_993n}, ${bytes}, ${identifiers}`;
|
||||||
|
expect(query.values).toEqual([9_007_199_254_740_993n, bytes, identifiers]);
|
||||||
|
expect(query.values[1]).toBe(bytes);
|
||||||
|
expect(query.values[2]).toBe(identifiers);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not stringify unsupported values before the ABI executor rejects them", async () => {
|
||||||
|
const invalid = new Date("2026-01-02T03:04:05.000Z");
|
||||||
|
const query = sql`SELECT ${invalid as unknown as ModuleDatabaseParameter}`;
|
||||||
|
expect(query.values[0]).toBe(invalid);
|
||||||
|
|
||||||
|
const executor = {
|
||||||
|
abiVersion: 1,
|
||||||
|
async query(request: { readonly values?: readonly ModuleDatabaseParameter[] }) {
|
||||||
|
if (request.values?.some((value) => value instanceof Date)) {
|
||||||
|
throw new ModuleDatabaseError("MODULE_DATABASE_PARAMETER_INVALID");
|
||||||
|
}
|
||||||
|
throw new Error("Expected the fake ABI validator to reject the value.");
|
||||||
|
},
|
||||||
|
async transaction() {
|
||||||
|
throw new Error("not used");
|
||||||
|
},
|
||||||
|
} as unknown as ModuleDatabaseExecutor;
|
||||||
|
|
||||||
|
await expect(executor.query(query)).rejects.toMatchObject({
|
||||||
|
code: "MODULE_DATABASE_PARAMETER_INVALID",
|
||||||
|
name: "ModuleDatabaseError",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("findEkklesiaDatabaseError", () => {
|
||||||
|
it("finds a stable ABI error through wrappers without parsing messages", () => {
|
||||||
|
const databaseError = new ModuleDatabaseError("MODULE_DATABASE_QUERY_FAILED", {
|
||||||
|
sqlState: "23505",
|
||||||
|
});
|
||||||
|
const wrapped = new Error("wrapper", { cause: new Error("second", { cause: databaseError }) });
|
||||||
|
expect(findEkklesiaDatabaseError(wrapped)).toBe(databaseError);
|
||||||
|
expect(findEkklesiaDatabaseError(wrapped)).toMatchObject({
|
||||||
|
code: "MODULE_DATABASE_QUERY_FAILED",
|
||||||
|
sqlState: "23505",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("terminates safely for cause cycles and unrelated errors", () => {
|
||||||
|
const cyclic = new Error("cyclic");
|
||||||
|
cyclic.cause = cyclic;
|
||||||
|
expect(findEkklesiaDatabaseError(cyclic)).toBeUndefined();
|
||||||
|
expect(findEkklesiaDatabaseError("not an error")).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"noEmit": false,
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"sourceMap": true,
|
||||||
|
"types": ["node"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"],
|
||||||
|
"exclude": ["src/**/*.test.ts", "tests"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "../typescript-config/node.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"noEmit": true,
|
||||||
|
"types": ["node", "vitest/globals"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "tests/**/*.ts"]
|
||||||
|
}
|
||||||
@@ -33,6 +33,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "2.5.3",
|
"@biomejs/biome": "2.5.3",
|
||||||
"@ekklesia/core": "workspace:*",
|
"@ekklesia/core": "workspace:*",
|
||||||
|
"@ekklesia/module-database": "workspace:*",
|
||||||
"@ekklesia/module-sdk": "workspace:*",
|
"@ekklesia/module-sdk": "workspace:*",
|
||||||
"@ekklesia/module-template": "workspace:*",
|
"@ekklesia/module-template": "workspace:*",
|
||||||
"@types/node": "24.13.3",
|
"@types/node": "24.13.3",
|
||||||
|
|||||||
@@ -181,6 +181,38 @@ describe("packModule: a self-contained backend", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("bundles the module-side Drizzle adapter with the module's ORM runtime", async () => {
|
||||||
|
const { archive } = await pack({
|
||||||
|
serverSource: `import { moduleDrizzle } from "@ekklesia/module-database/drizzle";
|
||||||
|
export const database = (executor) => moduleDrizzle(executor);
|
||||||
|
export default { id: "example", version: "0.1.0", register() {} };
|
||||||
|
`,
|
||||||
|
withModuleDatabase: true,
|
||||||
|
});
|
||||||
|
const files = fileEntries(archive);
|
||||||
|
const metadata = JSON.parse(files.get("example/ekklesia.package.json")?.toString("utf8") ?? "");
|
||||||
|
expect(metadata.incorporates).toMatchObject({
|
||||||
|
"@ekklesia/module-database": expect.any(String),
|
||||||
|
});
|
||||||
|
expect(metadata.transitional).toEqual({ databaseRuntime: { "drizzle-orm": "0.45.2" } });
|
||||||
|
for (const [file, data] of files) {
|
||||||
|
if (!file.endsWith(".js")) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const code = data.toString("utf8");
|
||||||
|
const specifiers = [
|
||||||
|
...code.matchAll(/(?:^|\n)\s*(?:import|export)\s[^;]*?from\s*"([^"]+)"/gu),
|
||||||
|
...code.matchAll(/(?:^|\n)\s*import\s*"([^"]+)"/gu),
|
||||||
|
...code.matchAll(/\bimport\(\s*"([^"]+)"\s*\)/gu),
|
||||||
|
].map((match) => match[1] ?? "");
|
||||||
|
for (const specifier of specifiers) {
|
||||||
|
expect(specifier.startsWith(".") || isBuiltin(specifier), `${file}: ${specifier}`).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("refuses a dependency the bundle cannot resolve rather than leaving it external", async () => {
|
it("refuses a dependency the bundle cannot resolve rather than leaving it external", async () => {
|
||||||
await packRejection(
|
await packRejection(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export interface FixtureModuleOptions {
|
|||||||
readonly id?: string;
|
readonly id?: string;
|
||||||
readonly version?: string;
|
readonly version?: string;
|
||||||
readonly serverSource?: string;
|
readonly serverSource?: string;
|
||||||
|
readonly withModuleDatabase?: boolean;
|
||||||
readonly withSchema?: boolean;
|
readonly withSchema?: boolean;
|
||||||
readonly fakePackages?: readonly FakePackage[];
|
readonly fakePackages?: readonly FakePackage[];
|
||||||
readonly manifest?: (manifest: Record<string, unknown>) => Record<string, unknown>;
|
readonly manifest?: (manifest: Record<string, unknown>) => Record<string, unknown>;
|
||||||
@@ -105,7 +106,11 @@ export async function fixtureModule(options: FixtureModuleOptions = {}): Promise
|
|||||||
private: true,
|
private: true,
|
||||||
type: "module",
|
type: "module",
|
||||||
scripts: { build: "tsc" },
|
scripts: { build: "tsc" },
|
||||||
dependencies: { "@ekklesia/module-sdk": "workspace:*", "drizzle-orm": "0.45.2" },
|
dependencies: {
|
||||||
|
"@ekklesia/module-sdk": "workspace:*",
|
||||||
|
...(options.withModuleDatabase ? { "@ekklesia/module-database": "workspace:*" } : {}),
|
||||||
|
"drizzle-orm": "0.45.2",
|
||||||
|
},
|
||||||
devDependencies: { typescript: "7.0.2" },
|
devDependencies: { typescript: "7.0.2" },
|
||||||
exports: { ".": "./dist/server/index.js" },
|
exports: { ".": "./dist/server/index.js" },
|
||||||
ekklesia: { layer: "module" },
|
ekklesia: { layer: "module" },
|
||||||
@@ -166,6 +171,12 @@ export async function fixtureModule(options: FixtureModuleOptions = {}): Promise
|
|||||||
await realpath(path.join(packageRoot, "node_modules", "@ekklesia", "module-sdk")),
|
await realpath(path.join(packageRoot, "node_modules", "@ekklesia", "module-sdk")),
|
||||||
path.join(modules, "@ekklesia", "module-sdk"),
|
path.join(modules, "@ekklesia", "module-sdk"),
|
||||||
);
|
);
|
||||||
|
if (options.withModuleDatabase) {
|
||||||
|
await symlink(
|
||||||
|
await realpath(path.join(packageRoot, "node_modules", "@ekklesia", "module-database")),
|
||||||
|
path.join(modules, "@ekklesia", "module-database"),
|
||||||
|
);
|
||||||
|
}
|
||||||
await symlink(
|
await symlink(
|
||||||
await realpath(path.join(packageRoot, "node_modules", "drizzle-orm")),
|
await realpath(path.join(packageRoot, "node_modules", "drizzle-orm")),
|
||||||
path.join(modules, "drizzle-orm"),
|
path.join(modules, "drizzle-orm"),
|
||||||
|
|||||||
Generated
+25
@@ -196,6 +196,28 @@ importers:
|
|||||||
specifier: 4.1.10
|
specifier: 4.1.10
|
||||||
version: 4.1.10(@types/[email protected])([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected]))
|
version: 4.1.10(@types/[email protected])([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected]))
|
||||||
|
|
||||||
|
packages/module-database:
|
||||||
|
dependencies:
|
||||||
|
'@ekklesia/contracts':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../contracts
|
||||||
|
devDependencies:
|
||||||
|
'@biomejs/biome':
|
||||||
|
specifier: 2.5.3
|
||||||
|
version: 2.5.3
|
||||||
|
'@types/node':
|
||||||
|
specifier: 24.13.3
|
||||||
|
version: 24.13.3
|
||||||
|
drizzle-orm:
|
||||||
|
specifier: 0.45.2
|
||||||
|
version: 0.45.2(@types/[email protected])([email protected])([email protected])
|
||||||
|
typescript:
|
||||||
|
specifier: 7.0.2
|
||||||
|
version: 7.0.2
|
||||||
|
vitest:
|
||||||
|
specifier: 4.1.10
|
||||||
|
version: 4.1.10(@types/[email protected])([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected]))
|
||||||
|
|
||||||
packages/module-pack:
|
packages/module-pack:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@ekklesia/contracts':
|
'@ekklesia/contracts':
|
||||||
@@ -217,6 +239,9 @@ importers:
|
|||||||
'@ekklesia/core':
|
'@ekklesia/core':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../core
|
version: link:../../core
|
||||||
|
'@ekklesia/module-database':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../module-database
|
||||||
'@ekklesia/module-sdk':
|
'@ekklesia/module-sdk':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../module-sdk
|
version: link:../module-sdk
|
||||||
|
|||||||
@@ -147,6 +147,16 @@ const DEPENDENCY_FIELDS = [
|
|||||||
"optionalDependencies",
|
"optionalDependencies",
|
||||||
"peerDependencies",
|
"peerDependencies",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runtime peers whose identity is deliberately supplied by the final consumer. The sole entry is
|
||||||
|
* the module-side Drizzle adapter: a module chooses and bundles its own compatible Drizzle runtime,
|
||||||
|
* while Core must never select one for it. Keep this exception package-and-dependency specific;
|
||||||
|
* ordinary workspace packages still need production dependencies for runtime imports.
|
||||||
|
*/
|
||||||
|
const CONSUMER_SELECTED_RUNTIME_PEERS = new Map<string, ReadonlySet<string>>([
|
||||||
|
["@ekklesia/module-database", new Set(["drizzle-orm"])],
|
||||||
|
]);
|
||||||
const SHARED_PACKAGE_ROLES = new Set([
|
const SHARED_PACKAGE_ROLES = new Set([
|
||||||
"api-client",
|
"api-client",
|
||||||
"auth-client",
|
"auth-client",
|
||||||
@@ -1006,7 +1016,9 @@ export async function checkBoundaries(rootDirectory: string): Promise<BoundaryRe
|
|||||||
dependencyName === runtimePackage.name ||
|
dependencyName === runtimePackage.name ||
|
||||||
NODE_BUILTIN_MODULES.has(dependencyName) ||
|
NODE_BUILTIN_MODULES.has(dependencyName) ||
|
||||||
manifest.dependencies?.[dependencyName] !== undefined ||
|
manifest.dependencies?.[dependencyName] !== undefined ||
|
||||||
manifest.optionalDependencies?.[dependencyName] !== undefined
|
manifest.optionalDependencies?.[dependencyName] !== undefined ||
|
||||||
|
(manifest.peerDependencies?.[dependencyName] !== undefined &&
|
||||||
|
CONSUMER_SELECTED_RUNTIME_PEERS.get(runtimePackage.name)?.has(dependencyName) === true)
|
||||||
) {
|
) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -119,6 +119,12 @@ describe("repository dependency boundaries", () => {
|
|||||||
expect(result.violations).toEqual([]);
|
expect(result.violations).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("accepts only the module database adapter's consumer-selected Drizzle runtime peer", async () => {
|
||||||
|
const result = await checkBoundaries(path.join(fixtures, "legal-consumer-runtime-peer"));
|
||||||
|
|
||||||
|
expect(result.violations).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
[
|
[
|
||||||
"runtime-peer-only-dependency",
|
"runtime-peer-only-dependency",
|
||||||
|
|||||||
Vendored
+22
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"name": "@ekklesia/module-database",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"peerDependencies": {
|
||||||
|
"drizzle-orm": ">=0.45.2 <0.46.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"drizzle-orm": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"drizzle-orm": "0.45.2"
|
||||||
|
},
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.ts"
|
||||||
|
},
|
||||||
|
"ekklesia": {
|
||||||
|
"layer": "module-sdk"
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+3
@@ -0,0 +1,3 @@
|
|||||||
|
import { drizzle } from "drizzle-orm/pg-proxy";
|
||||||
|
|
||||||
|
export const moduleDrizzle = drizzle;
|
||||||
Reference in New Issue
Block a user