@nanobpm/urban 0.43.0 → 0.45.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.
package/README.md CHANGED
@@ -64,8 +64,8 @@ urban run # start workers and serve surfaces
64
64
  ## Library API
65
65
 
66
66
  Everything the CLI does is available programmatically. Import the whole surface
67
- from `@nanobpm/urban`, or the focused subpaths `@nanobpm/urban/runtime` and
68
- `@nanobpm/urban/toolkit`.
67
+ from `@nanobpm/urban`, or the focused subpaths `@nanobpm/urban/runtime`,
68
+ `@nanobpm/urban/toolkit`, and `@nanobpm/urban/effect`.
69
69
 
70
70
  ### Runtime — run an app
71
71
 
@@ -244,6 +244,57 @@ survives restarts, model a timer **start**/**intermediate** event instead with
244
244
  `w.startOn(...)` / `w.timer(...)` from [`@nanobpm/workflow`](../workflow) — the
245
245
  engine owns those.
246
246
 
247
+ ### Effect — typed errors & scoped resources (`@nanobpm/urban/effect`)
248
+
249
+ A tiny, **zero-dependency**, Effect-like core for the imperative seams (workers,
250
+ provisioning, resource lifecycles) — without pulling in the `effect` package or
251
+ its viral paradigm. It gives you the three ergonomics you actually reach for:
252
+
253
+ - **Typed-error `Result<A, E>` with generator do-notation.** `gen(function* … )`
254
+ + `yield*` threads success values and short-circuits on the first failure,
255
+ automatically inferring the union of every failure type into `E` — like
256
+ `Effect.gen` + `yield*`.
257
+ - **Tagged errors + exhaustive matching.** `tag("NotFound")` builds a discriminated
258
+ error; `matchTags` forces you to handle **every** variant (omitting one is a
259
+ compile error), like `Data.TaggedError` + `catchTags`.
260
+ - **Scoped resources.** `scoped` + `acquireRelease` run every release on every exit
261
+ — success, failure, or thrown — LIFO, like `Effect.scoped`.
262
+
263
+ ```ts
264
+ import { gen, ok, fail, tag, matchTags, scoped, acquireRelease } from "@nanobpm/urban/effect";
265
+ import { mkdtempSync, rmSync } from "node:fs";
266
+ import { tmpdir } from "node:os";
267
+ import { join } from "node:path";
268
+
269
+ const parse = (s: string) => (s ? ok(s.length) : fail(tag("Empty")));
270
+ const check = (n: number) => (n > 3 ? fail(tag("TooLong", { n })) : ok(n));
271
+
272
+ const run = (s: string) =>
273
+ gen(function* () {
274
+ const n = yield* parse(s); // E gains "Empty"
275
+ return yield* check(n); // E gains "TooLong"
276
+ }); // Result<number, {_tag:"Empty"} | {_tag:"TooLong", n:number}>
277
+
278
+ const r = run("hello");
279
+ if (r._tag === "Fail") {
280
+ matchTags(r.error, { // must handle both — omit one and it won't compile
281
+ Empty: () => "was empty",
282
+ TooLong: (e) => `too long: ${e.n}`,
283
+ });
284
+ }
285
+
286
+ // `acquireRelease`'s `acquire` is synchronous, so use sync fs APIs (or `await`
287
+ // the acquisition yourself and register the disposer with `scope.add`).
288
+ await scoped(async (scope) => {
289
+ const dir = acquireRelease(
290
+ scope,
291
+ () => mkdtempSync(join(tmpdir(), "urban-")),
292
+ (d) => rmSync(d, { recursive: true, force: true }),
293
+ ); // released on any exit
294
+ // …use dir…
295
+ });
296
+ ```
297
+
247
298
  ## Related packages
248
299
 
249
300
  - [`@nanobpm/workflow`](../workflow) — the code-first process surface (`defineFlow`).
@@ -0,0 +1,39 @@
1
+ export type Result<A, E> = Ok<A> | Fail<E>;
2
+ export declare class Ok<A> {
3
+ readonly _tag: "Ok";
4
+ readonly value: A;
5
+ constructor(value: A);
6
+ [Symbol.iterator](): Generator<never, A, unknown>;
7
+ }
8
+ export declare class Fail<E> {
9
+ readonly _tag: "Fail";
10
+ readonly error: E;
11
+ constructor(error: E);
12
+ [Symbol.iterator](): Generator<Fail<E>, never, unknown>;
13
+ }
14
+ export declare const ok: <A>(value: A) => Ok<A>;
15
+ export declare const fail: <E>(error: E) => Fail<E>;
16
+ export declare const isOk: <A, E>(r: Result<A, E>) => r is Ok<A>;
17
+ export declare const isFail: <A, E>(r: Result<A, E>) => r is Fail<E>;
18
+ export declare function gen<Y extends Fail<unknown>, A>(body: () => Generator<Y, A, unknown>): Result<A, Y["error"]>;
19
+ export type OkOf<R> = R extends Ok<infer A> ? A : never;
20
+ export type ErrOf<R> = R extends Fail<infer E> ? E : never;
21
+ export declare const map: <R extends Result<unknown, unknown>, B>(r: R, f: (a: OkOf<R>) => B) => Result<B, ErrOf<R>>;
22
+ export declare const mapError: <R extends Result<unknown, unknown>, E2>(r: R, f: (e: ErrOf<R>) => E2) => Result<OkOf<R>, E2>;
23
+ export declare const match: <R extends Result<unknown, unknown>, T>(r: R, onOk: (a: OkOf<R>) => T, onFail: (e: ErrOf<R>) => T) => T;
24
+ export type Tagged<Tag extends string> = {
25
+ readonly _tag: Tag;
26
+ };
27
+ export type TagUnion<Tag extends string, P> = Tag extends string ? Tagged<Tag> & P : never;
28
+ export declare function tag<Tag extends string>(t: Tag): TagUnion<Tag, {}>;
29
+ export declare function tag<Tag extends string, P extends object>(t: Tag, props: P & {
30
+ _tag?: never;
31
+ }): TagUnion<Tag, P>;
32
+ export declare const matchTags: <E extends Tagged<string>, R>(e: E, handlers: { [K in E["_tag"]]: (e: Extract<E, Tagged<K>>) => R; }) => R;
33
+ export declare class Scope {
34
+ #private;
35
+ add(dispose: () => void | Promise<void>): void;
36
+ close(): Promise<void>;
37
+ }
38
+ export declare function acquireRelease<A>(scope: Scope, acquire: () => A, release: (a: A) => void | Promise<void>): A;
39
+ export declare function scoped<A>(body: (scope: Scope) => A | Promise<A>): Promise<A>;
@@ -0,0 +1,133 @@
1
+ // effectlite — a tiny, ZERO-DEPENDENCY, Effect-like core for Urban glue code.
2
+ //
3
+ // Urban's published surface is declarative JSON + a client renderer, so we do
4
+ // NOT want the `effect` package (bundle weight + viral paradigm) in the runtime.
5
+ // But the imperative seams — workers, provisioning, resource lifecycles — still
6
+ // benefit from the three Effect ergonomics we actually reach for. effectlite
7
+ // gives us those, in ~100 LOC of implementation with no runtime dependencies:
8
+ //
9
+ // 1. A typed-error `Result<A, E>` whose `E` composes automatically through
10
+ // generator do-notation (`gen(function*(){ const x = yield* step })`),
11
+ // short-circuiting on the first failure exactly like `Effect.gen` + `yield*`.
12
+ // 2. `tag()` tagged errors (cf. `Data.TaggedError`) + exhaustive `matchTags`
13
+ // (cf. `catchTags`) — the compiler forces every failure mode to be handled.
14
+ // 3. `scoped` + `acquireRelease` (cf. `Effect.scoped`) — releases run on every
15
+ // exit: success, failure, and thrown.
16
+ //
17
+ // It is synchronous over `Result`; the scope is async only so disposers may
18
+ // await. Consume via `import { gen, ok, fail } from "@nanobpm/urban/effect"`.
19
+ export class Ok {
20
+ _tag = "Ok";
21
+ value;
22
+ constructor(value) {
23
+ this.value = value;
24
+ }
25
+ // Delegated by `yield*`: returns the value, yields nothing to the driver.
26
+ *[Symbol.iterator]() {
27
+ return this.value;
28
+ }
29
+ }
30
+ export class Fail {
31
+ _tag = "Fail";
32
+ error;
33
+ constructor(error) {
34
+ this.error = error;
35
+ }
36
+ // Delegated by `yield*`: yields itself so the driver can short-circuit; never
37
+ // resumes (the driver abandons the generator on the first failure).
38
+ *[Symbol.iterator]() {
39
+ yield this;
40
+ throw new Error("effectlite: generator resumed after a failure");
41
+ }
42
+ }
43
+ export const ok = (value) => new Ok(value);
44
+ export const fail = (error) => new Fail(error);
45
+ export const isOk = (r) => r._tag === "Ok";
46
+ export const isFail = (r) => r._tag === "Fail";
47
+ // Run a generator that `yield*`s Results. Because only `Fail` yields to us, a
48
+ // single `next()` either lands on the first failure or on the final return.
49
+ // `Y` captures the UNION of every yielded `Fail<…>`; `Y["error"]` distributes
50
+ // that index access into the Result's error channel (a union in ⇒ a union out).
51
+ export function gen(body) {
52
+ // Typed as `Iterator` (not `Generator`) so `return` is the optional-arg
53
+ // variant — lets us close a short-circuited body without inventing a return value.
54
+ const it = body();
55
+ const step = it.next();
56
+ if (step.done)
57
+ return ok(step.value);
58
+ // Not done ⇒ a `Fail` yielded to short-circuit. Close the generator first so
59
+ // any `try/finally` in the body runs and captured resources aren't retained,
60
+ // then surface its error on the failure channel.
61
+ it.return?.();
62
+ return fail(step.value.error);
63
+ }
64
+ // Narrowing guards that carry the *extracted* channel type, so `r.value` /
65
+ // `r.error` land as `OkOf<R>` / `ErrOf<R>` (not `unknown`) with no assertion —
66
+ // keeping the combinators within the repo's no-type-assertion convention.
67
+ const isOkOf = (r) => r._tag === "Ok";
68
+ const isFailOf = (r) => r._tag === "Fail";
69
+ export const map = (r, f) => {
70
+ if (isOkOf(r))
71
+ return ok(f(r.value));
72
+ if (isFailOf(r))
73
+ return r;
74
+ throw new Error("effectlite: Result was neither Ok nor Fail");
75
+ };
76
+ export const mapError = (r, f) => {
77
+ if (isFailOf(r))
78
+ return fail(f(r.error));
79
+ if (isOkOf(r))
80
+ return r;
81
+ throw new Error("effectlite: Result was neither Ok nor Fail");
82
+ };
83
+ export const match = (r, onOk, onFail) => {
84
+ if (isOkOf(r))
85
+ return onOk(r.value);
86
+ if (isFailOf(r))
87
+ return onFail(r.error);
88
+ throw new Error("effectlite: Result was neither Ok nor Fail");
89
+ };
90
+ export function tag(t, props) {
91
+ return { ...props, _tag: t };
92
+ }
93
+ // Handle every tag in the union — the handlers object is keyed by `E["_tag"]`,
94
+ // so omitting a variant is a COMPILE error (no accidental dropped failure mode).
95
+ export const matchTags = (e, handlers) =>
96
+ // biome-ignore lint/plugin: the mapped handler type is precise for callers; internally we index it with the runtime tag, which needs this erasure to a plain record.
97
+ handlers[e._tag](e);
98
+ // ---------------------------------------------------------------------------
99
+ // Scoped resources
100
+ // ---------------------------------------------------------------------------
101
+ export class Scope {
102
+ #disposers = [];
103
+ add(dispose) {
104
+ this.#disposers.push(dispose);
105
+ }
106
+ async close() {
107
+ // LIFO, best-effort — a failing disposer never blocks the others.
108
+ for (const d of this.#disposers.reverse()) {
109
+ try {
110
+ await d();
111
+ }
112
+ catch { /* best effort */ }
113
+ }
114
+ this.#disposers = [];
115
+ }
116
+ }
117
+ // Acquire a resource and register its release with the scope in one step.
118
+ export function acquireRelease(scope, acquire, release) {
119
+ const a = acquire();
120
+ scope.add(() => release(a));
121
+ return a;
122
+ }
123
+ // Run `body` with a fresh scope; close it (releasing all resources) on every
124
+ // exit path — return, Result failure, or thrown.
125
+ export async function scoped(body) {
126
+ const scope = new Scope();
127
+ try {
128
+ return await body(scope);
129
+ }
130
+ finally {
131
+ await scope.close();
132
+ }
133
+ }
@@ -518,6 +518,12 @@ table.pc-grid th { font-weight:600; color:var(--nano-text-muted); }
518
518
  .pc-collapse-header { display:flex; align-items:center; gap:.5rem; width:100%; margin:0 0 .75rem; padding:0; background:transparent; border:0; color:inherit; font:inherit; font-size:1rem; font-weight:600; cursor:pointer; text-align:left; }
