@nanobpm/urban 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -99,6 +99,10 @@ export interface EngineClient {
99
99
  processInstanceKey: string;
100
100
  variables?: Record<string, unknown>;
101
101
  }>;
102
+ /** Cancel a running process instance (the pages surface's row-cancel action). */
103
+ cancelInstance(input: {
104
+ processInstanceKey: string;
105
+ }): Promise<void>;
102
106
  /** Publish a message for correlation. */
103
107
  publishMessage(input: {
104
108
  name: string;
@@ -36,6 +36,30 @@ export interface SurfaceDecl {
36
36
  enabled?: boolean;
37
37
  path?: string;
38
38
  agent?: string;
39
+ /** pages surface: directory of `*.page.json` (relative to app root). Default `pages`. */
40
+ pagesDir?: string;
41
+ /** pages surface: the page served at `/`. Default `home`. */
42
+ homePage?: string;
43
+ /** pages surface: max rows a `dataGrid` fetch returns. Default 200. */
44
+ rowLimit?: number;
45
+ /** pages surface: the injected default datasource name. Default `app`. */
46
+ sourceName?: string;
47
+ }
48
+ /**
49
+ * An app-authored action handler override (ADR 0055 phase 3). Each declaration binds a
50
+ * route to a handler module (default-exports an `ActionHandler`), letting an app wrap the
51
+ * generic pages start/cancel/message actions with business logic. Mounted before the
52
+ * generic pages action routes, so an exact override shadows the generic one.
53
+ */
54
+ export interface ActionDecl {
55
+ /** Route path to serve, e.g. "/app/actions/cancel" or "/app/actions/start/convergence-loop". */
56
+ path: string;
57
+ /** Handler module path relative to the app root; default-exports an `ActionHandler`. */
58
+ module: string;
59
+ /** HTTP method to match. Default "POST". */
60
+ method?: string;
61
+ /** Match `path` as a prefix rather than exactly. Default false. */
62
+ prefix?: boolean;
39
63
  }
40
64
  export interface AppManifest {
41
65
  $schema?: string;
@@ -63,6 +87,7 @@ export interface AppManifest {
63
87
  [k: string]: unknown;
64
88
  }>;
65
89
  surfaces?: Record<string, SurfaceDecl>;
90
+ actions?: ActionDecl[];
66
91
  workers?: WorkerDecl[];
67
92
  llm?: Record<string, unknown>;
68
93
  security?: Record<string, unknown>;
@@ -0,0 +1,45 @@
1
+ import type { AppApi, RuntimeContext } from "../context.ts";
2
+ import type { HttpRequest } from "../host.ts";
3
+ import { type Route } from "../router.ts";
4
+ /** The request handed to an action handler: the raw request plus its parsed JSON body. */
5
+ export interface ActionRequest {
6
+ /** The raw HTTP request (method, path, query, headers). */
7
+ req: HttpRequest;
8
+ /**
9
+ * The request body parsed as JSON (`{}` when the body is empty). A body that is present
10
+ * but not valid JSON is rejected with `400` before the handler runs.
11
+ */
12
+ body: unknown;
13
+ }
14
+ /** What an action handler returns; the runtime serializes `body` as JSON. */
15
+ export interface ActionResult {
16
+ /** HTTP status. Default `200`, or `204` when the handler returns nothing. */
17
+ status?: number;
18
+ /** Response payload, serialized as JSON. Omit for an empty response. */
19
+ body?: unknown;
20
+ /** Response headers merged over `content-type: application/json`. */
21
+ headers?: Record<string, string>;
22
+ }
23
+ /**
24
+ * An app-authored action handler: the parsed request plus the injected app API. Return an
25
+ * {@link ActionResult}, or nothing for an empty `204`. Throwing yields a `500` with the
26
+ * error message.
27
+ */
28
+ export type ActionHandler = (input: ActionRequest, app: AppApi) => Promise<ActionResult | void> | ActionResult | void;
29
+ /**
30
+ * Resolve an action handler from a loaded module, in priority order:
31
+ * 1. `default` (when it is a function — the common one-handler-per-file case)
32
+ * 2. a named export `handler`
33
+ */
34
+ export declare function resolveActionHandler(mod: Record<string, unknown>): ActionHandler | undefined;
35
+ export interface ActionsHandle {
36
+ readonly name: string;
37
+ routes: Route[];
38
+ describe(): Record<string, unknown>;
39
+ }
40
+ /**
41
+ * Mount the declared action handlers. Each declaration contributes one route; the handler
42
+ * module is imported lazily (and cached) on first request, so a missing or malformed module
43
+ * surfaces as a clear `500` on that route rather than failing the whole boot.
44
+ */
45
+ export declare function mountActions(ctx: RuntimeContext, app: AppApi): ActionsHandle;
@@ -0,0 +1,113 @@
1
+ // actions — per-app action handler files (ADR 0055 phase 3). The pages surface serves
2
+ // generic start/cancel/message actions; a real app usually needs to wrap those with
3
+ // business logic (register a domain row before starting, reconcile app-owned rest state
4
+ // on cancel, correlate a message to an aggregate). An app declares such overrides in the
5
+ // manifest `actions[]` and authors a small module per action. Each module is injected with
6
+ // the app's `AppApi` (typed datasource + engine + sdk + host utils) — the same contract as
7
+ // a worker handler — so the handler code reads exactly like the app's own service layer.
8
+ //
9
+ // Precedence: action routes are mounted BEFORE the generic pages action routes (the router
10
+ // is first-match-wins), so an override at `/app/actions/cancel` shadows the generic one,
11
+ // while an unmatched process still falls through to the generic `/app/actions/start/` route.
12
+ import { json, normalizeRoutePath } from "../router.js";
13
+ /**
14
+ * Resolve an action handler from a loaded module, in priority order:
15
+ * 1. `default` (when it is a function — the common one-handler-per-file case)
16
+ * 2. a named export `handler`
17
+ */
18
+ export function resolveActionHandler(mod) {
19
+ if (typeof mod.default === "function")
20
+ return mod.default;
21
+ if (typeof mod.handler === "function")
22
+ return mod.handler;
23
+ return undefined;
24
+ }
25
+ /**
26
+ * Mount the declared action handlers. Each declaration contributes one route; the handler
27
+ * module is imported lazily (and cached) on first request, so a missing or malformed module
28
+ * surfaces as a clear `500` on that route rather than failing the whole boot.
29
+ */
30
+ export function mountActions(ctx, app) {
31
+ const decls = (ctx.manifest.actions ?? []);
32
+ const routes = [];
33
+ const mounted = [];
34
+ const joinRoot = (p) => p.startsWith("/") ? p : `${ctx.root.replace(/\/+$/, "")}/${p.replace(/^\/+/, "")}`;
35
+ // Cache module loads so a module reused across several declarations is imported once.
36
+ const moduleCache = new Map();
37
+ const loadModule = (path) => {
38
+ const key = joinRoot(path);
39
+ let pending = moduleCache.get(key);
40
+ if (!pending) {
41
+ // Evict the entry if the import rejects, so a transient failure doesn't
42
+ // permanently wedge the route (a cached rejected Promise would reject forever).
43
+ pending = ctx.host.importModule(key).catch((err) => {
44
+ moduleCache.delete(key);
45
+ throw err;
46
+ });
47
+ moduleCache.set(key, pending);
48
+ }
49
+ return pending;
50
+ };
51
+ for (const decl of decls) {
52
+ if (!decl?.path || !decl?.module) {
53
+ ctx.host.log("warn", "skipping action: needs both `path` and `module`", {
54
+ action: decl,
55
+ });
56
+ continue;
57
+ }
58
+ const method = (decl.method ?? "POST").trim().toUpperCase() || "POST";
59
+ const prefix = decl.prefix === true;
60
+ // The router's prefix match is a raw `startsWith`, so a prefix route needs a trailing
61
+ // slash to stay boundary-safe (otherwise "/hooks" would also match "/hooks2"). Exact
62
+ // routes keep the normalized (trailing-slash-stripped) path. Guard the root case so a
63
+ // prefix path of "/" doesn't become "//" (which would never match).
64
+ const base = normalizeRoutePath(decl.path, decl.path);
65
+ const path = prefix ? (base.endsWith("/") ? base : `${base}/`) : base;
66
+ routes.push({
67
+ method,
68
+ path,
69
+ prefix,
70
+ source: `actions:${decl.module}`,
71
+ handler: async (req) => {
72
+ const raw = await req.text();
73
+ let body;
74
+ try {
75
+ body = raw ? JSON.parse(raw) : {};
76
+ }
77
+ catch {
78
+ return json({ error: "request body must be JSON" }, 400);
79
+ }
80
+ let handler;
81
+ try {
82
+ handler = resolveActionHandler(await loadModule(decl.module));
83
+ }
84
+ catch (e) {
85
+ ctx.host.log("error", "action handler module failed to load", {
86
+ path,
87
+ module: decl.module,
88
+ error: String(e?.message ?? e),
89
+ });
90
+ return json({ error: `action handler ${decl.module} failed to load` }, 500);
91
+ }
92
+ if (!handler) {
93
+ return json({ error: `action handler ${decl.module} exports no default function or named \`handler\`` }, 500);
94
+ }
95
+ try {
96
+ const result = await handler({ req, body }, app);
97
+ if (!result)
98
+ return { status: 204 };
99
+ return {
100
+ status: result.status ?? 200,
101
+ headers: { "content-type": "application/json", ...(result.headers ?? {}) },
102
+ body: result.body === undefined ? undefined : JSON.stringify(result.body),
103
+ };
104
+ }
105
+ catch (e) {
106
+ return json({ error: String(e?.message ?? e) }, 500);
107
+ }
108
+ },
109
+ });
110
+ mounted.push({ path, method, module: decl.module, prefix });
111
+ }
112
+ return { name: "actions", routes, describe: () => ({ actions: mounted }) };
113
+ }
@@ -47,6 +47,8 @@ export interface DataSource {
47
47
  * (default "id"). */
48
48
  table<T extends object = Row>(name: string, pk?: string): Table<T>;
49
49
  }
50
+ /** Double-quote a SQL identifier (table/column), escaping embedded quotes. */
51
+ export declare function quoteIdent(name: string): string;
50
52
  /** A typed gateway over a single table — the record-oriented data object a handler binds to
51
53
  * instead of hand-writing SQL (the Delphi `TTable`/data-module idea, ADR 0029 §6). It builds
52
54
  * parameterised SQL from a typed row object's own keys, so callers manipulate rows as records.
@@ -11,7 +11,8 @@
11
11
  // const orders = db.table<Order>("orders"); // a typed gateway over one table
12
12
  // await orders.insert({ id, status: "new" });
13
13
  // const o = await orders.get(id);
14
- function quoteIdent(name) {
14
+ /** Double-quote a SQL identifier (table/column), escaping embedded quotes. */
15
+ export function quoteIdent(name) {
15
16
  return `"${name.replaceAll('"', '""')}"`;
16
17
  }
17
18
  /** Build a parameterised ` WHERE a = ? AND b = ?` clause from an equality map; an empty map
@@ -0,0 +1,42 @@
1
+ import type { AppApi, RuntimeContext } from "../context.ts";
2
+ import type { EngineClient } from "../host.ts";
3
+ import { type Route } from "../router.ts";
4
+ /** The subset of the datasource gateway the page runtime needs. */
5
+ export interface PagesDataSource {
6
+ query(sql: string, params?: unknown[]): Promise<Record<string, unknown>[]>;
7
+ schema(): Promise<{
8
+ name: string;
9
+ }[]>;
10
+ }
11
+ export interface PagesOptions {
12
+ /** Directory holding `*.page.json` (relative to the app root). Default `pages`. */
13
+ pagesDir?: string;
14
+ /** The page served at `/`. Default `home`. */
15
+ homePage?: string;
16
+ /** Max rows a `dataGrid` fetch returns. Default 200. */
17
+ rowLimit?: number;
18
+ /** The injected default datasource name (the alias apps bind to). Default `app`. */
19
+ sourceName?: string;
20
+ }
21
+ export interface PagesDeps {
22
+ db: PagesDataSource;
23
+ engine: EngineClient;
24
+ /** Read a page file; injectable for tests. */
25
+ readPage(path: string): Promise<string>;
26
+ }
27
+ /**
28
+ * Build the page-runtime routes over an injected datasource + engine. Pure over its deps
29
+ * so it is unit-testable without a real host/server (see pages.test.ts).
30
+ */
31
+ export declare function createPagesRoutes(opts: PagesOptions, deps: PagesDeps): Route[];
32
+ export interface PagesHandle {
33
+ readonly name: string;
34
+ routes: Route[];
35
+ describe(): Record<string, unknown>;
36
+ }
37
+ /**
38
+ * Mount the `pages` surface when enabled in the manifest. Binds the page runtime to the
39
+ * app's datasource gateway (`app.data.open(source)`), engine, and the host's file read.
40
+ * Returns no routes when the surface is disabled or absent.
41
+ */
42
+ export declare function mountPages(ctx: RuntimeContext, app: AppApi): PagesHandle;