Initial Commit
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
export { createContactsPlugin, runOnInit } from "./plugin.js";
|
||||
export {
|
||||
type ApplyContactsSchemaOptions,
|
||||
applyContactsSchema,
|
||||
CONTACT_GROUP_TABLE_DDL,
|
||||
CONTACT_TABLE_DDL,
|
||||
CONTACTS_PLUGIN_SCHEMA,
|
||||
GROUP_MEMBERSHIP_TABLE_DDL,
|
||||
} from "./schema.js";
|
||||
@@ -0,0 +1,46 @@
|
||||
import { OpenAPIHono } from "@hono/zod-openapi";
|
||||
import { createPluginContext } from "@suaveplan/api-plugin";
|
||||
import { EventBus } from "@suaveplan/events";
|
||||
import type { SurrealAdapter } from "@suaveplan/surrealdb";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { CONTACTS_API_CODES } from "../error-codes/error-codes.js";
|
||||
import { createContactsPlugin, runOnInit } from "./plugin.js";
|
||||
|
||||
const noopLogger = {
|
||||
debug: () => undefined,
|
||||
info: () => undefined,
|
||||
warn: () => undefined,
|
||||
error: () => undefined,
|
||||
};
|
||||
|
||||
describe("plugin onInit error propagation", () => {
|
||||
it("onInit raises SCHEMA_APPLY_FAILED when execute throws", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const db = {
|
||||
async execute() {
|
||||
throw new Error("db disconnected");
|
||||
},
|
||||
async query() {
|
||||
return [[]];
|
||||
},
|
||||
} as unknown as SurrealAdapter;
|
||||
const app = new OpenAPIHono();
|
||||
const services = new Map<string, unknown>();
|
||||
services.set("app", app);
|
||||
services.set("db", db);
|
||||
const ctx = createPluginContext({
|
||||
logger: noopLogger,
|
||||
eventBus: new EventBus(),
|
||||
signal: new AbortController().signal,
|
||||
config: {} as never,
|
||||
services,
|
||||
});
|
||||
const desc = createContactsPlugin({ applySchemaOnInit: true });
|
||||
await expect(
|
||||
runOnInit(ctx as never, desc.defaults)
|
||||
).rejects.toMatchObject({
|
||||
code: CONTACTS_API_CODES.SCHEMA_APPLY_FAILED,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
# plugin/plugin — createContactsPlugin factory
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Returns the `PluginDescriptor<ContactsPluginConfig>` consumed by the
|
||||
`@suaveplan/api` plugin host. Runs the 5-step `onInit` lifecycle:
|
||||
|
||||
1. Validate the supplied options through Zod (`INVALID_INPUT` on fail).
|
||||
2. Resolve the host services (`app`, `db`, optional
|
||||
`openAPIRegistry`, optional `migrationRunner`).
|
||||
3. Apply the bundled SurrealQL schema unless
|
||||
`applySchemaOnInit: false`.
|
||||
4. Construct the three SurrealDB repositories and register them in
|
||||
the `ServiceRegistry`.
|
||||
5. Install a single mount-pattern middleware that injects repositories
|
||||
+ `eventBus` into every request context, then register the 18
|
||||
`/contacts` + `/groups` routes via `definePluginRoutes`.
|
||||
|
||||
The descriptor declares `dependencies: ["database", "auth"]` so the
|
||||
host topo-sorts after `@suaveplan/api-plugin-database` and
|
||||
`@suaveplan/api-plugin-auth`.
|
||||
|
||||
## Features
|
||||
|
||||
+ Single factory entry-point; idempotent within a process (re-calling
|
||||
with the same options is safe — every `onInit` build is a fresh
|
||||
bundle).
|
||||
+ ServiceRegistry slot registration: `contactRepository`,
|
||||
`groupRepository`, `membershipRepository`.
|
||||
+ Hono ContextVariableMap injection: same three slots + `eventBus`.
|
||||
+ Route-prefix collision detection raises
|
||||
`ROUTE_PREFIX_COLLISION`.
|
||||
+ Bare-mount fallback when `openAPIRegistry` is absent (routes
|
||||
still work; the OpenAPI document just excludes them).
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { createContactsPlugin } from "@suaveplan/api-plugin-contacts";
|
||||
|
||||
const plugin = createContactsPlugin();
|
||||
// pluginManager.use(plugin);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { createContactsPlugin } from "@suaveplan/api-plugin-contacts";
|
||||
|
||||
const plugin = createContactsPlugin({
|
||||
routePrefix: "/api/v1",
|
||||
maxImportBatchSize: 500,
|
||||
maxGroupDepth: 5,
|
||||
enforceGroupDepth: true,
|
||||
applySchemaOnInit: false, // host has its own migration pipeline
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
+ `createContactsPlugin(options?: ContactsPluginOptions): PluginDescriptor<ContactsPluginConfig>`
|
||||
+ `runOnInit(ctx, config): Promise<void>` — the lifecycle body; exported
|
||||
for testability so tests can inject a hand-built `PluginContext`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
+ The mount-pattern is computed as `*` when `routePrefix === "/"` and
|
||||
`${prefix}/*` otherwise; this matches the convention used by the
|
||||
user plugin and avoids the off-by-one Hono ignores trailing `/`.
|
||||
+ ROUTE_PREFIX_COLLISION uses `${prefix}/contacts` as the probe so a
|
||||
second mount of this plugin (even with a different prefix on the
|
||||
same registry) trips the check.
|
||||
+ The descriptor's `defaults` slot is the post-Zod-parse
|
||||
`ContactsPluginConfig`, frozen by the schema's
|
||||
`passthrough: false` semantics.
|
||||
@@ -0,0 +1,243 @@
|
||||
import { OpenAPIHono } from "@hono/zod-openapi";
|
||||
import { createOpenAPIRegistry } from "@suaveplan/api-openapi";
|
||||
import { createPluginContext } from "@suaveplan/api-plugin";
|
||||
import { EventBus } from "@suaveplan/events";
|
||||
import type { SurrealAdapter } from "@suaveplan/surrealdb";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
CONTACTS_API_CODES,
|
||||
ContactsApiError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import { createContactsPlugin, runOnInit } from "./plugin.js";
|
||||
|
||||
const noopLogger = {
|
||||
debug: () => undefined,
|
||||
info: () => undefined,
|
||||
warn: () => undefined,
|
||||
error: () => undefined,
|
||||
};
|
||||
|
||||
function makeFakeDb(): SurrealAdapter {
|
||||
return {
|
||||
async execute() {},
|
||||
async query() {
|
||||
return [[]];
|
||||
},
|
||||
} as unknown as SurrealAdapter;
|
||||
}
|
||||
|
||||
describe("createContactsPlugin", () => {
|
||||
it("createContactsPlugin returns a typed descriptor with default config", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const desc = createContactsPlugin();
|
||||
expect(desc.name).toBe("contacts");
|
||||
expect(desc.version).toBe("0.1.0");
|
||||
expect(desc.dependencies).toEqual(["database", "auth"]);
|
||||
expect(desc.defaults.routePrefix).toBe("/");
|
||||
expect(desc.defaults.maxImportBatchSize).toBe(1000);
|
||||
expect(desc.defaults.maxGroupDepth).toBe(10);
|
||||
expect(desc.defaults.enforceGroupDepth).toBe(true);
|
||||
expect(desc.defaults.applySchemaOnInit).toBe(true);
|
||||
});
|
||||
|
||||
it("createContactsPlugin throws CONTACTS_API.INVALID_INPUT on invalid options", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() =>
|
||||
createContactsPlugin({ maxGroupDepth: -1 } as never)
|
||||
).toThrowError(ContactsApiError);
|
||||
});
|
||||
|
||||
it("createContactsPlugin honours a custom routePrefix", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const desc = createContactsPlugin({ routePrefix: "/api" });
|
||||
expect(desc.defaults.routePrefix).toBe("/api");
|
||||
});
|
||||
|
||||
it("descriptor advertises name, dependencies, version, tags", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const desc = createContactsPlugin();
|
||||
expect(desc.tags).toEqual(["contacts", "groups", "audience"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("plugin onInit", () => {
|
||||
function makeContext(
|
||||
overrides: {
|
||||
app?: OpenAPIHono | undefined;
|
||||
db?: SurrealAdapter | undefined;
|
||||
registry?: ReturnType<typeof createOpenAPIRegistry> | undefined;
|
||||
} = {}
|
||||
) {
|
||||
const app = overrides.app ?? new OpenAPIHono();
|
||||
const services = new Map<string, unknown>();
|
||||
if (app !== undefined) services.set("app", app);
|
||||
if (overrides.db !== undefined) services.set("db", overrides.db);
|
||||
if (overrides.registry !== undefined)
|
||||
services.set("openAPIRegistry", overrides.registry);
|
||||
const ctx = createPluginContext({
|
||||
logger: noopLogger,
|
||||
eventBus: new EventBus(),
|
||||
signal: new AbortController().signal,
|
||||
config: {} as never,
|
||||
services,
|
||||
});
|
||||
return { ctx, app, services };
|
||||
}
|
||||
|
||||
it("throws SERVICE_NOT_AVAILABLE when app missing", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const services = new Map<string, unknown>();
|
||||
// Note: no `app` slot.
|
||||
services.set("db", makeFakeDb());
|
||||
const ctx = createPluginContext({
|
||||
logger: noopLogger,
|
||||
eventBus: new EventBus(),
|
||||
signal: new AbortController().signal,
|
||||
config: {} as never,
|
||||
services,
|
||||
});
|
||||
const desc = createContactsPlugin({ applySchemaOnInit: false });
|
||||
await expect(
|
||||
runOnInit(ctx as never, desc.defaults)
|
||||
).rejects.toMatchObject({
|
||||
code: CONTACTS_API_CODES.SERVICE_NOT_AVAILABLE,
|
||||
});
|
||||
});
|
||||
|
||||
it("throws SERVICE_NOT_AVAILABLE when db missing", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { ctx } = makeContext({});
|
||||
const desc = createContactsPlugin({ applySchemaOnInit: false });
|
||||
await expect(
|
||||
runOnInit(ctx as never, desc.defaults)
|
||||
).rejects.toMatchObject({
|
||||
code: CONTACTS_API_CODES.SERVICE_NOT_AVAILABLE,
|
||||
});
|
||||
});
|
||||
|
||||
it("registers the three repositories in the ServiceRegistry", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const db = makeFakeDb();
|
||||
const { ctx } = makeContext({ db });
|
||||
const desc = createContactsPlugin({ applySchemaOnInit: false });
|
||||
await runOnInit(ctx as never, desc.defaults);
|
||||
expect(ctx.getService("contactRepository")).toBeDefined();
|
||||
expect(ctx.getService("groupRepository")).toBeDefined();
|
||||
expect(ctx.getService("membershipRepository")).toBeDefined();
|
||||
});
|
||||
|
||||
it("logs a warning when openAPIRegistry is absent and still mounts handlers", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const db = makeFakeDb();
|
||||
const messages: string[] = [];
|
||||
const logger = {
|
||||
debug: () => undefined,
|
||||
info: () => undefined,
|
||||
warn: (msg: string) => messages.push(msg),
|
||||
error: () => undefined,
|
||||
};
|
||||
const app = new OpenAPIHono();
|
||||
const services = new Map<string, unknown>();
|
||||
services.set("app", app);
|
||||
services.set("db", db);
|
||||
const ctx = createPluginContext({
|
||||
logger,
|
||||
eventBus: new EventBus(),
|
||||
signal: new AbortController().signal,
|
||||
config: {} as never,
|
||||
services,
|
||||
});
|
||||
const desc = createContactsPlugin({ applySchemaOnInit: false });
|
||||
await runOnInit(ctx as never, desc.defaults);
|
||||
expect(messages.some((m) => m.includes("openAPIRegistry"))).toBe(true);
|
||||
});
|
||||
|
||||
it("registers routes when openAPIRegistry is supplied", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const db = makeFakeDb();
|
||||
const registry = createOpenAPIRegistry({
|
||||
info: { title: "t", version: "0.0.0" },
|
||||
});
|
||||
const { ctx } = makeContext({ db, registry });
|
||||
const desc = createContactsPlugin({ applySchemaOnInit: false });
|
||||
await runOnInit(ctx as never, desc.defaults);
|
||||
const paths = registry.getRoutes().map((r) => r.path);
|
||||
expect(paths).toContain("/contacts");
|
||||
expect(paths).toContain("/groups");
|
||||
});
|
||||
|
||||
it("raises ROUTE_PREFIX_COLLISION when /contacts already mounted", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const db = makeFakeDb();
|
||||
const registry = createOpenAPIRegistry({
|
||||
info: { title: "t", version: "0.0.0" },
|
||||
});
|
||||
const { ctx } = makeContext({ db, registry });
|
||||
const desc = createContactsPlugin({ applySchemaOnInit: false });
|
||||
await runOnInit(ctx as never, desc.defaults);
|
||||
// Second call should detect collision.
|
||||
await expect(
|
||||
runOnInit(ctx as never, desc.defaults)
|
||||
).rejects.toMatchObject({
|
||||
code: CONTACTS_API_CODES.ROUTE_PREFIX_COLLISION,
|
||||
});
|
||||
});
|
||||
|
||||
it("applies SurrealQL schema when applySchemaOnInit is true", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const executed: string[] = [];
|
||||
const db = {
|
||||
async execute(sql: string) {
|
||||
executed.push(sql);
|
||||
},
|
||||
async query() {
|
||||
return [[]];
|
||||
},
|
||||
} as unknown as SurrealAdapter;
|
||||
const { ctx } = makeContext({ db });
|
||||
const desc = createContactsPlugin({ applySchemaOnInit: true });
|
||||
await runOnInit(ctx as never, desc.defaults);
|
||||
expect(executed.length).toBeGreaterThan(0);
|
||||
expect(executed[0]).toContain("DEFINE TABLE OVERWRITE contact");
|
||||
});
|
||||
});
|
||||
|
||||
describe("plugin route prefix overrides", () => {
|
||||
it("mounts under /api when routePrefix is /api", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const db = makeFakeDb();
|
||||
const app = new OpenAPIHono();
|
||||
const services = new Map<string, unknown>();
|
||||
services.set("app", app);
|
||||
services.set("db", db);
|
||||
const ctx = createPluginContext({
|
||||
logger: noopLogger,
|
||||
eventBus: new EventBus(),
|
||||
signal: new AbortController().signal,
|
||||
config: {} as never,
|
||||
services,
|
||||
});
|
||||
const desc = createContactsPlugin({
|
||||
routePrefix: "/api",
|
||||
applySchemaOnInit: false,
|
||||
});
|
||||
await runOnInit(ctx as never, desc.defaults);
|
||||
// The route was registered onto the bare Hono app (no registry path).
|
||||
// Probe the app for a known route under /api.
|
||||
const res = await app.request("/api/contacts");
|
||||
// Will be 401 (no auth) but indicates handler is mounted.
|
||||
expect([401, 500]).toContain(res.status);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,216 @@
|
||||
/**
|
||||
* `createContactsPlugin` — `PluginDescriptor` factory for
|
||||
* `@suaveplan/api`. Validates options, constructs the three SurrealDB
|
||||
* repositories, registers them in the host `ServiceRegistry` + Hono
|
||||
* `ContextVariableMap`, applies the bundled SurrealQL schema, and
|
||||
* mounts the `/contacts` + `/groups` route trees via
|
||||
* `definePluginRoutes`.
|
||||
*/
|
||||
|
||||
import type { OpenAPIHono } from "@hono/zod-openapi";
|
||||
import { definePluginRoutes } from "@suaveplan/api-openapi";
|
||||
import {
|
||||
definePlugin,
|
||||
type PluginContext,
|
||||
type PluginDescriptor,
|
||||
} from "@suaveplan/api-plugin";
|
||||
import {
|
||||
CONTACTS_API_CODES,
|
||||
ContactsApiError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import {
|
||||
SurrealContactRepository,
|
||||
SurrealGroupRepository,
|
||||
SurrealMembershipRepository,
|
||||
} from "../repositories/index.js";
|
||||
import { buildContactRoutes } from "../routes/contacts/build-routes.js";
|
||||
import { buildGroupRoutes } from "../routes/groups/build-routes.js";
|
||||
import { ContactsPluginOptionsSchema } from "../schemas/options.js";
|
||||
import type {
|
||||
ContactsPluginConfig,
|
||||
ContactsPluginOptions,
|
||||
} from "../types/types.js";
|
||||
import { applyContactsSchema } from "./schema.js";
|
||||
import "../context/context.js";
|
||||
import "../services/registry-augmentation.js";
|
||||
|
||||
/**
|
||||
* Factory — returns the typed `PluginDescriptor`.
|
||||
*
|
||||
* Zod-validates `options` at construction time; invalid options
|
||||
* surface as `ContactsApiError(INVALID_INPUT)` with the `ZodError`
|
||||
* carried as `cause`. The descriptor declares
|
||||
* `dependencies: ["database", "auth"]` so the host topo-sorts after
|
||||
* `@suaveplan/api-plugin-database` and `@suaveplan/api-plugin-auth`.
|
||||
*/
|
||||
export function createContactsPlugin(
|
||||
options: ContactsPluginOptions = {}
|
||||
): PluginDescriptor<ContactsPluginConfig> {
|
||||
const parsed = ContactsPluginOptionsSchema.safeParse(options);
|
||||
if (!parsed.success) {
|
||||
throw new ContactsApiError(
|
||||
CONTACTS_API_CODES.INVALID_INPUT,
|
||||
"createContactsPlugin received invalid options",
|
||||
{ cause: parsed.error, context: { statusCode: 500 } }
|
||||
);
|
||||
}
|
||||
const resolved: ContactsPluginConfig = {
|
||||
routePrefix: parsed.data.routePrefix,
|
||||
maxImportBatchSize: parsed.data.maxImportBatchSize,
|
||||
maxGroupDepth: parsed.data.maxGroupDepth,
|
||||
enforceGroupDepth: parsed.data.enforceGroupDepth,
|
||||
applySchemaOnInit: parsed.data.applySchemaOnInit,
|
||||
...(parsed.data.logger !== undefined
|
||||
? { logger: parsed.data.logger }
|
||||
: {}),
|
||||
};
|
||||
|
||||
return definePlugin<ContactsPluginConfig>({
|
||||
name: "contacts",
|
||||
version: "0.1.0",
|
||||
description:
|
||||
"Contacts and groups HTTP plugin for @suaveplan/api. Mounts /contacts and /groups on a Hono OpenAPIHono backed by SurrealDB.",
|
||||
dependencies: ["database", "auth"],
|
||||
defaults: resolved,
|
||||
tags: ["contacts", "groups", "audience"],
|
||||
onInit: async (ctx) => runOnInit(ctx, resolved),
|
||||
});
|
||||
}
|
||||
|
||||
export async function runOnInit(
|
||||
ctx: PluginContext<ContactsPluginConfig>,
|
||||
config: ContactsPluginConfig
|
||||
): Promise<void> {
|
||||
const app = ctx.getService("app");
|
||||
if (app === undefined) {
|
||||
throw new ContactsApiError(
|
||||
CONTACTS_API_CODES.SERVICE_NOT_AVAILABLE,
|
||||
"contacts plugin requires the `app` service in ServiceRegistry",
|
||||
{ context: { missing: ["app"], statusCode: 500 } }
|
||||
);
|
||||
}
|
||||
const db = ctx.getService("db");
|
||||
if (db === undefined) {
|
||||
throw new ContactsApiError(
|
||||
CONTACTS_API_CODES.SERVICE_NOT_AVAILABLE,
|
||||
"contacts plugin requires the `db` service — load @suaveplan/api-plugin-database first",
|
||||
{ context: { missing: ["db"], statusCode: 500 } }
|
||||
);
|
||||
}
|
||||
const openAPIRegistry = ctx.getService("openAPIRegistry");
|
||||
const migrationRunner = ctx.getService("migrationRunner");
|
||||
const logger = config.logger ?? ctx.logger;
|
||||
|
||||
if (config.applySchemaOnInit) {
|
||||
await applyContactsSchema({
|
||||
...(migrationRunner !== undefined ? { migrationRunner } : {}),
|
||||
db,
|
||||
});
|
||||
}
|
||||
|
||||
const contactRepository = new SurrealContactRepository(db);
|
||||
const groupRepository = new SurrealGroupRepository(db, {
|
||||
maxGroupDepth: config.maxGroupDepth,
|
||||
enforceGroupDepth: config.enforceGroupDepth,
|
||||
});
|
||||
const membershipRepository = new SurrealMembershipRepository(db);
|
||||
|
||||
ctx.registerService("contactRepository", contactRepository);
|
||||
ctx.registerService("groupRepository", groupRepository);
|
||||
ctx.registerService("membershipRepository", membershipRepository);
|
||||
|
||||
// Inject the repositories + eventBus into every Hono request context.
|
||||
const eventBus = ctx.eventBus;
|
||||
const prefix = config.routePrefix;
|
||||
const mountPattern = prefix === "/" ? "*" : `${prefix}/*`;
|
||||
app.use(mountPattern, async (c, next) => {
|
||||
c.set("contactRepository", contactRepository);
|
||||
c.set("groupRepository", groupRepository);
|
||||
c.set("membershipRepository", membershipRepository);
|
||||
c.set("eventBus", eventBus);
|
||||
await next();
|
||||
});
|
||||
|
||||
registerRoutes({
|
||||
app,
|
||||
openAPIRegistry,
|
||||
routePrefix: prefix,
|
||||
maxImportBatchSize: config.maxImportBatchSize,
|
||||
logger,
|
||||
});
|
||||
}
|
||||
|
||||
interface RegisterRoutesArgs {
|
||||
readonly app: OpenAPIHono;
|
||||
readonly openAPIRegistry: ReturnType<
|
||||
PluginContext<ContactsPluginConfig>["getService"]
|
||||
>;
|
||||
readonly routePrefix: string;
|
||||
readonly maxImportBatchSize: number;
|
||||
readonly logger: {
|
||||
warn: (msg: string, ctx?: Readonly<Record<string, unknown>>) => void;
|
||||
};
|
||||
}
|
||||
|
||||
function registerRoutes(args: RegisterRoutesArgs): void {
|
||||
const { app, openAPIRegistry, routePrefix, maxImportBatchSize, logger } =
|
||||
args;
|
||||
const entries = [
|
||||
...buildContactRoutes({ maxImportBatchSize }),
|
||||
...buildGroupRoutes(),
|
||||
];
|
||||
if (openAPIRegistry !== undefined) {
|
||||
const reg = openAPIRegistry as {
|
||||
getRoutes: () => Array<{ path: string }>;
|
||||
};
|
||||
const collisionPrefix =
|
||||
routePrefix === "/" ? "/contacts" : `${routePrefix}/contacts`;
|
||||
const existing = reg
|
||||
.getRoutes()
|
||||
.filter((r) => r.path.startsWith(collisionPrefix));
|
||||
if (existing.length > 0) {
|
||||
throw new ContactsApiError(
|
||||
CONTACTS_API_CODES.ROUTE_PREFIX_COLLISION,
|
||||
`Route prefix '${routePrefix}' collides with previously registered routes`,
|
||||
{
|
||||
context: {
|
||||
prefix: routePrefix,
|
||||
existing: existing.map((r) => r.path),
|
||||
statusCode: 500,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
const prefixedEntries = entries.map((entry) => ({
|
||||
...entry,
|
||||
route: {
|
||||
...entry.route,
|
||||
path: `${routePrefix === "/" ? "" : routePrefix}${entry.route.path}`,
|
||||
},
|
||||
}));
|
||||
definePluginRoutes(prefixedEntries, {
|
||||
app,
|
||||
registry: openAPIRegistry as Parameters<
|
||||
typeof definePluginRoutes
|
||||
>[1]["registry"],
|
||||
pluginName: "contacts",
|
||||
});
|
||||
return;
|
||||
}
|
||||
logger.warn(
|
||||
"contacts plugin: openAPIRegistry not registered — routes will not appear in /openapi.json"
|
||||
);
|
||||
for (const entry of entries) {
|
||||
const method = entry.route.method as
|
||||
| "get"
|
||||
| "post"
|
||||
| "put"
|
||||
| "patch"
|
||||
| "delete";
|
||||
const path = `${routePrefix === "/" ? "" : routePrefix}${entry.route.path}`;
|
||||
app[method](
|
||||
path,
|
||||
async (c) => (await entry.handler(c as never)) as Response
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
# plugin/schema — bundled SurrealQL DDL
|
||||
|
||||
## Purpose
|
||||
|
||||
Inlined SurrealQL DDL for the three tables the contacts plugin owns:
|
||||
`contact`, `contact_group`, and `group_membership`. The constants in
|
||||
this module are bundled into the package's `dist/` so consumers
|
||||
never hit the filesystem at runtime; the `surql/tables/*.surql` files
|
||||
in the package root are the canonical editing source and are mirrored
|
||||
verbatim here. The integration test executes
|
||||
`CONTACTS_PLUGIN_SCHEMA` against a real SurrealDB testcontainer to
|
||||
catch drift.
|
||||
|
||||
`applyContactsSchema` exposes the apply entry-point used by the
|
||||
plugin's `onInit`. It prefers the host `MigrationRunner` (recording the
|
||||
apply in the host's migrations table) and falls back to direct
|
||||
`db.execute(...)` when no runner is present.
|
||||
|
||||
## Features
|
||||
|
||||
- Per-table constants (`CONTACT_TABLE_DDL`,
|
||||
`CONTACT_GROUP_TABLE_DDL`, `GROUP_MEMBERSHIP_TABLE_DDL`) plus the
|
||||
combined `CONTACTS_PLUGIN_SCHEMA`.
|
||||
- Idempotent `DEFINE TABLE OVERWRITE … SCHEMAFULL` statements — safe
|
||||
to re-apply.
|
||||
- `applyContactsSchema({ migrationRunner?, db? })` raises typed
|
||||
failures: `SCHEMA_APPLY_FAILED` on execute failure,
|
||||
`SERVICE_NOT_AVAILABLE` when neither transport supplied.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { applyContactsSchema } from "@suaveplan/api-plugin-contacts";
|
||||
|
||||
await applyContactsSchema({ db });
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
applyContactsSchema,
|
||||
CONTACTS_PLUGIN_SCHEMA,
|
||||
} from "@suaveplan/api-plugin-contacts";
|
||||
|
||||
// Recording the apply via the host migration runner:
|
||||
await applyContactsSchema({ migrationRunner });
|
||||
|
||||
// Or pre-rendering the DDL for inspection / golden tests:
|
||||
console.log(CONTACTS_PLUGIN_SCHEMA);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `CONTACT_TABLE_DDL: string`, `CONTACT_GROUP_TABLE_DDL: string`,
|
||||
`GROUP_MEMBERSHIP_TABLE_DDL: string` — per-table inline DDL.
|
||||
- `CONTACTS_PLUGIN_SCHEMA: string` — the three table DDLs joined by
|
||||
blank lines.
|
||||
- `ApplyContactsSchemaOptions` — `{ migrationRunner?, db? }`.
|
||||
- `applyContactsSchema(options): Promise<void>` — apply entry-point.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The DDL does not declare SurrealQL `PERMISSIONS` blocks; tenant
|
||||
scoping is enforced at the handler layer via `Principal.tenantId`.
|
||||
Operators wanting database-level enforcement can apply their own
|
||||
permission overlay after the table DDL.
|
||||
- `DEFINE INDEX` statements use `OVERWRITE` so re-applying the schema
|
||||
does not raise "index already exists" errors.
|
||||
- The migration record name `0001-contacts-plugin-schema` is stable
|
||||
across versions so the host migration table tracks the same logical
|
||||
apply across re-deploys.
|
||||
@@ -0,0 +1,92 @@
|
||||
import type { MigrationRunner } from "@suaveplan/api-migrations";
|
||||
import type { SurrealAdapter } from "@suaveplan/surrealdb";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { CONTACTS_API_CODES } from "../error-codes/error-codes.js";
|
||||
import {
|
||||
applyContactsSchema,
|
||||
CONTACT_GROUP_TABLE_DDL,
|
||||
CONTACT_TABLE_DDL,
|
||||
CONTACTS_PLUGIN_SCHEMA,
|
||||
GROUP_MEMBERSHIP_TABLE_DDL,
|
||||
} from "./schema.js";
|
||||
|
||||
describe("applyContactsSchema", () => {
|
||||
it("each table DDL declares its table name", { timeout: 5000 }, () => {
|
||||
expect(CONTACT_TABLE_DDL).toContain("DEFINE TABLE OVERWRITE contact ");
|
||||
expect(CONTACT_GROUP_TABLE_DDL).toContain(
|
||||
"DEFINE TABLE OVERWRITE contact_group "
|
||||
);
|
||||
expect(GROUP_MEMBERSHIP_TABLE_DDL).toContain(
|
||||
"DEFINE TABLE OVERWRITE group_membership "
|
||||
);
|
||||
});
|
||||
|
||||
it("CONTACTS_PLUGIN_SCHEMA concatenates all three table DDLs", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(CONTACTS_PLUGIN_SCHEMA).toContain("contact_group");
|
||||
expect(CONTACTS_PLUGIN_SCHEMA).toContain("group_membership");
|
||||
expect(CONTACTS_PLUGIN_SCHEMA).toContain("OVERWRITE contact ");
|
||||
});
|
||||
|
||||
it("uses migrationRunner when supplied", { timeout: 5000 }, async () => {
|
||||
const calls: unknown[] = [];
|
||||
const runner = {
|
||||
async run(m: unknown) {
|
||||
calls.push(m);
|
||||
},
|
||||
} as unknown as MigrationRunner;
|
||||
await applyContactsSchema({ migrationRunner: runner });
|
||||
expect(calls).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("wraps migrationRunner failures in SCHEMA_APPLY_FAILED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const runner = {
|
||||
async run() {
|
||||
throw new Error("apply failed");
|
||||
},
|
||||
} as unknown as MigrationRunner;
|
||||
await expect(
|
||||
applyContactsSchema({ migrationRunner: runner })
|
||||
).rejects.toMatchObject({
|
||||
code: CONTACTS_API_CODES.SCHEMA_APPLY_FAILED,
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to db.execute when no migrationRunner supplied", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const executed: string[] = [];
|
||||
const db = {
|
||||
async execute(sql: string) {
|
||||
executed.push(sql);
|
||||
},
|
||||
} as unknown as SurrealAdapter;
|
||||
await applyContactsSchema({ db });
|
||||
expect(executed).toHaveLength(1);
|
||||
expect(executed[0]).toContain("DEFINE TABLE OVERWRITE contact");
|
||||
});
|
||||
|
||||
it("wraps db.execute failures in SCHEMA_APPLY_FAILED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const db = {
|
||||
async execute() {
|
||||
throw new Error("connect failed");
|
||||
},
|
||||
} as unknown as SurrealAdapter;
|
||||
await expect(applyContactsSchema({ db })).rejects.toMatchObject({
|
||||
code: CONTACTS_API_CODES.SCHEMA_APPLY_FAILED,
|
||||
});
|
||||
});
|
||||
|
||||
it("throws SERVICE_NOT_AVAILABLE when neither transport supplied", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
await expect(applyContactsSchema({})).rejects.toMatchObject({
|
||||
code: CONTACTS_API_CODES.SERVICE_NOT_AVAILABLE,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* Bundled SurrealQL schema applied at `onInit`. Inlined as a string so
|
||||
* the package ships a single tree-shakable `.js` bundle with no
|
||||
* runtime file I/O. The `surql/tables/*.surql` files in the package
|
||||
* root are the canonical source for editing; this constant mirrors
|
||||
* them and is verified by the integration test (which executes the
|
||||
* inlined statements against a real SurrealDB testcontainer).
|
||||
*/
|
||||
|
||||
import type { MigrationRunner } from "@suaveplan/api-migrations";
|
||||
import type { SurrealAdapter } from "@suaveplan/surrealdb";
|
||||
import {
|
||||
CONTACTS_API_CODES,
|
||||
ContactsApiError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
|
||||
export const CONTACT_TABLE_DDL = `
|
||||
DEFINE TABLE OVERWRITE contact SCHEMAFULL;
|
||||
|
||||
DEFINE FIELD OVERWRITE tenantId ON contact TYPE string ASSERT $value != NONE;
|
||||
DEFINE FIELD OVERWRITE displayName ON contact TYPE string ASSERT $value != NONE;
|
||||
DEFINE FIELD OVERWRITE firstName ON contact TYPE option<string>;
|
||||
DEFINE FIELD OVERWRITE lastName ON contact TYPE option<string>;
|
||||
DEFINE FIELD OVERWRITE channels ON contact TYPE array<object>;
|
||||
DEFINE FIELD OVERWRITE channels.*.type ON contact TYPE string;
|
||||
DEFINE FIELD OVERWRITE channels.*.value ON contact TYPE string;
|
||||
DEFINE FIELD OVERWRITE channels.*.primary ON contact TYPE bool DEFAULT false;
|
||||
DEFINE FIELD OVERWRITE channels.*.verified ON contact TYPE bool DEFAULT false;
|
||||
DEFINE FIELD OVERWRITE tags ON contact TYPE array<string> DEFAULT [];
|
||||
DEFINE FIELD OVERWRITE status ON contact TYPE string DEFAULT 'active' ASSERT $value IN ['active', 'unsubscribed', 'bounced', 'complained', 'archived'];
|
||||
DEFINE FIELD OVERWRITE metadata ON contact TYPE object DEFAULT {};
|
||||
DEFINE FIELD OVERWRITE createdAt ON contact TYPE datetime DEFAULT time::now();
|
||||
DEFINE FIELD OVERWRITE updatedAt ON contact TYPE datetime VALUE time::now();
|
||||
|
||||
DEFINE INDEX OVERWRITE idx_contact_tenant ON contact FIELDS tenantId;
|
||||
DEFINE INDEX OVERWRITE idx_contact_status ON contact FIELDS tenantId, status;
|
||||
`;
|
||||
|
||||
export const CONTACT_GROUP_TABLE_DDL = `
|
||||
DEFINE TABLE OVERWRITE contact_group SCHEMAFULL;
|
||||
|
||||
DEFINE FIELD OVERWRITE tenantId ON contact_group TYPE string ASSERT $value != NONE;
|
||||
DEFINE FIELD OVERWRITE name ON contact_group TYPE string ASSERT $value != NONE;
|
||||
DEFINE FIELD OVERWRITE description ON contact_group TYPE option<string>;
|
||||
DEFINE FIELD OVERWRITE parentId ON contact_group TYPE option<string>;
|
||||
DEFINE FIELD OVERWRITE path ON contact_group TYPE string ASSERT $value != NONE;
|
||||
DEFINE FIELD OVERWRITE depth ON contact_group TYPE int DEFAULT 0;
|
||||
DEFINE FIELD OVERWRITE metadata ON contact_group TYPE object DEFAULT {};
|
||||
DEFINE FIELD OVERWRITE contactCount ON contact_group TYPE int DEFAULT 0;
|
||||
DEFINE FIELD OVERWRITE totalContactCount ON contact_group TYPE int DEFAULT 0;
|
||||
DEFINE FIELD OVERWRITE createdAt ON contact_group TYPE datetime DEFAULT time::now();
|
||||
DEFINE FIELD OVERWRITE updatedAt ON contact_group TYPE datetime VALUE time::now();
|
||||
|
||||
DEFINE INDEX OVERWRITE idx_group_tenant ON contact_group FIELDS tenantId;
|
||||
DEFINE INDEX OVERWRITE idx_group_parent ON contact_group FIELDS tenantId, parentId;
|
||||
DEFINE INDEX OVERWRITE idx_group_path ON contact_group FIELDS path;
|
||||
`;
|
||||
|
||||
export const GROUP_MEMBERSHIP_TABLE_DDL = `
|
||||
DEFINE TABLE OVERWRITE group_membership SCHEMAFULL;
|
||||
|
||||
DEFINE FIELD OVERWRITE groupId ON group_membership TYPE string ASSERT $value != NONE;
|
||||
DEFINE FIELD OVERWRITE contactId ON group_membership TYPE string ASSERT $value != NONE;
|
||||
DEFINE FIELD OVERWRITE metadata ON group_membership TYPE object DEFAULT {};
|
||||
DEFINE FIELD OVERWRITE addedAt ON group_membership TYPE datetime DEFAULT time::now();
|
||||
|
||||
DEFINE INDEX OVERWRITE idx_membership_unique ON group_membership FIELDS groupId, contactId UNIQUE;
|
||||
DEFINE INDEX OVERWRITE idx_membership_group ON group_membership FIELDS groupId;
|
||||
DEFINE INDEX OVERWRITE idx_membership_contact ON group_membership FIELDS contactId;
|
||||
`;
|
||||
|
||||
/** All-in-one DDL — the three tables in canonical order, separated by newlines. */
|
||||
export const CONTACTS_PLUGIN_SCHEMA: string = [
|
||||
CONTACT_TABLE_DDL,
|
||||
CONTACT_GROUP_TABLE_DDL,
|
||||
GROUP_MEMBERSHIP_TABLE_DDL,
|
||||
]
|
||||
.map((s) => s.trim())
|
||||
.join("\n\n");
|
||||
|
||||
export interface ApplyContactsSchemaOptions {
|
||||
readonly migrationRunner?: MigrationRunner;
|
||||
readonly db?: SurrealAdapter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the bundled DDL. Prefers `migrationRunner` (records the apply
|
||||
* in the host's tracking table); falls back to `db.execute`. Wraps
|
||||
* failures in `ContactsApiError(SCHEMA_APPLY_FAILED)`.
|
||||
*/
|
||||
export async function applyContactsSchema(
|
||||
options: ApplyContactsSchemaOptions
|
||||
): Promise<void> {
|
||||
const { migrationRunner, db } = options;
|
||||
if (migrationRunner !== undefined) {
|
||||
try {
|
||||
await migrationRunner.run([
|
||||
{
|
||||
name: "0001-contacts-plugin-schema",
|
||||
path: "<inline>",
|
||||
order: 1,
|
||||
content: CONTACTS_PLUGIN_SCHEMA,
|
||||
},
|
||||
]);
|
||||
return;
|
||||
} catch (err) {
|
||||
throw new ContactsApiError(
|
||||
CONTACTS_API_CODES.SCHEMA_APPLY_FAILED,
|
||||
"migrationRunner.run(CONTACTS_PLUGIN_SCHEMA) failed",
|
||||
{ cause: err as Error, context: { statusCode: 500 } }
|
||||
);
|
||||
}
|
||||
}
|
||||
if (db !== undefined) {
|
||||
try {
|
||||
await db.execute(CONTACTS_PLUGIN_SCHEMA);
|
||||
return;
|
||||
} catch (err) {
|
||||
throw new ContactsApiError(
|
||||
CONTACTS_API_CODES.SCHEMA_APPLY_FAILED,
|
||||
"db.execute(CONTACTS_PLUGIN_SCHEMA) failed",
|
||||
{ cause: err as Error, context: { statusCode: 500 } }
|
||||
);
|
||||
}
|
||||
}
|
||||
throw new ContactsApiError(
|
||||
CONTACTS_API_CODES.SERVICE_NOT_AVAILABLE,
|
||||
"applyContactsSchema requires either a migrationRunner or db",
|
||||
{
|
||||
context: {
|
||||
missing: ["migrationRunner", "db"],
|
||||
statusCode: 500,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user