519
519
  .pc-chevron-inline { color:var(--nano-text-faint); font-size:.75rem; width:1em; }
520
520
  .pc-card-body[hidden] { display:none; }
521
+ /* Grid group headers (dataGrid groupBy): a full-width, clickable band that
522
+ collapses/expands its member rows; the collapsed state is persisted. */
523
+ .pc-group-header td { padding:0; background:var(--nano-inset); border-top:1px solid var(--nano-edge); }
524
+ .pc-group-toggle { display:flex; align-items:center; gap:.5rem; width:100%; padding:.4rem .6rem; background:transparent; border:0; color:inherit; font:inherit; font-weight:600; cursor:pointer; text-align:left; }
525
+ .pc-group-toggle:hover { background:var(--nano-hover); }
526
+ .pc-group-title { font-size:.9rem; }
521
527
  /* A standalone button node + the modal it opens (e.g. a copy-pasteable prompt). */
522
528
  .pc-buttonrow { margin:1rem 0; }
523
529
  .pc-btn-ghost { background:transparent; color:var(--nano-text-muted); border:1px solid var(--nano-edge); }
@@ -534,6 +540,11 @@ table.pc-grid th { font-weight:600; color:var(--nano-text-muted); }
534
540
  .pc-main-col { flex:1 1 auto; min-width:0; }
535
541
  .pc-nav-title { font-weight:650; }
536
542
  .pc-bar { display:flex; align-items:center; gap:.6rem; flex-wrap:wrap; border-bottom:1px solid var(--nano-edge); padding-bottom:.6rem; margin-bottom:1.25rem; }
543
+ /* Opt-in sticky bar: stays pinned to the viewport top while the page scrolls.
544
+ The negative inline margin + matching padding lets its background span the
545
+ body's 2rem inline padding so scrolled content never shows beside it, and a
546
+ solid app-colored background hides content passing underneath. */
547
+ .pc-bar.pc-sticky { position:sticky; top:0; z-index:20; background:var(--nano-app); margin-inline:-2rem; padding-inline:2rem; padding-top:.6rem; }
537
548
  .pc-bar .pc-nav-title { margin-right:.5rem; }
538
549
  .pc-bar .pc-nav-items { display:flex; gap:.25rem; flex-wrap:wrap; }
539
550
  .pc-rail { display:flex; flex-direction:column; gap:.5rem; }
@@ -563,15 +574,34 @@ const PAGE_ID = /^[A-Za-z0-9_-]+$/;
563
574
  function safePageId(value) {
564
575
  return typeof value === "string" && PAGE_ID.test(value);
565
576
  }
