@nanobpm/urban 0.60.0 → 0.61.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/urban",
3
- "version": "0.60.0",
3
+ "version": "0.61.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",
@@ -2054,7 +2054,9 @@ function renderDataGrid(node) {
2054
2054
  // ({ label, page } -> an in-app #/<page> hash link, highlighted when it's the
2055
2055
  // current page) or to an external URL ({ label, href } -> a hardened new-tab
2056
2056
  // link; only http(s) is honoured). With items:"auto" (or omitted) the nav
2057
- // lists every page from the /app/pages index, using each page's title.
2057
+ // lists every page from the /app/pages index, using each page's title. An item
2058
+ // may also carry a `badge` ({ source, table, filter, tone, refreshMs,
2059
+ // hideWhenZero }) that renders a live count pill from the /app/data gateway.
2058
2060
  /** @param {any} item
2059
2061
  * @returns {HTMLAnchorElement|null}
2060
2062
  */
@@ -2073,8 +2075,87 @@ function navLink(item) {
2073
2075
  /** @type {Array<Node|string>} */
2074
2076
  const kids = [];
2075
2077
  if (item.icon != null) kids.push(el("span", { class: "pc-nav-icon" }, String(item.icon)));
2076
- kids.push(el("span", { class: "pc-nav-label" }, String(item.label != null ? item.label : (item.page || item.href))));
2077
- return el("a", attrs, ...kids);
2078
+ const labelText = String(item.label != null ? item.label : (item.page || item.href));
2079
+ kids.push(el("span", { class: "pc-nav-label" }, labelText));
2080
+ const link = el("a", attrs, ...kids);
2081
+ // A nav item may carry a live count badge sourced from a datasource (e.g. the
2082
+ // Tasks tab showing how many escalations await a human). It reuses the same
2083
+ // /app/data gateway + .pc-badge tone pills as dataGrid, and polls on refreshMs.
2084
+ // Absent/invalid badge → the link is exactly as before (backward compatible).
2085
+ wireNavBadge(link, labelText, item.badge);
2086
+ return link;
2087
+ }
2088
+
2089
+ // Attach + drive a nav item's live count badge. Best-effort throughout: a bad
2090
+ // config or a failed fetch degrades to no badge, never a broken nav link. The
2091
+ // pill is appended hidden and only appears once a fetch yields a count (and, when
2092
+ // hideWhenZero, only when that count is > 0). The accessible name of the whole
2093
+ // link is kept in sync ("Tasks (3 open)") so assistive tech announces the count,
2094
+ // which the small "3" glyph alone would not convey; the pill itself is aria-hidden
2095
+ // to avoid a double announcement.
2096
+ /** @param {HTMLElement} link
2097
+ * @param {string} baseLabel
2098
+ * @param {any} badge
2099
+ */
2100
+ function wireNavBadge(link, baseLabel, badge) {
2101
+ if (!badge || typeof badge !== "object") return;
2102
+ if (typeof badge.table !== "string" || badge.table === "") return;
2103
+ const source = typeof badge.source === "string" && badge.source !== "" ? badge.source : "app";
2104
+ const t = badge.tone;
2105
+ const tone = t === "warn" || t === "ok" || t === "info" || t === "danger" ? t : "danger";
2106
+ const hideWhenZero = badge.hideWhenZero === true;
2107
+ const pill = el("span", { class: "pc-badge pc-nav-badge pc-badge-" + tone, "aria-hidden": "true" });
2108
+ pill.hidden = true;
2109
+ link.append(pill);
2110
+ const filter = Array.isArray(badge.filter) ? badge.filter : [];
2111
+ const paramScoped = filter.some(/** @param {any} f */(f) => f && f.eqParam);
2112
+ const refresh = () => {
2113
+ // A param-scoped badge ("count for the selected entity") with no route param
2114
+ // present would otherwise build the URL with dataUrl baking in an empty PARAM
2115
+ // and query field="", surfacing a wrong count. Match the dataGrid/list pollers:
2116
+ // degrade to 0 (hidden when hideWhenZero) without a request instead.
2117
+ if (paramScoped && PARAM === "") {
2118
+ applyNavBadge(link, pill, baseLabel, hideWhenZero, 0);
2119
+ return;
2120
+ }
2121
+ const url = dataUrl(source, badge.table, filter, null);
2122
+ const countUrl = url + (url.indexOf("?") >= 0 ? "&" : "?") + "count=1";
2123
+ getJSON(countUrl)
2124
+ .then(/** @param {any} res */(res) => applyNavBadge(link, pill, baseLabel, hideWhenZero, res && res.count))
2125
+ .catch(() => applyNavBadge(link, pill, baseLabel, hideWhenZero, NaN));
2126
+ };
2127
+ refresh();
2128
+ const ms = Number(badge.refreshMs);
2129
+ if (Number.isFinite(ms) && ms > 0) {
2130
+ const timer = setInterval(refresh, ms);
2131
+ disposers.push(() => clearInterval(timer));
2132
+ }
2133
+ }
2134
+
2135
+ // Reflect a fetched count into the pill + the link's accessible name. A
2136
+ // non-finite/negative count (a failed or malformed fetch) hides the pill and
2137
+ // restores the plain label — the nav degrades quietly rather than showing a
2138
+ // broken/"NaN" badge. hideWhenZero also hides the pill at exactly 0.
2139
+ /** @param {HTMLElement} link
2140
+ * @param {any} pill
2141
+ * @param {string} baseLabel
2142
+ * @param {boolean} hideWhenZero
2143
+ * @param {any} count
2144
+ */
2145
+ function applyNavBadge(link, pill, baseLabel, hideWhenZero, count) {
2146
+ const n = Number(count);
2147
+ if (!Number.isFinite(n) || n < 0 || (n === 0 && hideWhenZero)) {
2148
+ pill.hidden = true;
2149
+ pill.textContent = "";
2150
+ link.setAttribute("aria-label", baseLabel);
2151
+ return;
2152
+ }
2153
+ const shown = String(Math.floor(n));
2154
+ pill.hidden = false;
2155
+ pill.textContent = shown;
2156
+ const aria = baseLabel + " (" + shown + " open)";
2157
+ pill.setAttribute("title", aria);
2158
+ link.setAttribute("aria-label", aria);
2078
2159
  }
2079
2160
  /** @param {HTMLElement} list
2080
2161
  * @param {any[]} items
@@ -2290,4 +2371,4 @@ function boot() {
2290
2371
  // renderer functions / RENDERERS registry can be imported directly.
2291
2372
  if (typeof document !== "undefined" && document.getElementById("page")) boot();
2292
2373
 
2293
- export { RENDERERS, renderText, renderButton, renderProse, renderNav, fmtCellValue, gridCell };
2374
+ export { RENDERERS, renderText, renderButton, renderProse, renderNav, navLink, wireNavBadge, applyNavBadge, teardown, fmtCellValue, gridCell };
@@ -7,7 +7,7 @@
7
7
  import { test } from "node:test";
8
8
  import assert from "node:assert/strict";
9
9
  import { PAGE_NODE_TYPES } from "@nanobpm/nano-app-schema";
10
- import { RENDERERS, renderText, fmtCellValue, gridCell } from "./runtime.browser.js";
10
+ import { RENDERERS, renderText, navLink, wireNavBadge, applyNavBadge, teardown, fmtCellValue, gridCell } from "./runtime.browser.js";
11
11
 
12
12
  // ── Minimal fake DOM ────────────────────────────────────────────────────────
13
13
  // Just enough of the Element/Document surface that el()/renderText touch:
@@ -158,3 +158,183 @@ test("#327: gridCell applies col.format to the cell text, leaving an unformatted
158
158
  const plain = gridCell({ field: "updated_at", header: "Updated" }, { updated_at: iso }, "secondary");
159
159
  assert.equal(plain.textContent, iso);
160
160
  });
161
+
162
+ // Find the first descendant span carrying `cls` in a FakeElement tree. Accepts
163
+ // `any` so it can walk either a FakeElement or a renderer's DOM-typed return.
164
+ function findByClass(node: any, cls: string): FakeElement | null {
165
+ for (const kid of node.children) {
166
+ if (kid instanceof FakeElement) {
167
+ if (kid.className.split(/\s+/).includes(cls)) return kid;
168
+ const deep = findByClass(kid, cls);
169
+ if (deep) return deep;
170
+ }
171
+ }
172
+ return null;
173
+ }
174
+
175
+ // Install a fake `fetch` on globalThis returning a fixed JSON body, so navLink's
176
+ // badge poll resolves deterministically without a real network call. Returns a
177
+ // restore fn (register with t.after) so it never leaks into other tests.
178
+ function installFakeFetch(body: unknown): () => void {
179
+ const prior = Reflect.getOwnPropertyDescriptor(globalThis, "fetch");
180
+ Reflect.set(globalThis, "fetch", () =>
181
+ Promise.resolve({ ok: true, json: () => Promise.resolve(body) }),
182
+ );
183
+ return () => {
184
+ if (prior) Reflect.defineProperty(globalThis, "fetch", prior);
185
+ else Reflect.deleteProperty(globalThis, "fetch");
186
+ };
187
+ }
188
+
189
+ test("#338: navLink without a badge is unchanged — no pill, no aria-label override", (t) => {
190
+ t.after(installFakeDom());
191
+ const link = navLink({ label: "Home", page: "home" });
192
+ assert.ok(link, "expected a nav link");
193
+ assert.equal(link.tagName, "A");
194
+ assert.equal(findByClass(link, "pc-nav-badge"), null, "no badge pill without item.badge");
195
+ assert.equal(link.getAttribute("aria-label"), null, "aria-label untouched without a badge");
196
+ });
197
+
198
+ test("#338: navLink with a badge appends a hidden tone-classed pill and polls a count", async (t) => {
199
+ t.after(installFakeDom());
200
+ t.after(installFakeFetch({ count: 3 }));
201
+ const link = navLink({
202
+ label: "Tasks",
203
+ page: "tasks",
204
+ badge: { source: "app", table: "user_tasks", filter: [], tone: "danger" },
205
+ });
206
+ assert.ok(link);
207
+ const pill = findByClass(link, "pc-nav-badge");
208
+ assert.ok(pill, "expected a badge pill for item.badge");
209
+ assert.ok(pill.className.includes("pc-badge"), "reuses the .pc-badge pill");
210
+ assert.ok(pill.className.includes("pc-badge-danger"), "carries the requested tone class");
211
+ // The poll resolves asynchronously; flush microtasks then assert the live count.
212
+ await new Promise((r) => setTimeout(r, 0));
213
+ assert.equal(pill.hidden, false, "pill shows once a positive count arrives");
214
+ assert.equal(pill.textContent, "3");
215
+ assert.equal(link.getAttribute("aria-label"), "Tasks (3 open)", "accessible name carries the count");
216
+ });
217
+
218
+ test("#338: navLink badge degrades to no pill (never a broken nav) when the fetch fails", async (t) => {
219
+ t.after(installFakeDom());
220
+ const prior = Reflect.getOwnPropertyDescriptor(globalThis, "fetch");
221
+ Reflect.set(globalThis, "fetch", () => Promise.reject(new Error("boom")));
222
+ t.after(() => {
223
+ if (prior) Reflect.defineProperty(globalThis, "fetch", prior);
224
+ else Reflect.deleteProperty(globalThis, "fetch");
225
+ });
226
+ const link = navLink({ label: "Tasks", page: "tasks", badge: { table: "user_tasks" } });
227
+ const pill = findByClass(link, "pc-nav-badge");
228
+ assert.ok(pill);
229
+ await new Promise((r) => setTimeout(r, 0));
230
+ assert.equal(pill.hidden, true, "a failed fetch leaves the pill hidden");
231
+ assert.equal(pill.textContent, "");
232
+ });
233
+
234
+ test("#338: applyNavBadge honours hideWhenZero and mirrors the count into the accessible name", (t) => {
235
+ const restore = installFakeDom();
236
+ t.after(restore);
237
+ const doc: { createElement: (tag: string) => FakeElement } = Reflect.get(globalThis, "document");
238
+ // The renderer's applyNavBadge is DOM-typed (HTMLElement); the fake elements
239
+ // satisfy the subset it touches, so the locals are `any` (no banned `as` cast).
240
+ const mk = (): { link: any; pill: any } => ({ link: doc.createElement("a"), pill: doc.createElement("span") });
241
+
242
+ // Positive count → visible pill + "(n open)" accessible name.
243
+ const a = mk();
244
+ applyNavBadge(a.link, a.pill, "Tasks", true, 5);
245
+ assert.equal(a.pill.hidden, false);
246
+ assert.equal(a.pill.textContent, "5");
247
+ assert.equal(a.link.getAttribute("aria-label"), "Tasks (5 open)");
248
+
249
+ // Zero with hideWhenZero → nothing (a quiet nav stays clean), label reset.
250
+ const b = mk();
251
+ applyNavBadge(b.link, b.pill, "Tasks", true, 0);
252
+ assert.equal(b.pill.hidden, true);
253
+ assert.equal(b.pill.textContent, "");
254
+ assert.equal(b.link.getAttribute("aria-label"), "Tasks");
255
+
256
+ // Zero WITHOUT hideWhenZero → the pill still shows "0".
257
+ const c = mk();
258
+ applyNavBadge(c.link, c.pill, "Tasks", false, 0);
259
+ assert.equal(c.pill.hidden, false);
260
+ assert.equal(c.pill.textContent, "0");
261
+
262
+ // A non-finite/malformed count degrades to hidden, never "NaN".
263
+ const d = mk();
264
+ applyNavBadge(d.link, d.pill, "Tasks", false, Number.NaN);
265
+ assert.equal(d.pill.hidden, true);
266
+ assert.equal(d.pill.textContent, "");
267
+ });
268
+
269
+ test("#338: wireNavBadge registers its poll interval with the per-page teardown so page switches stop it", (t) => {
270
+ // Defect-class guard: any refreshMs poller MUST clear its interval on teardown,
271
+ // or navigating between pages leaks a setInterval that fetches forever and pins
272
+ // the old link/pill alive via closure. This drives wireNavBadge with stubbed
273
+ // timers + fetch and asserts teardown() actually clears the interval it started.
274
+ const restore = installFakeDom();
275
+ t.after(restore);
276
+ const doc: { createElement: (tag: string) => FakeElement } = Reflect.get(globalThis, "document");
277
+
278
+ const cleared: number[] = [];
279
+ let nextId = 1;
280
+ const started: number[] = [];
281
+ const priorSet = Reflect.getOwnPropertyDescriptor(globalThis, "setInterval");
282
+ const priorClear = Reflect.getOwnPropertyDescriptor(globalThis, "clearInterval");
283
+ const priorFetch = Reflect.getOwnPropertyDescriptor(globalThis, "fetch");
284
+ Reflect.set(globalThis, "setInterval", (): number => {
285
+ const id = nextId++;
286
+ started.push(id);
287
+ return id;
288
+ });
289
+ Reflect.set(globalThis, "clearInterval", (id: number): void => {
290
+ cleared.push(id);
291
+ });
292
+ // getJSON awaits fetch(); a rejection is swallowed by wireNavBadge's .catch, so
293
+ // the immediate poll degrades quietly without an unhandled rejection.
294
+ Reflect.set(globalThis, "fetch", () => Promise.reject(new Error("offline")));
295
+ t.after(() => {
296
+ if (priorSet) Reflect.defineProperty(globalThis, "setInterval", priorSet);
297
+ if (priorClear) Reflect.defineProperty(globalThis, "clearInterval", priorClear);
298
+ if (priorFetch) Reflect.defineProperty(globalThis, "fetch", priorFetch);
299
+ else Reflect.deleteProperty(globalThis, "fetch");
300
+ });
301
+
302
+ const link: any = doc.createElement("a");
303
+ wireNavBadge(link, "Tasks", { source: "app", table: "tasks", refreshMs: 5000 });
304
+ assert.equal(started.length, 1, "wireNavBadge should start exactly one poll interval");
305
+ assert.equal(cleared.length, 0, "interval must not be cleared before teardown");
306
+
307
+ teardown();
308
+ assert.deepEqual(cleared, started, "teardown() must clear the interval wireNavBadge started");
309
+ });
310
+
311
+ test("#338: a param-scoped nav badge with no route param degrades to 0 without querying field=\"\"", (t) => {
312
+ // Defect-class guard mirroring the dataGrid/list pollers: a badge whose filter is
313
+ // param-scoped ({ eqParam: true }) but with an empty route PARAM must NOT build a
314
+ // where=field: query (which the server reads as field equals empty string and can
315
+ // surface a wrong count). It must short-circuit to a 0/hidden badge with no fetch.
316
+ const restore = installFakeDom();
317
+ t.after(restore);
318
+ const doc: { createElement: (tag: string) => FakeElement } = Reflect.get(globalThis, "document");
319
+
320
+ let fetchCalls = 0;
321
+ const priorFetch = Reflect.getOwnPropertyDescriptor(globalThis, "fetch");
322
+ Reflect.set(globalThis, "fetch", () => {
323
+ fetchCalls++;
324
+ return Promise.reject(new Error("should not fetch"));
325
+ });
326
+ t.after(() => {
327
+ if (priorFetch) Reflect.defineProperty(globalThis, "fetch", priorFetch);
328
+ else Reflect.deleteProperty(globalThis, "fetch");
329
+ });
330
+
331
+ // PARAM defaults to "" (no renderPage has run), so a param-scoped badge is the
332
+ // empty-selection case the guard must short-circuit.
333
+ const link: any = doc.createElement("a");
334
+ wireNavBadge(link, "Tasks", { source: "app", table: "tasks", filter: [{ field: "owner", eqParam: true }] });
335
+
336
+ assert.equal(fetchCalls, 0, "param-scoped badge with empty PARAM must not issue a count query");
337
+ const pill: any = link.children[link.children.length - 1];
338
+ assert.equal(pill.textContent, "0", "badge degrades to 0 rather than an empty-string-filtered count");
339
+ assert.equal(pill.hidden, false, "0 is shown (hideWhenZero is off) rather than a stale count");
340
+ });
@@ -1047,6 +1047,33 @@ test("GET /app/data whitelists filter columns", async () => {
1047
1047
  assert.equal(bad.status, 400);
1048
1048
  });
1049
1049
 
1050
+ test("#338: GET /app/data?count=1 returns a COUNT(*), honouring the same whitelisted where", async () => {
1051
+ // count mode issues a COUNT(*) (not SELECT *) so a nav badge can show a live
1052
+ // number without transferring rows; the where filter is still column-whitelisted.
1053
+ let seenSql = "";
1054
+ let seenParams: unknown[] = [];
1055
+ const { router } = build({
1056
+ query: async (sql: string, params?: unknown[]) => {
1057
+ if (/PRAGMA table_info/i.test(sql)) {
1058
+ return [{ name: "id" }, { name: "status" }, { name: "total" }];
1059
+ }
1060
+ seenSql = sql;
1061
+ seenParams = params ?? [];
1062
+ return [{ n: 7 }];
1063
+ },
1064
+ });
1065
+ const res = await router(req("GET", "/app/data/app/orders", { query: "count=1&where=status:new" }));
1066
+ assert.equal(res.status, 200);
1067
+ assert.equal(JSON.parse(res.body ?? "{}").count, 7);
1068
+ assert.match(seenSql, /SELECT COUNT\(\*\) AS n FROM/i, "count mode must issue a COUNT(*), not SELECT *");
1069
+ assert.doesNotMatch(seenSql, /LIMIT/i, "a count has no LIMIT");
1070
+ assert.deepEqual(seenParams, ["new"], "the where value is still bound as a parameter");
1071
+
1072
+ // An unknown filter column is rejected in count mode too (no whitelist bypass).
1073
+ const bad = await router(req("GET", "/app/data/app/orders", { query: "count=1&where=evil:1" }));
1074
+ assert.equal(bad.status, 400);
1075
+ });
1076
+
1050
1077
  test("POST /app/actions/start/<process> creates an instance", async () => {
1051
1078
  const { router, calls } = build();
1052
1079
  const res = await router(req("POST", "/app/actions/start/my-proc", { body: { variables: { a: 1 } } }));
@@ -92,6 +92,15 @@ function javascript(
92
92
  * (checked against `schema()`), so `/app/data/:table` can never inject SQL. */
93
93
  const IDENT = /^[A-Za-z_][A-Za-z0-9_]*$/;
94
94
 
95
+ // A query flag (e.g. ?count=1) is "on" when present and not one of the falsy
96
+ // spellings. A bare `?count` (empty string) still counts as on, so a caller can
97
+ // opt in without inventing a value.
98
+ function isTruthyParam(v: string | null): boolean {
99
+ if (v === null) return false;
100
+ const s = v.trim().toLowerCase();
101
+ return s !== "0" && s !== "false" && s !== "no";
102
+ }
103
+
95
104
  /**
96
105
  * Build the page-runtime routes over an injected datasource + engine. Pure over its deps
97
106
  * so it is unit-testable without a real host/server (see pages.test.ts).
@@ -272,6 +281,24 @@ export function createPagesRoutes(opts: PagesOptions, deps: PagesDeps): Route[]
272
281
  orderSql = ` ORDER BY ${quoteIdent(field)} ${dir}`;
273
282
  }
274
283
  const whereSql = clauses.length ? ` WHERE ${clauses.join(" AND ")}` : "";
284
+ // ?count=1 → a lightweight COUNT(*) instead of the rows themselves. The same
285
+ // whitelisted where clauses apply, so a caller (e.g. a nav item's live count
286
+ // badge) gets exactly the number of matching rows without transferring or
287
+ // materialising them. ORDER BY / LIMIT are irrelevant to a count and skipped.
288
+ if (isTruthyParam(req.query.get("count"))) {
289
+ try {
290
+ const rows = await db.query(
291
+ `SELECT COUNT(*) AS n FROM ${quoteIdent(table)}${whereSql}`,
292
+ params,
293
+ );
294
+ const first = Array.isArray(rows) && rows.length > 0 ? rows[0] : undefined;
295
+ const rawCount = isRecord(first) ? first.n : 0;
296
+ const count = Number(rawCount);
297
+ return json({ count: Number.isFinite(count) ? count : 0 });
298
+ } catch (e) {
299
+ return json({ error: errorMessage(e) }, 500);
300
+ }
301
+ }
275
302
  try {
276
303
  const rows = await db.query(
277
304
  `SELECT * FROM ${quoteIdent(table)}${whereSql}${orderSql} LIMIT ${rowLimit}`,
@@ -682,6 +709,11 @@ table.pc-grid th { font-weight:600; color:var(--nano-text-muted); }
682
709
  .pc-nav-link:hover { background:var(--nano-hover); color:var(--nano-text); }
683
710
  .pc-nav-link.active { background:var(--nano-accent); color:var(--nano-on-accent); }
684
711
  .pc-nav-icon { line-height:1; }
712
+ /* A nav item's live count pill (item.badge). Slightly more compact than the base
713
+ .pc-badge so it sits neatly beside a nav label; [hidden] must win over the
714
+ .pc-badge inline-flex display so hideWhenZero / a failed fetch truly hides it. */
715
+ .pc-nav-badge { min-width:1.1rem; height:1.1rem; font-size:.68rem; padding:0 .35rem; margin-left:.1rem; }
716
+ .pc-nav-badge[hidden] { display:none; }
685
717
  .pc-nav-empty { color:var(--nano-text-faint); font-size:.85rem; padding:.4rem .7rem; }
686
718
  /* The per-row mobile "More" toggle (revealing mobile:{priority:"hidden"} columns)
687
719
  only exists for the card view — hidden on the desktop table, shown by the