@storyshelf/app 0.8.0 → 0.9.0-next-d2cc082
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/dist/design-events.mjs +71 -0
- package/dist/design-events.mjs.map +1 -0
- package/dist/index.mjs +1 -1
- package/dist/notify.mjs +4 -1
- package/dist/notify.mjs.map +1 -1
- package/dist/pages/build-diff-environment.mjs +33 -3
- package/dist/pages/build-diff-environment.mjs.map +1 -1
- package/dist/pages/environment-change.mjs +15 -2
- package/dist/pages/environment-change.mjs.map +1 -1
- package/dist/routers/design.handlers.mjs +7 -0
- package/dist/routers/design.handlers.mjs.map +1 -1
- package/package.json +6 -15
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import "node:path";
|
|
2
|
+
import "node:url";
|
|
3
|
+
import.meta.url;
|
|
4
|
+
import { credentialKeys } from "./credential-keys.mjs";
|
|
5
|
+
import { clearPaused, notificationOrigin, warnPaused } from "./notifications-pause.mjs";
|
|
6
|
+
import { notifyProjectWith } from "./notify.mjs";
|
|
7
|
+
import { designChange } from "@storyshelf/core/models";
|
|
8
|
+
import { emitWebhookEvent } from "@storyshelf/core/adapter/webhook-events";
|
|
9
|
+
import { createUrlBuilder } from "@storyshelf/core/urls";
|
|
10
|
+
// src/design-events.ts
|
|
11
|
+
/**
|
|
12
|
+
* `design:changed`: a design moved after its baseline was approved, or no longer exists. It rides
|
|
13
|
+
* the existing paths (signed webhooks and the notification fan-out) and is a normal subscribable
|
|
14
|
+
* topic: it is not default-on (see `notify.ts`), so only people who opted in hear about it.
|
|
15
|
+
*
|
|
16
|
+
* Sent once per (project, design node, design version) from the first health check that sees the
|
|
17
|
+
* change. The durable claim is `DesignNodeModel.claimChange`; with no confirmed public origin
|
|
18
|
+
* nothing is sent and nothing is claimed, so the change is still announced once an origin exists.
|
|
19
|
+
* There is no boot replay: an unannounced change waits for the next health check.
|
|
20
|
+
*/
|
|
21
|
+
/** Event name of a design change. */
|
|
22
|
+
const DESIGN_CHANGED_EVENT = "design:changed";
|
|
23
|
+
/**
|
|
24
|
+
* Announce every observed design change not announced before. Never throws: a failure is logged
|
|
25
|
+
* and the next health check tries again.
|
|
26
|
+
*/
|
|
27
|
+
async function announceDesignChangesWith(deps, nodes, project, observations) {
|
|
28
|
+
const changes = observations.flatMap((observation) => {
|
|
29
|
+
const change = observation.componentPath ? designChange(observation.node, observation.baselineVersion) : null;
|
|
30
|
+
return observation.node && observation.componentPath && change ? [{
|
|
31
|
+
...observation,
|
|
32
|
+
node: observation.node,
|
|
33
|
+
componentPath: observation.componentPath,
|
|
34
|
+
change
|
|
35
|
+
}] : [];
|
|
36
|
+
});
|
|
37
|
+
if (changes.length === 0 || !await originConfirmed(deps)) return;
|
|
38
|
+
await Promise.all(changes.map(async (item) => {
|
|
39
|
+
try {
|
|
40
|
+
if (await nodes.claimChange(item.node, item.change)) await announce(deps, project, item);
|
|
41
|
+
} catch (error) {
|
|
42
|
+
deps.logger.warn({
|
|
43
|
+
err: error,
|
|
44
|
+
projectId: project.id
|
|
45
|
+
}, "design change not announced");
|
|
46
|
+
}
|
|
47
|
+
}));
|
|
48
|
+
}
|
|
49
|
+
async function originConfirmed(deps) {
|
|
50
|
+
if (await notificationOrigin(deps) === void 0) {
|
|
51
|
+
warnPaused(deps.logger, DESIGN_CHANGED_EVENT);
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
clearPaused(deps.logger);
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
async function announce(deps, project, item) {
|
|
58
|
+
const data = {
|
|
59
|
+
projectId: project.id,
|
|
60
|
+
componentPath: item.componentPath,
|
|
61
|
+
figmaUrl: item.url,
|
|
62
|
+
change: item.change.change,
|
|
63
|
+
designVersion: item.change.designVersion
|
|
64
|
+
};
|
|
65
|
+
await emitWebhookEvent(deps.db, { webhooks: deps.db.tables.webhooks }, project.id, DESIGN_CHANGED_EVENT, data, credentialKeys(deps.config), deps.logger);
|
|
66
|
+
const component = createUrlBuilder("").component(project.slug, item.componentPath);
|
|
67
|
+
await notifyProjectWith(deps, project, DESIGN_CHANGED_EVENT, data, component.slice(`/projects/${project.slug}`.length));
|
|
68
|
+
}
|
|
69
|
+
export { DESIGN_CHANGED_EVENT, announceDesignChangesWith };
|
|
70
|
+
|
|
71
|
+
//# sourceMappingURL=design-events.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"design-events.mjs","names":[],"sources":["../src/design-events.ts"],"sourcesContent":["/**\n * `design:changed`: a design moved after its baseline was approved, or no longer exists. It rides\n * the existing paths (signed webhooks and the notification fan-out) and is a normal subscribable\n * topic: it is not default-on (see `notify.ts`), so only people who opted in hear about it.\n *\n * Sent once per (project, design node, design version) from the first health check that sees the\n * change. The durable claim is `DesignNodeModel.claimChange`; with no confirmed public origin\n * nothing is sent and nothing is claimed, so the change is still announced once an origin exists.\n * There is no boot replay: an unannounced change waits for the next health check.\n */\nimport { emitWebhookEvent } from \"@storyshelf/core/adapter/webhook-events\";\nimport { designChange, type DesignChange, type DesignNodeModel } from \"@storyshelf/core/models\";\nimport type { DesignNode, Project } from \"@storyshelf/core/schema\";\nimport { createUrlBuilder } from \"@storyshelf/core/urls\";\nimport { credentialKeys } from \"./credential-keys.ts\";\nimport { clearPaused, notificationOrigin, warnPaused } from \"./notifications-pause.ts\";\nimport { notifyProjectWith, type NotifyDeps } from \"./notify.ts\";\n\n/** Event name of a design change. */\nexport const DESIGN_CHANGED_EVENT = \"design:changed\";\n\n/** What `design:changed` carries. */\nexport interface DesignChangedData extends Record<string, unknown> {\n projectId: string;\n /** Story file (the component) whose design changed. */\n componentPath: string;\n figmaUrl: string;\n change: DesignChange[\"change\"];\n designVersion: string | null;\n}\n\n/** One design a health check looked at. */\nexport interface DesignObservation {\n url: string;\n node: DesignNode | undefined;\n /** The component (story file) the design belongs to. */\n componentPath: string | null;\n /** Version of the baseline the component was approved at. */\n baselineVersion: string | null;\n}\n\n/**\n * Announce every observed design change not announced before. Never throws: a failure is logged\n * and the next health check tries again.\n */\nexport async function announceDesignChangesWith(\n deps: NotifyDeps,\n nodes: Pick<DesignNodeModel, \"claimChange\">,\n project: Pick<Project, \"id\" | \"slug\">,\n observations: readonly DesignObservation[],\n): Promise<void> {\n const changes = observations.flatMap((observation) => {\n const change = observation.componentPath\n ? designChange(observation.node, observation.baselineVersion)\n : null;\n return observation.node && observation.componentPath && change\n ? [\n {\n ...observation,\n node: observation.node,\n componentPath: observation.componentPath,\n change,\n },\n ]\n : [];\n });\n if (changes.length === 0 || !(await originConfirmed(deps))) return;\n await Promise.all(\n changes.map(async (item) => {\n try {\n if (await nodes.claimChange(item.node, item.change)) await announce(deps, project, item);\n } catch (error) {\n deps.logger.warn({ err: error, projectId: project.id }, \"design change not announced\");\n }\n }),\n );\n}\n\nasync function originConfirmed(deps: NotifyDeps): Promise<boolean> {\n const origin = await notificationOrigin(deps);\n if (origin === undefined) {\n warnPaused(deps.logger, DESIGN_CHANGED_EVENT);\n return false;\n }\n clearPaused(deps.logger);\n return true;\n}\n\nasync function announce(\n deps: NotifyDeps,\n project: Pick<Project, \"id\" | \"slug\">,\n item: { url: string; componentPath: string; change: DesignChange },\n): Promise<void> {\n const data: DesignChangedData = {\n projectId: project.id,\n componentPath: item.componentPath,\n figmaUrl: item.url,\n change: item.change.change,\n designVersion: item.change.designVersion,\n };\n await emitWebhookEvent(\n deps.db,\n { webhooks: deps.db.tables.webhooks },\n project.id,\n DESIGN_CHANGED_EVENT,\n data,\n credentialKeys(deps.config),\n deps.logger,\n );\n const component = createUrlBuilder(\"\").component(project.slug, item.componentPath);\n await notifyProjectWith(\n deps,\n project,\n DESIGN_CHANGED_EVENT,\n data,\n component.slice(`/projects/${project.slug}`.length),\n );\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;AAmBA,MAAa,uBAAuB;;;;;AA0BpC,eAAsB,0BACpB,MACA,OACA,SACA,cACe;CACf,MAAM,UAAU,aAAa,SAAS,gBAAgB;EACpD,MAAM,SAAS,YAAY,gBACvB,aAAa,YAAY,MAAM,YAAY,eAAe,IAC1D;EACJ,OAAO,YAAY,QAAQ,YAAY,iBAAiB,SACpD,CACE;GACE,GAAG;GACH,MAAM,YAAY;GAClB,eAAe,YAAY;GAC3B;EACF,CACF,IACA,CAAC;CACP,CAAC;CACD,IAAI,QAAQ,WAAW,KAAK,CAAE,MAAM,gBAAgB,IAAI,GAAI;CAC5D,MAAM,QAAQ,IACZ,QAAQ,IAAI,OAAO,SAAS;EAC1B,IAAI;GACF,IAAI,MAAM,MAAM,YAAY,KAAK,MAAM,KAAK,MAAM,GAAG,MAAM,SAAS,MAAM,SAAS,IAAI;EACzF,SAAS,OAAO;GACd,KAAK,OAAO,KAAK;IAAE,KAAK;IAAO,WAAW,QAAQ;GAAG,GAAG,6BAA6B;EACvF;CACF,CAAC,CACH;AACF;AAEA,eAAe,gBAAgB,MAAoC;CAEjE,IAAI,MADiB,mBAAmB,IAAI,MAC7B,KAAA,GAAW;EACxB,WAAW,KAAK,QAAQ,oBAAoB;EAC5C,OAAO;CACT;CACA,YAAY,KAAK,MAAM;CACvB,OAAO;AACT;AAEA,eAAe,SACb,MACA,SACA,MACe;CACf,MAAM,OAA0B;EAC9B,WAAW,QAAQ;EACnB,eAAe,KAAK;EACpB,UAAU,KAAK;EACf,QAAQ,KAAK,OAAO;EACpB,eAAe,KAAK,OAAO;CAC7B;CACA,MAAM,iBACJ,KAAK,IACL,EAAE,UAAU,KAAK,GAAG,OAAO,SAAS,GACpC,QAAQ,IACR,sBACA,MACA,eAAe,KAAK,MAAM,GAC1B,KAAK,MACP;CACA,MAAM,YAAY,iBAAiB,EAAE,CAAC,CAAC,UAAU,QAAQ,MAAM,KAAK,aAAa;CACjF,MAAM,kBACJ,MACA,SACA,sBACA,MACA,UAAU,MAAM,aAAa,QAAQ,OAAO,MAAM,CACpD;AACF"}
|
package/dist/index.mjs
CHANGED
package/dist/notify.mjs
CHANGED
|
@@ -76,7 +76,10 @@ async function targetsForSub(db, sub, wired) {
|
|
|
76
76
|
enabled: true
|
|
77
77
|
}));
|
|
78
78
|
}
|
|
79
|
-
/**
|
|
79
|
+
/**
|
|
80
|
+
* Topics that reach a project's approvers and admins unless they opted out (ADR 0013, decision 4).
|
|
81
|
+
* Every other topic (`design:changed` included) reaches only people who subscribed to it.
|
|
82
|
+
*/
|
|
80
83
|
const DEFAULT_ON_EVENTS = /* @__PURE__ */ new Set(["review:requested"]);
|
|
81
84
|
/** Roles that review builds: the default recipients of {@link DEFAULT_ON_EVENTS}. */
|
|
82
85
|
const REVIEWER_ROLES = /* @__PURE__ */ new Set(["approver", "admin"]);
|
package/dist/notify.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"notify.mjs","names":[],"sources":["../src/notify.ts"],"sourcesContent":["/**\n * Notification fan-out for routers: loads stored channels and subscriber\n * emails, then delegates to the wired notifier providers. Best-effort and\n * non-fatal — failures are logged inside the emitters, never thrown here.\n */\nimport type { DatabaseAdapter } from \"@storyshelf/core/adapter/database\";\nimport type { NotifierProvider } from \"@storyshelf/core/adapter/notifier\";\nimport {\n emitNotifications,\n emitSystemNotification,\n type ChannelOutcome,\n type NotificationBrand,\n type NotificationChannel,\n type SystemEventName,\n} from \"@storyshelf/core/adapter/notifier\";\nimport type { NotificationsConfig, ShelfConfig, UIConfig } from \"@storyshelf/core/config\";\nimport type { Logger } from \"@storyshelf/core/logger\";\nimport {\n MemberModel,\n NotificationChannelModel,\n NotificationSubscriptionModel,\n UserModel,\n type PublicOriginResolver,\n} from \"@storyshelf/core/models\";\nimport type { NotificationSubscriptionRow } from \"@storyshelf/core/schema\";\nimport type { Project } from \"@storyshelf/core/schema\";\nimport { credentialKeys } from \"./credential-keys.ts\";\nimport { clearPaused, notificationOrigin, warnPaused } from \"./notifications-pause.ts\";\nimport { getStore } from \"./store.ts\";\n\n/** Explicit dependencies for background fan-out (no request scope). */\nexport interface NotifyDeps {\n db: DatabaseAdapter;\n config: ShelfConfig;\n ui: UIConfig;\n logger: Logger;\n notifiers: NotifierProvider[];\n /** Brand/sender settings (`notifications.config`). */\n notificationsConfig?: NotificationsConfig;\n /** The public origin resolver; absent ⇒ `config.publicBaseUrl` only. */\n publicOrigin?: Pick<PublicOriginResolver, \"url\"> | undefined;\n}\n\n/** Absolute review URL for a project page under the resolved public origin. */\nfunction reviewUrlFor(origin: string, slug: string, suffix: string): string {\n return `${origin}/projects/${slug}${suffix}`;\n}\n\n/**\n * The origin notifications link to; `undefined` (after one log line per pause) while none is\n * resolved, which pauses every notification (ADR 0014, decision 11).\n */\nasync function originOrPause(\n deps: Pick<NotifyDeps, \"config\" | \"publicOrigin\" | \"logger\">,\n event: string,\n): Promise<string | undefined> {\n const origin = await notificationOrigin(deps);\n if (origin === undefined) {\n warnPaused(deps.logger, event);\n } else {\n clearPaused(deps.logger);\n }\n return origin;\n}\n\n/** Brand inputs from UI config plus the notification sender settings. */\nfunction brandFor(\n ui: UIConfig,\n settings: NotificationsConfig | undefined,\n reviewUrl?: string,\n): NotificationBrand {\n return {\n name: ui.name,\n logo: ui.logo,\n reviewUrl,\n fromEmail: settings?.fromEmail,\n fromName: settings?.fromName ?? ui.name,\n footerText: settings?.footerText,\n };\n}\n\n/** Load stored channels as send-ready views (skips undecryptable secrets). */\nasync function loadChannels(\n deps: Pick<NotifyDeps, \"db\" | \"config\">,\n projectId: string | null,\n): Promise<NotificationChannel[]> {\n const model = channelModel(deps);\n const rows = projectId === null ? await model.listSystem() : await model.list(projectId);\n return rows.filter((row) => row.enabled).flatMap((row) => model.toView(row) ?? []);\n}\n\n/** The channel model bound to the server secret. */\nfunction channelModel(deps: Pick<NotifyDeps, \"db\" | \"config\">): NotificationChannelModel {\n return new NotificationChannelModel(deps.db, undefined, credentialKeys(deps.config));\n}\n\n/** Record each delivery outcome on its channel row (the last error shown in settings). */\nfunction recordOutcomes(\n deps: Pick<NotifyDeps, \"db\" | \"config\" | \"logger\">,\n): (outcome: ChannelOutcome) => Promise<void> {\n const model = channelModel(deps);\n return async (outcome) => {\n if (outcome.channelId.startsWith(\"sub:\") || outcome.channelId.startsWith(\"default:\")) {\n return;\n }\n await model.recordOutcome(outcome.channelId, outcome.error).catch((error: unknown) => {\n deps.logger.warn({ err: error, channelId: outcome.channelId }, \"failed to record outcome\");\n });\n };\n}\n\n/** Subscriptions matching an event (enabled + allowlist). */\nfunction matchingSubs(\n rows: NotificationSubscriptionRow[],\n event: string,\n): NotificationSubscriptionRow[] {\n return rows.filter((row) => {\n if (!row.enabled) {\n return false;\n }\n const events = NotificationSubscriptionModel.eventsOf(row);\n return events.length === 0 || events.includes(event);\n });\n}\n\n/** Per-subscriber targets for one subscription (wired kinds only). */\nasync function targetsForSub(\n db: DatabaseAdapter,\n sub: NotificationSubscriptionRow,\n wired: ReadonlySet<string>,\n): Promise<NotificationChannel[]> {\n const user = await new UserModel(db).get(sub.userId);\n if (!user || user.disabled) {\n return [];\n }\n const kinds = NotificationSubscriptionModel.viaOf(sub);\n const wants = kinds.length > 0 ? kinds : [\"email\"];\n return wants\n .filter((kind) => wired.has(kind))\n .map((kind) => ({\n id: `sub:${sub.id}:${kind}`,\n projectId: sub.projectId,\n provider: kind,\n config: { to: user.email },\n events: [],\n enabled: true,\n }));\n}\n\n/** Topics that reach a project's approvers and admins unless they opted out (ADR 0013, decision 4). */\nconst DEFAULT_ON_EVENTS: ReadonlySet<string> = new Set([\"review:requested\"]);\n\n/** Roles that review builds: the default recipients of {@link DEFAULT_ON_EVENTS}. */\nconst REVIEWER_ROLES: ReadonlySet<string> = new Set([\"approver\", \"admin\"]);\n\n/**\n * Reviewers with no subscription row for the project: a default-on topic reaches them by email.\n * Anyone with a row decides for themselves (`enabled` off or a topic list without the event opts\n * out), so a row never adds a second message here.\n */\nasync function defaultTargets(\n db: DatabaseAdapter,\n projectId: string,\n rows: NotificationSubscriptionRow[],\n wired: ReadonlySet<string>,\n): Promise<NotificationChannel[]> {\n if (!wired.has(\"email\")) {\n return [];\n }\n const subscribed = new Set(rows.map((row) => row.userId));\n const members = (await new MemberModel(db).list(projectId)).filter(\n (member) => REVIEWER_ROLES.has(member.role) && !subscribed.has(member.userId),\n );\n const targets = await Promise.all(\n members.map(async (member): Promise<NotificationChannel[]> => {\n const user = await new UserModel(db).get(member.userId);\n return !user || user.disabled\n ? []\n : [\n {\n id: `default:${projectId}:${member.userId}`,\n projectId,\n provider: \"email\",\n config: { to: user.email },\n events: [],\n enabled: true,\n },\n ];\n }),\n );\n return targets.flat();\n}\n\n/** Per-subscriber targets for an event (only kinds that are wired). */\nasync function subscriberTargets(\n db: DatabaseAdapter,\n projectId: string,\n event: string,\n wired: ReadonlySet<string>,\n): Promise<NotificationChannel[]> {\n const rows = await new NotificationSubscriptionModel(db).list(projectId);\n const nested = await Promise.all(\n matchingSubs(rows, event).map(async (sub) => await targetsForSub(db, sub, wired)),\n );\n const defaults = DEFAULT_ON_EVENTS.has(event)\n ? await defaultTargets(db, projectId, rows, wired)\n : [];\n return [...nested.flat(), ...defaults];\n}\n\n/**\n * Fan out a project event to stored channels plus opted-in subscribers.\n * Runs alongside (never instead of) webhook delivery.\n */\nexport async function notifyProjectWith(\n deps: NotifyDeps,\n project: Pick<Project, \"id\" | \"slug\">,\n event: string,\n data: Record<string, unknown>,\n reviewSuffix?: string,\n): Promise<void> {\n if (deps.notifiers.length === 0) {\n return;\n }\n const origin = await originOrPause(deps, event);\n if (origin === undefined) {\n return;\n }\n const reviewUrl = reviewSuffix ? reviewUrlFor(origin, project.slug, reviewSuffix) : undefined;\n const wired = new Set(deps.notifiers.map((candidate) => candidate.metadata.kind));\n const channels = await loadChannels(deps, project.id);\n const targets = await subscriberTargets(deps.db, project.id, event, wired);\n await emitNotifications(\n { event, projectId: project.id, projectSlug: project.slug, data, timestamp: now() },\n {\n notifiers: deps.notifiers,\n channels: [...channels, ...targets],\n brand: brandFor(deps.ui, deps.notificationsConfig, reviewUrl),\n logger: deps.logger,\n onOutcome: recordOutcomes(deps),\n },\n );\n}\n\n/**\n * Fan out a project event to stored channels plus opted-in subscribers.\n * Runs alongside (never instead of) webhook delivery.\n */\nexport async function notifyProject(\n project: Pick<Project, \"id\" | \"slug\">,\n event: string,\n data: Record<string, unknown>,\n reviewSuffix?: string,\n): Promise<void> {\n await notifyProjectWith(getStore(), project, event, data, reviewSuffix);\n}\n\n/** Fan out a site-wide admin alert to project-less channels. */\nexport async function notifySystemWith(\n deps: NotifyDeps,\n event: SystemEventName,\n data: Record<string, unknown>,\n): Promise<void> {\n if (deps.notifiers.length === 0 || (await originOrPause(deps, event)) === undefined) {\n return;\n }\n await emitSystemNotification(\n { event, data, timestamp: now() },\n {\n notifiers: deps.notifiers,\n channels: await loadChannels(deps, null),\n brand: brandFor(deps.ui, deps.notificationsConfig),\n logger: deps.logger,\n onOutcome: recordOutcomes(deps),\n },\n );\n}\n\n/** Fan out a site-wide admin alert to project-less channels. */\nexport async function notifySystem(\n event: SystemEventName,\n data: Record<string, unknown>,\n): Promise<void> {\n await notifySystemWith(getStore(), event, data);\n}\n\n/**\n * Host hook for the auth engine (`onAuthSystemEvent`): fans `sys:*` auth\n * events out to admin channels. Safe to call anywhere — outside a request\n * scope (e.g. boot) it resolves to a no-op instead of throwing.\n */\nexport function createAuthSystemHook(): (\n event: \"sys:user-created\" | \"sys:invite-issued\",\n data: Record<string, unknown>,\n) => Promise<void> {\n return async (event, data): Promise<void> => {\n await notifySystem(event, data).catch(() => {\n // Intentionally empty — best-effort fan-out must never break auth\n });\n };\n}\n\n/** Current timestamp for notification envelopes. */\nfunction now(): string {\n return new Date().toISOString();\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"notify.mjs","names":[],"sources":["../src/notify.ts"],"sourcesContent":["/**\n * Notification fan-out for routers: loads stored channels and subscriber\n * emails, then delegates to the wired notifier providers. Best-effort and\n * non-fatal — failures are logged inside the emitters, never thrown here.\n */\nimport type { DatabaseAdapter } from \"@storyshelf/core/adapter/database\";\nimport type { NotifierProvider } from \"@storyshelf/core/adapter/notifier\";\nimport {\n emitNotifications,\n emitSystemNotification,\n type ChannelOutcome,\n type NotificationBrand,\n type NotificationChannel,\n type SystemEventName,\n} from \"@storyshelf/core/adapter/notifier\";\nimport type { NotificationsConfig, ShelfConfig, UIConfig } from \"@storyshelf/core/config\";\nimport type { Logger } from \"@storyshelf/core/logger\";\nimport {\n MemberModel,\n NotificationChannelModel,\n NotificationSubscriptionModel,\n UserModel,\n type PublicOriginResolver,\n} from \"@storyshelf/core/models\";\nimport type { NotificationSubscriptionRow } from \"@storyshelf/core/schema\";\nimport type { Project } from \"@storyshelf/core/schema\";\nimport { credentialKeys } from \"./credential-keys.ts\";\nimport { clearPaused, notificationOrigin, warnPaused } from \"./notifications-pause.ts\";\nimport { getStore } from \"./store.ts\";\n\n/** Explicit dependencies for background fan-out (no request scope). */\nexport interface NotifyDeps {\n db: DatabaseAdapter;\n config: ShelfConfig;\n ui: UIConfig;\n logger: Logger;\n notifiers: NotifierProvider[];\n /** Brand/sender settings (`notifications.config`). */\n notificationsConfig?: NotificationsConfig;\n /** The public origin resolver; absent ⇒ `config.publicBaseUrl` only. */\n publicOrigin?: Pick<PublicOriginResolver, \"url\"> | undefined;\n}\n\n/** Absolute review URL for a project page under the resolved public origin. */\nfunction reviewUrlFor(origin: string, slug: string, suffix: string): string {\n return `${origin}/projects/${slug}${suffix}`;\n}\n\n/**\n * The origin notifications link to; `undefined` (after one log line per pause) while none is\n * resolved, which pauses every notification (ADR 0014, decision 11).\n */\nasync function originOrPause(\n deps: Pick<NotifyDeps, \"config\" | \"publicOrigin\" | \"logger\">,\n event: string,\n): Promise<string | undefined> {\n const origin = await notificationOrigin(deps);\n if (origin === undefined) {\n warnPaused(deps.logger, event);\n } else {\n clearPaused(deps.logger);\n }\n return origin;\n}\n\n/** Brand inputs from UI config plus the notification sender settings. */\nfunction brandFor(\n ui: UIConfig,\n settings: NotificationsConfig | undefined,\n reviewUrl?: string,\n): NotificationBrand {\n return {\n name: ui.name,\n logo: ui.logo,\n reviewUrl,\n fromEmail: settings?.fromEmail,\n fromName: settings?.fromName ?? ui.name,\n footerText: settings?.footerText,\n };\n}\n\n/** Load stored channels as send-ready views (skips undecryptable secrets). */\nasync function loadChannels(\n deps: Pick<NotifyDeps, \"db\" | \"config\">,\n projectId: string | null,\n): Promise<NotificationChannel[]> {\n const model = channelModel(deps);\n const rows = projectId === null ? await model.listSystem() : await model.list(projectId);\n return rows.filter((row) => row.enabled).flatMap((row) => model.toView(row) ?? []);\n}\n\n/** The channel model bound to the server secret. */\nfunction channelModel(deps: Pick<NotifyDeps, \"db\" | \"config\">): NotificationChannelModel {\n return new NotificationChannelModel(deps.db, undefined, credentialKeys(deps.config));\n}\n\n/** Record each delivery outcome on its channel row (the last error shown in settings). */\nfunction recordOutcomes(\n deps: Pick<NotifyDeps, \"db\" | \"config\" | \"logger\">,\n): (outcome: ChannelOutcome) => Promise<void> {\n const model = channelModel(deps);\n return async (outcome) => {\n if (outcome.channelId.startsWith(\"sub:\") || outcome.channelId.startsWith(\"default:\")) {\n return;\n }\n await model.recordOutcome(outcome.channelId, outcome.error).catch((error: unknown) => {\n deps.logger.warn({ err: error, channelId: outcome.channelId }, \"failed to record outcome\");\n });\n };\n}\n\n/** Subscriptions matching an event (enabled + allowlist). */\nfunction matchingSubs(\n rows: NotificationSubscriptionRow[],\n event: string,\n): NotificationSubscriptionRow[] {\n return rows.filter((row) => {\n if (!row.enabled) {\n return false;\n }\n const events = NotificationSubscriptionModel.eventsOf(row);\n return events.length === 0 || events.includes(event);\n });\n}\n\n/** Per-subscriber targets for one subscription (wired kinds only). */\nasync function targetsForSub(\n db: DatabaseAdapter,\n sub: NotificationSubscriptionRow,\n wired: ReadonlySet<string>,\n): Promise<NotificationChannel[]> {\n const user = await new UserModel(db).get(sub.userId);\n if (!user || user.disabled) {\n return [];\n }\n const kinds = NotificationSubscriptionModel.viaOf(sub);\n const wants = kinds.length > 0 ? kinds : [\"email\"];\n return wants\n .filter((kind) => wired.has(kind))\n .map((kind) => ({\n id: `sub:${sub.id}:${kind}`,\n projectId: sub.projectId,\n provider: kind,\n config: { to: user.email },\n events: [],\n enabled: true,\n }));\n}\n\n/**\n * Topics that reach a project's approvers and admins unless they opted out (ADR 0013, decision 4).\n * Every other topic (`design:changed` included) reaches only people who subscribed to it.\n */\nconst DEFAULT_ON_EVENTS: ReadonlySet<string> = new Set([\"review:requested\"]);\n\n/** Roles that review builds: the default recipients of {@link DEFAULT_ON_EVENTS}. */\nconst REVIEWER_ROLES: ReadonlySet<string> = new Set([\"approver\", \"admin\"]);\n\n/**\n * Reviewers with no subscription row for the project: a default-on topic reaches them by email.\n * Anyone with a row decides for themselves (`enabled` off or a topic list without the event opts\n * out), so a row never adds a second message here.\n */\nasync function defaultTargets(\n db: DatabaseAdapter,\n projectId: string,\n rows: NotificationSubscriptionRow[],\n wired: ReadonlySet<string>,\n): Promise<NotificationChannel[]> {\n if (!wired.has(\"email\")) {\n return [];\n }\n const subscribed = new Set(rows.map((row) => row.userId));\n const members = (await new MemberModel(db).list(projectId)).filter(\n (member) => REVIEWER_ROLES.has(member.role) && !subscribed.has(member.userId),\n );\n const targets = await Promise.all(\n members.map(async (member): Promise<NotificationChannel[]> => {\n const user = await new UserModel(db).get(member.userId);\n return !user || user.disabled\n ? []\n : [\n {\n id: `default:${projectId}:${member.userId}`,\n projectId,\n provider: \"email\",\n config: { to: user.email },\n events: [],\n enabled: true,\n },\n ];\n }),\n );\n return targets.flat();\n}\n\n/** Per-subscriber targets for an event (only kinds that are wired). */\nasync function subscriberTargets(\n db: DatabaseAdapter,\n projectId: string,\n event: string,\n wired: ReadonlySet<string>,\n): Promise<NotificationChannel[]> {\n const rows = await new NotificationSubscriptionModel(db).list(projectId);\n const nested = await Promise.all(\n matchingSubs(rows, event).map(async (sub) => await targetsForSub(db, sub, wired)),\n );\n const defaults = DEFAULT_ON_EVENTS.has(event)\n ? await defaultTargets(db, projectId, rows, wired)\n : [];\n return [...nested.flat(), ...defaults];\n}\n\n/**\n * Fan out a project event to stored channels plus opted-in subscribers.\n * Runs alongside (never instead of) webhook delivery.\n */\nexport async function notifyProjectWith(\n deps: NotifyDeps,\n project: Pick<Project, \"id\" | \"slug\">,\n event: string,\n data: Record<string, unknown>,\n reviewSuffix?: string,\n): Promise<void> {\n if (deps.notifiers.length === 0) {\n return;\n }\n const origin = await originOrPause(deps, event);\n if (origin === undefined) {\n return;\n }\n const reviewUrl = reviewSuffix ? reviewUrlFor(origin, project.slug, reviewSuffix) : undefined;\n const wired = new Set(deps.notifiers.map((candidate) => candidate.metadata.kind));\n const channels = await loadChannels(deps, project.id);\n const targets = await subscriberTargets(deps.db, project.id, event, wired);\n await emitNotifications(\n { event, projectId: project.id, projectSlug: project.slug, data, timestamp: now() },\n {\n notifiers: deps.notifiers,\n channels: [...channels, ...targets],\n brand: brandFor(deps.ui, deps.notificationsConfig, reviewUrl),\n logger: deps.logger,\n onOutcome: recordOutcomes(deps),\n },\n );\n}\n\n/**\n * Fan out a project event to stored channels plus opted-in subscribers.\n * Runs alongside (never instead of) webhook delivery.\n */\nexport async function notifyProject(\n project: Pick<Project, \"id\" | \"slug\">,\n event: string,\n data: Record<string, unknown>,\n reviewSuffix?: string,\n): Promise<void> {\n await notifyProjectWith(getStore(), project, event, data, reviewSuffix);\n}\n\n/** Fan out a site-wide admin alert to project-less channels. */\nexport async function notifySystemWith(\n deps: NotifyDeps,\n event: SystemEventName,\n data: Record<string, unknown>,\n): Promise<void> {\n if (deps.notifiers.length === 0 || (await originOrPause(deps, event)) === undefined) {\n return;\n }\n await emitSystemNotification(\n { event, data, timestamp: now() },\n {\n notifiers: deps.notifiers,\n channels: await loadChannels(deps, null),\n brand: brandFor(deps.ui, deps.notificationsConfig),\n logger: deps.logger,\n onOutcome: recordOutcomes(deps),\n },\n );\n}\n\n/** Fan out a site-wide admin alert to project-less channels. */\nexport async function notifySystem(\n event: SystemEventName,\n data: Record<string, unknown>,\n): Promise<void> {\n await notifySystemWith(getStore(), event, data);\n}\n\n/**\n * Host hook for the auth engine (`onAuthSystemEvent`): fans `sys:*` auth\n * events out to admin channels. Safe to call anywhere — outside a request\n * scope (e.g. boot) it resolves to a no-op instead of throwing.\n */\nexport function createAuthSystemHook(): (\n event: \"sys:user-created\" | \"sys:invite-issued\",\n data: Record<string, unknown>,\n) => Promise<void> {\n return async (event, data): Promise<void> => {\n await notifySystem(event, data).catch(() => {\n // Intentionally empty — best-effort fan-out must never break auth\n });\n };\n}\n\n/** Current timestamp for notification envelopes. */\nfunction now(): string {\n return new Date().toISOString();\n}\n"],"mappings":"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"}
|
|
@@ -3,7 +3,7 @@ import "node:url";
|
|
|
3
3
|
import.meta.url;
|
|
4
4
|
import { Alert } from "../ui/feedback.mjs";
|
|
5
5
|
import { StaleBaselineNotice } from "./build-diff-stale.mjs";
|
|
6
|
-
import { environmentChangeOf, labelOf, summariseEnvironmentChanges } from "./environment-change.mjs";
|
|
6
|
+
import { environmentChangeOf, labelOf, summariseEnvironmentChanges, summarisePartlyUnknown } from "./environment-change.mjs";
|
|
7
7
|
import { Fragment, jsx, jsxs } from "hono/jsx/jsx-runtime";
|
|
8
8
|
// src/pages/build-diff-environment.tsx
|
|
9
9
|
/**
|
|
@@ -11,6 +11,30 @@ import { Fragment, jsx, jsxs } from "hono/jsx/jsx-runtime";
|
|
|
11
11
|
* The default branch migrates them without a diff; a feature branch still diffs them (ADR 0005).
|
|
12
12
|
*/
|
|
13
13
|
function EnvironmentChangeNotice(props) {
|
|
14
|
+
const unknown = summarisePartlyUnknown(props.snapshots);
|
|
15
|
+
const changed = /* @__PURE__ */ jsx(EnvironmentMigrationNotice, { ...props });
|
|
16
|
+
return unknown ? /* @__PURE__ */ jsxs(Fragment, { children: [changed, /* @__PURE__ */ jsx(PartlyUnknownNotice, {
|
|
17
|
+
count: unknown.count,
|
|
18
|
+
fields: unknown.fields
|
|
19
|
+
})] }) : changed;
|
|
20
|
+
}
|
|
21
|
+
/** The measurement version changed: some components were compared as unknown, not as different. */
|
|
22
|
+
function PartlyUnknownNotice(props) {
|
|
23
|
+
const { count, fields } = props;
|
|
24
|
+
return /* @__PURE__ */ jsxs(Alert, {
|
|
25
|
+
tone: "info",
|
|
26
|
+
title: "Environment partly unknown (measurement updated)",
|
|
27
|
+
children: [
|
|
28
|
+
count,
|
|
29
|
+
" ",
|
|
30
|
+
count === 1 ? "snapshot has" : "snapshots have",
|
|
31
|
+
" a baseline whose environment was measured an older way, so the ",
|
|
32
|
+
fields.join(", "),
|
|
33
|
+
" could not be compared. They are not treated as a change, and nothing was migrated because of them."
|
|
34
|
+
]
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
function EnvironmentMigrationNotice(props) {
|
|
14
38
|
const summary = summariseEnvironmentChanges(props.snapshots);
|
|
15
39
|
if (!summary) return null;
|
|
16
40
|
const { count, fields, browserVersion } = summary;
|
|
@@ -43,8 +67,14 @@ function EnvironmentChangeNotice(props) {
|
|
|
43
67
|
function EnvironmentChangeSnapshotNotice(props) {
|
|
44
68
|
const change = environmentChangeOf(props.snapshot);
|
|
45
69
|
if (!change) return null;
|
|
70
|
+
const unknown = (change.unknown ?? []).map((field) => labelOf(field));
|
|
71
|
+
const note = unknown.length > 0 ? /* @__PURE__ */ jsx(PartlyUnknownNotice, {
|
|
72
|
+
count: 1,
|
|
73
|
+
fields: unknown
|
|
74
|
+
}) : null;
|
|
75
|
+
if (change.changed.length === 0) return note;
|
|
46
76
|
const what = change.changed.map((field) => labelOf(field)).join(", ");
|
|
47
|
-
return props.migrated ? /* @__PURE__ */ jsxs(Alert, {
|
|
77
|
+
return /* @__PURE__ */ jsxs(Fragment, { children: [props.migrated ? /* @__PURE__ */ jsxs(Alert, {
|
|
48
78
|
tone: "info",
|
|
49
79
|
title: "No diff: capture environment changed",
|
|
50
80
|
children: [
|
|
@@ -60,7 +90,7 @@ function EnvironmentChangeSnapshotNotice(props) {
|
|
|
60
90
|
what,
|
|
61
91
|
"), so a difference may come from the environment rather than the code."
|
|
62
92
|
]
|
|
63
|
-
});
|
|
93
|
+
}), note] });
|
|
64
94
|
}
|
|
65
95
|
/** Build-level notices above the review grid: stale baselines and environment migrations. */
|
|
66
96
|
function ReviewNotices(props) {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"build-diff-environment.mjs","names":[],"sources":["../../src/pages/build-diff-environment.tsx"],"sourcesContent":["import type { Build, Project, Snapshot } from \"@storyshelf/core/schema\";\nimport type { HtmlEscapedString } from \"hono/utils/html\";\nimport { Alert } from \"../ui/components.tsx\";\nimport { StaleBaselineNotice } from \"./build-diff-stale.tsx\";\nimport {
|
|
1
|
+
{"version":3,"file":"build-diff-environment.mjs","names":[],"sources":["../../src/pages/build-diff-environment.tsx"],"sourcesContent":["import type { Build, Project, Snapshot } from \"@storyshelf/core/schema\";\nimport type { HtmlEscapedString } from \"hono/utils/html\";\nimport { Alert } from \"../ui/components.tsx\";\nimport { StaleBaselineNotice } from \"./build-diff-stale.tsx\";\nimport {\n environmentChangeOf,\n labelOf,\n summariseEnvironmentChanges,\n summarisePartlyUnknown,\n} from \"./environment-change.ts\";\n\n/* eslint-disable promise-function-async -- Hono JSX components return HtmlEscapedString | Promise<HtmlEscapedString> */\n\n/**\n * Build-level note: some snapshots were captured in a different environment than their baseline.\n * The default branch migrates them without a diff; a feature branch still diffs them (ADR 0005).\n */\nexport function EnvironmentChangeNotice(props: {\n snapshots: readonly Snapshot[];\n migrated: boolean;\n}): HtmlEscapedString | Promise<HtmlEscapedString> | null {\n const unknown = summarisePartlyUnknown(props.snapshots);\n const changed = <EnvironmentMigrationNotice {...props} />;\n return unknown ? (\n <>\n {changed}\n <PartlyUnknownNotice count={unknown.count} fields={unknown.fields} />\n </>\n ) : (\n changed\n );\n}\n\n/** The measurement version changed: some components were compared as unknown, not as different. */\nfunction PartlyUnknownNotice(props: {\n count: number;\n fields: string[];\n}): HtmlEscapedString | Promise<HtmlEscapedString> {\n const { count, fields } = props;\n return (\n <Alert tone=\"info\" title=\"Environment partly unknown (measurement updated)\">\n {count} {count === 1 ? \"snapshot has\" : \"snapshots have\"} a baseline whose environment was\n measured an older way, so the {fields.join(\", \")} could not be compared. They are not treated\n as a change, and nothing was migrated because of them.\n </Alert>\n );\n}\n\nfunction EnvironmentMigrationNotice(props: {\n snapshots: readonly Snapshot[];\n migrated: boolean;\n}): HtmlEscapedString | Promise<HtmlEscapedString> | null {\n const summary = summariseEnvironmentChanges(props.snapshots);\n if (!summary) {\n return null;\n }\n const { count, fields, browserVersion } = summary;\n const what = `the ${fields.join(\", \")} changed${\n browserVersion ? ` (${browserVersion.from} → ${browserVersion.to})` : \"\"\n }`;\n return props.migrated ? (\n <Alert tone=\"info\" title=\"Capture environment changed\">\n {count} {count === 1 ? \"snapshot was\" : \"snapshots were\"} accepted without a pixel diff\n because {what}. The same code renders differently here, so a diff would only show the\n environment. The baselines were updated to match.\n </Alert>\n ) : (\n <Alert tone=\"info\" title=\"Capture environment changed\">\n {count} {count === 1 ? \"snapshot was\" : \"snapshots were\"} compared against a baseline from a\n different capture environment: {what}. A difference may come from the environment rather than\n the code; once the default branch captures in the new environment, a rebuild compares like\n with like.\n </Alert>\n );\n}\n\n/** Per-snapshot note shown above the viewer for a snapshot whose capture environment changed. */\nexport function EnvironmentChangeSnapshotNotice(props: {\n snapshot: Pick<Snapshot, \"envChange\">;\n migrated: boolean;\n}): HtmlEscapedString | Promise<HtmlEscapedString> | null {\n const change = environmentChangeOf(props.snapshot);\n if (!change) {\n return null;\n }\n const unknown = (change.unknown ?? []).map((field) => labelOf(field));\n const note = unknown.length > 0 ? <PartlyUnknownNotice count={1} fields={unknown} /> : null;\n if (change.changed.length === 0) {\n return note;\n }\n const what = change.changed.map((field) => labelOf(field)).join(\", \");\n return (\n <>\n {props.migrated ? (\n <Alert tone=\"info\" title=\"No diff: capture environment changed\">\n This snapshot was not compared pixel by pixel. What changed: {what}.\n </Alert>\n ) : (\n <Alert tone=\"info\" title=\"Capture environment changed\">\n The baseline was captured in a different environment ({what}), so a difference may come\n from the environment rather than the code.\n </Alert>\n )}\n {note}\n </>\n );\n}\n\n/** Build-level notices above the review grid: stale baselines and environment migrations. */\nexport function ReviewNotices(props: {\n project: Project;\n build: Build;\n snapshots: readonly Snapshot[];\n drifted: Record<string, unknown>;\n canReview: boolean;\n}): HtmlEscapedString | Promise<HtmlEscapedString> {\n const { project, build, snapshots, drifted, canReview } = props;\n const staleCount = Object.keys(drifted).length;\n return (\n <>\n {staleCount > 0 ? (\n <StaleBaselineNotice\n project={project}\n build={build}\n staleCount={staleCount}\n canReview={canReview}\n />\n ) : null}\n <EnvironmentChangeNotice snapshots={snapshots} migrated={build.isDefault} />\n </>\n );\n}\n"],"mappings":";;;;;;;;;;;;AAiBA,SAAgB,wBAAwB,OAGkB;CACxD,MAAM,UAAU,uBAAuB,MAAM,SAAS;CACtD,MAAM,UAAU,oBAAC,4BAAD,EAA4B,GAAI,MAAQ,CAAA;CACxD,OAAO,UACL,qBAAA,UAAA,EAAA,UAAA,CACG,SACD,oBAAC,qBAAD;EAAqB,OAAO,QAAQ;EAAO,QAAQ,QAAQ;CAAS,CAAA,CACpE,EAAA,CAAA,IAEF;AAEJ;;AAGA,SAAS,oBAAoB,OAGsB;CACjD,MAAM,EAAE,OAAO,WAAW;CAC1B,OACE,qBAAC,OAAD;EAAO,MAAK;EAAO,OAAM;EAAzB,UAAA;GACG;GAAM;GAAE,UAAU,IAAI,iBAAiB;GAAiB;GAC1B,OAAO,KAAK,IAAI;GAAE;EAE5C;;AAEX;AAEA,SAAS,2BAA2B,OAGsB;CACxD,MAAM,UAAU,4BAA4B,MAAM,SAAS;CAC3D,IAAI,CAAC,SACH,OAAO;CAET,MAAM,EAAE,OAAO,QAAQ,mBAAmB;CAC1C,MAAM,OAAO,OAAO,OAAO,KAAK,IAAI,EAAE,UACpC,iBAAiB,KAAK,eAAe,KAAK,KAAK,eAAe,GAAG,KAAK;CAExE,OAAO,MAAM,WACX,qBAAC,OAAD;EAAO,MAAK;EAAO,OAAM;EAAzB,UAAA;GACG;GAAM;GAAE,UAAU,IAAI,iBAAiB;GAAiB;GAChD;GAAK;EAET;CAEP,CAAA,IAAA,qBAAC,OAAD;EAAO,MAAK;EAAO,OAAM;EAAzB,UAAA;GACG;GAAM;GAAE,UAAU,IAAI,iBAAiB;GAAiB;GACzB;GAAK;EAGhC;;AAEX;;AAGA,SAAgB,gCAAgC,OAGU;CACxD,MAAM,SAAS,oBAAoB,MAAM,QAAQ;CACjD,IAAI,CAAC,QACH,OAAO;CAET,MAAM,WAAW,OAAO,WAAW,CAAC,EAAA,CAAG,KAAK,UAAU,QAAQ,KAAK,CAAC;CACpE,MAAM,OAAO,QAAQ,SAAS,IAAI,oBAAC,qBAAD;EAAqB,OAAO;EAAG,QAAQ;CAAU,CAAA,IAAI;CACvF,IAAI,OAAO,QAAQ,WAAW,GAC5B,OAAO;CAET,MAAM,OAAO,OAAO,QAAQ,KAAK,UAAU,QAAQ,KAAK,CAAC,CAAC,CAAC,KAAK,IAAI;CACpE,OACE,qBAAA,UAAA,EAAA,UAAA,CACG,MAAM,WACL,qBAAC,OAAD;EAAO,MAAK;EAAO,OAAM;EAAzB,UAAA;GAAgE;GACA;GAAK;EAC9D;CAEP,CAAA,IAAA,qBAAC,OAAD;EAAO,MAAK;EAAO,OAAM;EAAzB,UAAA;GAAuD;GACE;GAAK;EAEvD;CAER,CAAA,GAAA,IACD,EAAA,CAAA;AAEN;;AAGA,SAAgB,cAAc,OAMqB;CACjD,MAAM,EAAE,SAAS,OAAO,WAAW,SAAS,cAAc;CAC1D,MAAM,aAAa,OAAO,KAAK,OAAO,CAAC,CAAC;CACxC,OACE,qBAAA,UAAA,EAAA,UAAA,CACG,aAAa,IACZ,oBAAC,qBAAD;EACW;EACF;EACK;EACD;CACZ,CAAA,IACC,MACJ,oBAAC,yBAAD;EAAoC;EAAW,UAAU,MAAM;CAAY,CAAA,CAC3E,EAAA,CAAA;AAEN"}
|
|
@@ -25,7 +25,7 @@ function environmentChangeOf(snapshot) {
|
|
|
25
25
|
}
|
|
26
26
|
/** Summarise the build's environment migrations, or null when there were none. */
|
|
27
27
|
function summariseEnvironmentChanges(snapshots) {
|
|
28
|
-
const changes = snapshots.map((snapshot) => environmentChangeOf(snapshot)).filter((change) => change !== null);
|
|
28
|
+
const changes = snapshots.map((snapshot) => environmentChangeOf(snapshot)).filter((change) => change !== null && change.changed.length > 0);
|
|
29
29
|
if (changes.length === 0) return null;
|
|
30
30
|
const fields = [...new Set(changes.flatMap((change) => change.changed))].map((field) => labelOf(field));
|
|
31
31
|
return {
|
|
@@ -34,6 +34,19 @@ function summariseEnvironmentChanges(snapshots) {
|
|
|
34
34
|
...browserVersionOf(changes)
|
|
35
35
|
};
|
|
36
36
|
}
|
|
37
|
+
/**
|
|
38
|
+
* Summarise the snapshots whose baseline was measured under another measurement version, so some
|
|
39
|
+
* components were compared as unknown (never as a change); null when there were none.
|
|
40
|
+
*/
|
|
41
|
+
function summarisePartlyUnknown(snapshots) {
|
|
42
|
+
const unknown = snapshots.map((snapshot) => environmentChangeOf(snapshot)?.unknown ?? []).filter((fields) => fields.length > 0);
|
|
43
|
+
if (unknown.length === 0) return null;
|
|
44
|
+
const fields = [...new Set(unknown.flat())].map((field) => labelOf(field));
|
|
45
|
+
return {
|
|
46
|
+
count: unknown.length,
|
|
47
|
+
fields
|
|
48
|
+
};
|
|
49
|
+
}
|
|
37
50
|
/** Human label for a changed component. */
|
|
38
51
|
function labelOf(field) {
|
|
39
52
|
return FIELD_LABELS[field] ?? field;
|
|
@@ -47,6 +60,6 @@ function browserVersionOf(changes) {
|
|
|
47
60
|
to
|
|
48
61
|
} } : {};
|
|
49
62
|
}
|
|
50
|
-
export { environmentChangeOf, labelOf, summariseEnvironmentChanges };
|
|
63
|
+
export { environmentChangeOf, labelOf, summariseEnvironmentChanges, summarisePartlyUnknown };
|
|
51
64
|
|
|
52
65
|
//# sourceMappingURL=environment-change.mjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"environment-change.mjs","names":[],"sources":["../../src/pages/environment-change.ts"],"sourcesContent":["/** Reads the capture-environment migrations recorded on a build's snapshots. */\nimport type { EnvironmentChange } from \"@storyshelf/core/capture\";\nimport type { Snapshot } from \"@storyshelf/core/schema\";\n\n/** What changed, in words a reviewer recognises. */\nconst FIELD_LABELS: Record<string, string> = {\n engine: \"browser engine\",\n browserVersion: \"browser version\",\n platform: \"host platform\",\n deviceScaleFactor: \"device scale factor\",\n fontsHash: \"installed fonts\",\n viewport: \"viewport size\",\n sizing: \"screenshot sizing\",\n unknown: \"capture environment\",\n};\n\n/** A snapshot's recorded change; null when it has none or the stored value is unreadable. */\nexport function environmentChangeOf(\n snapshot: Pick<Snapshot, \"envChange\">,\n): EnvironmentChange | null {\n if (!snapshot.envChange) {\n return null;\n }\n try {\n const value = JSON.parse(snapshot.envChange) as Partial<EnvironmentChange>;\n return Array.isArray(value.changed) && value.to ? (value as EnvironmentChange) : null;\n } catch {\n return null;\n }\n}\n\n/** Summary across a build's snapshots. */\nexport interface EnvironmentChangeSummary {\n /** Snapshots accepted as a migration. */\n count: number;\n /** Distinct labels of what changed, in first-seen order. */\n fields: string[];\n /** `from -> to` for the browser version when every migrated snapshot agrees on it. */\n browserVersion?: { from: string; to: string };\n}\n\n/** Summarise the build's environment migrations, or null when there were none. */\nexport function summariseEnvironmentChanges(\n snapshots: readonly Pick<Snapshot, \"envChange\">[],\n): EnvironmentChangeSummary | null {\n const changes = snapshots\n .map((snapshot) => environmentChangeOf(snapshot))\n .filter((change): change is EnvironmentChange => change !== null);\n if (changes.length === 0) {\n return null;\n }\n const fields = [...new Set(changes.flatMap((change) => change.changed))].map((field) =>\n labelOf(field),\n );\n return { count: changes.length, fields, ...browserVersionOf(changes) };\n}\n\n/** Human label for a changed component. */\nexport function labelOf(field: string): string {\n return FIELD_LABELS[field] ?? field;\n}\n\nfunction browserVersionOf(\n changes: EnvironmentChange[],\n): Pick<EnvironmentChangeSummary, \"browserVersion\"> {\n const pairs = new Set(\n changes.map(\n (change) => `${change.from?.browserVersion ?? \"\"}\\u0000${change.to.browserVersion ?? \"\"}`,\n ),\n );\n if (pairs.size !== 1) {\n return {};\n }\n const [from, to] = [...pairs][0]?.split(\"\\u0000\") ?? [];\n return from && to && from !== to ? { browserVersion: { from, to } } : {};\n}\n"],"mappings":";;;;;AAKA,MAAM,eAAuC;CAC3C,QAAQ;CACR,gBAAgB;CAChB,UAAU;CACV,mBAAmB;CACnB,WAAW;CACX,UAAU;CACV,QAAQ;CACR,SAAS;AACX;;AAGA,SAAgB,oBACd,UAC0B;CAC1B,IAAI,CAAC,SAAS,WACZ,OAAO;CAET,IAAI;EACF,MAAM,QAAQ,KAAK,MAAM,SAAS,SAAS;EAC3C,OAAO,MAAM,QAAQ,MAAM,OAAO,KAAK,MAAM,KAAM,QAA8B;CACnF,QAAQ;EACN,OAAO;CACT;AACF;;AAaA,SAAgB,4BACd,WACiC;CACjC,MAAM,UAAU,UACb,KAAK,aAAa,oBAAoB,QAAQ,CAAC,CAAC,CAChD,QAAQ,WAAwC,WAAW,
|
|
1
|
+
{"version":3,"file":"environment-change.mjs","names":[],"sources":["../../src/pages/environment-change.ts"],"sourcesContent":["/** Reads the capture-environment migrations recorded on a build's snapshots. */\nimport type { EnvironmentChange } from \"@storyshelf/core/capture\";\nimport type { Snapshot } from \"@storyshelf/core/schema\";\n\n/** What changed, in words a reviewer recognises. */\nconst FIELD_LABELS: Record<string, string> = {\n engine: \"browser engine\",\n browserVersion: \"browser version\",\n platform: \"host platform\",\n deviceScaleFactor: \"device scale factor\",\n fontsHash: \"installed fonts\",\n viewport: \"viewport size\",\n sizing: \"screenshot sizing\",\n unknown: \"capture environment\",\n};\n\n/** A snapshot's recorded change; null when it has none or the stored value is unreadable. */\nexport function environmentChangeOf(\n snapshot: Pick<Snapshot, \"envChange\">,\n): EnvironmentChange | null {\n if (!snapshot.envChange) {\n return null;\n }\n try {\n const value = JSON.parse(snapshot.envChange) as Partial<EnvironmentChange>;\n return Array.isArray(value.changed) && value.to ? (value as EnvironmentChange) : null;\n } catch {\n return null;\n }\n}\n\n/** Summary across a build's snapshots. */\nexport interface EnvironmentChangeSummary {\n /** Snapshots accepted as a migration. */\n count: number;\n /** Distinct labels of what changed, in first-seen order. */\n fields: string[];\n /** `from -> to` for the browser version when every migrated snapshot agrees on it. */\n browserVersion?: { from: string; to: string };\n}\n\n/** Summarise the build's environment migrations, or null when there were none. */\nexport function summariseEnvironmentChanges(\n snapshots: readonly Pick<Snapshot, \"envChange\">[],\n): EnvironmentChangeSummary | null {\n const changes = snapshots\n .map((snapshot) => environmentChangeOf(snapshot))\n .filter((change): change is EnvironmentChange => change !== null && change.changed.length > 0);\n if (changes.length === 0) {\n return null;\n }\n const fields = [...new Set(changes.flatMap((change) => change.changed))].map((field) =>\n labelOf(field),\n );\n return { count: changes.length, fields, ...browserVersionOf(changes) };\n}\n\n/** Snapshots compared with a baseline measured another way, and the components that could not be compared. */\nexport interface PartlyUnknownSummary {\n count: number;\n fields: string[];\n}\n\n/**\n * Summarise the snapshots whose baseline was measured under another measurement version, so some\n * components were compared as unknown (never as a change); null when there were none.\n */\nexport function summarisePartlyUnknown(\n snapshots: readonly Pick<Snapshot, \"envChange\">[],\n): PartlyUnknownSummary | null {\n const unknown = snapshots\n .map((snapshot) => environmentChangeOf(snapshot)?.unknown ?? [])\n .filter((fields) => fields.length > 0);\n if (unknown.length === 0) {\n return null;\n }\n const fields = [...new Set(unknown.flat())].map((field) => labelOf(field));\n return { count: unknown.length, fields };\n}\n\n/** Human label for a changed component. */\nexport function labelOf(field: string): string {\n return FIELD_LABELS[field] ?? field;\n}\n\nfunction browserVersionOf(\n changes: EnvironmentChange[],\n): Pick<EnvironmentChangeSummary, \"browserVersion\"> {\n const pairs = new Set(\n changes.map(\n (change) => `${change.from?.browserVersion ?? \"\"}\\u0000${change.to.browserVersion ?? \"\"}`,\n ),\n );\n if (pairs.size !== 1) {\n return {};\n }\n const [from, to] = [...pairs][0]?.split(\"\\u0000\") ?? [];\n return from && to && from !== to ? { browserVersion: { from, to } } : {};\n}\n"],"mappings":";;;;;AAKA,MAAM,eAAuC;CAC3C,QAAQ;CACR,gBAAgB;CAChB,UAAU;CACV,mBAAmB;CACnB,WAAW;CACX,UAAU;CACV,QAAQ;CACR,SAAS;AACX;;AAGA,SAAgB,oBACd,UAC0B;CAC1B,IAAI,CAAC,SAAS,WACZ,OAAO;CAET,IAAI;EACF,MAAM,QAAQ,KAAK,MAAM,SAAS,SAAS;EAC3C,OAAO,MAAM,QAAQ,MAAM,OAAO,KAAK,MAAM,KAAM,QAA8B;CACnF,QAAQ;EACN,OAAO;CACT;AACF;;AAaA,SAAgB,4BACd,WACiC;CACjC,MAAM,UAAU,UACb,KAAK,aAAa,oBAAoB,QAAQ,CAAC,CAAC,CAChD,QAAQ,WAAwC,WAAW,QAAQ,OAAO,QAAQ,SAAS,CAAC;CAC/F,IAAI,QAAQ,WAAW,GACrB,OAAO;CAET,MAAM,SAAS,CAAC,GAAG,IAAI,IAAI,QAAQ,SAAS,WAAW,OAAO,OAAO,CAAC,CAAC,CAAC,CAAC,KAAK,UAC5E,QAAQ,KAAK,CACf;CACA,OAAO;EAAE,OAAO,QAAQ;EAAQ;EAAQ,GAAG,iBAAiB,OAAO;CAAE;AACvE;;;;;AAYA,SAAgB,uBACd,WAC6B;CAC7B,MAAM,UAAU,UACb,KAAK,aAAa,oBAAoB,QAAQ,CAAC,EAAE,WAAW,CAAC,CAAC,CAAC,CAC/D,QAAQ,WAAW,OAAO,SAAS,CAAC;CACvC,IAAI,QAAQ,WAAW,GACrB,OAAO;CAET,MAAM,SAAS,CAAC,GAAG,IAAI,IAAI,QAAQ,KAAK,CAAC,CAAC,CAAC,CAAC,KAAK,UAAU,QAAQ,KAAK,CAAC;CACzE,OAAO;EAAE,OAAO,QAAQ;EAAQ;CAAO;AACzC;;AAGA,SAAgB,QAAQ,OAAuB;CAC7C,OAAO,aAAa,UAAU;AAChC;AAEA,SAAS,iBACP,SACkD;CAClD,MAAM,QAAQ,IAAI,IAChB,QAAQ,KACL,WAAW,GAAG,OAAO,MAAM,kBAAkB,GAAG,QAAQ,OAAO,GAAG,kBAAkB,IACvF,CACF;CACA,IAAI,MAAM,SAAS,GACjB,OAAO,CAAC;CAEV,MAAM,CAAC,MAAM,MAAM,CAAC,GAAG,KAAK,CAAC,CAAC,EAAE,EAAE,MAAM,IAAQ,KAAK,CAAC;CACtD,OAAO,QAAQ,MAAM,SAAS,KAAK,EAAE,gBAAgB;EAAE;EAAM;CAAG,EAAE,IAAI,CAAC;AACzE"}
|
|
@@ -4,6 +4,7 @@ import.meta.url;
|
|
|
4
4
|
import { credentialKeys } from "../credential-keys.mjs";
|
|
5
5
|
import { getStore } from "../store.mjs";
|
|
6
6
|
import { connectionModel } from "../connections.mjs";
|
|
7
|
+
import { announceDesignChangesWith } from "../design-events.mjs";
|
|
7
8
|
import { loadConnectStates, viewerIdOf } from "./design-connect.handlers.mjs";
|
|
8
9
|
import { DesignCredentialModel, DesignModel, DesignNodeModel, isDesignStale, normalizeImportPath, resolveDesigns } from "@storyshelf/core/models";
|
|
9
10
|
import { parseDesignUrl } from "@storyshelf/core/adapter/design";
|
|
@@ -47,6 +48,12 @@ async function loadReviewDesign(project, snapshot, urls, returnTo = "/") {
|
|
|
47
48
|
const viewerId = viewerIdOf();
|
|
48
49
|
await refreshWithinBudget(nodes, project.id, urls, viewerId);
|
|
49
50
|
const cached = await nodes.cachedFor(project.id, urls, viewerId).catch(() => /* @__PURE__ */ new Map());
|
|
51
|
+
await announceDesignChangesWith(getStore(), nodes, project, urls.map((url) => ({
|
|
52
|
+
url,
|
|
53
|
+
node: cached.get(url),
|
|
54
|
+
componentPath: snapshot.storyImportPath,
|
|
55
|
+
baselineVersion: snapshot.baselineVersion
|
|
56
|
+
})));
|
|
50
57
|
const connects = await loadConnectStates(project.id, urls.map((url) => kindOf(url)), returnTo).catch(() => /* @__PURE__ */ new Map());
|
|
51
58
|
return urls.map((url) => {
|
|
52
59
|
const node = cached.get(url);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"design.handlers.mjs","names":[],"sources":["../../src/routers/design.handlers.ts"],"sourcesContent":["import { parseDesignUrl } from \"@storyshelf/core/adapter/design\";\nimport {\n DesignCredentialModel,\n DesignModel,\n DesignNodeModel,\n isDesignStale,\n resolveDesigns,\n normalizeImportPath,\n type DesignResolution,\n} from \"@storyshelf/core/models\";\nimport type { DesignNode, Project, Snapshot } from \"@storyshelf/core/schema\";\nimport { connectionModel } from \"../connections.ts\";\nimport { credentialKeys } from \"../credential-keys.ts\";\nimport type {\n DesignConnect,\n DesignItem,\n DesignPreviewInfo,\n ProjectDesignLink,\n} from \"../pages/design-links.tsx\";\nimport { getStore } from \"../store.ts\";\nimport { loadConnectStates, viewerIdOf } from \"./design-connect.handlers.ts\";\n\n/** Most a page render waits for fresh design metadata; slower fetches finish into the cache for next time. */\nconst REFRESH_BUDGET_MS = 1500;\n\n/** Path + query of the current request, to return to after a connect round trip. */\nexport function returnPathOf(c: { req: { url: string } }): string {\n const url = new URL(c.req.url);\n return `${url.pathname}${url.search}`;\n}\n\n/** Component (story file) → design link(s) for a build's snapshots; files without a link are absent. */\nexport async function loadDesignResolutions(\n project: Project,\n snapshots: readonly Snapshot[],\n): Promise<Map<string, DesignResolution>> {\n const { db, design } = getStore();\n if (design.length === 0) return new Map();\n const model = new DesignModel(db, design);\n return resolveDesigns(await model.componentLinks(project.id), snapshots);\n}\n\n/** Design URLs of one snapshot's component. */\nexport function designUrlsFor(\n resolutions: Map<string, DesignResolution>,\n snapshot: Pick<Snapshot, \"storyImportPath\">,\n): string[] {\n const key = snapshot.storyImportPath ? normalizeImportPath(snapshot.storyImportPath) : \"\";\n return resolutions.get(key)?.urls ?? [];\n}\n\n/** Project-level design file links for the library header. */\nexport async function loadProjectDesignLinks(project: Project): Promise<ProjectDesignLink[]> {\n const { db, design } = getStore();\n if (design.length === 0) return [];\n const links = await new DesignModel(db, design).projectLinks(project.id);\n return links.map((link) => ({ label: link.label ?? \"Design\", url: link.url }));\n}\n\n/**\n * Design items for the review page: each link with cached name/state and a\n * stale flag against the snapshot's baseline. Refreshes expired metadata\n * within a short budget; failures degrade to plain deep links, never blocking review.\n */\nexport async function loadReviewDesign(\n project: Project,\n snapshot: Snapshot,\n urls: readonly string[],\n returnTo = \"/\",\n): Promise<DesignItem[]> {\n if (urls.length === 0) return [];\n const nodes = nodeModel();\n const viewerId = viewerIdOf();\n await refreshWithinBudget(nodes, project.id, urls, viewerId);\n const cached = await nodes\n .cachedFor(project.id, urls, viewerId)\n .catch(() => new Map<string, DesignNode>());\n const connects = await loadConnectStates(\n project.id,\n urls.map((url) => kindOf(url)),\n returnTo,\n ).catch(() => new Map<string, DesignConnect>());\n return urls.map((url) => {\n const node = cached.get(url);\n const connect = connects.get(kindOf(url));\n return {\n ...(connect ? { connect } : {}),\n url,\n name: node?.name ?? null,\n providerName: providerNameFor(url),\n renderUrl: renderUrlOf(project, node),\n state: node?.state ?? \"unknown\",\n stale: isDesignStale(node, snapshot.baselineVersion),\n };\n });\n}\n\n/** Most distinct design links a library render refreshes (the model bounds file concurrency). */\nconst WARM_LIMIT = 10;\n\n/**\n * Cached name/render per design URL for library popovers. Missing or expired\n * entries are refreshed first, within the page-render budget and with bounded\n * concurrency, so a slow design API never holds the library up or fans out unbounded.\n */\nexport async function loadDesignPreviews(\n project: Project,\n urls: readonly string[],\n): Promise<Map<string, DesignPreviewInfo>> {\n const distinct = [...new Set(urls)];\n if (distinct.length === 0) return new Map();\n const nodes = nodeModel();\n const viewerId = viewerIdOf();\n await refreshWithinBudget(nodes, project.id, distinct.slice(0, WARM_LIMIT), viewerId);\n const cached = await nodes\n .cachedFor(project.id, distinct, viewerId)\n .catch(() => new Map<string, DesignNode>());\n return new Map(\n [...cached].map(([url, node]) => [\n url,\n { name: node.name, renderUrl: renderUrlOf(project, node) },\n ]),\n );\n}\n\nfunction nodeModel(): DesignNodeModel {\n const { db, config, logger, storage, design } = getStore();\n const credentials = new DesignCredentialModel(db, credentialKeys(config));\n return new DesignNodeModel(db, credentials, design, {\n logger,\n storage,\n connections: connectionModel(),\n });\n}\n\nfunction kindOf(url: string): string {\n return parseDesignUrl(getStore().design, url)?.provider.metadata.kind ?? \"\";\n}\n\n/** Display name of the provider that owns a design link (\"Figma\"), or a generic fallback. */\nexport function providerNameFor(url: string): string {\n return parseDesignUrl(getStore().design, url)?.provider.metadata.name ?? \"Design\";\n}\n\n/** API URL of a node's cached render (version-busted), or null when none is cached. */\nfunction renderUrlOf(project: Project, node: DesignNode | undefined): string | null {\n return node?.renderPath\n ? `/api/v1/projects/${project.slug}/design/renders/${node.id}?v=${encodeURIComponent(node.renderVersion ?? \"\")}`\n : null;\n}\n\n/** Refresh the links, aborting every provider call once the budget is spent (the cache keeps what landed). */\nasync function refreshWithinBudget(\n nodes: DesignNodeModel,\n projectId: string,\n urls: readonly string[],\n viewerId?: string,\n): Promise<void> {\n await nodes\n .refreshMany(projectId, urls, {\n signal: AbortSignal.timeout(REFRESH_BUDGET_MS),\n ...(viewerId ? { viewerId } : {}),\n })\n .catch(() => new Map());\n}\n"],"mappings":"
|
|
1
|
+
{"version":3,"file":"design.handlers.mjs","names":[],"sources":["../../src/routers/design.handlers.ts"],"sourcesContent":["import { parseDesignUrl } from \"@storyshelf/core/adapter/design\";\nimport {\n DesignCredentialModel,\n DesignModel,\n DesignNodeModel,\n isDesignStale,\n resolveDesigns,\n normalizeImportPath,\n type DesignResolution,\n} from \"@storyshelf/core/models\";\nimport type { DesignNode, Project, Snapshot } from \"@storyshelf/core/schema\";\nimport { connectionModel } from \"../connections.ts\";\nimport { credentialKeys } from \"../credential-keys.ts\";\nimport { announceDesignChangesWith } from \"../design-events.ts\";\nimport type {\n DesignConnect,\n DesignItem,\n DesignPreviewInfo,\n ProjectDesignLink,\n} from \"../pages/design-links.tsx\";\nimport { getStore } from \"../store.ts\";\nimport { loadConnectStates, viewerIdOf } from \"./design-connect.handlers.ts\";\n\n/** Most a page render waits for fresh design metadata; slower fetches finish into the cache for next time. */\nconst REFRESH_BUDGET_MS = 1500;\n\n/** Path + query of the current request, to return to after a connect round trip. */\nexport function returnPathOf(c: { req: { url: string } }): string {\n const url = new URL(c.req.url);\n return `${url.pathname}${url.search}`;\n}\n\n/** Component (story file) → design link(s) for a build's snapshots; files without a link are absent. */\nexport async function loadDesignResolutions(\n project: Project,\n snapshots: readonly Snapshot[],\n): Promise<Map<string, DesignResolution>> {\n const { db, design } = getStore();\n if (design.length === 0) return new Map();\n const model = new DesignModel(db, design);\n return resolveDesigns(await model.componentLinks(project.id), snapshots);\n}\n\n/** Design URLs of one snapshot's component. */\nexport function designUrlsFor(\n resolutions: Map<string, DesignResolution>,\n snapshot: Pick<Snapshot, \"storyImportPath\">,\n): string[] {\n const key = snapshot.storyImportPath ? normalizeImportPath(snapshot.storyImportPath) : \"\";\n return resolutions.get(key)?.urls ?? [];\n}\n\n/** Project-level design file links for the library header. */\nexport async function loadProjectDesignLinks(project: Project): Promise<ProjectDesignLink[]> {\n const { db, design } = getStore();\n if (design.length === 0) return [];\n const links = await new DesignModel(db, design).projectLinks(project.id);\n return links.map((link) => ({ label: link.label ?? \"Design\", url: link.url }));\n}\n\n/**\n * Design items for the review page: each link with cached name/state and a\n * stale flag against the snapshot's baseline. Refreshes expired metadata\n * within a short budget; failures degrade to plain deep links, never blocking review.\n */\nexport async function loadReviewDesign(\n project: Project,\n snapshot: Snapshot,\n urls: readonly string[],\n returnTo = \"/\",\n): Promise<DesignItem[]> {\n if (urls.length === 0) return [];\n const nodes = nodeModel();\n const viewerId = viewerIdOf();\n await refreshWithinBudget(nodes, project.id, urls, viewerId);\n const cached = await nodes\n .cachedFor(project.id, urls, viewerId)\n .catch(() => new Map<string, DesignNode>());\n await announceDesignChangesWith(\n getStore(),\n nodes,\n project,\n urls.map((url) => ({\n url,\n node: cached.get(url),\n componentPath: snapshot.storyImportPath,\n baselineVersion: snapshot.baselineVersion,\n })),\n );\n const connects = await loadConnectStates(\n project.id,\n urls.map((url) => kindOf(url)),\n returnTo,\n ).catch(() => new Map<string, DesignConnect>());\n return urls.map((url) => {\n const node = cached.get(url);\n const connect = connects.get(kindOf(url));\n return {\n ...(connect ? { connect } : {}),\n url,\n name: node?.name ?? null,\n providerName: providerNameFor(url),\n renderUrl: renderUrlOf(project, node),\n state: node?.state ?? \"unknown\",\n stale: isDesignStale(node, snapshot.baselineVersion),\n };\n });\n}\n\n/** Most distinct design links a library render refreshes (the model bounds file concurrency). */\nconst WARM_LIMIT = 10;\n\n/**\n * Cached name/render per design URL for library popovers. Missing or expired\n * entries are refreshed first, within the page-render budget and with bounded\n * concurrency, so a slow design API never holds the library up or fans out unbounded.\n */\nexport async function loadDesignPreviews(\n project: Project,\n urls: readonly string[],\n): Promise<Map<string, DesignPreviewInfo>> {\n const distinct = [...new Set(urls)];\n if (distinct.length === 0) return new Map();\n const nodes = nodeModel();\n const viewerId = viewerIdOf();\n await refreshWithinBudget(nodes, project.id, distinct.slice(0, WARM_LIMIT), viewerId);\n const cached = await nodes\n .cachedFor(project.id, distinct, viewerId)\n .catch(() => new Map<string, DesignNode>());\n return new Map(\n [...cached].map(([url, node]) => [\n url,\n { name: node.name, renderUrl: renderUrlOf(project, node) },\n ]),\n );\n}\n\nfunction nodeModel(): DesignNodeModel {\n const { db, config, logger, storage, design } = getStore();\n const credentials = new DesignCredentialModel(db, credentialKeys(config));\n return new DesignNodeModel(db, credentials, design, {\n logger,\n storage,\n connections: connectionModel(),\n });\n}\n\nfunction kindOf(url: string): string {\n return parseDesignUrl(getStore().design, url)?.provider.metadata.kind ?? \"\";\n}\n\n/** Display name of the provider that owns a design link (\"Figma\"), or a generic fallback. */\nexport function providerNameFor(url: string): string {\n return parseDesignUrl(getStore().design, url)?.provider.metadata.name ?? \"Design\";\n}\n\n/** API URL of a node's cached render (version-busted), or null when none is cached. */\nfunction renderUrlOf(project: Project, node: DesignNode | undefined): string | null {\n return node?.renderPath\n ? `/api/v1/projects/${project.slug}/design/renders/${node.id}?v=${encodeURIComponent(node.renderVersion ?? \"\")}`\n : null;\n}\n\n/** Refresh the links, aborting every provider call once the budget is spent (the cache keeps what landed). */\nasync function refreshWithinBudget(\n nodes: DesignNodeModel,\n projectId: string,\n urls: readonly string[],\n viewerId?: string,\n): Promise<void> {\n await nodes\n .refreshMany(projectId, urls, {\n signal: AbortSignal.timeout(REFRESH_BUDGET_MS),\n ...(viewerId ? { viewerId } : {}),\n })\n .catch(() => new Map());\n}\n"],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@storyshelf/app",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0-next-d2cc082",
|
|
4
4
|
"description": "StoryShelf app: Hono app, API routes, and server-rendered UI over @storyshelf/core.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"chromatic-alternative",
|
|
@@ -58,8 +58,8 @@
|
|
|
58
58
|
"dependencies": {
|
|
59
59
|
"@hono/swagger-ui": "^0.6.1",
|
|
60
60
|
"@hono/zod-openapi": "^1.1.5",
|
|
61
|
-
"@storyshelf/core": "0.
|
|
62
|
-
"@storyshelf/observability": "0.
|
|
61
|
+
"@storyshelf/core": "0.9.0-next-d2cc082",
|
|
62
|
+
"@storyshelf/observability": "0.9.0-next-d2cc082",
|
|
63
63
|
"hono": "^4.11.0",
|
|
64
64
|
"zod": "^4.6.7"
|
|
65
65
|
},
|
|
@@ -68,9 +68,9 @@
|
|
|
68
68
|
"@opentelemetry/context-async-hooks": "^2.11.0",
|
|
69
69
|
"@opentelemetry/core": "^2.11.0",
|
|
70
70
|
"@opentelemetry/sdk-trace-base": "^2.11.0",
|
|
71
|
-
"@storyshelf/auth": "0.
|
|
72
|
-
"@storyshelf/db-sqlite": "0.
|
|
73
|
-
"@storyshelf/design-figma": "0.
|
|
71
|
+
"@storyshelf/auth": "0.9.0-next-d2cc082",
|
|
72
|
+
"@storyshelf/db-sqlite": "0.9.0-next-d2cc082",
|
|
73
|
+
"@storyshelf/design-figma": "0.9.0-next-d2cc082",
|
|
74
74
|
"@types/node": "^26.0.0",
|
|
75
75
|
"@vitest/coverage-v8": "^4.0.0",
|
|
76
76
|
"adm-zip": "0.6.1",
|
|
@@ -84,14 +84,5 @@
|
|
|
84
84
|
},
|
|
85
85
|
"engines": {
|
|
86
86
|
"node": ">=24"
|
|
87
|
-
},
|
|
88
|
-
"jsr": {
|
|
89
|
-
"runtimeCompat": {
|
|
90
|
-
"node": true,
|
|
91
|
-
"deno": true,
|
|
92
|
-
"browser": false,
|
|
93
|
-
"workerd": false,
|
|
94
|
-
"bun": true
|
|
95
|
-
}
|
|
96
87
|
}
|
|
97
88
|
}
|