566
- function currentPage() {
567
- try {
568
- const raw = decodeURIComponent((location.hash || "").replace(/^#\/?/, ""));
569
- return safePageId(raw) ? raw : HOME;
570
- } catch (e) {
571
- return HOME;
572
- }
577
+ // A route is #/<page> or #/<page>/<url-encoded-param>. We split on the FIRST
578
+ // "/" *before* decoding, so a param that itself contains encoded slashes or
579
+ // hashes (e.g. a plan_key "owner/repo#123" carried as "owner%2Frepo%23123")
580
+ // survives intact instead of the inner "%2F" being decoded into a second
581
+ // segment. The page segment is still restricted to the safe id charset; the
582
+ // param is opaque free text (bound only as a whitelisted '?' SQL value, or
583
+ // interpolated as DOM text — never as HTML/markup).
584
+ function parseRoute() {
585
+ const raw = (location.hash || "").replace(/^#\/?/, "");
586
+ const slash = raw.indexOf("/");
587
+ const pageRaw = slash >= 0 ? raw.slice(0, slash) : raw;
588
+ const paramRaw = slash >= 0 ? raw.slice(slash + 1) : "";
589
+ let page = HOME, param = "", pageOk = false;
590
+ try { const p = decodeURIComponent(pageRaw); if (safePageId(p)) { page = p; pageOk = true; } } catch (e) { /* keep HOME */ }
591
+ // Only carry the param when the page segment resolved to a real (safe-id) page.
592
+ // A malformed/unknown page like #/not-a-page/x falls back to HOME and must NOT
593
+ // inherit x, or that stray param would silently scope every eqParam grid and
594
+ // {{param}} on the home page to an entity the user never selected.
595
+ if (pageOk) { try { param = paramRaw ? decodeURIComponent(paramRaw) : ""; } catch (e) { param = ""; } }
596
+ return { page, param };
573
597
  }
598
+ function currentPage() { return parseRoute().page; }
574
599
  let CURRENT = currentPage();
600
+ // The current route param (the "/<param>" tail, or "" when absent). Exposed to a
601
+ // page's datasource filters (a filter with { eqParam: true } binds its value to
602
+ // this) and to {{param}} text interpolation, so one page template can scope
603
+ // every section to a selected entity (e.g. an epic's plan_key).
604
+ let PARAM = parseRoute().param;
575
605
 
576
606
  // Per-render teardown. Each dataGrid registers its poll interval + pc:refresh
577
607
  // listener here; navigating to another page runs every disposer before the new
@@ -740,10 +770,19 @@ function el(tag, attrs = {}, ...kids) {
740
770
  return n;
741
771
  }
742
772
 
773
+ // Interpolate the {{param}} token in a schema-supplied string with the current
774
+ // route param. The result is only ever set as DOM text (textContent), so the
775
+ // substituted value cannot inject markup. Other tokens are left untouched.
776
+ // A function replacement inserts PARAM literally; a plain string replacement would
777
+ // treat replacement patterns inside PARAM (an id like the dollar-one token) as
778
+ // special and render the wrong text.
779
+ function interpParam(s) {
780
+ return typeof s === "string" ? s.replace(/\{\{param\}\}/g, () => PARAM) : s;
781
+ }
743
782
  function renderText(node) {
744
783
  const v = node.props.variant;
745
784
  const cls = v === "heading" ? "pc-heading" : v === "sub" ? "pc-sub" : "pc-body";
746
- return el(v === "heading" ? "h1" : "p", { class: cls }, node.props.text || "");
785
+ return el(v === "heading" ? "h1" : "p", { class: cls }, interpParam(node.props.text) || "");
747
786
  }
748
787
 
749
788
  // Copy text to the clipboard, resiliently. The async Clipboard API is tried
@@ -937,6 +976,20 @@ function gridCell(col, row) {
937
976
  );
938
977
  }
939
978
  }
979
+ if (col.link && col.link.kind === "page" && col.link.page && col.link.keyField) {
980
+ // An in-app link to another page, scoped to this row: #/<page>/<key>. The
981
+ // key (e.g. a plan_key "owner/repo#123") is URL-encoded into the route's
982
+ // param tail, which parseRoute() decodes back verbatim; the page id is
983
+ // validated against the safe-id charset and the key must be non-empty, so a
984
+ // blank/keyless cell stays plain text. Navigation is a pure hash change
985
+ // (no new tab, no server hit) — the SPA router re-renders in place.
986
+ const key = row[col.link.keyField];
987
+ const keyStr = key == null ? "" : String(key).trim();
988
+ if (text !== "" && safePageId(col.link.page) && keyStr !== "") {
989
+ const href = "#/" + encodeURIComponent(col.link.page) + "/" + encodeURIComponent(keyStr);
990
+ return el("td", {}, el("a", { class: "pc-link", href }, text));
991
+ }
992
+ }
940
993
  if (col.link && col.link.kind === "processExplorer" && col.link.keyField) {
941
994
  const key = row[col.link.keyField];
942
995
  const keyStr = key == null ? "" : String(key).trim();
@@ -1109,6 +1162,7 @@ function renderDataGrid(node) {
1109
1162
  const qs = [];
1110
1163
  for (const f of filters || []) {
1111
1164
  if (Array.isArray(f.in)) qs.push("where=" + encodeURIComponent(f.field + ":in:" + f.in.join(",")));
1165
+ else if (f.eqParam) qs.push("where=" + encodeURIComponent(f.field + ":" + PARAM));
1112
1166
  else qs.push("where=" + encodeURIComponent(f.field + ":" + f.eq));
1113
1167
  }
1114
1168
  if (order && order.field) qs.push("order=" + encodeURIComponent(order.field + ":" + (order.dir || "asc")));
@@ -1239,7 +1293,7 @@ function renderDataGrid(node) {
1239
1293
  return box;
1240
1294
  }
1241
1295
 
1242
- function renderRow(row) {
1296
+ function renderRow(row, sink) {
1243
1297
  const cells = cols.map((c) => gridCell(c, row));
1244
1298
  const key = rowKeyOf(row);
1245
1299
  let toggle = null;
@@ -1257,6 +1311,9 @@ function renderDataGrid(node) {
1257
1311
  }
1258
1312
  const tr = el("tr", {}, ...cells);
1259
1313
  tbody.append(tr);
1314
+ // When grouping, the caller collects every <tr> this row produced (the data
1315
+ // row plus any detail row) so a group header can hide/show them as one unit.
1316
+ if (sink) sink.push({ tr, isDetail: false, key });
1260
1317
  if (detail && toggle) {
1261
1318
  const isOpen = key != null && expanded.has(key);
1262
1319
  // Reuse an already-built detail row for an open PR across refreshes so its
@@ -1284,6 +1341,67 @@ function renderDataGrid(node) {
1284
1341
  }
1285
1342
  });
1286
1343
  tbody.append(dtr);
1344
+ if (sink) sink.push({ tr: dtr, isDetail: true, key });
1345
+ }
1346
+ }
1347
+
1348
+ // Append the fetched rows into the tbody, either flat (default) or partitioned
1349
+ // into collapsible groups when the grid declares 'groupBy: "<field>"'. Each
1350
+ // group renders a clickable header row ("<Label> <value> (<count>)") that
1351
+ // collapses/expands its member rows as one unit; the collapsed state is
1352
+ // persisted (localStorage, keyed by page + node + group value) so it survives
1353
+ // both the refresh poll and a full reload — the same durability the top-level
1354
+ // 'collapsible' node wrapper gives. Group order is numeric-aware ascending
1355
+ // (so waves 1,2,10 sort naturally), falling back to lexical for non-numeric
1356
+ // keys. 'groupDefaultCollapsed' seeds each group collapsed until toggled.
1357
+ function appendRows(rows) {
1358
+ const groupBy = p.groupBy;
1359
+ // Treat a non-string groupBy as "no grouping": labelBase calls groupBy.charAt()
1360
+ // below, so a truthy non-string (number/object from a malformed page schema)
1361
+ // would throw at render time and break the whole page instead of one grid.
1362
+ if (!groupBy || typeof groupBy !== "string") { for (const row of rows) renderRow(row, null); return; }
1363
+ const order = [];
1364
+ const groups = new Map();
1365
+ for (const row of rows) {
1366
+ const gvRaw = row[groupBy];
1367
+ const gv = gvRaw == null ? "" : String(gvRaw);
1368
+ if (!groups.has(gv)) { groups.set(gv, []); order.push(gv); }
1369
+ groups.get(gv).push(row);
1370
+ }
1371
+ order.sort((a, b) => {
1372
+ const na = Number(a), nb = Number(b);
1373
+ if (a !== "" && b !== "" && !Number.isNaN(na) && !Number.isNaN(nb)) return na - nb;
1374
+ return a < b ? -1 : a > b ? 1 : 0;
1375
+ });
1376
+ const labelBase = p.groupLabel != null
1377
+ ? String(p.groupLabel)
1378
+ : groupBy.charAt(0).toUpperCase() + groupBy.slice(1);
1379
+ for (const gv of order) {
1380
+ const groupRows = groups.get(gv);
1381
+ const gkey = "pc:collapsed:" + CURRENT + ":" + (node.id || p.title || "grid") + ":g:" + gv;
1382
+ let gcollapsed = readCollapsed(gkey, !!p.groupDefaultCollapsed);
1383
+ const chevron = el("span", { class: "pc-chevron-inline" }, gcollapsed ? "▸" : "▾");
1384
+ const label = (gv === "" ? labelBase + " —" : labelBase + " " + gv) + " (" + groupRows.length + ")";
1385
+ const btn = el("button", { class: "pc-group-toggle", type: "button" },
1386
+ chevron, el("span", { class: "pc-group-title" }, label));
1387
+ tbody.append(el("tr", { class: "pc-group-header" }, el("td", { colspan: span }, btn)));
1388
+ const members = [];
1389
+ for (const row of groupRows) renderRow(row, members);
1390
+ const applyGroup = () => {
1391
+ chevron.textContent = gcollapsed ? "▸" : "▾";
1392
+ btn.setAttribute("aria-expanded", String(!gcollapsed));
1393
+ for (const m of members) {
1394
+ m.tr.hidden = gcollapsed
1395
+ ? true
1396
+ : (m.isDetail ? !(m.key != null && expanded.has(m.key)) : false);
1397
+ }
1398
+ };
1399
+ btn.addEventListener("click", () => {
1400
+ gcollapsed = !gcollapsed;
1401
+ writeCollapsed(gkey, gcollapsed);
1402
+ applyGroup();
1403
+ });
1404
+ applyGroup();
1287
1405
  }
1288
1406
  }
1289
1407
 
@@ -1299,7 +1417,15 @@ function renderDataGrid(node) {
1299
1417
 
1300
1418
  async function refresh() {
1301
1419
  try {
1302
- const { rows } = await getJSON(dataUrl(p.data.source, p.data.table, activeFilter, p.data.orderBy));
1420
+ // A grid scoped to a route param (any eqParam filter) means "show the rows
1421
+ // for the selected entity". With no param present, PARAM is empty and emitting
1422
+ // a where clause of field-colon-empty would read server-side as field equals
1423
+ // empty string and surface empty-valued rows. That is not the selected entity's
1424
+ // rows (there is no selection), so short-circuit to zero rows without a request.
1425
+ const paramScoped = (activeFilter || []).some((f) => f && f.eqParam);
1426
+ const { rows } = paramScoped && PARAM === ""
1427
+ ? { rows: [] }
1428
+ : await getJSON(dataUrl(p.data.source, p.data.table, activeFilter, p.data.orderBy));
1303
1429
  // Forget expansion / cached detail nodes for rows no longer present so the maps
1304
1430
  // don't grow without bound and a stale answer can't resurface on a key reuse.
1305
1431
  if (p.rowKey) {
@@ -1338,7 +1464,7 @@ function renderDataGrid(node) {
1338
1464
  }
1339
1465
  }
1340
1466
  tbody.replaceChildren();
1341
- for (const row of rows) renderRow(row);
1467
+ appendRows(rows);
1342
1468
  if (!rows.length) tbody.append(el("tr", {}, el("td", { colspan: span }, "No rows")));
1343
1469
  if (keepFocus && active.isConnected) {
1344
1470
  active.focus();
@@ -1405,7 +1531,14 @@ function fillNav(list, items) {
1405
1531
  function renderNav(node) {
1406
1532
  const p = node.props || {};
1407
1533
  const rail = p.variant === "rail";
1408
- const nav = el("nav", { class: rail ? "pc-nav pc-rail" : "pc-nav pc-bar" });
1534
+ // A bar nav can opt into staying pinned to the top of the viewport while the
1535
+ // page scrolls (sticky) — useful on the long, multi-section operator pages so
1536
+ // the page switcher is always reachable. Rails are already full-height, so the
1537
+ // flag only applies to the bar variant.
1538
+ const barSticky = !rail && p.sticky === true;
1539
+ const nav = el("nav", {
1540
+ class: (rail ? "pc-nav pc-rail" : "pc-nav pc-bar") + (barSticky ? " pc-sticky" : ""),
1541
+ });
1409
1542
  if (p.title != null) nav.append(el("div", { class: "pc-nav-title" }, String(p.title)));
1410
1543
  const list = el("div", { class: "pc-nav-items" });
1411
1544
  nav.append(list);
@@ -1499,7 +1632,9 @@ function makeCollapsible(node, card) {
1499
1632
  // tearing down the previous page's grid polls first.
1500
1633
  async function renderPage() {
1501
1634
  teardown();
1502
- CURRENT = currentPage();
1635
+ const route = parseRoute();
1636
+ CURRENT = route.page;
1637
+ PARAM = route.param;
1503
1638
  try {
1504
1639
  const doc = await getJSON("/app/pages/" + encodeURIComponent(CURRENT));
1505
1640
  document.title = (doc && doc.title) || "Urban App";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/urban",
3
- "version": "0.43.0",
3
+ "version": "0.45.0",
4
4
  "description": "Urban: build and run code-first apps on Nano — runtime, derivation toolkit, and CLI in one. Author durable processes with @nanobpm/workflow (defineFlow), a typed datasource, forms, triggers and surfaces from a nano.app.json manifest, on Node or Deno.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -47,6 +47,10 @@
47
47
  "types": "./dist/worker.d.ts",
48
48
  "default": "./dist/worker.js"
49
49
  },
50
+ "./effect": {
51
+ "types": "./dist/effect/index.d.ts",
52
+ "default": "./dist/effect/index.js"
53
+ },
50
54
  "./source": "./src/index.ts"
51
55
  },
52
56
  "files": [
@@ -0,0 +1,185 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import {
4
+ ok,
5
+ fail,
6
+ isOk,
7
+ isFail,
8
+ gen,
9
+ map,
10
+ mapError,
11
+ match,
12
+ tag,
13
+ matchTags,
14
+ scoped,
15
+ acquireRelease,
16
+ type Result,
17
+ type Tagged,
18
+ } from "./index.ts";
19
+
20
+ test("ok/fail construct and narrow", () => {
21
+ const a = ok(42);
22
+ const b = fail("boom");
23
+ assert.equal(isOk(a), true);
24
+ assert.equal(isFail(a), false);
25
+ assert.equal(isOk(b), false);
26
+ assert.equal(isFail(b), true);
27
+ if (isOk(a)) assert.equal(a.value, 42);
28
+ if (isFail(b)) assert.equal(b.error, "boom");
29
+ });
30
+
31
+ test("gen threads values through yield* on the happy path", () => {
32
+ const r = gen(function* () {
33
+ const x = yield* ok(2);
34
+ const y = yield* ok(3);
35
+ return x + y;
36
+ });
37
+ assert.equal(isOk(r), true);
38
+ if (isOk(r)) assert.equal(r.value, 5);
39
+ });
40
+
41
+ test("gen short-circuits on the first failure", () => {
42
+ const steps: string[] = [];
43
+ const r = gen(function* () {
44
+ steps.push("a");
45
+ const x = yield* ok(1);
46
+ steps.push("b");
47
+ yield* fail("nope");
48
+ steps.push("c"); // must not run
49
+ return x;
50
+ });
51
+ assert.equal(isFail(r), true);
52
+ if (isFail(r)) assert.equal(r.error, "nope");
53
+ assert.deepEqual(steps, ["a", "b"]);
54
+ });
55
+
56
+ test("gen unions error types from multiple failure sources", () => {
57
+ const parse = (s: string) => (s === "" ? fail(tag("Empty")) : ok(s.length));
58
+ const check = (n: number) => (n > 3 ? fail(tag("TooLong")) : ok(n));
59
+ const run = (s: string) =>
60
+ gen(function* () {
61
+ const n = yield* parse(s);
62
+ const ok2 = yield* check(n);
63
+ return ok2;
64
+ });
65
+
66
+ const good = run("ab");
67
+ assert.equal(isOk(good), true);
68
+ if (isOk(good)) assert.equal(good.value, 2);
69
+
70
+ const empty = run("");
71
+ assert.equal(isFail(empty), true);
72
+ if (isFail(empty)) assert.equal(empty.error._tag, "Empty");
73
+
74
+ const long = run("abcd");
75
+ assert.equal(isFail(long), true);
76
+ if (isFail(long)) assert.equal(long.error._tag, "TooLong");
77
+ });
78
+
79
+ test("gen closes the generator so try/finally runs on short-circuit", () => {
80
+ const events: string[] = [];
81
+ const r = gen(function* () {
82
+ try {
83
+ events.push("enter");
84
+ yield* fail("boom");
85
+ events.push("after-fail"); // must not run
86
+ return 1;
87
+ } finally {
88
+ events.push("cleanup"); // must run even though we short-circuited
89
+ }
90
+ });
91
+ assert.equal(isFail(r), true);
92
+ if (isFail(r)) assert.equal(r.error, "boom");
93
+ // The failure short-circuits before "after-fail", but the generator is closed
94
+ // so its `finally` still releases (LIFO: enter → cleanup, no after-fail).
95
+ assert.deepEqual(events, ["enter", "cleanup"]);
96
+ });
97
+
98
+ test("map/mapError/match", () => {
99
+ assert.equal(match(map(ok(2), (n) => n * 10), (a) => a, () => -1), 20);
100
+ const failed: Result<number, string> = fail("e");
101
+ assert.equal(match(map(failed, (n) => n * 10), (n) => String(n), (e) => e), "e");
102
+ assert.equal(match(mapError(fail("e"), (e) => e + "!"), () => "", (e) => e), "e!");
103
+ assert.equal(match(mapError(ok(7), (e: string) => e), (a) => a, () => -1), 7);
104
+ });
105
+
106
+ test("tag builds tagged errors with props", () => {
107
+ const e = tag("Http", { status: 404 });
108
+ assert.equal(e._tag, "Http");
109
+ assert.equal(e.status, 404);
110
+ });
111
+
112
+ test("tag without props carries only the tag (no phantom prop keys)", () => {
113
+ const e = tag("Empty");
114
+ assert.equal(e._tag, "Empty");
115
+ assert.deepEqual(Object.keys(e), ["_tag"]);
116
+ });
117
+
118
+ test("tag() sets _tag last so untyped props cannot clobber the discriminant", () => {
119
+ // Props arriving through an untyped boundary must never override the tag —
120
+ // otherwise a stray `_tag` would break the discriminant `matchTags` relies on.
121
+ const untyped: object = JSON.parse('{"_tag":"EVIL","detail":1}');
122
+ const e = tag("Real", untyped);
123
+ assert.equal(e._tag, "Real");
124
+ });
125
+
126
+ test("matchTags dispatches on the tag", () => {
127
+ type E = Tagged<"NotFound"> | (Tagged<"Denied"> & { who: string });
128
+ const describe = (e: E) =>
129
+ matchTags(e, {
130
+ NotFound: () => "missing",
131
+ Denied: (d) => `denied:${d.who}`,
132
+ });
133
+ assert.equal(describe(tag("NotFound")), "missing");
134
+ assert.equal(describe(tag("Denied", { who: "bob" })), "denied:bob");
135
+ });
136
+
137
+ test("tag distributes a union tag into a discriminated union for matchTags", () => {
138
+ // `outcome` is a plain string union, as returned by e.g. a land/merge step.
139
+ const dispatch = (outcome: "merged" | "queued" | "blocked", detail: string) =>
140
+ matchTags(tag(outcome, { detail }), {
141
+ merged: () => "done",
142
+ queued: () => "waiting",
143
+ // The shared `detail` prop is present on every variant.
144
+ blocked: (b) => `blocked:${b.detail}`,
145
+ });
146
+ assert.equal(dispatch("merged", "x"), "done");
147
+ assert.equal(dispatch("queued", "x"), "waiting");
148
+ assert.equal(dispatch("blocked", "perms"), "blocked:perms");
149
+ });
150
+
151
+ test("scoped releases resources LIFO on success", async () => {
152
+ const released: string[] = [];
153
+ const out = await scoped(async (scope) => {
154
+ acquireRelease(scope, () => "a", () => { released.push("a"); });
155
+ acquireRelease(scope, () => "b", () => { released.push("b"); });
156
+ return "done";
157
+ });
158
+ assert.equal(out, "done");
159
+ assert.deepEqual(released, ["b", "a"]);
160
+ });
161
+
162
+ test("scoped releases resources even when the body throws", async () => {
163
+ const released: string[] = [];
164
+ await assert.rejects(
165
+ scoped(async (scope) => {
166
+ acquireRelease(scope, () => "a", () => { released.push("a"); });
167
+ acquireRelease(scope, () => "b", () => { released.push("b"); });
168
+ throw new Error("kaboom");
169
+ }),
170
+ /kaboom/,
171
+ );
172
+ assert.deepEqual(released, ["b", "a"]);
173
+ });
174
+
175
+ test("scoped best-effort: a failing disposer does not block the others", async () => {
176
+ const released: string[] = [];
177
+ await scoped(async (scope) => {
178
+ acquireRelease(scope, () => "a", () => { released.push("a"); });
179
+ acquireRelease(scope, () => "b", () => { throw new Error("dispose fail"); });
180
+ acquireRelease(scope, () => "c", () => { released.push("c"); });
181
+ return null;
182
+ });
183
+ // b's disposer threw but a and c still ran (LIFO: c, [b throws], a).
184
+ assert.deepEqual(released, ["c", "a"]);
185
+ });
@@ -0,0 +1,193 @@
1
+ // effectlite — a tiny, ZERO-DEPENDENCY, Effect-like core for Urban glue code.
2
+ //
3
+ // Urban's published surface is declarative JSON + a client renderer, so we do
4
+ // NOT want the `effect` package (bundle weight + viral paradigm) in the runtime.
5
+ // But the imperative seams — workers, provisioning, resource lifecycles — still
6
+ // benefit from the three Effect ergonomics we actually reach for. effectlite
7
+ // gives us those, in ~100 LOC of implementation with no runtime dependencies:
8
+ //
9
+ // 1. A typed-error `Result<A, E>` whose `E` composes automatically through
10
+ // generator do-notation (`gen(function*(){ const x = yield* step })`),
11
+ // short-circuiting on the first failure exactly like `Effect.gen` + `yield*`.
12
+ // 2. `tag()` tagged errors (cf. `Data.TaggedError`) + exhaustive `matchTags`
13
+ // (cf. `catchTags`) — the compiler forces every failure mode to be handled.
14
+ // 3. `scoped` + `acquireRelease` (cf. `Effect.scoped`) — releases run on every
15
+ // exit: success, failure, and thrown.
16
+ //
17
+ // It is synchronous over `Result`; the scope is async only so disposers may
18
+ // await. Consume via `import { gen, ok, fail } from "@nanobpm/urban/effect"`.
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // Result + generator do-notation
22
+ // ---------------------------------------------------------------------------
23
+
24
+ export type Result<A, E> = Ok<A> | Fail<E>;
25
+
26
+ export class Ok<A> {
27
+ readonly _tag = "Ok" as const;
28
+ readonly value: A;
29
+ constructor(value: A) {
30
+ this.value = value;
31
+ }
32
+ // Delegated by `yield*`: returns the value, yields nothing to the driver.
33
+ *[Symbol.iterator](): Generator<never, A, unknown> {
34
+ return this.value;
35
+ }
36
+ }
37
+
38
+ export class Fail<E> {
39
+ readonly _tag = "Fail" as const;
40
+ readonly error: E;
41
+ constructor(error: E) {
42
+ this.error = error;
43
+ }
44
+ // Delegated by `yield*`: yields itself so the driver can short-circuit; never
45
+ // resumes (the driver abandons the generator on the first failure).
46
+ *[Symbol.iterator](): Generator<Fail<E>, never, unknown> {
47
+ yield this;
48
+ throw new Error("effectlite: generator resumed after a failure");
49
+ }
50
+ }
51
+
52
+ export const ok = <A>(value: A): Ok<A> => new Ok(value);
53
+ export const fail = <E>(error: E): Fail<E> => new Fail(error);
54
+
55
+ export const isOk = <A, E>(r: Result<A, E>): r is Ok<A> => r._tag === "Ok";
56
+ export const isFail = <A, E>(r: Result<A, E>): r is Fail<E> => r._tag === "Fail";
57
+
58
+ // Run a generator that `yield*`s Results. Because only `Fail` yields to us, a
59
+ // single `next()` either lands on the first failure or on the final return.
60
+ // `Y` captures the UNION of every yielded `Fail<…>`; `Y["error"]` distributes
61
+ // that index access into the Result's error channel (a union in ⇒ a union out).
62
+ export function gen<Y extends Fail<unknown>, A>(
63
+ body: () => Generator<Y, A, unknown>,
64
+ ): Result<A, Y["error"]> {
65
+ // Typed as `Iterator` (not `Generator`) so `return` is the optional-arg
66
+ // variant — lets us close a short-circuited body without inventing a return value.
67
+ const it: Iterator<Y, A> = body();
68
+ const step = it.next();
69
+ if (step.done) return ok(step.value);
70
+ // Not done ⇒ a `Fail` yielded to short-circuit. Close the generator first so
71
+ // any `try/finally` in the body runs and captured resources aren't retained,
72
+ // then surface its error on the failure channel.
73
+ it.return?.();
74
+ return fail(step.value.error);
75
+ }
76
+
77
+ // ---------------------------------------------------------------------------
78
+ // Combinators
79
+ // ---------------------------------------------------------------------------
80
+
81
+ // Extract the success/error channels from a `Result` (including a value typed as
82
+ // the union `Result<A, E>`), so the combinators below infer `A`/`E` cleanly
83
+ // instead of collapsing them to `unknown` on union inputs.
84
+ export type OkOf<R> = R extends Ok<infer A> ? A : never;
85
+ export type ErrOf<R> = R extends Fail<infer E> ? E : never;
86
+
87
+ // Narrowing guards that carry the *extracted* channel type, so `r.value` /
88
+ // `r.error` land as `OkOf<R>` / `ErrOf<R>` (not `unknown`) with no assertion —
89
+ // keeping the combinators within the repo's no-type-assertion convention.
90
+ const isOkOf = <R extends Result<unknown, unknown>>(r: R): r is R & Ok<OkOf<R>> => r._tag === "Ok";
91
+ const isFailOf = <R extends Result<unknown, unknown>>(r: R): r is R & Fail<ErrOf<R>> => r._tag === "Fail";
92
+
93
+ export const map = <R extends Result<unknown, unknown>, B>(
94
+ r: R,
95
+ f: (a: OkOf<R>) => B,
96
+ ): Result<B, ErrOf<R>> => {
97
+ if (isOkOf(r)) return ok(f(r.value));
98
+ if (isFailOf(r)) return r;
99
+ throw new Error("effectlite: Result was neither Ok nor Fail");
100
+ };
101
+
102
+ export const mapError = <R extends Result<unknown, unknown>, E2>(
103
+ r: R,
104
+ f: (e: ErrOf<R>) => E2,
105
+ ): Result<OkOf<R>, E2> => {
106
+ if (isFailOf(r)) return fail(f(r.error));
107
+ if (isOkOf(r)) return r;
108
+ throw new Error("effectlite: Result was neither Ok nor Fail");
109
+ };
110
+
111
+ export const match = <R extends Result<unknown, unknown>, T>(
112
+ r: R,
113
+ onOk: (a: OkOf<R>) => T,
114
+ onFail: (e: ErrOf<R>) => T,
115
+ ): T => {
116
+ if (isOkOf(r)) return onOk(r.value);
117
+ if (isFailOf(r)) return onFail(r.error);
118
+ throw new Error("effectlite: Result was neither Ok nor Fail");
119
+ };
120
+
121
+ // ---------------------------------------------------------------------------
122
+ // Tagged errors + exhaustive matching
123
+ // ---------------------------------------------------------------------------
124
+
125
+ export type Tagged<Tag extends string> = { readonly _tag: Tag };
126
+
127
+ // A distributive `Tagged<Tag> & P`: when `Tag` is a union (e.g. a
128
+ // `"merged" | "queued" | "blocked"` outcome), this expands to a discriminated
129
+ // UNION of tagged objects — `({_tag:"merged"} & P) | ({_tag:"queued"} & P) | …`
130
+ // — not a single object with a union-typed `_tag`. That is what lets `matchTags`
131
+ // `Extract` each variant and force an exhaustive handler per case.
132
+ export type TagUnion<Tag extends string, P> = Tag extends string ? Tagged<Tag> & P : never;
133
+
134
+ // Overloaded so the extra props type only appears when props are actually
135
+ // passed: `tag("X")` is exactly `Tagged<"X">` (no phantom `& P` that could read
136
+ // as `undefined` at runtime), while `tag("X", props)` carries them. Both return
137
+ // the distributive `TagUnion`, so a union `Tag` expands to a discriminated union.
138
+ // `props` is forbidden from carrying its own `_tag` (`{ _tag?: never }`) so it
139
+ // cannot shadow the discriminant, and the runtime spreads `props` *first* so the
140
+ // tag always wins even for props arriving through an untyped boundary.
141
+ export function tag<Tag extends string>(t: Tag): TagUnion<Tag, {}>;
142
+ export function tag<Tag extends string, P extends object>(
143
+ t: Tag,
144
+ props: P & { _tag?: never },
145
+ ): TagUnion<Tag, P>;
146
+ export function tag(t: string, props?: object): Tagged<string> {
147
+ return { ...props, _tag: t };
148
+ }
149
+
150
+ // Handle every tag in the union — the handlers object is keyed by `E["_tag"]`,
151
+ // so omitting a variant is a COMPILE error (no accidental dropped failure mode).
152
+ export const matchTags = <E extends Tagged<string>, R>(
153
+ e: E,
154
+ handlers: { [K in E["_tag"]]: (e: Extract<E, Tagged<K>>) => R },
155
+ ): R =>
156
+ // biome-ignore lint/plugin: the mapped handler type is precise for callers; internally we index it with the runtime tag, which needs this erasure to a plain record.
157
+ (handlers as unknown as Record<string, (e: E) => R>)[e._tag](e);
158
+
159
+ // ---------------------------------------------------------------------------
160
+ // Scoped resources
161
+ // ---------------------------------------------------------------------------
162
+
163
+ export class Scope {
164
+ #disposers: Array<() => void | Promise<void>> = [];
165
+ add(dispose: () => void | Promise<void>): void {
166
+ this.#disposers.push(dispose);
167
+ }
168
+ async close(): Promise<void> {
169
+ // LIFO, best-effort — a failing disposer never blocks the others.
170
+ for (const d of this.#disposers.reverse()) {
171
+ try { await d(); } catch { /* best effort */ }
172
+ }
173
+ this.#disposers = [];
174
+ }
175
+ }
176
+
177
+ // Acquire a resource and register its release with the scope in one step.
178
+ export function acquireRelease<A>(scope: Scope, acquire: () => A, release: (a: A) => void | Promise<void>): A {
179
+ const a = acquire();
180
+ scope.add(() => release(a));
181
+ return a;
182
+ }
183
+
184
+ // Run `body` with a fresh scope; close it (releasing all resources) on every
185
+ // exit path — return, Result failure, or thrown.
186
+ export async function scoped<A>(body: (scope: Scope) => A | Promise<A>): Promise<A> {
187
+ const scope = new Scope();
188
+ try {
189
+ return await body(scope);
190
+ } finally {
191
+ await scope.close();
192
+ }
193
+ }
@@ -364,6 +364,84 @@ test("renderer wires a column's processExplorer link to the console explorer", a
364
364
  assert.match(js, /ev\.preventDefault\(\)/);
365
365
  });
366
366
 
367
+ test("renderer binds a route param: parseRoute splits page/param, filters + text consume it (#134)", async () => {
368
+ const res = await dispatch("GET", "/app/runtime.js");
369
+ const js = res.body ?? "";
370
+ // A route is #/<page>/<url-encoded-param>. parseRoute splits on the FIRST "/"
371
+ // BEFORE decoding so a param carrying encoded slashes/hashes (a plan_key like
372
+ // "owner/repo#123") survives; the param segment is decoded verbatim into PARAM.
373
+ assert.match(js, /const slash = raw\.indexOf\("\/"\)/);
374
+ assert.match(js, /const paramRaw = slash >= 0 \? raw\.slice\(slash \+ 1\) : ""/);
375
+ assert.match(js, /param = paramRaw \? decodeURIComponent\(paramRaw\) : ""/);
376
+ assert.match(js, /let PARAM = parseRoute\(\)\.param/);
377
+ // renderPage refreshes PARAM from the current route on every (re)render.
378
+ assert.match(js, /const route = parseRoute\(\);\s*CURRENT = route\.page;\s*PARAM = route\.param;/s);
379
+ // A datasource filter with { eqParam: true } binds its value to the live PARAM,
380
+ // so one page template scopes every section to the selected entity.
381
+ assert.match(js, /else if \(f\.eqParam\) qs\.push\("where=" \+ encodeURIComponent\(f\.field \+ ":" \+ PARAM\)\)/);
382
+ // {{param}} interpolates into text nodes as DOM text (never markup). A function
383
+ // replacement inserts PARAM literally, so replacement patterns inside a param id
384
+ // can't be reinterpreted.
385
+ assert.match(js, /function interpParam\(s\)/);
386
+ assert.match(js, /replace\(\/\\\{\\\{param\\\}\\\}\/g, \(\) => PARAM\)/);
387
+ assert.match(js, /interpParam\(node\.props\.text\)/);
388
+ // A grid whose active filter binds to the route param renders zero rows (no data
389
+ // request) when no param is present, rather than emitting a where clause of
390
+ // field-colon-empty (server reads that as field equals empty string and would
391
+ // surface empty-valued rows).
392
+ assert.match(js, /const paramScoped = \(activeFilter \|\| \[\]\)\.some\(\(f\) => f && f\.eqParam\)/);
393
+ assert.match(js, /paramScoped && PARAM === ""\s*\?\s*\{ rows: \[\] \}/s);
394
+ });
395
+
396
+ test("renderer wires a column's page link to an in-app scoped hash route", async () => {
397
+ const res = await dispatch("GET", "/app/runtime.js");
398
+ const js = res.body ?? "";
399
+ // link: { kind: "page", page, keyField } → an in-app #/<page>/<key> hash link
400
+ // (no new tab, no server hit). The page id is safe-id-validated, the key is
401
+ // trimmed + URL-encoded into the param tail, and a blank/keyless cell stays text.
402
+ assert.match(js, /col\.link && col\.link\.kind === "page" && col\.link\.page && col\.link\.keyField/);
403
+ assert.match(js, /safePageId\(col\.link\.page\)/);
404
+ assert.match(js, /"#\/" \+ encodeURIComponent\(col\.link\.page\) \+ "\/" \+ encodeURIComponent\(keyStr\)/);
405
+ // In-app: NOT a new-tab link (no target/rel on this anchor).
406
+ assert.match(js, /el\("a", \{ class: "pc-link", href \}, text\)/);
407
+ });
408
+
409
+ test("renderer groups a dataGrid by a field into persisted collapsible bands (waves)", async () => {
410
+ const res = await dispatch("GET", "/app/runtime.js");
411
+ const js = res.body ?? "";
412
+ // groupBy partitions rows into collapsible groups; each header toggles its
413
+ // members as a unit and the collapsed state is persisted (survives poll+reload).
414
+ assert.match(js, /function appendRows\(rows\)/);
415
+ assert.match(js, /const groupBy = p\.groupBy/);
416
+ assert.match(js, /if \(!groupBy \|\| typeof groupBy !== "string"\) \{ for \(const row of rows\) renderRow\(row, null\); return; \}/);
417
+ // Numeric-aware ascending group order (waves 1,2,10 sort naturally).
418
+ assert.match(js, /const na = Number\(a\), nb = Number\(b\)/);
419
+ // Collapsed state persisted per page + node + group value via the shared helpers.
420
+ assert.match(js, /readCollapsed\(gkey, !!p\.groupDefaultCollapsed\)/);
421
+ assert.match(js, /writeCollapsed\(gkey, gcollapsed\)/);
422
+ assert.match(js, /"pc:collapsed:" \+ CURRENT \+ ":" \+ \(node\.id \|\| p\.title \|\| "grid"\) \+ ":g:" \+ gv/);
423
+ // A collapsed group hides every member row; expanded restores data rows and
424
+ // leaves detail rows at their own expansion state.
425
+ assert.match(js, /m\.tr\.hidden = gcollapsed/);
426
+ assert.match(js, /class: "pc-group-header"/);
427
+ // The group toggle exposes its expanded/collapsed state to assistive tech,
428
+ // mirroring makeCollapsible's header (screen readers announce the change).
429
+ assert.match(js, /btn\.setAttribute\("aria-expanded", String\(!gcollapsed\)\)/);
430
+ // refresh() renders through appendRows so grouping applies on every poll.
431
+ assert.match(js, /tbody\.replaceChildren\(\);\s*appendRows\(rows\);/s);
432
+ });
433
+
434
+ test("renderer supports an opt-in sticky nav bar", async () => {
435
+ const res = await dispatch("GET", "/app/runtime.js");
436
+ const js = res.body ?? "";
437
+ const css = (await dispatch("GET", "/")).body ?? "";
438
+ // A bar nav with { sticky: true } gets the pc-sticky class (rails are excluded).
439
+ assert.match(js, /const barSticky = !rail && p\.sticky === true/);
440
+ assert.match(js, /\(barSticky \? " pc-sticky" : ""\)/);
441
+ // The CSS pins it to the viewport top with a solid background.
442
+ assert.match(css, /\.pc-bar\.pc-sticky \{[^}]*position:sticky[^}]*top:0/);
443
+ });
444
+
367
445
  test("renderer exposes an embed-gated host-navigation bridge", async () => {
368
446
  const res = await dispatch("GET", "/app/runtime.js");
369
447
  const js = res.body ?? "";
@@ -489,8 +567,15 @@ test("the renderer routes between pages by hash and tears down grid polls", asyn
489
567
  assert.match(js, /const PAGE_ID = \/\^\[A-Za-z0-9_-/);
490
568
  assert.match(js, /function safePageId\(value\)/);
491
569
  assert.match(js, /function currentPage\(\)/);
492
- assert.match(js, /try \{\s*const raw = decodeURIComponent/s);
493
- assert.match(js, /catch \(e\) \{\s*return HOME;/s);
570
+ // The hash is parsed into a page + optional param by parseRoute(); the page
571
+ // segment is decoded and safe-id-validated (falling back to HOME).
572
+ assert.match(js, /function parseRoute\(\)/);
573
+ assert.match(js, /const raw = \(location\.hash \|\| ""\)\.replace/s);
574
+ assert.match(js, /if \(safePageId\(p\)\) \{ page = p; pageOk = true; \}/s);
575
+ // A stray param is only carried when the page segment resolved to a real page,
576
+ // so a malformed/unknown page (#/not-a-page/x) falls back to HOME without
577
+ // inheriting x (which would otherwise silently scope home's eqParam grids).
578
+ assert.match(js, /if \(pageOk\) \{ try \{ param = paramRaw \? decodeURIComponent\(paramRaw\) : ""; \}/s);
494
579
  assert.match(js, /location\.hash/);
495
580
  assert.match(js, /window\.addEventListener\("hashchange", renderPage\)/);
496
581
  assert.match(js, /async function renderPage\(\)/);
@@ -569,6 +569,12 @@ table.pc-grid th { font-weight:600; color:var(--nano-text-muted); }
569
569
  .pc-collapse-header { display:flex; align-items:center; gap:.5rem; width:100%; margin:0 0 .75rem; padding:0; background:transparent; border:0; color:inherit; font:inherit; font-size:1rem; font-weight:600; cursor:pointer; text-align:left; }
570
570
  .pc-chevron-inline { color:var(--nano-text-faint); font-size:.75rem; width:1em; }
571
571
  .pc-card-body[hidden] { display:none; }
572
+ /* Grid group headers (dataGrid groupBy): a full-width, clickable band that
573
+ collapses/expands its member rows; the collapsed state is persisted. */
574
+ .pc-group-header td { padding:0; background:var(--nano-inset); border-top:1px solid var(--nano-edge); }
575
+ .pc-group-toggle { display:flex; align-items:center; gap:.5rem; width:100%; padding:.4rem .6rem; background:transparent; border:0; color:inherit; font:inherit; font-weight:600; cursor:pointer; text-align:left; }
576
+ .pc-group-toggle:hover { background:var(--nano-hover); }
577
+ .pc-group-title { font-size:.9rem; }
572
578
  /* A standalone button node + the modal it opens (e.g. a copy-pasteable prompt). */
573
579
  .pc-buttonrow { margin:1rem 0; }
574
580
  .pc-btn-ghost { background:transparent; color:var(--nano-text-muted); border:1px solid var(--nano-edge); }
@@ -585,6 +591,11 @@ table.pc-grid th { font-weight:600; color:var(--nano-text-muted); }
585
591
  .pc-main-col { flex:1 1 auto; min-width:0; }
586
592
  .pc-nav-title { font-weight:650; }
587
593
  .pc-bar { display:flex; align-items:center; gap:.6rem; flex-wrap:wrap; border-bottom:1px solid var(--nano-edge); padding-bottom:.6rem; margin-bottom:1.25rem; }
594
+ /* Opt-in sticky bar: stays pinned to the viewport top while the page scrolls.
595
+ The negative inline margin + matching padding lets its background span the
596
+ body's 2rem inline padding so scrolled content never shows beside it, and a
597
+ solid app-colored background hides content passing underneath. */
598
+ .pc-bar.pc-sticky { position:sticky; top:0; z-index:20; background:var(--nano-app); margin-inline:-2rem; padding-inline:2rem; padding-top:.6rem; }
588
599
  .pc-bar .pc-nav-title { margin-right:.5rem; }
589
600
  .pc-bar .pc-nav-items { display:flex; gap:.25rem; flex-wrap:wrap; }
590
601
  .pc-rail { display:flex; flex-direction:column; gap:.5rem; }
@@ -615,15 +626,34 @@ const PAGE_ID = /^[A-Za-z0-9_-]+$/;
615
626
  function safePageId(value) {
616
627
  return typeof value === "string" && PAGE_ID.test(value);
617
628
  }
618
- function currentPage() {
619
- try {
620
- const raw = decodeURIComponent((location.hash || "").replace(/^#\/?/, ""));
621
- return safePageId(raw) ? raw : HOME;
622
- } catch (e) {
623
- return HOME;
624
- }
629
+ // A route is #/<page> or #/<page>/<url-encoded-param>. We split on the FIRST
630
+ // "/" *before* decoding, so a param that itself contains encoded slashes or
631
+ // hashes (e.g. a plan_key "owner/repo#123" carried as "owner%2Frepo%23123")
632
+ // survives intact instead of the inner "%2F" being decoded into a second
633
+ // segment. The page segment is still restricted to the safe id charset; the
634
+ // param is opaque free text (bound only as a whitelisted '?' SQL value, or
635
+ // interpolated as DOM text — never as HTML/markup).
636
+ function parseRoute() {
637
+ const raw = (location.hash || "").replace(/^#\/?/, "");
638
+ const slash = raw.indexOf("/");
639
+ const pageRaw = slash >= 0 ? raw.slice(0, slash) : raw;
640
+ const paramRaw = slash >= 0 ? raw.slice(slash + 1) : "";
641
+ let page = HOME, param = "", pageOk = false;
642
+ try { const p = decodeURIComponent(pageRaw); if (safePageId(p)) { page = p; pageOk = true; } } catch (e) { /* keep HOME */ }
643
+ // Only carry the param when the page segment resolved to a real (safe-id) page.
644
+ // A malformed/unknown page like #/not-a-page/x falls back to HOME and must NOT
645
+ // inherit x, or that stray param would silently scope every eqParam grid and
646
+ // {{param}} on the home page to an entity the user never selected.
647
+ if (pageOk) { try { param = paramRaw ? decodeURIComponent(paramRaw) : ""; } catch (e) { param = ""; } }
648
+ return { page, param };
625
649
  }
650
+ function currentPage() { return parseRoute().page; }
626
651
  let CURRENT = currentPage();
652
+ // The current route param (the "/<param>" tail, or "" when absent). Exposed to a
653
+ // page's datasource filters (a filter with { eqParam: true } binds its value to
654
+ // this) and to {{param}} text interpolation, so one page template can scope
655
+ // every section to a selected entity (e.g. an epic's plan_key).
656
+ let PARAM = parseRoute().param;
627
657
 
628
658
  // Per-render teardown. Each dataGrid registers its poll interval + pc:refresh
629
659
  // listener here; navigating to another page runs every disposer before the new
@@ -792,10 +822,19 @@ function el(tag, attrs = {}, ...kids) {
792
822
  return n;
793
823
  }
794
824
 
825
+ // Interpolate the {{param}} token in a schema-supplied string with the current
826
+ // route param. The result is only ever set as DOM text (textContent), so the
827
+ // substituted value cannot inject markup. Other tokens are left untouched.
828
+ // A function replacement inserts PARAM literally; a plain string replacement would
829
+ // treat replacement patterns inside PARAM (an id like the dollar-one token) as
830
+ // special and render the wrong text.
831
+ function interpParam(s) {
832
+ return typeof s === "string" ? s.replace(/\{\{param\}\}/g, () => PARAM) : s;
833
+ }
795
834
  function renderText(node) {
796
835
  const v = node.props.variant;
797
836
  const cls = v === "heading" ? "pc-heading" : v === "sub" ? "pc-sub" : "pc-body";
798
- return el(v === "heading" ? "h1" : "p", { class: cls }, node.props.text || "");
837
+ return el(v === "heading" ? "h1" : "p", { class: cls }, interpParam(node.props.text) || "");
799
838
  }
800
839
 
801
840
  // Copy text to the clipboard, resiliently. The async Clipboard API is tried
@@ -989,6 +1028,20 @@ function gridCell(col, row) {
989
1028
  );
990
1029
  }
991
1030
  }
1031
+ if (col.link && col.link.kind === "page" && col.link.page && col.link.keyField) {
1032
+ // An in-app link to another page, scoped to this row: #/<page>/<key>. The
1033
+ // key (e.g. a plan_key "owner/repo#123") is URL-encoded into the route's
1034
+ // param tail, which parseRoute() decodes back verbatim; the page id is
1035
+ // validated against the safe-id charset and the key must be non-empty, so a
1036
+ // blank/keyless cell stays plain text. Navigation is a pure hash change
1037
+ // (no new tab, no server hit) — the SPA router re-renders in place.
1038
+ const key = row[col.link.keyField];
1039
+ const keyStr = key == null ? "" : String(key).trim();
1040
+ if (text !== "" && safePageId(col.link.page) && keyStr !== "") {
1041
+ const href = "#/" + encodeURIComponent(col.link.page) + "/" + encodeURIComponent(keyStr);
1042
+ return el("td", {}, el("a", { class: "pc-link", href }, text));
1043
+ }
1044
+ }
992
1045
  if (col.link && col.link.kind === "processExplorer" && col.link.keyField) {
993
1046
  const key = row[col.link.keyField];
994
1047
  const keyStr = key == null ? "" : String(key).trim();
@@ -1161,6 +1214,7 @@ function renderDataGrid(node) {
1161
1214
  const qs = [];
1162
1215
  for (const f of filters || []) {
1163
1216
  if (Array.isArray(f.in)) qs.push("where=" + encodeURIComponent(f.field + ":in:" + f.in.join(",")));
1217
+ else if (f.eqParam) qs.push("where=" + encodeURIComponent(f.field + ":" + PARAM));
1164
1218
  else qs.push("where=" + encodeURIComponent(f.field + ":" + f.eq));
1165
1219
  }
1166
1220
  if (order && order.field) qs.push("order=" + encodeURIComponent(order.field + ":" + (order.dir || "asc")));
@@ -1291,7 +1345,7 @@ function renderDataGrid(node) {
1291
1345
  return box;
1292
1346
  }
1293
1347
 
1294
- function renderRow(row) {
1348
+ function renderRow(row, sink) {
1295
1349
  const cells = cols.map((c) => gridCell(c, row));
1296
1350
  const key = rowKeyOf(row);
1297
1351
  let toggle = null;
@@ -1309,6 +1363,9 @@ function renderDataGrid(node) {
1309
1363
  }
1310
1364
  const tr = el("tr", {}, ...cells);
1311
1365
  tbody.append(tr);
1366
+ // When grouping, the caller collects every <tr> this row produced (the data
1367
+ // row plus any detail row) so a group header can hide/show them as one unit.
1368
+ if (sink) sink.push({ tr, isDetail: false, key });
1312
1369
  if (detail && toggle) {
1313
1370
  const isOpen = key != null && expanded.has(key);
1314
1371
  // Reuse an already-built detail row for an open PR across refreshes so its
@@ -1336,6 +1393,67 @@ function renderDataGrid(node) {
1336
1393
  }
1337
1394
  });
1338
1395
  tbody.append(dtr);
1396
+ if (sink) sink.push({ tr: dtr, isDetail: true, key });
1397
+ }
1398
+ }
1399
+
1400
+ // Append the fetched rows into the tbody, either flat (default) or partitioned
1401
+ // into collapsible groups when the grid declares 'groupBy: "<field>"'. Each
1402
+ // group renders a clickable header row ("<Label> <value> (<count>)") that
1403
+ // collapses/expands its member rows as one unit; the collapsed state is
1404
+ // persisted (localStorage, keyed by page + node + group value) so it survives
1405
+ // both the refresh poll and a full reload — the same durability the top-level
1406
+ // 'collapsible' node wrapper gives. Group order is numeric-aware ascending
1407
+ // (so waves 1,2,10 sort naturally), falling back to lexical for non-numeric
1408
+ // keys. 'groupDefaultCollapsed' seeds each group collapsed until toggled.
1409
+ function appendRows(rows) {
1410
+ const groupBy = p.groupBy;
1411
+ // Treat a non-string groupBy as "no grouping": labelBase calls groupBy.charAt()
1412
+ // below, so a truthy non-string (number/object from a malformed page schema)
1413
+ // would throw at render time and break the whole page instead of one grid.
1414
+ if (!groupBy || typeof groupBy !== "string") { for (const row of rows) renderRow(row, null); return; }
1415
+ const order = [];
1416
+ const groups = new Map();
1417
+ for (const row of rows) {
1418
+ const gvRaw = row[groupBy];
1419
+ const gv = gvRaw == null ? "" : String(gvRaw);
1420
+ if (!groups.has(gv)) { groups.set(gv, []); order.push(gv); }
1421
+ groups.get(gv).push(row);
1422
+ }
1423
+ order.sort((a, b) => {
1424
+ const na = Number(a), nb = Number(b);
1425
+ if (a !== "" && b !== "" && !Number.isNaN(na) && !Number.isNaN(nb)) return na - nb;
1426
+ return a < b ? -1 : a > b ? 1 : 0;
1427
+ });
1428
+ const labelBase = p.groupLabel != null
1429
+ ? String(p.groupLabel)
1430
+ : groupBy.charAt(0).toUpperCase() + groupBy.slice(1);
1431
+ for (const gv of order) {
1432
+ const groupRows = groups.get(gv);
1433
+ const gkey = "pc:collapsed:" + CURRENT + ":" + (node.id || p.title || "grid") + ":g:" + gv;
1434
+ let gcollapsed = readCollapsed(gkey, !!p.groupDefaultCollapsed);
1435
+ const chevron = el("span", { class: "pc-chevron-inline" }, gcollapsed ? "▸" : "▾");
1436
+ const label = (gv === "" ? labelBase + " —" : labelBase + " " + gv) + " (" + groupRows.length + ")";
1437
+ const btn = el("button", { class: "pc-group-toggle", type: "button" },
1438
+ chevron, el("span", { class: "pc-group-title" }, label));
1439
+ tbody.append(el("tr", { class: "pc-group-header" }, el("td", { colspan: span }, btn)));
1440
+ const members = [];
1441
+ for (const row of groupRows) renderRow(row, members);
1442
+ const applyGroup = () => {
1443
+ chevron.textContent = gcollapsed ? "▸" : "▾";
1444
+ btn.setAttribute("aria-expanded", String(!gcollapsed));
1445
+ for (const m of members) {
1446
+ m.tr.hidden = gcollapsed
1447
+ ? true
1448
+ : (m.isDetail ? !(m.key != null && expanded.has(m.key)) : false);
1449
+ }
1450
+ };
1451
+ btn.addEventListener("click", () => {
1452
+ gcollapsed = !gcollapsed;
1453
+ writeCollapsed(gkey, gcollapsed);
1454
+ applyGroup();
1455
+ });
1456
+ applyGroup();
1339
1457
  }
1340
1458
  }
1341
1459
 
@@ -1351,7 +1469,15 @@ function renderDataGrid(node) {
1351
1469
 
1352
1470
  async function refresh() {
1353
1471
  try {
1354
- const { rows } = await getJSON(dataUrl(p.data.source, p.data.table, activeFilter, p.data.orderBy));
1472
+ // A grid scoped to a route param (any eqParam filter) means "show the rows
1473
+ // for the selected entity". With no param present, PARAM is empty and emitting
1474
+ // a where clause of field-colon-empty would read server-side as field equals
1475
+ // empty string and surface empty-valued rows. That is not the selected entity's
1476
+ // rows (there is no selection), so short-circuit to zero rows without a request.
1477
+ const paramScoped = (activeFilter || []).some((f) => f && f.eqParam);
1478
+ const { rows } = paramScoped && PARAM === ""
1479
+ ? { rows: [] }
1480
+ : await getJSON(dataUrl(p.data.source, p.data.table, activeFilter, p.data.orderBy));
1355
1481
  // Forget expansion / cached detail nodes for rows no longer present so the maps
1356
1482
  // don't grow without bound and a stale answer can't resurface on a key reuse.
1357
1483
  if (p.rowKey) {
@@ -1390,7 +1516,7 @@ function renderDataGrid(node) {
1390
1516
  }
1391
1517
  }
1392
1518
  tbody.replaceChildren();
1393
- for (const row of rows) renderRow(row);
1519
+ appendRows(rows);
1394
1520
  if (!rows.length) tbody.append(el("tr", {}, el("td", { colspan: span }, "No rows")));
1395
1521
  if (keepFocus && active.isConnected) {
1396
1522
  active.focus();
@@ -1457,7 +1583,14 @@ function fillNav(list, items) {
1457
1583
  function renderNav(node) {
1458
1584
  const p = node.props || {};
1459
1585
  const rail = p.variant === "rail";
1460
- const nav = el("nav", { class: rail ? "pc-nav pc-rail" : "pc-nav pc-bar" });
1586
+ // A bar nav can opt into staying pinned to the top of the viewport while the
1587
+ // page scrolls (sticky) — useful on the long, multi-section operator pages so
1588
+ // the page switcher is always reachable. Rails are already full-height, so the
1589
+ // flag only applies to the bar variant.
1590
+ const barSticky = !rail && p.sticky === true;
1591
+ const nav = el("nav", {
1592
+ class: (rail ? "pc-nav pc-rail" : "pc-nav pc-bar") + (barSticky ? " pc-sticky" : ""),
1593
+ });
1461
1594
  if (p.title != null) nav.append(el("div", { class: "pc-nav-title" }, String(p.title)));
1462
1595
  const list = el("div", { class: "pc-nav-items" });
1463
1596
  nav.append(list);
@@ -1551,7 +1684,9 @@ function makeCollapsible(node, card) {
1551
1684
  // tearing down the previous page's grid polls first.
1552
1685
  async function renderPage() {
1553
1686
  teardown();
1554
- CURRENT = currentPage();
1687
+ const route = parseRoute();
1688
+ CURRENT = route.page;
1689
+ PARAM = route.param;
1555
1690
  try {
1556
1691
  const doc = await getJSON("/app/pages/" + encodeURIComponent(CURRENT));
1557
1692
  document.title = (doc && doc.title) || "Urban App";