@aiaiai-pt/design-system 0.33.0 → 0.35.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.
@@ -0,0 +1,619 @@
1
+ /**
2
+ * `resolveData(block)` — the single data path between a block's pointer and a
3
+ * widget (#73, spec §5). Reads the `binding`, fetches through the host-injected
4
+ * `DataProvider` (#492 P0), and returns normalised `{ schema, data, actionDef }`.
5
+ * The block stores ONLY the pointer — rows are never baked in.
6
+ *
7
+ * Pure + provider-injectable so the security-critical decisions (which paths are
8
+ * reachable, fail-closed on misconfig/upstream error) are unit/mutation-
9
+ * testable without SvelteKit. The route loader is a thin wrapper that passes a
10
+ * `createPublicDataProvider` wrapping its server `fetch` (which carries the
11
+ * tenant + graduated principal).
12
+ *
13
+ * Fail-closed (§14.8): an unmappable binding never fetches; a non-OK upstream
14
+ * yields `{ ok: false }` and the caller renders per blast radius (see
15
+ * `decideRender` in `./dispatch.ts`).
16
+ */
17
+ import type {
18
+ Binding,
19
+ Block,
20
+ FeedView,
21
+ OntologySchema,
22
+ WidgetKind,
23
+ } from "./types";
24
+ import type { DataProvider } from "./data-provider";
25
+ import { type Ballot, isClosed, toOptions, voteReadConfig } from "./vote";
26
+
27
+ /**
28
+ * Presentational kinds (#75 M5 hero/lookup + #105 Phase 7 landing/content) that
29
+ * render PURELY from their block props — no data path, no fetch. resolveData
30
+ * short-circuits them with `{ data: null }`, which still satisfies decideRender's
31
+ * "matched + dataOk" contract (the widget owns its own props). Keeping them in
32
+ * one set means a new content section never accidentally tries to fetch.
33
+ */
34
+ export const PRESENTATIONAL_KINDS: ReadonlySet<WidgetKind> =
35
+ new Set<WidgetKind>([
36
+ "hero",
37
+ "lookup",
38
+ "feature-grid",
39
+ "cta",
40
+ "media-text",
41
+ "steps",
42
+ "testimonial",
43
+ "faq",
44
+ "logo-strip",
45
+ "media-gallery",
46
+ ]);
47
+
48
+ export interface ResolvedData {
49
+ data: unknown;
50
+ /** Ontology schema for the bound entity — wired in 73d. */
51
+ schema: OntologySchema | null;
52
+ /** Action definition for form bindings — wired in 73f. */
53
+ actionDef: unknown | null;
54
+ /** The BFF path the data came from (#75 M5 slice 4) — widgets that page
55
+ * (list "load more") re-fetch it client-side with `?after=`. */
56
+ dataPath?: string;
57
+ }
58
+
59
+ export type ResolveDataResult =
60
+ | { ok: true; value: ResolvedData }
61
+ | { ok: false; status: number; reason: string };
62
+
63
+ /** The minimal fetch shape we depend on (injectable; SvelteKit `fetch` fits). */
64
+ export type FetchLike = (path: string) => Promise<{
65
+ ok: boolean;
66
+ status: number;
67
+ json: () => Promise<unknown>;
68
+ }>;
69
+
70
+ export interface ResolveDataDeps {
71
+ /** Host-injected data transport seam (#492 P0). */
72
+ provider: DataProvider;
73
+ /**
74
+ * "Now", injected by the route loader (`new Date()`), used to compute the
75
+ * calendar's required `start`/`end` window (73e). Optional — only the
76
+ * `calendar` kind reads it. Injected (not read here) so the path stays
77
+ * deterministic in tests.
78
+ */
79
+ now?: Date;
80
+ /**
81
+ * The active feed view (`?view=` — #308), used only by the `feed` kind to
82
+ * pick which underlying feed (list/map) to resolve. Absent → the block's
83
+ * first configured view.
84
+ */
85
+ view?: string;
86
+ /**
87
+ * The URL's facet/search params (#308), forwarded to a feed read so the SSR
88
+ * feed is server-FILTERED (and paging rides `dataPath`). The BFF drops any
89
+ * param it doesn't declare (its security contract), so the whole set is safe
90
+ * to pass. Reserved keys (`view`, `reference`) are stripped by the loader.
91
+ */
92
+ filterParams?: Record<string, string>;
93
+ }
94
+
95
+ function enc(value: string): string {
96
+ return encodeURIComponent(value);
97
+ }
98
+
99
+ /** The feed sub-views a `feed` block may toggle between (#308). Bounds the
100
+ * operator-authored `props.views` to a known allowlist; an empty/invalid list
101
+ * falls back to `["list"]` (every browse surface has a list). */
102
+ const FEED_VIEW_ALLOWLIST: readonly FeedView[] = ["list", "map"];
103
+
104
+ export function feedViews(
105
+ props: Record<string, unknown> | undefined,
106
+ ): FeedView[] {
107
+ const raw = props?.views;
108
+ const parsed = Array.isArray(raw)
109
+ ? raw.filter((v): v is FeedView =>
110
+ (FEED_VIEW_ALLOWLIST as readonly string[]).includes(v as string),
111
+ )
112
+ : [];
113
+ return parsed.length > 0 ? parsed : ["list"];
114
+ }
115
+
116
+ /** The active view = the requested one when it's an available view, else the
117
+ * default (first). Keeps `?view=` shareable + an unknown value harmless. */
118
+ export function activeFeedView(
119
+ views: FeedView[],
120
+ requested: string | undefined,
121
+ ): FeedView {
122
+ return requested && (views as string[]).includes(requested)
123
+ ? (requested as FeedView)
124
+ : views[0];
125
+ }
126
+
127
+ /** Merge facet/search params into a BFF path (handles an existing `?`). Empty
128
+ * values drop; an empty param set returns the path unchanged. */
129
+ export function appendQuery(
130
+ path: string,
131
+ params: Record<string, string> | undefined,
132
+ ): string {
133
+ if (!params) return path;
134
+ const entries = Object.entries(params).filter(
135
+ ([, v]) => v !== undefined && v !== null && v !== "",
136
+ );
137
+ if (entries.length === 0) return path;
138
+ const sep = path.includes("?") ? "&" : "?";
139
+ return `${path}${sep}${new URLSearchParams(entries).toString()}`;
140
+ }
141
+
142
+ /**
143
+ * Pure mapping: pointer → BFF public path (#70 surface, app-first). Returns
144
+ * `null` for any binding that doesn't map to a wired read endpoint — the
145
+ * caller fails closed (no fetch, no DOM). `binding.filter` carries the
146
+ * per-route selector (ref/key/id) for the single-resource kinds.
147
+ */
148
+ export function bffPathForBinding(
149
+ binding: Binding,
150
+ app: string,
151
+ ): string | null {
152
+ switch (binding.kind) {
153
+ case "list": {
154
+ if (!binding.entity) return null;
155
+ // Optional list scope (#75 M5): cap + ordering ride the query string.
156
+ // The BFF shape-sanitises `order_by` (drops malformed values whole)
157
+ // and clamps `limit`; both are plain passthrough params here.
158
+ const params = new URLSearchParams();
159
+ if (typeof binding.limit === "number" && binding.limit > 0) {
160
+ params.set("limit", String(Math.floor(binding.limit)));
161
+ }
162
+ if (typeof binding.order === "string" && binding.order) {
163
+ params.set("order_by", binding.order);
164
+ }
165
+ const qs = params.toString();
166
+ return `/${app}/public/${enc(binding.entity)}${qs ? `?${qs}` : ""}`;
167
+ }
168
+ case "aggregate": {
169
+ // #105 Phase 4 — a public aggregate VIEW's rows as primary data. Same
170
+ // public-list URL as a `list` (the BFF routes to the view lane off the
171
+ // surface row's `source_kind: view`, #250 M3), but a distinct kind so the
172
+ // registry dispatches RankingBoard/ResultsChart (which read `{label,
173
+ // value}` rows) instead of EntityList (which needs a per-type schema a
174
+ // view doesn't have). `limit`/`order` ride the query string; the view's
175
+ // sortable columns ARE its output columns, so the BFF drops undeclared
176
+ // order terms.
177
+ if (!binding.entity) return null;
178
+ const params = new URLSearchParams();
179
+ if (typeof binding.limit === "number" && binding.limit > 0) {
180
+ params.set("limit", String(Math.floor(binding.limit)));
181
+ }
182
+ if (typeof binding.order === "string" && binding.order) {
183
+ params.set("order_by", binding.order);
184
+ }
185
+ const qs = params.toString();
186
+ return `/${app}/public/${enc(binding.entity)}${qs ? `?${qs}` : ""}`;
187
+ }
188
+ case "forms":
189
+ // The service directory (#75 M5) — citizen-startable write placements.
190
+ return `/${app}/public/forms`;
191
+ case "detail": {
192
+ if (!binding.entity || !binding.filter) return null;
193
+ const base = `/${app}/public/${enc(binding.entity)}/${enc(binding.filter)}`;
194
+ const names = (binding.expand ?? []).filter(
195
+ (n): n is string =>
196
+ typeof n === "string" && /^[a-z][a-z0-9_]*$/.test(n),
197
+ );
198
+ return names.length > 0 ? `${base}?expand=${enc(names.join(","))}` : base;
199
+ }
200
+ case "actions": {
201
+ // #313 — citizen action availability for a detail row. Needs the entity
202
+ // type + row id (carried like `detail`); the BFF projects which
203
+ // citizen-write actions are OPEN on this row. No expand.
204
+ if (!binding.entity || !binding.filter) return null;
205
+ return `/${app}/public/${enc(binding.entity)}/${enc(binding.filter)}/actions`;
206
+ }
207
+ case "status":
208
+ return binding.filter
209
+ ? `/${app}/public/submission/${enc(binding.filter)}`
210
+ : null;
211
+ case "content":
212
+ return binding.filter
213
+ ? `/${app}/public/content/${enc(binding.filter)}`
214
+ : null;
215
+ case "map":
216
+ return `/${app}/public/map`;
217
+ case "calendar":
218
+ return `/${app}/public/calendar`;
219
+ case "form":
220
+ // The action FORM contract, keyed by the citizen-write PLACEMENT key
221
+ // (carried in `filter`). 73f — the converged renderer mounts this.
222
+ return binding.filter
223
+ ? `/${app}/public/form/${enc(binding.filter)}`
224
+ : null;
225
+ // kpi: no wired read endpoint yet. `vote` is handled in a dedicated
226
+ // resolveData branch (two reads composed into a ballot), never here. Fail
227
+ // closed for anything unmapped.
228
+ default:
229
+ return null;
230
+ }
231
+ }
232
+
233
+ /**
234
+ * Pure mapping: pointer → BFF public *schema* path (#73 / 73d). Only the
235
+ * schema-driven kinds (`list`, `detail`) have one; the entity's field shape
236
+ * (key/type/label) lets EntityList/DetailView render columns/fields without a
237
+ * hardcoded entity-type union (§14.6). Returns `null` for every other kind, and
238
+ * for list/detail without an entity. `binding.filter` (the detail row id) is
239
+ * NOT part of the schema path — the schema is per *type*, not per row.
240
+ */
241
+ export function schemaPathForBinding(
242
+ binding: Binding,
243
+ app: string,
244
+ ): string | null {
245
+ switch (binding.kind) {
246
+ case "list":
247
+ case "detail":
248
+ return binding.entity
249
+ ? `/${app}/public/schema/${enc(binding.entity)}`
250
+ : null;
251
+ default:
252
+ return null;
253
+ }
254
+ }
255
+
256
+ /**
257
+ * The date window the `/public/calendar` fetch requires (73e). The endpoint
258
+ * 422s without `start`/`end`, so the calendar block fetches a window computed
259
+ * from the injected "now": the current month through two months out (covering
260
+ * the default month view plus a buffer for next-month navigation). Pure given
261
+ * `now`, computed in UTC so it doesn't drift with the server timezone.
262
+ */
263
+ export function calendarWindow(now: Date): { start: string; end: string } {
264
+ const year = now.getUTCFullYear();
265
+ const month = now.getUTCMonth();
266
+ const start = new Date(Date.UTC(year, month, 1));
267
+ const end = new Date(Date.UTC(year, month + 2, 1));
268
+ return {
269
+ start: start.toISOString().slice(0, 10),
270
+ end: end.toISOString().slice(0, 10),
271
+ };
272
+ }
273
+
274
+ export async function resolveData(
275
+ block: Block,
276
+ deps: ResolveDataDeps,
277
+ ): Promise<ResolveDataResult> {
278
+ // Presentational kinds (#75 M5 hero/lookup + #105 Phase 7 landing/content):
279
+ // they render purely from block props — no data path, no fetch. Resolving OK
280
+ // with null data keeps decideRender's "matched + dataOk" contract without a
281
+ // network round-trip.
282
+ if (PRESENTATIONAL_KINDS.has(block.binding.kind)) {
283
+ return {
284
+ ok: true,
285
+ value: { data: null, schema: null, actionDef: null },
286
+ };
287
+ }
288
+
289
+ // #105 Phase 5 — the `subscriptions` block reads the signed-in citizen's OWN
290
+ // notification subscriptions through the AUTHED account proxy
291
+ // (`/me/notifications/subscriptions`), NOT the public `/bff` surface: the lane
292
+ // is `require_auth` + OWN-scoped, so anonymous/unresolved-tenant calls 401/400
293
+ // at the proxy. The page is auth-gated upstream (the loader redirects an
294
+ // anonymous caller to login), so a non-OK here is a real upstream failure →
295
+ // fail the block CLOSED (the widget owns its own busy/empty UI once data
296
+ // loads). `dataPath` carries the lane so the widget mutates the same surface.
297
+ if (block.binding.kind === "subscriptions") {
298
+ const accountPath = deps.provider.accountUrl("notifications/subscriptions");
299
+ const resp = await deps.provider.fetch(accountPath);
300
+ if (!resp.ok) {
301
+ return {
302
+ ok: false,
303
+ status: resp.status,
304
+ reason: `upstream ${resp.status}`,
305
+ };
306
+ }
307
+ const envelope = (await resp.json()) as Record<string, unknown>;
308
+
309
+ // #252 Anexo 110 — per edition/category/freguesia toggles. Each declared
310
+ // scope names a PUBLIC option-view + its value/label fields (DATA, no
311
+ // civic literal here); we read each view and shape `{value,label}` options.
312
+ // Soft per-dimension: a missing/failed view drops that section, never the
313
+ // whole panel. The backend already matches a subscription's single-key
314
+ // `filters` against the dispatch payload (#241), so enabling a toggle =
315
+ // creating `{filters:{<key>:<value>}}`.
316
+ const scopeDefs = Array.isArray(
317
+ (block.props as { scopes?: unknown })?.scopes,
318
+ )
319
+ ? ((block.props as { scopes: unknown[] }).scopes as Record<
320
+ string,
321
+ unknown
322
+ >[])
323
+ : [];
324
+ const sp = (o: Record<string, unknown>, k: string): string =>
325
+ typeof o[k] === "string" ? (o[k] as string) : "";
326
+ const scopes: {
327
+ key: string;
328
+ group: string;
329
+ options: { value: string; label: string }[];
330
+ }[] = [];
331
+ for (const s of scopeDefs) {
332
+ const key = sp(s, "key");
333
+ const group = sp(s, "group");
334
+ const view = sp(s, "view");
335
+ const vf = sp(s, "value_field");
336
+ const lf = sp(s, "label_field");
337
+ if (!key || !group || !view || !vf || !lf) continue;
338
+ try {
339
+ const r = await deps.provider.fetch(
340
+ deps.provider.optionViewUrl(view, 200),
341
+ );
342
+ if (!r.ok) continue;
343
+ const rows = ((await r.json()) as { items?: Record<string, unknown>[] })
344
+ .items;
345
+ if (!Array.isArray(rows)) continue;
346
+ const options = rows
347
+ .map((row) => ({
348
+ value: row[vf] == null ? "" : String(row[vf]),
349
+ label: row[lf] == null ? String(row[vf] ?? "") : String(row[lf]),
350
+ }))
351
+ .filter((o) => o.value);
352
+ if (options.length) scopes.push({ key, group, options });
353
+ } catch {
354
+ /* soft-empty this dimension */
355
+ }
356
+ }
357
+
358
+ return {
359
+ ok: true,
360
+ value: {
361
+ data: { ...envelope, scopes },
362
+ schema: null,
363
+ actionDef: null,
364
+ dataPath: accountPath,
365
+ },
366
+ };
367
+ }
368
+
369
+ // #105 Phase 5 slice 2 — a `consent` block reads the signed-in citizen's OWN
370
+ // RGPD state (`/me/consent`: the tenant's active consent purposes joined with
371
+ // the caller's consent records) through the AUTHED account proxy, NOT the
372
+ // public `/bff` surface. Same auth-gated, fail-closed posture as
373
+ // `subscriptions` (the page loader redirects an anonymous caller to login).
374
+ // `dataPath` carries the lane so the widget revokes/erases/exports against it.
375
+ if (block.binding.kind === "consent") {
376
+ const accountPath = deps.provider.accountUrl("consent");
377
+ const resp = await deps.provider.fetch(accountPath);
378
+ if (!resp.ok) {
379
+ return {
380
+ ok: false,
381
+ status: resp.status,
382
+ reason: `upstream ${resp.status}`,
383
+ };
384
+ }
385
+ return {
386
+ ok: true,
387
+ value: {
388
+ data: await resp.json(),
389
+ schema: null,
390
+ actionDef: null,
391
+ dataPath: accountPath,
392
+ },
393
+ };
394
+ }
395
+
396
+ // #252 Anexo 110 — a `deliveries` block reads the signed-in citizen's OWN
397
+ // notification HISTORY (`/me/notifications/deliveries`: require_auth +
398
+ // OWN-scoped on recipient_user_id) through the AUTHED account proxy. The
399
+ // `notification_delivery` ledger rows, written AFTER each dispatch. Read-only
400
+ // (no mutation). Same auth-gated, fail-CLOSED posture as `subscriptions` /
401
+ // `consent` — the page loader redirects an anonymous caller to login, so a
402
+ // non-OK here is a real upstream failure (the block soft-empties only when
403
+ // it is optional, per decideRender).
404
+ if (block.binding.kind === "deliveries") {
405
+ const accountPath = deps.provider.accountUrl("notifications/deliveries");
406
+ const resp = await deps.provider.fetch(accountPath);
407
+ if (!resp.ok) {
408
+ return {
409
+ ok: false,
410
+ status: resp.status,
411
+ reason: `upstream ${resp.status}`,
412
+ };
413
+ }
414
+ return {
415
+ ok: true,
416
+ value: {
417
+ data: await resp.json(),
418
+ schema: null,
419
+ actionDef: null,
420
+ dataPath: accountPath,
421
+ },
422
+ };
423
+ }
424
+
425
+ // #105 — a `vote` block COMPOSES two existing PUBLIC reads into a ballot (no
426
+ // backend): the edition row (its `current_phase` decides open/closed) and the
427
+ // votable-entity list (the finalists, as radio options). Both go through the
428
+ // auth-optional `/bff` proxy (a signed-in citizen's Bearer rides along for the
429
+ // cast, but the reads are public). The votable entity is on the binding; the
430
+ // edition entity / status / phase / label config is DATA in the block props,
431
+ // so this stays vertical-agnostic. Fail CLOSED on a missing edition id, a
432
+ // half-configured block, or a non-OK upstream — the page is auth-gated and a
433
+ // ballot that can't enumerate its options must not render an empty radio group.
434
+ if (block.binding.kind === "vote") {
435
+ const editionId = block.binding.filter;
436
+ const cfg = voteReadConfig(block.binding.entity, block.props);
437
+ if (!editionId || cfg === null) {
438
+ return { ok: false, status: 0, reason: "vote binding misconfigured" };
439
+ }
440
+ const [edResp, opResp] = await Promise.all([
441
+ deps.provider.fetch(deps.provider.voteEditionUrl(cfg, editionId)),
442
+ deps.provider.fetch(deps.provider.voteOptionsUrl(cfg, editionId)),
443
+ ]);
444
+ if (!edResp.ok) {
445
+ return {
446
+ ok: false,
447
+ status: edResp.status,
448
+ reason: `edition ${edResp.status}`,
449
+ };
450
+ }
451
+ if (!opResp.ok) {
452
+ return {
453
+ ok: false,
454
+ status: opResp.status,
455
+ reason: `options ${opResp.status}`,
456
+ };
457
+ }
458
+ const editionRow = await edResp.json();
459
+ const proposalsBody = await opResp.json();
460
+ const ballot: Ballot = {
461
+ options: toOptions(proposalsBody, cfg),
462
+ closed: isClosed(editionRow, cfg),
463
+ edition: editionId,
464
+ };
465
+ return {
466
+ ok: true,
467
+ value: { data: ballot, schema: null, actionDef: null },
468
+ };
469
+ }
470
+
471
+ // #105 — an `owned-list` block reads the signed-in citizen's OWN rows of an
472
+ // entity through the AUTHED account proxy (`/me/owned/{entity}`: require_auth +
473
+ // OWN-scoped, resolves a user-entity-rel owner the public list can't). Fetches
474
+ // the public SCHEMA alongside (best-effort, like list/detail) so EntityList
475
+ // renders schema-driven columns; a schema miss falls back to row introspection.
476
+ // Same auth-gated, fail-closed posture as `subscriptions`/`consent`.
477
+ if (block.binding.kind === "owned-list") {
478
+ if (!block.binding.entity) {
479
+ return { ok: false, status: 0, reason: "owned-list missing entity" };
480
+ }
481
+ const ownedPath = deps.provider.accountUrl(
482
+ `owned/${enc(block.binding.entity)}`,
483
+ );
484
+ // The schema path uses the `list` kind convention (same schema endpoint
485
+ // as a public list — owned-list reads the OWN rows but schema is per-type).
486
+ const schemaUrl = deps.provider.schemaUrl({
487
+ kind: "list",
488
+ entity: block.binding.entity,
489
+ });
490
+ const schemaPromise =
491
+ schemaUrl !== null
492
+ ? deps.provider.fetch(schemaUrl).then(
493
+ (r) => r,
494
+ () => null,
495
+ )
496
+ : null;
497
+ const resp = await deps.provider.fetch(ownedPath);
498
+ if (!resp.ok) {
499
+ return {
500
+ ok: false,
501
+ status: resp.status,
502
+ reason: `upstream ${resp.status}`,
503
+ };
504
+ }
505
+ const data = await resp.json();
506
+ let schema: OntologySchema | null = null;
507
+ const sresp = schemaPromise !== null ? await schemaPromise : null;
508
+ if (sresp !== null && sresp.ok) {
509
+ schema = (await sresp.json().catch(() => null)) as OntologySchema | null;
510
+ }
511
+ return {
512
+ ok: true,
513
+ value: { data, schema, actionDef: null, dataPath: ownedPath },
514
+ };
515
+ }
516
+
517
+ // #308 — a `filters` block renders the declared facets of ITS feed; its DATA
518
+ // is the entity schema (filterable_fields / searchable / fields). No entity →
519
+ // nothing to filter, soft-empty (the FilterBar renders nothing). The facet
520
+ // OPTIONS are resolved client-side by the widget (row-derived or via a
521
+ // relation `optionsSource`), the same machinery the inline bars used.
522
+ if (block.binding.kind === "filters") {
523
+ // The schema path uses the `list` kind convention (same schema endpoint
524
+ // as a feed's underlying list surface).
525
+ const schemaUrl = block.binding.entity
526
+ ? deps.provider.schemaUrl({ kind: "list", entity: block.binding.entity })
527
+ : null;
528
+ if (schemaUrl === null) {
529
+ return { ok: true, value: { data: null, schema: null, actionDef: null } };
530
+ }
531
+ const r = await deps.provider.fetch(schemaUrl);
532
+ if (!r.ok) {
533
+ // Optional slot → soft-empty (the FilterBar renders nothing). The schema
534
+ // is the canonical declared-facet source; a 404 means the surface has no
535
+ // browse placement, which is a provisioning gap to fix at the surface,
536
+ // not to paper over here.
537
+ return { ok: true, value: { data: null, schema: null, actionDef: null } };
538
+ }
539
+ return {
540
+ ok: true,
541
+ value: {
542
+ data: await r.json(),
543
+ schema: null,
544
+ actionDef: null,
545
+ dataPath: schemaUrl,
546
+ },
547
+ };
548
+ }
549
+
550
+ // #308 — a `feed` block resolves the ACTIVE view's underlying feed. The view
551
+ // comes from the URL (`?view=`, default = first configured view); the
552
+ // synthetic binding lets the existing list/map path + schema logic run
553
+ // unchanged. `dataPath` then carries that view's feed — so the FeedView
554
+ // widget pages / re-derives the right surface.
555
+ const effective: Binding =
556
+ block.binding.kind === "feed"
557
+ ? {
558
+ ...block.binding,
559
+ kind: activeFeedView(feedViews(block.props), deps.view),
560
+ }
561
+ : block.binding;
562
+
563
+ // Compute the calendar window from `now` (if present) and pass it to the
564
+ // provider so resolveData stays URL-logic-free (§14.8 / #492 P0).
565
+ const calWin = deps.now !== undefined ? calendarWindow(deps.now) : undefined;
566
+
567
+ const dataUrl = deps.provider.dataUrl(effective, {
568
+ calendarWindow: calWin,
569
+ filterParams: deps.filterParams,
570
+ });
571
+ if (dataUrl === null) {
572
+ return { ok: false, status: 0, reason: "unresolvable binding" };
573
+ }
574
+
575
+ // Fire the data fetch and the (best-effort) schema fetch in parallel. Data is
576
+ // the security boundary — a non-OK data response fails the block CLOSED.
577
+ // Schema is an enhancement: the widgets fall back to `props.columns` /
578
+ // row-key introspection, so a schema miss or network error never blocks
579
+ // rendering — it just yields `schema: null`. The `.then(ok, err)` neutralises
580
+ // a schema network rejection so it can't surface as an unhandled rejection
581
+ // when the data path returns early.
582
+ const schemaUrl = deps.provider.schemaUrl(effective);
583
+ const schemaPromise =
584
+ schemaUrl !== null
585
+ ? deps.provider.fetch(schemaUrl).then(
586
+ (r) => r,
587
+ () => null,
588
+ )
589
+ : null;
590
+
591
+ const resp = await deps.provider.fetch(dataUrl);
592
+ if (!resp.ok) {
593
+ return {
594
+ ok: false,
595
+ status: resp.status,
596
+ reason: `upstream ${resp.status}`,
597
+ };
598
+ }
599
+ const data = await resp.json();
600
+
601
+ let schema: OntologySchema | null = null;
602
+ // Stryker disable next-line ConditionalExpression: awaiting `null` is harmless
603
+ // — when schemaPromise is null the inner `sresp !== null` guard skips anyway,
604
+ // so flipping this guard to `true` yields the identical result (equivalent).
605
+ if (schemaPromise !== null) {
606
+ const sresp = await schemaPromise;
607
+ if (sresp !== null && sresp.ok) {
608
+ // A malformed schema body must not fail the block — best-effort. `.catch`
609
+ // keeps the parse failure local (schema stays null) instead of rejecting
610
+ // the whole resolve.
611
+ schema = (await sresp.json().catch(() => null)) as OntologySchema | null;
612
+ }
613
+ }
614
+
615
+ return {
616
+ ok: true,
617
+ value: { data, schema, actionDef: null, dataPath: dataUrl },
618
+ };
619
+ }