@storyshelf/core 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.
@@ -1 +1 @@
1
- {"version":3,"file":"pipeline.mjs","names":[],"sources":["../../src/capture/pipeline.ts"],"sourcesContent":["import type { Logger } from \"pino\";\nimport { DEFAULT_BROWSER, type RenderedSnapshot } from \"../adapters/capture-runner.ts\";\nimport { ReferenceGenerationConflict, type DatabaseAdapter } from \"../adapters/database.ts\";\nimport type { StorageAdapter } from \"../adapters/storage.ts\";\nimport { BaselineWriteConflict } from \"../models/baseline-conflict.ts\";\nimport { NO_BASELINE } from \"../models/baseline-status.ts\";\nimport { BaselineModel, type BaselineTables } from \"../models/baseline.ts\";\nimport { BuildModel, type BuildTables } from \"../models/build.ts\";\nimport { emitAttemptLog, type AttemptLogRecorder } from \"../models/capture-attempt.ts\";\nimport { SnapshotModel, type SnapshotTables } from \"../models/snapshot.ts\";\nimport type { Baseline } from \"../schema/baseline.ts\";\nimport type { Build } from \"../schema/build.ts\";\nimport type { Project } from \"../schema/project.ts\";\nimport type { BuildStatus, SnapshotStatus } from \"../types.ts\";\nimport type { SecretInput } from \"../utils/encrypt.ts\";\nimport { mapLimit } from \"../utils/map-limit.ts\";\nimport { diffPath } from \"../utils/paths.ts\";\nimport { storeScreenshot } from \"../utils/screenshot-store.ts\";\nimport type { Viewport } from \"./adapter.ts\";\nimport type { InheritedStory } from \"./affected.ts\";\nimport { freezeComparison, sourceSnapshotId } from \"./comparison-reference.ts\";\nimport { diffStoredScreenshots, writeDiffOverlay } from \"./diff-record.ts\";\nimport {\n buildFingerprint,\n changedFields,\n fingerprintHash,\n mergeFingerprint,\n parseFingerprint,\n type EnvironmentChange,\n type Fingerprint,\n} from \"./fingerprint.ts\";\nimport { assertScreenshotBuffer } from \"./guards.ts\";\nimport type { CaptureProgress } from \"./progress.ts\";\n\n/** Snapshots persisted at once: each decodes two PNGs for the diff, so unbounded fan-out spikes memory. */\nexport const PERSIST_CONCURRENCY = 4;\n\n/**\n * Persist a completed capture run: write screenshots, diff against the branch\n * baseline, create snapshots, and finalize the build.\n *\n * Rendering is performed by a pure `CaptureRunner`; this function owns only\n * storage writes and record keeping, so it can run against any renderer.\n *\n * @param ctx - Capture context.\n * @param renderFailedStoryIds - Story ids that the renderer could not capture\n * (blocking failures). They force the build to `failed`.\n * @param flakyFailedStoryIds - Story ids that failed but are marked flaky\n * (non-blocking). They are logged as warnings and do not block the build.\n */\nexport async function persistCapture(\n input: CaptureContext,\n renderFailedStoryIds: ReadonlySet<string> = new Set(),\n flakyFailedStoryIds: ReadonlySet<string> = new Set(),\n a11yFailedStoryIds: ReadonlySet<string> = new Set(),\n): Promise<void> {\n const sets = await persistRendered(\n input,\n renderFailedStoryIds,\n flakyFailedStoryIds,\n a11yFailedStoryIds,\n );\n for (const id of await persistInherited(input)) {\n sets.failed.add(id);\n }\n await finalizeBuild(input, allStoryIds(input), sets.failed, sets.flaky, sets.a11y);\n}\n\n/** Blocking, flaky and a11y story ids a persist run ended with. */\nexport interface PersistedSets {\n failed: Set<string>;\n flaky: Set<string>;\n a11y: Set<string>;\n}\n\n/**\n * Persist the rendered captures only: screenshots, diffs and snapshot rows, without the\n * inherited snapshots or the build finalize. A sharded capture runs this once per shard.\n */\nexport async function persistRendered(\n input: CaptureContext,\n renderFailedStoryIds: ReadonlySet<string> = new Set(),\n flakyFailedStoryIds: ReadonlySet<string> = new Set(),\n a11yFailedStoryIds: ReadonlySet<string> = new Set(),\n): Promise<PersistedSets> {\n const ctx = await withFreshBrowsers(input);\n const failedStoryIds = new Set(renderFailedStoryIds);\n const flakyIds = new Set(flakyFailedStoryIds);\n const a11yIds = new Set(a11yFailedStoryIds);\n // Primary engine first: a newly enabled engine bootstraps from its counterpart.\n for (const group of capturesByPhase(ctx)) {\n // oxlint-disable-next-line no-await-in-loop -- phases must run in order\n await mapLimit(group, PERSIST_CONCURRENCY, async (capture) => {\n try {\n await persistSnapshot(ctx, capture);\n // Each diff is progress: a long, healthy persist phase must not read as a hung job.\n ctx.progress?.();\n } catch (error) {\n // A failure in one story/viewport must not abort the other persists.\n // Flaky and a11y stories remain non-blocking even on persist failure.\n if (flakyIds.has(capture.story.id) || a11yIds.has(capture.story.id)) {\n if (flakyIds.has(capture.story.id)) flakyIds.add(capture.story.id);\n if (a11yIds.has(capture.story.id)) a11yIds.add(capture.story.id);\n ctx.logger?.warn(\n { storyId: capture.story.id, viewport: capture.viewportName, err: error },\n \"capture failed for flaky/a11y story (non-blocking)\",\n );\n await emitAttemptLog(\n ctx.recordLog,\n ctx.logger,\n \"warn\",\n \"capture failed for flaky/a11y story (non-blocking)\",\n {\n storyId: capture.story.id,\n viewport: capture.viewportName,\n },\n );\n } else {\n failedStoryIds.add(capture.story.id);\n }\n ctx.logger?.error(\n { storyId: capture.story.id, viewport: capture.viewportName, err: error },\n \"capture failed for story\",\n );\n await emitAttemptLog(ctx.recordLog, ctx.logger, \"error\", \"capture failed for story\", {\n storyId: capture.story.id,\n viewport: capture.viewportName,\n });\n }\n });\n }\n // Also log flaky and a11y render failures as warnings\n for (const id of flakyIds) {\n if (!failedStoryIds.has(id)) {\n ctx.logger?.warn({ storyId: id }, \"flaky story failed (non-blocking)\");\n await emitAttemptLog(ctx.recordLog, ctx.logger, \"warn\", \"flaky story failed (non-blocking)\", {\n storyId: id,\n });\n }\n }\n for (const id of a11yIds) {\n if (!failedStoryIds.has(id) && !flakyIds.has(id)) {\n ctx.logger?.warn({ storyId: id }, \"a11y violations found (non-blocking)\");\n await emitAttemptLog(\n ctx.recordLog,\n ctx.logger,\n \"warn\",\n \"a11y violations found (non-blocking)\",\n {\n storyId: id,\n },\n );\n }\n }\n return { failed: failedStoryIds, flaky: flakyIds, a11y: a11yIds };\n}\n\n/** Engines of this run, primary (first) first. */\nfunction runBrowsers(ctx: CaptureContext): string[] {\n const listed = ctx.browsers && ctx.browsers.length > 0 ? ctx.browsers : [DEFAULT_BROWSER];\n return [...new Set(listed)];\n}\n\nfunction primaryBrowser(ctx: CaptureContext): string {\n return runBrowsers(ctx)[0] ?? DEFAULT_BROWSER;\n}\n\n/** The capture's axe violation texts as the JSON stored on the snapshot; null when there are none. */\nfunction a11yJsonOf(capture: { a11y?: string[] }): string | null {\n return capture.a11y && capture.a11y.length > 0 ? JSON.stringify(capture.a11y) : null;\n}\n\nfunction browserOf(capture: { browser?: string }): string {\n return capture.browser ?? DEFAULT_BROWSER;\n}\n\n/** Captures split into the primary engine's, then everyone else's. */\nfunction capturesByPhase(ctx: CaptureContext): RenderedSnapshot[][] {\n const primary = primaryBrowser(ctx);\n const first = ctx.captures.filter((capture) => browserOf(capture) === primary);\n const rest = ctx.captures.filter((capture) => browserOf(capture) !== primary);\n return rest.length > 0 ? [first, rest] : [first];\n}\n\n/** Secondary engines with no baseline yet (their first snapshots bootstrap from the primary). */\nexport async function freshBrowsersOf(ctx: CaptureContext): Promise<Set<string>> {\n const primary = primaryBrowser(ctx);\n const baselines = new BaselineModel(ctx.db, ctx.tables, ctx.storage, ctx.secret);\n const secondary = runBrowsers(ctx).filter((browser) => browser !== primary);\n const checks = await Promise.all(\n secondary.map(async (browser) => ({\n browser,\n fresh: !(await baselines.hasAny(ctx.project.id, browser)),\n })),\n );\n return new Set(checks.filter((check) => check.fresh).map((check) => check.browser));\n}\n\n/** Mark secondary engines with no baseline yet: their first snapshots bootstrap. */\nasync function withFreshBrowsers(ctx: CaptureContext): Promise<CaptureContext> {\n return ctx.freshBrowsers ? ctx : { ...ctx, freshBrowsers: await freshBrowsersOf(ctx) };\n}\n\n/**\n * A newly enabled engine accepts a story/mode's first snapshot when the\n * primary engine's snapshot of it is clean (Chromatic's \"auto-accepted where\n * the primary browser was accepted\"); anything else goes to review as `new`.\n */\nasync function bootstrapAccepts(ctx: CaptureContext, capture: RenderedSnapshot): Promise<boolean> {\n const browser = browserOf(capture);\n if (!ctx.freshBrowsers?.has(browser)) return false;\n const primary = primaryBrowser(ctx);\n const siblings = await new SnapshotModel(ctx.db, ctx.tables).listByBuild(ctx.build.id);\n return siblings.some(\n (snapshot) =>\n snapshot.storyId === capture.story.id &&\n snapshot.viewportName === capture.viewportName &&\n (snapshot.browser ?? DEFAULT_BROWSER) === primary &&\n (snapshot.status === \"approved\" || snapshot.status === \"unchanged\"),\n );\n}\n\n/** Persist one inherited snapshot; returns the story id on failure. */\nasync function tryPersistInherited(\n ctx: CaptureContext,\n inherited: InheritedStory,\n): Promise<string | null> {\n try {\n await persistInheritedSnapshot(ctx, inherited);\n return null;\n } catch (error) {\n ctx.logger?.error(\n { storyId: inherited.story.id, viewport: inherited.viewport.name, err: error },\n \"inherit failed for story\",\n );\n await emitAttemptLog(ctx.recordLog, ctx.logger, \"error\", \"inherit failed for story\", {\n storyId: inherited.story.id,\n viewport: inherited.viewport.name,\n });\n return inherited.story.id;\n }\n}\n\n/** Persist inherited snapshots; a failure marks the story failed. */\n/** Persist the inherited snapshots (stories reusing their baseline); returns the story ids that failed. */\nexport async function persistInherited(ctx: CaptureContext): Promise<ReadonlySet<string>> {\n const outcomes = await mapLimit(ctx.inherited ?? [], PERSIST_CONCURRENCY, async (inherited) => {\n const failed = await tryPersistInherited(ctx, inherited);\n ctx.progress?.();\n return failed;\n });\n return new Set(outcomes.filter((id): id is string => id !== null));\n}\n\n/** Create an unchanged snapshot reusing the baseline screenshot. */\nasync function persistInheritedSnapshot(\n ctx: CaptureContext,\n inherited: InheritedStory,\n): Promise<void> {\n const browser = browserOf(inherited);\n // Nothing rendered, so there is no new fingerprint: the snapshot carries the baseline's.\n const infraHash = inherited.baseline.infraHash;\n const snapshots = new SnapshotModel(ctx.db, ctx.tables);\n const comparison = await freezeComparison(\n ctx.storage,\n ctx.project.id,\n ctx.build.id,\n inherited.baseline,\n {\n defaultBranch: ctx.project.gitDefaultBranch,\n mode: modeJsonOf(inherited.viewport),\n target: browser,\n width: inherited.viewport.width,\n height: inherited.viewport.height,\n captureFingerprint: inherited.baseline.fingerprint ?? null,\n sourceSnapshotId: await sourceSnapshotId(ctx.db, inherited.baseline),\n },\n );\n const snapshot = await snapshots.create(ctx.project.id, ctx.build.id, {\n ...comparison,\n browser,\n storyId: inherited.story.id,\n storyName: inherited.story.name,\n storyTitle: inherited.story.title,\n storyImportPath: inherited.story.importPath ?? \"\",\n designUrl: inherited.story.designUrl ?? null,\n viewportName: inherited.viewport.name,\n viewportWidth: inherited.viewport.width,\n viewportHeight: inherited.viewport.height,\n screenshotPath: inherited.baseline.screenshotPath,\n infraHash,\n mode: modeJsonOf(inherited.viewport),\n inherited: true,\n sourceBuildId: inherited.baseline.sourceBuildId ?? null,\n baselineId: inherited.baseline.id,\n baselineVersion: inherited.baseline.updatedAt,\n });\n await snapshots.update(snapshot.id, {\n status: \"unchanged\",\n diffPixels: 0,\n diffRatio: 0,\n diffPassed: true,\n diffPath: null,\n infraHash,\n });\n}\n\n/** Every story covered by a capture run, rendered or inherited. */\nfunction allStoryIds(ctx: CaptureContext): ReadonlySet<string> {\n const ids = new Set(ctx.captures.map((capture) => capture.story.id));\n for (const inherited of ctx.inherited ?? []) {\n ids.add(inherited.story.id);\n }\n return ids;\n}\n\n/** Table handles required by the capture pipeline. */\nexport type PipelineTables = BuildTables & BaselineTables & SnapshotTables;\n\n/** Persistence inputs for a completed capture run. */\nexport interface CaptureContext {\n /** Database adapter. */\n db: DatabaseAdapter;\n /** Table handles. */\n tables: PipelineTables;\n /** Storage adapter. */\n storage: StorageAdapter;\n /** The project being captured. */\n project: Project;\n /** The build being captured. */\n build: Build;\n /** Viewports at which stories were captured. */\n viewports: Viewport[];\n /** Engines captured this run, primary first (default: chromium only). */\n browsers?: string[];\n /** Secondary engines with no baselines yet; worked out by `persistCapture`, or fixed up front by a shard plan. */\n freshBrowsers?: ReadonlySet<string>;\n /** Screenshot buffers produced by the capture renderer. */\n captures: RenderedSnapshot[];\n /** Stories inheriting their baseline without rendering (affected capture). */\n inherited?: InheritedStory[];\n /** Optional logger for capture diagnostics. */\n logger?: Logger;\n /** Optional per-attempt log recorder (mirrors logger lines into the DB). */\n recordLog?: AttemptLogRecorder;\n /** Called per snapshot persisted, so the job's lease sees a long diff phase making progress. */\n progress?: CaptureProgress | undefined;\n /** Server secret for decrypting webhook secrets at send time. */\n secret?: SecretInput;\n}\n\n/** JSON for the snapshot `mode` column: the globals a mode applied (null for plain viewports). */\nfunction modeJsonOf(viewport: Viewport | undefined): string | null {\n return viewport?.globals ? JSON.stringify(viewport.globals) : null;\n}\n\nfunction viewportByName(ctx: CaptureContext, name: string): Viewport {\n return ctx.viewports.find((v) => v.name === name) ?? { name, width: 0, height: 0 };\n}\n\n/** Where the capture's screenshot lives: already stored by the orchestrator, or stored now. */\nasync function screenshotLocation(ctx: CaptureContext, capture: RenderedSnapshot): Promise<string> {\n if (capture.storedPath) {\n return capture.storedPath;\n }\n assertScreenshotBuffer(capture.screenshot);\n return await storeScreenshot(ctx.storage, ctx.project.id, capture.screenshot);\n}\n\nasync function persistSnapshot(ctx: CaptureContext, capture: RenderedSnapshot): Promise<void> {\n const viewport = capture.viewport ?? viewportByName(ctx, capture.viewportName);\n const browser = browserOf(capture);\n const screenshot = await screenshotLocation(ctx, capture);\n\n const baseline = await resolveBaseline(ctx, capture.story.id, capture.viewportName, browser);\n if (!baseline) {\n await createWithoutBaseline(ctx, capture, viewport, screenshot);\n return;\n }\n await createWithBaseline(ctx, capture, viewport, screenshot, baseline);\n}\n\nasync function resolveBaseline(\n ctx: CaptureContext,\n storyId: string,\n viewport: string,\n browser: string,\n): Promise<Baseline | null> {\n const baselines = new BaselineModel(ctx.db, ctx.tables, ctx.storage, ctx.secret);\n return await baselines.resolve(\n ctx.project.id,\n storyId,\n viewport,\n ctx.build.gitBranch,\n ctx.project.gitDefaultBranch,\n browser,\n );\n}\n\nasync function createWithoutBaseline(\n ctx: CaptureContext,\n capture: RenderedSnapshot,\n viewport: Viewport,\n screenshot: string,\n): Promise<void> {\n const snapshots = new SnapshotModel(ctx.db, ctx.tables);\n const browser = browserOf(capture);\n const accepted = ctx.build.isDefault || (await bootstrapAccepts(ctx, capture));\n const status = accepted ? \"approved\" : \"new\";\n const now = fingerprintOf(capture, viewport);\n const snapshot = await snapshots.create(ctx.project.id, ctx.build.id, {\n browser,\n durationMs: capture.durationMs ?? null,\n a11yViolations: a11yJsonOf(capture),\n storyId: capture.story.id,\n storyName: capture.story.name,\n storyTitle: capture.story.title,\n storyImportPath: capture.story.importPath ?? \"\",\n designUrl: capture.story.designUrl ?? null,\n viewportName: capture.viewportName,\n viewportWidth: viewport.width,\n viewportHeight: viewport.height,\n screenshotPath: screenshot,\n infraHash: now.infraHash,\n mode: modeJsonOf(viewport),\n baselineVersion: NO_BASELINE,\n });\n await snapshots.setStatus(snapshot.id, status);\n\n if (accepted) {\n const baselines = new BaselineModel(ctx.db, ctx.tables, ctx.storage, ctx.secret);\n await baselines.upsert(\n ctx.project.id,\n capture.story.id,\n capture.viewportName,\n ctx.build.gitBranch,\n snapshot.id,\n screenshot,\n now.infraHash,\n { mode: modeJsonOf(viewport), browser, fingerprint: now.json },\n );\n }\n}\n\n/** A capture's fingerprint: its components, their hash (stored as `infraHash`), and the stored JSON. */\ninterface CaptureFingerprint {\n fingerprint: Fingerprint;\n infraHash: string;\n json: string;\n}\n\nfunction fingerprintOf(capture: RenderedSnapshot, viewport: Viewport): CaptureFingerprint {\n return captureFingerprintOf(buildFingerprint(browserOf(capture), viewport, capture.environment));\n}\n\n/** A fingerprint as stored: its hash (the `infraHash`) and JSON. */\nfunction captureFingerprintOf(fingerprint: Fingerprint): CaptureFingerprint {\n return {\n fingerprint,\n infraHash: fingerprintHash(fingerprint),\n json: JSON.stringify(fingerprint),\n };\n}\n\n/**\n * What changed in the capture environment since the baseline was recorded, or null when nothing\n * did. Compared component by component, only where both sides report a value: a capture with no\n * environment (spooled before a browser crash) is unknown, not a change. A baseline from before\n * fingerprints cannot be compared, so it is never reported as changed; it is backfilled instead.\n */\nfunction environmentChange(baseline: Baseline, now: CaptureFingerprint): EnvironmentChange | null {\n const from = parseFingerprint(baseline.fingerprint);\n if (!from) {\n return null;\n }\n const changed = changedFields(from, now.fingerprint);\n return changed.length > 0 ? { changed, from, to: now.fingerprint } : null;\n}\n\n/**\n * A changed environment migrates the baseline without a diff only on the default branch, which is\n * authoritative and auto-approves its own changes anyway. A feature branch still diffs: a real\n * change is reported as `changed` with the environment change attached, never auto-accepted.\n */\nasync function createWithBaseline(\n ctx: CaptureContext,\n capture: RenderedSnapshot,\n viewport: Viewport,\n screenshot: string,\n baseline: Baseline,\n): Promise<void> {\n const now = fingerprintOf(capture, viewport);\n const change = environmentChange(baseline, now);\n const comparison = await freezeComparison(ctx.storage, ctx.project.id, ctx.build.id, baseline, {\n defaultBranch: ctx.project.gitDefaultBranch,\n mode: modeJsonOf(viewport),\n target: browserOf(capture),\n width: viewport.width,\n height: viewport.height,\n captureFingerprint: now.json,\n sourceSnapshotId: await sourceSnapshotId(ctx.db, baseline),\n });\n const input = { ctx, capture, viewport, screenshot, baseline, now, change, comparison };\n if (change && ctx.build.isDefault) {\n await persistMigration(input, change);\n } else {\n await persistDiff(input);\n }\n}\n\ninterface BaselineCapture {\n ctx: CaptureContext;\n capture: RenderedSnapshot;\n viewport: Viewport;\n screenshot: string;\n baseline: Baseline;\n now: CaptureFingerprint;\n change: EnvironmentChange | null;\n comparison: { comparisonImagePath: string; comparisonMetadata: string };\n}\n\n/** The fingerprint to write on the baseline: the capture's, keeping components it did not report. */\nfunction baselineFingerprintOf(input: BaselineCapture): CaptureFingerprint {\n const earlier = parseFingerprint(input.baseline.fingerprint);\n return captureFingerprintOf(mergeFingerprint(earlier, input.now.fingerprint));\n}\n\nfunction snapshotInput(input: BaselineCapture): Parameters<SnapshotModel[\"create\"]>[2] {\n const { capture, viewport, screenshot, baseline, now } = input;\n return {\n browser: browserOf(capture),\n durationMs: capture.durationMs ?? null,\n a11yViolations: a11yJsonOf(capture),\n storyId: capture.story.id,\n storyName: capture.story.name,\n storyTitle: capture.story.title,\n storyImportPath: capture.story.importPath ?? \"\",\n designUrl: capture.story.designUrl ?? null,\n viewportName: capture.viewportName,\n viewportWidth: viewport.width,\n viewportHeight: viewport.height,\n screenshotPath: screenshot,\n infraHash: now.infraHash,\n mode: modeJsonOf(viewport),\n baselineId: baseline.id,\n baselineVersion: baseline.updatedAt,\n ...input.comparison,\n };\n}\n\n/**\n * The default branch's capture environment changed (browser build, fonts, platform, scale): pixels\n * would differ with no code change, so no diff is computed. The snapshot is accepted, records what\n * changed, and the new screenshot becomes the baseline.\n */\nasync function persistMigration(input: BaselineCapture, change: EnvironmentChange): Promise<void> {\n const { ctx, capture, viewport, screenshot, now } = input;\n const snapshots = new SnapshotModel(ctx.db, ctx.tables);\n const snapshot = await snapshots.create(ctx.project.id, ctx.build.id, {\n ...snapshotInput(input),\n envChange: JSON.stringify(change),\n });\n await snapshots.update(snapshot.id, {\n status: \"unchanged\",\n diffPath: null,\n diffPixels: 0,\n diffRatio: 0,\n diffPassed: true,\n infraHash: now.infraHash,\n });\n await upsertDefaultBaseline(ctx, snapshot.id, capture, screenshot, baselineFingerprintOf(input));\n ctx.logger?.debug(\n { storyId: capture.story.id, viewport: viewport.name, changed: change.changed },\n \"capture environment changed; accepted without a diff\",\n );\n}\n\n/** Diff against the baseline; a feature-branch environment change rides along as `envChange`. */\nasync function persistDiff(input: BaselineCapture): Promise<void> {\n const { ctx, capture, screenshot, baseline, now, change } = input;\n const result = await diffStoredScreenshots(\n ctx.storage,\n ctx.project,\n baseline.screenshotPath === screenshot ? screenshot : input.comparison.comparisonImagePath,\n screenshot,\n capture.story.parameters?.diffThreshold,\n );\n const snapshots = new SnapshotModel(ctx.db, ctx.tables);\n const snapshot = await snapshots.create(ctx.project.id, ctx.build.id, {\n ...snapshotInput(input),\n ...(change ? { envChange: JSON.stringify(change) } : {}),\n });\n const diff = diffPath(\n ctx.project.id,\n ctx.build.id,\n capture.story.id,\n capture.viewportName,\n browserOf(capture),\n );\n const overlay = await writeDiffOverlay(ctx.storage, diff, result, result.passed);\n await snapshots.update(snapshot.id, {\n status: diffStatus(result.passed, ctx.build.isDefault),\n diffPath: overlay,\n diffPixels: result.diffPixels,\n diffRatio: result.diffRatio,\n diffPassed: result.passed,\n infraHash: now.infraHash,\n });\n if (ctx.build.isDefault) {\n await refreshDefaultBaseline(input, snapshot.id, result.passed);\n }\n}\n\n/**\n * After a default-branch diff: a failed diff moves the baseline to the new screenshot; a passing\n * one backfills the fingerprint when it was missing or had components the capture now reports.\n */\nasync function refreshDefaultBaseline(\n input: BaselineCapture,\n snapshotId: string,\n passed: boolean,\n): Promise<void> {\n const { ctx, capture, screenshot, baseline } = input;\n const target = baselineFingerprintOf(input);\n if (!passed) {\n await upsertDefaultBaseline(ctx, snapshotId, capture, screenshot, target);\n } else if (!baseline.fingerprint || baseline.infraHash !== target.infraHash) {\n try {\n await new BaselineModel(ctx.db, ctx.tables, ctx.storage, ctx.secret).backfillFingerprint(\n baseline,\n target.infraHash,\n target.json,\n );\n } catch (error) {\n if (!(error instanceof BaselineWriteConflict || error instanceof ReferenceGenerationConflict))\n throw error;\n ctx.logger?.debug(\n { err: error, baselineId: baseline.id },\n \"skipped baseline fingerprint backfill after concurrent write\",\n );\n }\n }\n}\n\n/** Default-branch builds are authoritative: the captured screenshot becomes the baseline. */\nasync function upsertDefaultBaseline(\n ctx: CaptureContext,\n snapshotId: string,\n capture: RenderedSnapshot,\n screenshot: string,\n now: CaptureFingerprint,\n): Promise<void> {\n const baselines = new BaselineModel(ctx.db, ctx.tables, ctx.storage, ctx.secret);\n await baselines.upsert(\n ctx.project.id,\n capture.story.id,\n capture.viewportName,\n ctx.build.gitBranch,\n snapshotId,\n screenshot,\n now.infraHash,\n { mode: modeJsonOf(capture.viewport), browser: browserOf(capture), fingerprint: now.json },\n );\n}\n\n/** Snapshot status after diffing: default-branch changes are auto-approved, not queued for review. */\nexport function diffStatus(passed: boolean, isDefault: boolean): SnapshotStatus {\n if (passed) {\n return \"unchanged\";\n }\n return isDefault ? \"approved\" : \"changed\";\n}\n\n/** A failed capture fails the build; default-branch builds otherwise auto-approve. */\nfunction finalBuildStatus(\n isDefault: boolean,\n failedCount: number,\n hasCaptures: boolean,\n build: { changedCount: number },\n): BuildStatus {\n if (failedCount > 0) {\n return \"failed\";\n }\n if (isDefault || (hasCaptures && build.changedCount === 0)) {\n return \"approved\";\n }\n return \"reviewing\";\n}\n\n/**\n * Close out a capture: roll the snapshot counts up, set the build's final status from the failed\n * story ids, and (on the default branch) drop baselines of stories that no longer exist.\n */\nexport async function finalizeBuild(\n ctx: CaptureContext,\n storyIds: ReadonlySet<string>,\n failedStoryIds: ReadonlySet<string>,\n flakyFailedStoryIds: ReadonlySet<string> = new Set(),\n a11yFailedStoryIds: ReadonlySet<string> = new Set(),\n): Promise<void> {\n const builds = new BuildModel(ctx.db, ctx.tables, ctx.logger);\n if ((await builds.get(ctx.build.id))?.status === \"cancelled\") {\n ctx.logger?.debug({ buildId: ctx.build.id }, \"build is cancelled; finalize skipped\");\n return;\n }\n const build = await builds.updateCounts(ctx.build.id);\n const hasCaptures = storyIds.size > 0;\n const status = finalBuildStatus(ctx.build.isDefault, failedStoryIds.size, hasCaptures, build);\n // Flaky and a11y failures do not block: log warning if any failed non-blocking\n if (flakyFailedStoryIds.size > 0 && failedStoryIds.size === 0) {\n ctx.logger?.warn(\n { flakyStoryIds: [...flakyFailedStoryIds] },\n \"flaky stories failed (non-blocking)\",\n );\n await emitAttemptLog(ctx.recordLog, ctx.logger, \"warn\", \"flaky stories failed (non-blocking)\", {\n flakyStoryIds: [...flakyFailedStoryIds],\n });\n }\n if (a11yFailedStoryIds.size > 0 && failedStoryIds.size === 0) {\n ctx.logger?.warn(\n { a11yStoryIds: [...a11yFailedStoryIds] },\n \"a11y violations found (non-blocking)\",\n );\n await emitAttemptLog(\n ctx.recordLog,\n ctx.logger,\n \"warn\",\n \"a11y violations found (non-blocking)\",\n {\n a11yStoryIds: [...a11yFailedStoryIds],\n },\n );\n }\n await builds.setStatus(ctx.build.id, status);\n\n if (ctx.build.isDefault && hasCaptures) {\n const baselines = new BaselineModel(ctx.db, ctx.tables, ctx.storage, ctx.secret);\n await baselines.removeOrphans(ctx.project.id, new Set(storyIds));\n }\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"pipeline.mjs","names":[],"sources":["../../src/capture/pipeline.ts"],"sourcesContent":["import type { Logger } from \"pino\";\nimport { DEFAULT_BROWSER, type RenderedSnapshot } from \"../adapters/capture-runner.ts\";\nimport { ReferenceGenerationConflict, type DatabaseAdapter } from \"../adapters/database.ts\";\nimport type { StorageAdapter } from \"../adapters/storage.ts\";\nimport { BaselineWriteConflict } from \"../models/baseline-conflict.ts\";\nimport { NO_BASELINE } from \"../models/baseline-status.ts\";\nimport { BaselineModel, type BaselineTables } from \"../models/baseline.ts\";\nimport { BuildModel, type BuildTables } from \"../models/build.ts\";\nimport { emitAttemptLog, type AttemptLogRecorder } from \"../models/capture-attempt.ts\";\nimport { SnapshotModel, type SnapshotTables } from \"../models/snapshot.ts\";\nimport type { Baseline } from \"../schema/baseline.ts\";\nimport type { Build } from \"../schema/build.ts\";\nimport type { Project } from \"../schema/project.ts\";\nimport type { BuildStatus, SnapshotStatus } from \"../types.ts\";\nimport type { SecretInput } from \"../utils/encrypt.ts\";\nimport { mapLimit } from \"../utils/map-limit.ts\";\nimport { diffPath } from \"../utils/paths.ts\";\nimport { storeScreenshot } from \"../utils/screenshot-store.ts\";\nimport type { Viewport } from \"./adapter.ts\";\nimport type { InheritedStory } from \"./affected.ts\";\nimport { freezeComparison, sourceSnapshotId } from \"./comparison-reference.ts\";\nimport { diffStoredScreenshots, writeDiffOverlay } from \"./diff-record.ts\";\nimport {\n buildFingerprint,\n changedFields,\n fingerprintHash,\n mergeFingerprint,\n parseFingerprint,\n unknownFields,\n type EnvironmentChange,\n type Fingerprint,\n} from \"./fingerprint.ts\";\nimport { assertScreenshotBuffer } from \"./guards.ts\";\nimport type { CaptureProgress } from \"./progress.ts\";\n\n/** Snapshots persisted at once: each decodes two PNGs for the diff, so unbounded fan-out spikes memory. */\nexport const PERSIST_CONCURRENCY = 4;\n\n/**\n * Persist a completed capture run: write screenshots, diff against the branch\n * baseline, create snapshots, and finalize the build.\n *\n * Rendering is performed by a pure `CaptureRunner`; this function owns only\n * storage writes and record keeping, so it can run against any renderer.\n *\n * @param ctx - Capture context.\n * @param renderFailedStoryIds - Story ids that the renderer could not capture\n * (blocking failures). They force the build to `failed`.\n * @param flakyFailedStoryIds - Story ids that failed but are marked flaky\n * (non-blocking). They are logged as warnings and do not block the build.\n */\nexport async function persistCapture(\n input: CaptureContext,\n renderFailedStoryIds: ReadonlySet<string> = new Set(),\n flakyFailedStoryIds: ReadonlySet<string> = new Set(),\n a11yFailedStoryIds: ReadonlySet<string> = new Set(),\n): Promise<void> {\n const sets = await persistRendered(\n input,\n renderFailedStoryIds,\n flakyFailedStoryIds,\n a11yFailedStoryIds,\n );\n for (const id of await persistInherited(input)) {\n sets.failed.add(id);\n }\n await finalizeBuild(input, allStoryIds(input), sets.failed, sets.flaky, sets.a11y);\n}\n\n/** Blocking, flaky and a11y story ids a persist run ended with. */\nexport interface PersistedSets {\n failed: Set<string>;\n flaky: Set<string>;\n a11y: Set<string>;\n}\n\n/**\n * Persist the rendered captures only: screenshots, diffs and snapshot rows, without the\n * inherited snapshots or the build finalize. A sharded capture runs this once per shard.\n */\nexport async function persistRendered(\n input: CaptureContext,\n renderFailedStoryIds: ReadonlySet<string> = new Set(),\n flakyFailedStoryIds: ReadonlySet<string> = new Set(),\n a11yFailedStoryIds: ReadonlySet<string> = new Set(),\n): Promise<PersistedSets> {\n const ctx = await withFreshBrowsers(input);\n const failedStoryIds = new Set(renderFailedStoryIds);\n const flakyIds = new Set(flakyFailedStoryIds);\n const a11yIds = new Set(a11yFailedStoryIds);\n // Primary engine first: a newly enabled engine bootstraps from its counterpart.\n for (const group of capturesByPhase(ctx)) {\n // oxlint-disable-next-line no-await-in-loop -- phases must run in order\n await mapLimit(group, PERSIST_CONCURRENCY, async (capture) => {\n try {\n await persistSnapshot(ctx, capture);\n // Each diff is progress: a long, healthy persist phase must not read as a hung job.\n ctx.progress?.();\n } catch (error) {\n // A failure in one story/viewport must not abort the other persists.\n // Flaky and a11y stories remain non-blocking even on persist failure.\n if (flakyIds.has(capture.story.id) || a11yIds.has(capture.story.id)) {\n if (flakyIds.has(capture.story.id)) flakyIds.add(capture.story.id);\n if (a11yIds.has(capture.story.id)) a11yIds.add(capture.story.id);\n ctx.logger?.warn(\n { storyId: capture.story.id, viewport: capture.viewportName, err: error },\n \"capture failed for flaky/a11y story (non-blocking)\",\n );\n await emitAttemptLog(\n ctx.recordLog,\n ctx.logger,\n \"warn\",\n \"capture failed for flaky/a11y story (non-blocking)\",\n {\n storyId: capture.story.id,\n viewport: capture.viewportName,\n },\n );\n } else {\n failedStoryIds.add(capture.story.id);\n }\n ctx.logger?.error(\n { storyId: capture.story.id, viewport: capture.viewportName, err: error },\n \"capture failed for story\",\n );\n await emitAttemptLog(ctx.recordLog, ctx.logger, \"error\", \"capture failed for story\", {\n storyId: capture.story.id,\n viewport: capture.viewportName,\n });\n }\n });\n }\n // Also log flaky and a11y render failures as warnings\n for (const id of flakyIds) {\n if (!failedStoryIds.has(id)) {\n ctx.logger?.warn({ storyId: id }, \"flaky story failed (non-blocking)\");\n await emitAttemptLog(ctx.recordLog, ctx.logger, \"warn\", \"flaky story failed (non-blocking)\", {\n storyId: id,\n });\n }\n }\n for (const id of a11yIds) {\n if (!failedStoryIds.has(id) && !flakyIds.has(id)) {\n ctx.logger?.warn({ storyId: id }, \"a11y violations found (non-blocking)\");\n await emitAttemptLog(\n ctx.recordLog,\n ctx.logger,\n \"warn\",\n \"a11y violations found (non-blocking)\",\n {\n storyId: id,\n },\n );\n }\n }\n return { failed: failedStoryIds, flaky: flakyIds, a11y: a11yIds };\n}\n\n/** Engines of this run, primary (first) first. */\nfunction runBrowsers(ctx: CaptureContext): string[] {\n const listed = ctx.browsers && ctx.browsers.length > 0 ? ctx.browsers : [DEFAULT_BROWSER];\n return [...new Set(listed)];\n}\n\nfunction primaryBrowser(ctx: CaptureContext): string {\n return runBrowsers(ctx)[0] ?? DEFAULT_BROWSER;\n}\n\n/** The capture's axe violation texts as the JSON stored on the snapshot; null when there are none. */\nfunction a11yJsonOf(capture: { a11y?: string[] }): string | null {\n return capture.a11y && capture.a11y.length > 0 ? JSON.stringify(capture.a11y) : null;\n}\n\nfunction browserOf(capture: { browser?: string }): string {\n return capture.browser ?? DEFAULT_BROWSER;\n}\n\n/** Captures split into the primary engine's, then everyone else's. */\nfunction capturesByPhase(ctx: CaptureContext): RenderedSnapshot[][] {\n const primary = primaryBrowser(ctx);\n const first = ctx.captures.filter((capture) => browserOf(capture) === primary);\n const rest = ctx.captures.filter((capture) => browserOf(capture) !== primary);\n return rest.length > 0 ? [first, rest] : [first];\n}\n\n/** Secondary engines with no baseline yet (their first snapshots bootstrap from the primary). */\nexport async function freshBrowsersOf(ctx: CaptureContext): Promise<Set<string>> {\n const primary = primaryBrowser(ctx);\n const baselines = new BaselineModel(ctx.db, ctx.tables, ctx.storage, ctx.secret);\n const secondary = runBrowsers(ctx).filter((browser) => browser !== primary);\n const checks = await Promise.all(\n secondary.map(async (browser) => ({\n browser,\n fresh: !(await baselines.hasAny(ctx.project.id, browser)),\n })),\n );\n return new Set(checks.filter((check) => check.fresh).map((check) => check.browser));\n}\n\n/** Mark secondary engines with no baseline yet: their first snapshots bootstrap. */\nasync function withFreshBrowsers(ctx: CaptureContext): Promise<CaptureContext> {\n return ctx.freshBrowsers ? ctx : { ...ctx, freshBrowsers: await freshBrowsersOf(ctx) };\n}\n\n/**\n * A newly enabled engine accepts a story/mode's first snapshot when the\n * primary engine's snapshot of it is clean (Chromatic's \"auto-accepted where\n * the primary browser was accepted\"); anything else goes to review as `new`.\n */\nasync function bootstrapAccepts(ctx: CaptureContext, capture: RenderedSnapshot): Promise<boolean> {\n const browser = browserOf(capture);\n if (!ctx.freshBrowsers?.has(browser)) return false;\n const primary = primaryBrowser(ctx);\n const siblings = await new SnapshotModel(ctx.db, ctx.tables).listByBuild(ctx.build.id);\n return siblings.some(\n (snapshot) =>\n snapshot.storyId === capture.story.id &&\n snapshot.viewportName === capture.viewportName &&\n (snapshot.browser ?? DEFAULT_BROWSER) === primary &&\n (snapshot.status === \"approved\" || snapshot.status === \"unchanged\"),\n );\n}\n\n/** Persist one inherited snapshot; returns the story id on failure. */\nasync function tryPersistInherited(\n ctx: CaptureContext,\n inherited: InheritedStory,\n): Promise<string | null> {\n try {\n await persistInheritedSnapshot(ctx, inherited);\n return null;\n } catch (error) {\n ctx.logger?.error(\n { storyId: inherited.story.id, viewport: inherited.viewport.name, err: error },\n \"inherit failed for story\",\n );\n await emitAttemptLog(ctx.recordLog, ctx.logger, \"error\", \"inherit failed for story\", {\n storyId: inherited.story.id,\n viewport: inherited.viewport.name,\n });\n return inherited.story.id;\n }\n}\n\n/** Persist inherited snapshots; a failure marks the story failed. */\n/** Persist the inherited snapshots (stories reusing their baseline); returns the story ids that failed. */\nexport async function persistInherited(ctx: CaptureContext): Promise<ReadonlySet<string>> {\n const outcomes = await mapLimit(ctx.inherited ?? [], PERSIST_CONCURRENCY, async (inherited) => {\n const failed = await tryPersistInherited(ctx, inherited);\n ctx.progress?.();\n return failed;\n });\n return new Set(outcomes.filter((id): id is string => id !== null));\n}\n\n/** Create an unchanged snapshot reusing the baseline screenshot. */\nasync function persistInheritedSnapshot(\n ctx: CaptureContext,\n inherited: InheritedStory,\n): Promise<void> {\n const browser = browserOf(inherited);\n // Nothing rendered, so there is no new fingerprint: the snapshot carries the baseline's.\n const infraHash = inherited.baseline.infraHash;\n const snapshots = new SnapshotModel(ctx.db, ctx.tables);\n const comparison = await freezeComparison(\n ctx.storage,\n ctx.project.id,\n ctx.build.id,\n inherited.baseline,\n {\n defaultBranch: ctx.project.gitDefaultBranch,\n mode: modeJsonOf(inherited.viewport),\n target: browser,\n width: inherited.viewport.width,\n height: inherited.viewport.height,\n captureFingerprint: inherited.baseline.fingerprint ?? null,\n sourceSnapshotId: await sourceSnapshotId(ctx.db, inherited.baseline),\n },\n );\n const snapshot = await snapshots.create(ctx.project.id, ctx.build.id, {\n ...comparison,\n browser,\n storyId: inherited.story.id,\n storyName: inherited.story.name,\n storyTitle: inherited.story.title,\n storyImportPath: inherited.story.importPath ?? \"\",\n designUrl: inherited.story.designUrl ?? null,\n viewportName: inherited.viewport.name,\n viewportWidth: inherited.viewport.width,\n viewportHeight: inherited.viewport.height,\n screenshotPath: inherited.baseline.screenshotPath,\n infraHash,\n mode: modeJsonOf(inherited.viewport),\n inherited: true,\n sourceBuildId: inherited.baseline.sourceBuildId ?? null,\n baselineId: inherited.baseline.id,\n baselineVersion: inherited.baseline.updatedAt,\n });\n await snapshots.update(snapshot.id, {\n status: \"unchanged\",\n diffPixels: 0,\n diffRatio: 0,\n diffPassed: true,\n diffPath: null,\n infraHash,\n });\n}\n\n/** Every story covered by a capture run, rendered or inherited. */\nfunction allStoryIds(ctx: CaptureContext): ReadonlySet<string> {\n const ids = new Set(ctx.captures.map((capture) => capture.story.id));\n for (const inherited of ctx.inherited ?? []) {\n ids.add(inherited.story.id);\n }\n return ids;\n}\n\n/** Table handles required by the capture pipeline. */\nexport type PipelineTables = BuildTables & BaselineTables & SnapshotTables;\n\n/** Persistence inputs for a completed capture run. */\nexport interface CaptureContext {\n /** Database adapter. */\n db: DatabaseAdapter;\n /** Table handles. */\n tables: PipelineTables;\n /** Storage adapter. */\n storage: StorageAdapter;\n /** The project being captured. */\n project: Project;\n /** The build being captured. */\n build: Build;\n /** Viewports at which stories were captured. */\n viewports: Viewport[];\n /** Engines captured this run, primary first (default: chromium only). */\n browsers?: string[];\n /** Secondary engines with no baselines yet; worked out by `persistCapture`, or fixed up front by a shard plan. */\n freshBrowsers?: ReadonlySet<string>;\n /** Screenshot buffers produced by the capture renderer. */\n captures: RenderedSnapshot[];\n /** Stories inheriting their baseline without rendering (affected capture). */\n inherited?: InheritedStory[];\n /** Optional logger for capture diagnostics. */\n logger?: Logger;\n /** Optional per-attempt log recorder (mirrors logger lines into the DB). */\n recordLog?: AttemptLogRecorder;\n /** Called per snapshot persisted, so the job's lease sees a long diff phase making progress. */\n progress?: CaptureProgress | undefined;\n /** Server secret for decrypting webhook secrets at send time. */\n secret?: SecretInput;\n}\n\n/** JSON for the snapshot `mode` column: the globals a mode applied (null for plain viewports). */\nfunction modeJsonOf(viewport: Viewport | undefined): string | null {\n return viewport?.globals ? JSON.stringify(viewport.globals) : null;\n}\n\nfunction viewportByName(ctx: CaptureContext, name: string): Viewport {\n return ctx.viewports.find((v) => v.name === name) ?? { name, width: 0, height: 0 };\n}\n\n/** Where the capture's screenshot lives: already stored by the orchestrator, or stored now. */\nasync function screenshotLocation(ctx: CaptureContext, capture: RenderedSnapshot): Promise<string> {\n if (capture.storedPath) {\n return capture.storedPath;\n }\n assertScreenshotBuffer(capture.screenshot);\n return await storeScreenshot(ctx.storage, ctx.project.id, capture.screenshot);\n}\n\nasync function persistSnapshot(ctx: CaptureContext, capture: RenderedSnapshot): Promise<void> {\n const viewport = capture.viewport ?? viewportByName(ctx, capture.viewportName);\n const browser = browserOf(capture);\n const screenshot = await screenshotLocation(ctx, capture);\n\n const baseline = await resolveBaseline(ctx, capture.story.id, capture.viewportName, browser);\n if (!baseline) {\n await createWithoutBaseline(ctx, capture, viewport, screenshot);\n return;\n }\n await createWithBaseline(ctx, capture, viewport, screenshot, baseline);\n}\n\nasync function resolveBaseline(\n ctx: CaptureContext,\n storyId: string,\n viewport: string,\n browser: string,\n): Promise<Baseline | null> {\n const baselines = new BaselineModel(ctx.db, ctx.tables, ctx.storage, ctx.secret);\n return await baselines.resolve(\n ctx.project.id,\n storyId,\n viewport,\n ctx.build.gitBranch,\n ctx.project.gitDefaultBranch,\n browser,\n );\n}\n\nasync function createWithoutBaseline(\n ctx: CaptureContext,\n capture: RenderedSnapshot,\n viewport: Viewport,\n screenshot: string,\n): Promise<void> {\n const snapshots = new SnapshotModel(ctx.db, ctx.tables);\n const browser = browserOf(capture);\n const accepted = ctx.build.isDefault || (await bootstrapAccepts(ctx, capture));\n const status = accepted ? \"approved\" : \"new\";\n const now = fingerprintOf(capture, viewport);\n const snapshot = await snapshots.create(ctx.project.id, ctx.build.id, {\n browser,\n durationMs: capture.durationMs ?? null,\n a11yViolations: a11yJsonOf(capture),\n storyId: capture.story.id,\n storyName: capture.story.name,\n storyTitle: capture.story.title,\n storyImportPath: capture.story.importPath ?? \"\",\n designUrl: capture.story.designUrl ?? null,\n viewportName: capture.viewportName,\n viewportWidth: viewport.width,\n viewportHeight: viewport.height,\n screenshotPath: screenshot,\n infraHash: now.infraHash,\n mode: modeJsonOf(viewport),\n baselineVersion: NO_BASELINE,\n });\n await snapshots.setStatus(snapshot.id, status);\n\n if (accepted) {\n const baselines = new BaselineModel(ctx.db, ctx.tables, ctx.storage, ctx.secret);\n await baselines.upsert(\n ctx.project.id,\n capture.story.id,\n capture.viewportName,\n ctx.build.gitBranch,\n snapshot.id,\n screenshot,\n now.infraHash,\n { mode: modeJsonOf(viewport), browser, fingerprint: now.json },\n );\n }\n}\n\n/** A capture's fingerprint: its components, their hash (stored as `infraHash`), and the stored JSON. */\ninterface CaptureFingerprint {\n fingerprint: Fingerprint;\n infraHash: string;\n json: string;\n}\n\nfunction fingerprintOf(capture: RenderedSnapshot, viewport: Viewport): CaptureFingerprint {\n return captureFingerprintOf(buildFingerprint(browserOf(capture), viewport, capture.environment));\n}\n\n/** A fingerprint as stored: its hash (the `infraHash`) and JSON. */\nfunction captureFingerprintOf(fingerprint: Fingerprint): CaptureFingerprint {\n return {\n fingerprint,\n infraHash: fingerprintHash(fingerprint),\n json: JSON.stringify(fingerprint),\n };\n}\n\n/**\n * What changed in the capture environment since the baseline was recorded, or null when nothing\n * did. Compared component by component, only where both sides report a value: a capture with no\n * environment (spooled before a browser crash) is unknown, not a change. A baseline from before\n * fingerprints cannot be compared, so it is never reported as changed; it is backfilled instead.\n */\nfunction environmentChange(baseline: Baseline, now: CaptureFingerprint): EnvironmentChange | null {\n const from = parseFingerprint(baseline.fingerprint);\n if (!from) {\n return null;\n }\n const changed = changedFields(from, now.fingerprint);\n const unknown = unknownFields(from, now.fingerprint);\n if (changed.length === 0 && unknown.length === 0) {\n return null;\n }\n return {\n changed,\n ...(unknown.length > 0 ? { unknown } : {}),\n from,\n to: now.fingerprint,\n };\n}\n\n/**\n * A changed environment migrates the baseline without a diff only on the default branch, which is\n * authoritative and auto-approves its own changes anyway. A feature branch still diffs: a real\n * change is reported as `changed` with the environment change attached, never auto-accepted.\n */\nasync function createWithBaseline(\n ctx: CaptureContext,\n capture: RenderedSnapshot,\n viewport: Viewport,\n screenshot: string,\n baseline: Baseline,\n): Promise<void> {\n const now = fingerprintOf(capture, viewport);\n const change = environmentChange(baseline, now);\n const comparison = await freezeComparison(ctx.storage, ctx.project.id, ctx.build.id, baseline, {\n defaultBranch: ctx.project.gitDefaultBranch,\n mode: modeJsonOf(viewport),\n target: browserOf(capture),\n width: viewport.width,\n height: viewport.height,\n captureFingerprint: now.json,\n sourceSnapshotId: await sourceSnapshotId(ctx.db, baseline),\n });\n const input = { ctx, capture, viewport, screenshot, baseline, now, change, comparison };\n // Components measured differently across versions are only recorded as unknown, never migrated.\n if (change && change.changed.length > 0 && ctx.build.isDefault) {\n await persistMigration(input, change);\n } else {\n await persistDiff(input);\n }\n}\n\ninterface BaselineCapture {\n ctx: CaptureContext;\n capture: RenderedSnapshot;\n viewport: Viewport;\n screenshot: string;\n baseline: Baseline;\n now: CaptureFingerprint;\n change: EnvironmentChange | null;\n comparison: { comparisonImagePath: string; comparisonMetadata: string };\n}\n\n/** The fingerprint to write on the baseline: the capture's, keeping components it did not report. */\nfunction baselineFingerprintOf(input: BaselineCapture): CaptureFingerprint {\n const earlier = parseFingerprint(input.baseline.fingerprint);\n return captureFingerprintOf(mergeFingerprint(earlier, input.now.fingerprint));\n}\n\nfunction snapshotInput(input: BaselineCapture): Parameters<SnapshotModel[\"create\"]>[2] {\n const { capture, viewport, screenshot, baseline, now } = input;\n return {\n browser: browserOf(capture),\n durationMs: capture.durationMs ?? null,\n a11yViolations: a11yJsonOf(capture),\n storyId: capture.story.id,\n storyName: capture.story.name,\n storyTitle: capture.story.title,\n storyImportPath: capture.story.importPath ?? \"\",\n designUrl: capture.story.designUrl ?? null,\n viewportName: capture.viewportName,\n viewportWidth: viewport.width,\n viewportHeight: viewport.height,\n screenshotPath: screenshot,\n infraHash: now.infraHash,\n mode: modeJsonOf(viewport),\n baselineId: baseline.id,\n baselineVersion: baseline.updatedAt,\n ...input.comparison,\n };\n}\n\n/**\n * The default branch's capture environment changed (browser build, fonts, platform, scale): pixels\n * would differ with no code change, so no diff is computed. The snapshot is accepted, records what\n * changed, and the new screenshot becomes the baseline.\n */\nasync function persistMigration(input: BaselineCapture, change: EnvironmentChange): Promise<void> {\n const { ctx, capture, viewport, screenshot, now } = input;\n const snapshots = new SnapshotModel(ctx.db, ctx.tables);\n const snapshot = await snapshots.create(ctx.project.id, ctx.build.id, {\n ...snapshotInput(input),\n envChange: JSON.stringify(change),\n });\n await snapshots.update(snapshot.id, {\n status: \"unchanged\",\n diffPath: null,\n diffPixels: 0,\n diffRatio: 0,\n diffPassed: true,\n infraHash: now.infraHash,\n });\n await upsertDefaultBaseline(ctx, snapshot.id, capture, screenshot, baselineFingerprintOf(input));\n ctx.logger?.debug(\n { storyId: capture.story.id, viewport: viewport.name, changed: change.changed },\n \"capture environment changed; accepted without a diff\",\n );\n}\n\n/** Diff against the baseline; a feature-branch environment change rides along as `envChange`. */\nasync function persistDiff(input: BaselineCapture): Promise<void> {\n const { ctx, capture, screenshot, baseline, now, change } = input;\n const result = await diffStoredScreenshots(\n ctx.storage,\n ctx.project,\n baseline.screenshotPath === screenshot ? screenshot : input.comparison.comparisonImagePath,\n screenshot,\n capture.story.parameters?.diffThreshold,\n );\n const snapshots = new SnapshotModel(ctx.db, ctx.tables);\n const snapshot = await snapshots.create(ctx.project.id, ctx.build.id, {\n ...snapshotInput(input),\n ...(change ? { envChange: JSON.stringify(change) } : {}),\n });\n const diff = diffPath(\n ctx.project.id,\n ctx.build.id,\n capture.story.id,\n capture.viewportName,\n browserOf(capture),\n );\n const overlay = await writeDiffOverlay(ctx.storage, diff, result, result.passed);\n await snapshots.update(snapshot.id, {\n status: diffStatus(result.passed, ctx.build.isDefault),\n diffPath: overlay,\n diffPixels: result.diffPixels,\n diffRatio: result.diffRatio,\n diffPassed: result.passed,\n infraHash: now.infraHash,\n });\n if (ctx.build.isDefault) {\n await refreshDefaultBaseline(input, snapshot.id, result.passed);\n }\n}\n\n/**\n * After a default-branch diff: a failed diff moves the baseline to the new screenshot; a passing\n * one backfills the fingerprint when it was missing or had components the capture now reports.\n */\nasync function refreshDefaultBaseline(\n input: BaselineCapture,\n snapshotId: string,\n passed: boolean,\n): Promise<void> {\n const { ctx, capture, screenshot, baseline } = input;\n const target = baselineFingerprintOf(input);\n if (!passed) {\n await upsertDefaultBaseline(ctx, snapshotId, capture, screenshot, target);\n } else if (!baseline.fingerprint || baseline.infraHash !== target.infraHash) {\n try {\n await new BaselineModel(ctx.db, ctx.tables, ctx.storage, ctx.secret).backfillFingerprint(\n baseline,\n target.infraHash,\n target.json,\n );\n } catch (error) {\n if (!(error instanceof BaselineWriteConflict || error instanceof ReferenceGenerationConflict))\n throw error;\n ctx.logger?.debug(\n { err: error, baselineId: baseline.id },\n \"skipped baseline fingerprint backfill after concurrent write\",\n );\n }\n }\n}\n\n/** Default-branch builds are authoritative: the captured screenshot becomes the baseline. */\nasync function upsertDefaultBaseline(\n ctx: CaptureContext,\n snapshotId: string,\n capture: RenderedSnapshot,\n screenshot: string,\n now: CaptureFingerprint,\n): Promise<void> {\n const baselines = new BaselineModel(ctx.db, ctx.tables, ctx.storage, ctx.secret);\n await baselines.upsert(\n ctx.project.id,\n capture.story.id,\n capture.viewportName,\n ctx.build.gitBranch,\n snapshotId,\n screenshot,\n now.infraHash,\n { mode: modeJsonOf(capture.viewport), browser: browserOf(capture), fingerprint: now.json },\n );\n}\n\n/** Snapshot status after diffing: default-branch changes are auto-approved, not queued for review. */\nexport function diffStatus(passed: boolean, isDefault: boolean): SnapshotStatus {\n if (passed) {\n return \"unchanged\";\n }\n return isDefault ? \"approved\" : \"changed\";\n}\n\n/** A failed capture fails the build; default-branch builds otherwise auto-approve. */\nfunction finalBuildStatus(\n isDefault: boolean,\n failedCount: number,\n hasCaptures: boolean,\n build: { changedCount: number },\n): BuildStatus {\n if (failedCount > 0) {\n return \"failed\";\n }\n if (isDefault || (hasCaptures && build.changedCount === 0)) {\n return \"approved\";\n }\n return \"reviewing\";\n}\n\n/**\n * Close out a capture: roll the snapshot counts up, set the build's final status from the failed\n * story ids, and (on the default branch) drop baselines of stories that no longer exist.\n */\nexport async function finalizeBuild(\n ctx: CaptureContext,\n storyIds: ReadonlySet<string>,\n failedStoryIds: ReadonlySet<string>,\n flakyFailedStoryIds: ReadonlySet<string> = new Set(),\n a11yFailedStoryIds: ReadonlySet<string> = new Set(),\n): Promise<void> {\n const builds = new BuildModel(ctx.db, ctx.tables, ctx.logger);\n if ((await builds.get(ctx.build.id))?.status === \"cancelled\") {\n ctx.logger?.debug({ buildId: ctx.build.id }, \"build is cancelled; finalize skipped\");\n return;\n }\n const build = await builds.updateCounts(ctx.build.id);\n const hasCaptures = storyIds.size > 0;\n const status = finalBuildStatus(ctx.build.isDefault, failedStoryIds.size, hasCaptures, build);\n // Flaky and a11y failures do not block: log warning if any failed non-blocking\n if (flakyFailedStoryIds.size > 0 && failedStoryIds.size === 0) {\n ctx.logger?.warn(\n { flakyStoryIds: [...flakyFailedStoryIds] },\n \"flaky stories failed (non-blocking)\",\n );\n await emitAttemptLog(ctx.recordLog, ctx.logger, \"warn\", \"flaky stories failed (non-blocking)\", {\n flakyStoryIds: [...flakyFailedStoryIds],\n });\n }\n if (a11yFailedStoryIds.size > 0 && failedStoryIds.size === 0) {\n ctx.logger?.warn(\n { a11yStoryIds: [...a11yFailedStoryIds] },\n \"a11y violations found (non-blocking)\",\n );\n await emitAttemptLog(\n ctx.recordLog,\n ctx.logger,\n \"warn\",\n \"a11y violations found (non-blocking)\",\n {\n a11yStoryIds: [...a11yFailedStoryIds],\n },\n );\n }\n await builds.setStatus(ctx.build.id, status);\n\n if (ctx.build.isDefault && hasCaptures) {\n const baselines = new BaselineModel(ctx.db, ctx.tables, ctx.storage, ctx.secret);\n await baselines.removeOrphans(ctx.project.id, new Set(storyIds));\n }\n}\n"],"mappings":"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"}
@@ -5,10 +5,25 @@ import { DesignNode } from "../schema/design.mjs";
5
5
  declare const DESIGN_TTL_MS: number;
6
6
  /** True when the design changed after the baseline it is compared with was approved. */
7
7
  declare function isDesignStale(node: Pick<DesignNode, "state" | "lastModified"> | null | undefined, baselineVersion: string | null | undefined): boolean;
8
+ /** What happened to a design after its baseline was approved. */
9
+ type DesignChangeKind = "modified-after-approval" | "deleted";
10
+ /** A design change to tell people about, with the identity it is de-duplicated on. */
11
+ interface DesignChange {
12
+ change: DesignChangeKind;
13
+ /** `<change>:<version>`: one notification per design version and kind of change. */
14
+ key: string;
15
+ /** The design's version when it changed (a deleted node keeps its last known one). */
16
+ designVersion: string | null;
17
+ }
18
+ /**
19
+ * The change worth a `design:changed` notification, or null. A deleted node always counts; a
20
+ * modified one only when it moved after `baselineVersion` (see {@link isDesignStale}).
21
+ */
22
+ declare function designChange(node: Pick<DesignNode, "state" | "version" | "lastModified"> | null | undefined, baselineVersion: string | null | undefined): DesignChange | null;
8
23
  /**
9
24
  * Days until a credential passes its provider's maximum lifetime (negative once
10
25
  * past). `issuedAt` is when the credential was set or replaced — never a status write.
11
26
  */
12
27
  declare function credentialDaysLeft(issuedAt: string, maxAgeDays: number, now?: Date): number;
13
- export { DESIGN_TTL_MS, credentialDaysLeft, isDesignStale };
28
+ export { DESIGN_TTL_MS, DesignChange, DesignChangeKind, credentialDaysLeft, designChange, isDesignStale };
14
29
  //# sourceMappingURL=design-health.d.mts.map
@@ -12,6 +12,25 @@ function isDesignStale(node, baselineVersion) {
12
12
  return Number.isFinite(modified) && Number.isFinite(approved) && modified > approved;
13
13
  }
14
14
  /**
15
+ * The change worth a `design:changed` notification, or null. A deleted node always counts; a
16
+ * modified one only when it moved after `baselineVersion` (see {@link isDesignStale}).
17
+ */
18
+ function designChange(node, baselineVersion) {
19
+ if (!node) return null;
20
+ const designVersion = node.version ?? node.lastModified;
21
+ if (node.state === "deleted") return {
22
+ change: "deleted",
23
+ key: `deleted:${designVersion ?? ""}`,
24
+ designVersion
25
+ };
26
+ if (!isDesignStale(node, baselineVersion)) return null;
27
+ return {
28
+ change: "modified-after-approval",
29
+ key: `modified-after-approval:${designVersion ?? ""}`,
30
+ designVersion
31
+ };
32
+ }
33
+ /**
15
34
  * Days until a credential passes its provider's maximum lifetime (negative once
16
35
  * past). `issuedAt` is when the credential was set or replaced — never a status write.
17
36
  */
@@ -19,6 +38,6 @@ function credentialDaysLeft(issuedAt, maxAgeDays, now = /* @__PURE__ */ new Date
19
38
  const ageDays = (now.getTime() - Date.parse(issuedAt)) / 864e5;
20
39
  return Math.ceil(maxAgeDays - ageDays);
21
40
  }
22
- export { DESIGN_TTL_MS, credentialDaysLeft, isDesignStale };
41
+ export { DESIGN_TTL_MS, credentialDaysLeft, designChange, isDesignStale };
23
42
 
24
43
  //# sourceMappingURL=design-health.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"design-health.mjs","names":[],"sources":["../../src/models/design-health.ts"],"sourcesContent":["/** Design cache freshness, staleness and credential-age rules (provider-neutral). */\nimport type { DesignNode } from \"../schema/design.ts\";\n\n/** Cached metadata younger than this is served without a refetch. */\nexport const DESIGN_TTL_MS = 15 * 60_000;\n\n/** True when the design changed after the baseline it is compared with was approved. */\nexport function isDesignStale(\n node: Pick<DesignNode, \"state\" | \"lastModified\"> | null | undefined,\n baselineVersion: string | null | undefined,\n): boolean {\n if (node?.state !== \"ok\" || !node.lastModified || !baselineVersion) return false;\n const modified = Date.parse(node.lastModified);\n const approved = Date.parse(baselineVersion);\n return Number.isFinite(modified) && Number.isFinite(approved) && modified > approved;\n}\n\n/**\n * Days until a credential passes its provider's maximum lifetime (negative once\n * past). `issuedAt` is when the credential was set or replaced — never a status write.\n */\nexport function credentialDaysLeft(\n issuedAt: string,\n maxAgeDays: number,\n now: Date = new Date(),\n): number {\n const ageDays = (now.getTime() - Date.parse(issuedAt)) / 86_400_000;\n return Math.ceil(maxAgeDays - ageDays);\n}\n"],"mappings":";;;;;AAIA,MAAa,gBAAgB;;AAG7B,SAAgB,cACd,MACA,iBACS;CACT,IAAI,MAAM,UAAU,QAAQ,CAAC,KAAK,gBAAgB,CAAC,iBAAiB,OAAO;CAC3E,MAAM,WAAW,KAAK,MAAM,KAAK,YAAY;CAC7C,MAAM,WAAW,KAAK,MAAM,eAAe;CAC3C,OAAO,OAAO,SAAS,QAAQ,KAAK,OAAO,SAAS,QAAQ,KAAK,WAAW;AAC9E;;;;;AAMA,SAAgB,mBACd,UACA,YACA,sBAAY,IAAI,KAAK,GACb;CACR,MAAM,WAAW,IAAI,QAAQ,IAAI,KAAK,MAAM,QAAQ,KAAK;CACzD,OAAO,KAAK,KAAK,aAAa,OAAO;AACvC"}
1
+ {"version":3,"file":"design-health.mjs","names":[],"sources":["../../src/models/design-health.ts"],"sourcesContent":["/** Design cache freshness, staleness and credential-age rules (provider-neutral). */\nimport type { DesignNode } from \"../schema/design.ts\";\n\n/** Cached metadata younger than this is served without a refetch. */\nexport const DESIGN_TTL_MS = 15 * 60_000;\n\n/** True when the design changed after the baseline it is compared with was approved. */\nexport function isDesignStale(\n node: Pick<DesignNode, \"state\" | \"lastModified\"> | null | undefined,\n baselineVersion: string | null | undefined,\n): boolean {\n if (node?.state !== \"ok\" || !node.lastModified || !baselineVersion) return false;\n const modified = Date.parse(node.lastModified);\n const approved = Date.parse(baselineVersion);\n return Number.isFinite(modified) && Number.isFinite(approved) && modified > approved;\n}\n\n/** What happened to a design after its baseline was approved. */\nexport type DesignChangeKind = \"modified-after-approval\" | \"deleted\";\n\n/** A design change to tell people about, with the identity it is de-duplicated on. */\nexport interface DesignChange {\n change: DesignChangeKind;\n /** `<change>:<version>`: one notification per design version and kind of change. */\n key: string;\n /** The design's version when it changed (a deleted node keeps its last known one). */\n designVersion: string | null;\n}\n\n/**\n * The change worth a `design:changed` notification, or null. A deleted node always counts; a\n * modified one only when it moved after `baselineVersion` (see {@link isDesignStale}).\n */\nexport function designChange(\n node: Pick<DesignNode, \"state\" | \"version\" | \"lastModified\"> | null | undefined,\n baselineVersion: string | null | undefined,\n): DesignChange | null {\n if (!node) return null;\n const designVersion = node.version ?? node.lastModified;\n if (node.state === \"deleted\") {\n return { change: \"deleted\", key: `deleted:${designVersion ?? \"\"}`, designVersion };\n }\n if (!isDesignStale(node, baselineVersion)) return null;\n return {\n change: \"modified-after-approval\",\n key: `modified-after-approval:${designVersion ?? \"\"}`,\n designVersion,\n };\n}\n\n/**\n * Days until a credential passes its provider's maximum lifetime (negative once\n * past). `issuedAt` is when the credential was set or replaced — never a status write.\n */\nexport function credentialDaysLeft(\n issuedAt: string,\n maxAgeDays: number,\n now: Date = new Date(),\n): number {\n const ageDays = (now.getTime() - Date.parse(issuedAt)) / 86_400_000;\n return Math.ceil(maxAgeDays - ageDays);\n}\n"],"mappings":";;;;;AAIA,MAAa,gBAAgB;;AAG7B,SAAgB,cACd,MACA,iBACS;CACT,IAAI,MAAM,UAAU,QAAQ,CAAC,KAAK,gBAAgB,CAAC,iBAAiB,OAAO;CAC3E,MAAM,WAAW,KAAK,MAAM,KAAK,YAAY;CAC7C,MAAM,WAAW,KAAK,MAAM,eAAe;CAC3C,OAAO,OAAO,SAAS,QAAQ,KAAK,OAAO,SAAS,QAAQ,KAAK,WAAW;AAC9E;;;;;AAkBA,SAAgB,aACd,MACA,iBACqB;CACrB,IAAI,CAAC,MAAM,OAAO;CAClB,MAAM,gBAAgB,KAAK,WAAW,KAAK;CAC3C,IAAI,KAAK,UAAU,WACjB,OAAO;EAAE,QAAQ;EAAW,KAAK,WAAW,iBAAiB;EAAM;CAAc;CAEnF,IAAI,CAAC,cAAc,MAAM,eAAe,GAAG,OAAO;CAClD,OAAO;EACL,QAAQ;EACR,KAAK,2BAA2B,iBAAiB;EACjD;CACF;AACF;;;;;AAMA,SAAgB,mBACd,UACA,YACA,sBAAY,IAAI,KAAK,GACb;CACR,MAAM,WAAW,IAAI,QAAQ,IAAI,KAAK,MAAM,QAAQ,KAAK;CACzD,OAAO,KAAK,KAAK,aAAa,OAAO;AACvC"}
@@ -7,6 +7,7 @@ import { StorageAdapter } from "../adapter/storage.mjs";
7
7
  import { DesignNode } from "../schema/design.mjs";
8
8
  import { ConnectionModel } from "./connections.mjs";
9
9
  import { DesignCredentialModel } from "./design-credentials.mjs";
10
+ import { DesignChange } from "./design-health.mjs";
10
11
  import { Table } from "drizzle-orm";
11
12
  import { Buffer } from "node:buffer";
12
13
  /** Tables required by {@link DesignNodeModel}. */
@@ -46,6 +47,14 @@ declare class DesignNodeModel {
46
47
  * absent). A row fetched with someone's own connection is only visible to that viewer.
47
48
  */
48
49
  cachedFor(projectId: string, urls: readonly string[], viewerId?: string): Promise<Map<string, DesignNode>>;
50
+ /**
51
+ * Claim the right to announce `change` for a node: true for exactly one caller per
52
+ * `change.key`, even when health checks run concurrently. The claim is a compare-and-set of
53
+ * `notified_version` from the value read here to a fresh `<key>#<token>`; a caller that finds
54
+ * the key already claimed, or loses the swap, gets false. Shared (project-credential) rows only:
55
+ * a member's own-connection row never announces, so one design is never announced twice.
56
+ */
57
+ claimChange(node: DesignNode, change: DesignChange): Promise<boolean>;
49
58
  /** Refresh one link (see {@link refreshMany}); null for unsupported URLs or nothing cached. */
50
59
  refresh(projectId: string, url: string, now?: Date, viewerId?: string): Promise<DesignNode | null>;
51
60
  /**
@@ -8,7 +8,7 @@ import { mapLimit } from "../utils/map-limit.mjs";
8
8
  import "./design-credentials.mjs";
9
9
  import "./design-health.mjs";
10
10
  import { DesignRenderStore } from "./design-renders.mjs";
11
- import { and, eq } from "drizzle-orm";
11
+ import { and, eq, isNull } from "drizzle-orm";
12
12
  // src/models/design-nodes.ts
13
13
  /** Design metadata + render cache: TTL refresh, batched per file, bounded concurrency, abortable. */
14
14
  /** Most files refreshed in parallel by one {@link DesignNodeModel.refreshMany} call. */
@@ -51,6 +51,21 @@ var DesignNodeModel = class {
51
51
  }));
52
52
  return new Map(rows.flatMap(([url, row]) => row ? [[url, row]] : []));
53
53
  }
54
+ /**
55
+ * Claim the right to announce `change` for a node: true for exactly one caller per
56
+ * `change.key`, even when health checks run concurrently. The claim is a compare-and-set of
57
+ * `notified_version` from the value read here to a fresh `<key>#<token>`; a caller that finds
58
+ * the key already claimed, or loses the swap, gets false. Shared (project-credential) rows only:
59
+ * a member's own-connection row never announces, so one design is never announced twice.
60
+ */
61
+ async claimChange(node, change) {
62
+ if (node.viewerId !== "") return false;
63
+ const column = col(this.tables.designNodes, "notifiedVersion");
64
+ const current = await this.db.get(this.tables.designNodes, node.id);
65
+ if (!current || current.notifiedVersion?.startsWith(`${change.key}#`) === true) return false;
66
+ const token = `${change.key}#${ulid()}`;
67
+ return (await this.db.updateWhere(this.tables.designNodes, node.id, { notifiedVersion: token }, current.notifiedVersion === null ? isNull(column) : eq(column, current.notifiedVersion)))?.notifiedVersion === token;
68
+ }
54
69
  /** Refresh one link (see {@link refreshMany}); null for unsupported URLs or nothing cached. */
55
70
  async refresh(projectId, url, now, viewerId) {
56
71
  return (await this.refreshMany(projectId, [url], {
@@ -181,6 +196,7 @@ var DesignNodeModel = class {
181
196
  nodeId: ref.nodeId ?? "",
182
197
  renderPath: null,
183
198
  renderVersion: null,
199
+ notifiedVersion: null,
184
200
  createdAt: stamp,
185
201
  ...values
186
202
  });
@@ -1 +1 @@
1
- {"version":3,"file":"design-nodes.mjs","names":[],"sources":["../../src/models/design-nodes.ts"],"sourcesContent":["/** Design metadata + render cache: TTL refresh, batched per file, bounded concurrency, abortable. */\nimport { and, eq } from \"drizzle-orm\";\nimport type { Table } from \"drizzle-orm\";\nimport type { Buffer } from \"node:buffer\";\nimport type { DatabaseAdapter } from \"../adapters/database.ts\";\nimport type {\n DesignCallOptions,\n DesignClient,\n DesignMeta,\n DesignProvider,\n DesignRef,\n} from \"../adapters/design.ts\";\nimport { parseDesignUrl } from \"../adapters/design.ts\";\nimport type { StorageAdapter } from \"../adapters/storage.ts\";\nimport type { Logger } from \"../logger.ts\";\nimport type { DesignNode } from \"../schema/design.ts\";\nimport { mapLimit } from \"../utils/map-limit.ts\";\nimport { ulid } from \"../utils/ulid.ts\";\nimport { col } from \"./columns.ts\";\nimport type { ConnectionModel } from \"./connections.ts\";\nimport { PROJECT_SCOPE, type DesignCredentialModel } from \"./design-credentials.ts\";\nimport { DESIGN_TTL_MS } from \"./design-health.ts\";\nimport { DesignRenderStore } from \"./design-renders.ts\";\nimport type { DesignCredentialStatus } from \"./design-resolve.ts\";\n\n/** Most files refreshed in parallel by one {@link DesignNodeModel.refreshMany} call. */\nconst FILE_CONCURRENCY = 3;\n\n/** Tables required by {@link DesignNodeModel}. */\nexport interface DesignNodeTables {\n designNodes: Table;\n}\n\n/** Options of {@link DesignNodeModel.refreshMany}. */\nexport interface RefreshOptions {\n now?: Date;\n /** Cancels in-flight provider calls (e.g. a page-render budget). */\n signal?: AbortSignal;\n /** The visiting user: their own connection is used when the project has no credential. */\n viewerId?: string;\n}\n\ninterface Due {\n url: string;\n provider: DesignProvider;\n ref: DesignRef;\n cached: DesignNode | null;\n /** Whose credential fetches this link: {@link PROJECT_SCOPE} or the viewer's user id. */\n scope: string;\n}\n\n/** Cache of design file/node metadata and node renders with TTL refresh. */\nexport class DesignNodeModel {\n private readonly tables: DesignNodeTables;\n private readonly renders: DesignRenderStore;\n private readonly logger: Logger | undefined;\n private readonly connections: ConnectionModel | undefined;\n private readonly db: DatabaseAdapter;\n private readonly credentials: DesignCredentialModel;\n private readonly providers: readonly DesignProvider[];\n\n constructor(\n db: DatabaseAdapter,\n credentials: DesignCredentialModel,\n providers: readonly DesignProvider[],\n options: {\n tables?: DesignNodeTables;\n logger?: Logger;\n storage?: StorageAdapter;\n /** Members' own connections, used when the project has no credential. */\n connections?: ConnectionModel;\n } = {},\n ) {\n this.db = db;\n this.credentials = credentials;\n this.providers = providers;\n this.tables = options.tables ?? { designNodes: db.tables.designNodes };\n this.renders = new DesignRenderStore(\n db,\n this.tables.designNodes,\n options.storage,\n options.logger,\n );\n this.logger = options.logger;\n this.connections = options.connections;\n }\n\n /** Cached row by id, scoped to the project, or null. */\n async get(projectId: string, id: string): Promise<DesignNode | null> {\n const row = (await this.db.get(this.tables.designNodes, id)) as unknown as DesignNode | null;\n return row?.projectId === projectId ? row : null;\n }\n\n /** Cached render bytes of a node, or null when it has none (or storage is not wired). */\n async readRender(node: DesignNode): Promise<Buffer | null> {\n return await this.renders.read(node);\n }\n\n /**\n * Cached rows for the given design URLs, keyed by URL (unparseable or uncached URLs are\n * absent). A row fetched with someone's own connection is only visible to that viewer.\n */\n async cachedFor(\n projectId: string,\n urls: readonly string[],\n viewerId?: string,\n ): Promise<Map<string, DesignNode>> {\n const rows = await Promise.all(\n [...new Set(urls)].map(async (url) => {\n const parsed = parseDesignUrl(this.providers, url);\n const row = parsed\n ? await this.visible(projectId, parsed.provider.metadata.kind, parsed.ref, viewerId)\n : null;\n return [url, row] as const;\n }),\n );\n return new Map(rows.flatMap(([url, row]) => (row ? [[url, row] as const] : [])));\n }\n\n /** Refresh one link (see {@link refreshMany}); null for unsupported URLs or nothing cached. */\n async refresh(\n projectId: string,\n url: string,\n now?: Date,\n viewerId?: string,\n ): Promise<DesignNode | null> {\n const results = await this.refreshMany(projectId, [url], {\n ...(now ? { now } : {}),\n ...(viewerId ? { viewerId } : {}),\n });\n return results.get(url) ?? null;\n }\n\n /**\n * Refresh the given links' metadata and renders when missing or past the TTL.\n * Links of one file share one metadata call and one render call, and files run\n * with bounded concurrency. Transient failures keep the previous cache; auth\n * failures update the credential's health. Never throws.\n */\n async refreshMany(\n projectId: string,\n urls: readonly string[],\n options: RefreshOptions = {},\n ): Promise<Map<string, DesignNode | null>> {\n const now = options.now ?? new Date();\n const scopes = new Map<string, Promise<string>>();\n const lookups = await Promise.all(\n [...new Set(urls)].map(async (url) => {\n const parsed = parseDesignUrl(this.providers, url);\n if (!parsed) return null;\n const kind = parsed.provider.metadata.kind;\n const scope = await memo(scopes, kind, async () => {\n return await this.scopeFor(projectId, kind, options.viewerId);\n });\n const cached = await this.lookup(projectId, kind, parsed.ref, scope);\n return { url, provider: parsed.provider, ref: parsed.ref, cached, scope };\n }),\n );\n const known = lookups.filter((item) => item !== null);\n const results = new Map<string, DesignNode | null>(\n known.map((item) => [item.url, item.cached]),\n );\n const due = known.filter((item) => isDue(item.cached, now));\n if (due.length === 0) return results;\n const fresh = await this.refreshDue(projectId, due, now, callOptions(this.logger, options));\n for (const [url, node] of fresh) results.set(url, node);\n return results;\n }\n\n /** Refresh due links grouped by provider and file, files in parallel (bounded). */\n private async refreshDue(\n projectId: string,\n due: Due[],\n now: Date,\n call: DesignCallOptions,\n ): Promise<Map<string, DesignNode>> {\n const byFile = [\n ...Map.groupBy(due, (item) => `${item.provider.metadata.kind}\\u0000${item.ref.fileKey}`),\n ];\n const batches = await mapLimit(byFile, FILE_CONCURRENCY, async ([, items]) => {\n return await this.refreshFile(projectId, items, now, call);\n });\n const merged = new Map<string, DesignNode>();\n for (const batch of batches) {\n for (const [url, node] of batch) merged.set(url, node);\n }\n return merged;\n }\n\n private async refreshFile(\n projectId: string,\n items: Due[],\n now: Date,\n call: DesignCallOptions,\n ): Promise<Map<string, DesignNode>> {\n const client = await this.clientFor(projectId, items);\n const first = items[0];\n if (!client || !first) return new Map();\n const { provider, ref } = first;\n const metas = await client.fetchMetas(\n ref.fileKey,\n items.map((item) => item.ref),\n call,\n );\n const answered = [...metas.values()].find((meta) => meta.state !== \"error\");\n if (answered && first.scope === PROJECT_SCOPE) {\n await this.credentials.recordStatus(\n projectId,\n provider.metadata.kind,\n statusFor(answered.state),\n );\n }\n const stored = await this.storeAll(projectId, items, metas, now);\n return await this.renders.ensure(projectId, client, ref.fileKey, stored, call);\n }\n\n /** Upsert a row per item that got a usable answer (`error` keeps its cached row), keyed by URL. */\n private async storeAll(\n projectId: string,\n items: Due[],\n metas: Map<string, DesignMeta>,\n now: Date,\n ): Promise<Map<string, DesignNode>> {\n const rows = await Promise.all(\n items.map(async (item) => {\n const meta = metas.get(item.ref.nodeId ?? \"\");\n const row =\n meta && meta.state !== \"error\" ? await this.store(projectId, item, meta, now) : null;\n return row ? ([item.url, row] as const) : null;\n }),\n );\n return new Map(rows.filter((entry) => entry !== null));\n }\n\n /** A client for the provider of `items` bound to the items' credential scope, or null without one. */\n private async clientFor(projectId: string, items: Due[]): Promise<DesignClient | null> {\n const first = items[0];\n if (!first) return null;\n const { provider, scope } = first;\n const kind = provider.metadata.kind;\n const own = scope !== PROJECT_SCOPE;\n const credential = own\n ? await this.connections?.accessToken(scope, kind, provider.oauth).catch(() => null)\n : await this.credentials.get(projectId, kind).catch(() => null);\n if (!credential) return null;\n return provider.create({\n credential,\n authType: own ? \"oauth\" : \"token\",\n ...(this.logger ? { logger: this.logger } : {}),\n });\n }\n\n /**\n * Whose credential serves a provider: the project's when set (it is shared and\n * wins), else the viewer's own connection, else the project scope (nothing to fetch with).\n */\n private async scopeFor(projectId: string, provider: string, viewerId?: string): Promise<string> {\n if (!viewerId || !this.connections || (await this.credentials.info(projectId, provider))) {\n return PROJECT_SCOPE;\n }\n return (await this.connections.info(viewerId, provider)) ? viewerId : PROJECT_SCOPE;\n }\n\n /** The shared row, else the viewer's own (a connected member's entries are never served to others). */\n private async visible(\n projectId: string,\n provider: string,\n ref: DesignRef,\n viewerId?: string,\n ): Promise<DesignNode | null> {\n const shared = await this.lookup(projectId, provider, ref, PROJECT_SCOPE);\n if (shared || !viewerId) return shared;\n return await this.lookup(projectId, provider, ref, viewerId);\n }\n\n private async lookup(\n projectId: string,\n provider: string,\n ref: DesignRef,\n scope: string,\n ): Promise<DesignNode | null> {\n const rows = (await this.db.list(this.tables.designNodes, {\n where: and(\n eq(col(this.tables.designNodes, \"projectId\"), projectId),\n eq(col(this.tables.designNodes, \"provider\"), provider),\n eq(col(this.tables.designNodes, \"viewerId\"), scope),\n eq(col(this.tables.designNodes, \"fileKey\"), ref.fileKey),\n eq(col(this.tables.designNodes, \"nodeId\"), ref.nodeId ?? \"\"),\n ),\n limit: 1,\n })) as unknown as DesignNode[];\n return rows[0] ?? null;\n }\n\n /**\n * Upsert the row. Non-ok states keep the last good name/version/modified time\n * (an auth failure must not erase the label); a deleted node also drops its\n * cached render, so a design that no longer exists is never shown as reference.\n */\n private async store(\n projectId: string,\n item: Due,\n meta: DesignMeta,\n now: Date,\n ): Promise<DesignNode> {\n const { ref, cached } = item;\n const stamp = now.toISOString();\n const keep = meta.state !== \"ok\" && cached;\n const values = {\n name: keep ? cached.name : meta.name,\n version: keep ? cached.version : meta.version,\n lastModified: keep ? cached.lastModified : meta.lastModified,\n state: meta.state,\n fetchedAt: stamp,\n updatedAt: stamp,\n };\n if (!cached) {\n return (await this.db.insert(this.tables.designNodes, {\n id: ulid(),\n projectId,\n provider: item.provider.metadata.kind,\n viewerId: item.scope,\n fileKey: ref.fileKey,\n nodeId: ref.nodeId ?? \"\",\n renderPath: null,\n renderVersion: null,\n createdAt: stamp,\n ...values,\n })) as unknown as DesignNode;\n }\n const dropRender = meta.state === \"deleted\" && cached.renderPath !== null;\n if (dropRender) await this.renders.remove(cached);\n return (await this.db.update(this.tables.designNodes, cached.id, {\n ...values,\n ...(dropRender ? { renderPath: null, renderVersion: null } : {}),\n })) as unknown as DesignNode;\n }\n}\n\n/** Never fetched, or cached longer ago than the TTL. */\nfunction isDue(cached: DesignNode | null, now: Date): boolean {\n return !cached || now.getTime() - Date.parse(cached.fetchedAt) >= DESIGN_TTL_MS;\n}\n\nfunction callOptions(logger: Logger | undefined, options: RefreshOptions): DesignCallOptions {\n return {\n ...(logger ? { logger } : {}),\n ...(options.signal ? { signal: options.signal } : {}),\n };\n}\n\nfunction statusFor(state: DesignMeta[\"state\"]): DesignCredentialStatus {\n return state === \"unauthorized\" || state === \"forbidden\" ? state : \"ok\";\n}\n\n/** Run `load` once per key, sharing the in-flight promise between concurrent callers. */\nasync function memo<T>(\n cache: Map<string, Promise<T>>,\n key: string,\n load: () => Promise<T>,\n): Promise<T> {\n const hit = cache.get(key);\n if (hit) return await hit;\n const created = load();\n cache.set(key, created);\n return await created;\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"design-nodes.mjs","names":[],"sources":["../../src/models/design-nodes.ts"],"sourcesContent":["/** Design metadata + render cache: TTL refresh, batched per file, bounded concurrency, abortable. */\nimport { and, eq, isNull } from \"drizzle-orm\";\nimport type { Table } from \"drizzle-orm\";\nimport type { Buffer } from \"node:buffer\";\nimport type { DatabaseAdapter } from \"../adapters/database.ts\";\nimport type {\n DesignCallOptions,\n DesignClient,\n DesignMeta,\n DesignProvider,\n DesignRef,\n} from \"../adapters/design.ts\";\nimport { parseDesignUrl } from \"../adapters/design.ts\";\nimport type { StorageAdapter } from \"../adapters/storage.ts\";\nimport type { Logger } from \"../logger.ts\";\nimport type { DesignNode } from \"../schema/design.ts\";\nimport { mapLimit } from \"../utils/map-limit.ts\";\nimport { ulid } from \"../utils/ulid.ts\";\nimport { col } from \"./columns.ts\";\nimport type { ConnectionModel } from \"./connections.ts\";\nimport { PROJECT_SCOPE, type DesignCredentialModel } from \"./design-credentials.ts\";\nimport { DESIGN_TTL_MS, type DesignChange } from \"./design-health.ts\";\nimport { DesignRenderStore } from \"./design-renders.ts\";\nimport type { DesignCredentialStatus } from \"./design-resolve.ts\";\n\n/** Most files refreshed in parallel by one {@link DesignNodeModel.refreshMany} call. */\nconst FILE_CONCURRENCY = 3;\n\n/** Tables required by {@link DesignNodeModel}. */\nexport interface DesignNodeTables {\n designNodes: Table;\n}\n\n/** Options of {@link DesignNodeModel.refreshMany}. */\nexport interface RefreshOptions {\n now?: Date;\n /** Cancels in-flight provider calls (e.g. a page-render budget). */\n signal?: AbortSignal;\n /** The visiting user: their own connection is used when the project has no credential. */\n viewerId?: string;\n}\n\ninterface Due {\n url: string;\n provider: DesignProvider;\n ref: DesignRef;\n cached: DesignNode | null;\n /** Whose credential fetches this link: {@link PROJECT_SCOPE} or the viewer's user id. */\n scope: string;\n}\n\n/** Cache of design file/node metadata and node renders with TTL refresh. */\nexport class DesignNodeModel {\n private readonly tables: DesignNodeTables;\n private readonly renders: DesignRenderStore;\n private readonly logger: Logger | undefined;\n private readonly connections: ConnectionModel | undefined;\n private readonly db: DatabaseAdapter;\n private readonly credentials: DesignCredentialModel;\n private readonly providers: readonly DesignProvider[];\n\n constructor(\n db: DatabaseAdapter,\n credentials: DesignCredentialModel,\n providers: readonly DesignProvider[],\n options: {\n tables?: DesignNodeTables;\n logger?: Logger;\n storage?: StorageAdapter;\n /** Members' own connections, used when the project has no credential. */\n connections?: ConnectionModel;\n } = {},\n ) {\n this.db = db;\n this.credentials = credentials;\n this.providers = providers;\n this.tables = options.tables ?? { designNodes: db.tables.designNodes };\n this.renders = new DesignRenderStore(\n db,\n this.tables.designNodes,\n options.storage,\n options.logger,\n );\n this.logger = options.logger;\n this.connections = options.connections;\n }\n\n /** Cached row by id, scoped to the project, or null. */\n async get(projectId: string, id: string): Promise<DesignNode | null> {\n const row = (await this.db.get(this.tables.designNodes, id)) as unknown as DesignNode | null;\n return row?.projectId === projectId ? row : null;\n }\n\n /** Cached render bytes of a node, or null when it has none (or storage is not wired). */\n async readRender(node: DesignNode): Promise<Buffer | null> {\n return await this.renders.read(node);\n }\n\n /**\n * Cached rows for the given design URLs, keyed by URL (unparseable or uncached URLs are\n * absent). A row fetched with someone's own connection is only visible to that viewer.\n */\n async cachedFor(\n projectId: string,\n urls: readonly string[],\n viewerId?: string,\n ): Promise<Map<string, DesignNode>> {\n const rows = await Promise.all(\n [...new Set(urls)].map(async (url) => {\n const parsed = parseDesignUrl(this.providers, url);\n const row = parsed\n ? await this.visible(projectId, parsed.provider.metadata.kind, parsed.ref, viewerId)\n : null;\n return [url, row] as const;\n }),\n );\n return new Map(rows.flatMap(([url, row]) => (row ? [[url, row] as const] : [])));\n }\n\n /**\n * Claim the right to announce `change` for a node: true for exactly one caller per\n * `change.key`, even when health checks run concurrently. The claim is a compare-and-set of\n * `notified_version` from the value read here to a fresh `<key>#<token>`; a caller that finds\n * the key already claimed, or loses the swap, gets false. Shared (project-credential) rows only:\n * a member's own-connection row never announces, so one design is never announced twice.\n */\n async claimChange(node: DesignNode, change: DesignChange): Promise<boolean> {\n if (node.viewerId !== PROJECT_SCOPE) return false;\n const column = col(this.tables.designNodes, \"notifiedVersion\");\n const current = (await this.db.get(\n this.tables.designNodes,\n node.id,\n )) as unknown as DesignNode | null;\n if (!current || current.notifiedVersion?.startsWith(`${change.key}#`) === true) return false;\n const token = `${change.key}#${ulid()}`;\n const won = (await this.db.updateWhere(\n this.tables.designNodes,\n node.id,\n { notifiedVersion: token },\n current.notifiedVersion === null ? isNull(column) : eq(column, current.notifiedVersion),\n )) as unknown as DesignNode | null;\n return won?.notifiedVersion === token;\n }\n\n /** Refresh one link (see {@link refreshMany}); null for unsupported URLs or nothing cached. */\n async refresh(\n projectId: string,\n url: string,\n now?: Date,\n viewerId?: string,\n ): Promise<DesignNode | null> {\n const results = await this.refreshMany(projectId, [url], {\n ...(now ? { now } : {}),\n ...(viewerId ? { viewerId } : {}),\n });\n return results.get(url) ?? null;\n }\n\n /**\n * Refresh the given links' metadata and renders when missing or past the TTL.\n * Links of one file share one metadata call and one render call, and files run\n * with bounded concurrency. Transient failures keep the previous cache; auth\n * failures update the credential's health. Never throws.\n */\n async refreshMany(\n projectId: string,\n urls: readonly string[],\n options: RefreshOptions = {},\n ): Promise<Map<string, DesignNode | null>> {\n const now = options.now ?? new Date();\n const scopes = new Map<string, Promise<string>>();\n const lookups = await Promise.all(\n [...new Set(urls)].map(async (url) => {\n const parsed = parseDesignUrl(this.providers, url);\n if (!parsed) return null;\n const kind = parsed.provider.metadata.kind;\n const scope = await memo(scopes, kind, async () => {\n return await this.scopeFor(projectId, kind, options.viewerId);\n });\n const cached = await this.lookup(projectId, kind, parsed.ref, scope);\n return { url, provider: parsed.provider, ref: parsed.ref, cached, scope };\n }),\n );\n const known = lookups.filter((item) => item !== null);\n const results = new Map<string, DesignNode | null>(\n known.map((item) => [item.url, item.cached]),\n );\n const due = known.filter((item) => isDue(item.cached, now));\n if (due.length === 0) return results;\n const fresh = await this.refreshDue(projectId, due, now, callOptions(this.logger, options));\n for (const [url, node] of fresh) results.set(url, node);\n return results;\n }\n\n /** Refresh due links grouped by provider and file, files in parallel (bounded). */\n private async refreshDue(\n projectId: string,\n due: Due[],\n now: Date,\n call: DesignCallOptions,\n ): Promise<Map<string, DesignNode>> {\n const byFile = [\n ...Map.groupBy(due, (item) => `${item.provider.metadata.kind}\\u0000${item.ref.fileKey}`),\n ];\n const batches = await mapLimit(byFile, FILE_CONCURRENCY, async ([, items]) => {\n return await this.refreshFile(projectId, items, now, call);\n });\n const merged = new Map<string, DesignNode>();\n for (const batch of batches) {\n for (const [url, node] of batch) merged.set(url, node);\n }\n return merged;\n }\n\n private async refreshFile(\n projectId: string,\n items: Due[],\n now: Date,\n call: DesignCallOptions,\n ): Promise<Map<string, DesignNode>> {\n const client = await this.clientFor(projectId, items);\n const first = items[0];\n if (!client || !first) return new Map();\n const { provider, ref } = first;\n const metas = await client.fetchMetas(\n ref.fileKey,\n items.map((item) => item.ref),\n call,\n );\n const answered = [...metas.values()].find((meta) => meta.state !== \"error\");\n if (answered && first.scope === PROJECT_SCOPE) {\n await this.credentials.recordStatus(\n projectId,\n provider.metadata.kind,\n statusFor(answered.state),\n );\n }\n const stored = await this.storeAll(projectId, items, metas, now);\n return await this.renders.ensure(projectId, client, ref.fileKey, stored, call);\n }\n\n /** Upsert a row per item that got a usable answer (`error` keeps its cached row), keyed by URL. */\n private async storeAll(\n projectId: string,\n items: Due[],\n metas: Map<string, DesignMeta>,\n now: Date,\n ): Promise<Map<string, DesignNode>> {\n const rows = await Promise.all(\n items.map(async (item) => {\n const meta = metas.get(item.ref.nodeId ?? \"\");\n const row =\n meta && meta.state !== \"error\" ? await this.store(projectId, item, meta, now) : null;\n return row ? ([item.url, row] as const) : null;\n }),\n );\n return new Map(rows.filter((entry) => entry !== null));\n }\n\n /** A client for the provider of `items` bound to the items' credential scope, or null without one. */\n private async clientFor(projectId: string, items: Due[]): Promise<DesignClient | null> {\n const first = items[0];\n if (!first) return null;\n const { provider, scope } = first;\n const kind = provider.metadata.kind;\n const own = scope !== PROJECT_SCOPE;\n const credential = own\n ? await this.connections?.accessToken(scope, kind, provider.oauth).catch(() => null)\n : await this.credentials.get(projectId, kind).catch(() => null);\n if (!credential) return null;\n return provider.create({\n credential,\n authType: own ? \"oauth\" : \"token\",\n ...(this.logger ? { logger: this.logger } : {}),\n });\n }\n\n /**\n * Whose credential serves a provider: the project's when set (it is shared and\n * wins), else the viewer's own connection, else the project scope (nothing to fetch with).\n */\n private async scopeFor(projectId: string, provider: string, viewerId?: string): Promise<string> {\n if (!viewerId || !this.connections || (await this.credentials.info(projectId, provider))) {\n return PROJECT_SCOPE;\n }\n return (await this.connections.info(viewerId, provider)) ? viewerId : PROJECT_SCOPE;\n }\n\n /** The shared row, else the viewer's own (a connected member's entries are never served to others). */\n private async visible(\n projectId: string,\n provider: string,\n ref: DesignRef,\n viewerId?: string,\n ): Promise<DesignNode | null> {\n const shared = await this.lookup(projectId, provider, ref, PROJECT_SCOPE);\n if (shared || !viewerId) return shared;\n return await this.lookup(projectId, provider, ref, viewerId);\n }\n\n private async lookup(\n projectId: string,\n provider: string,\n ref: DesignRef,\n scope: string,\n ): Promise<DesignNode | null> {\n const rows = (await this.db.list(this.tables.designNodes, {\n where: and(\n eq(col(this.tables.designNodes, \"projectId\"), projectId),\n eq(col(this.tables.designNodes, \"provider\"), provider),\n eq(col(this.tables.designNodes, \"viewerId\"), scope),\n eq(col(this.tables.designNodes, \"fileKey\"), ref.fileKey),\n eq(col(this.tables.designNodes, \"nodeId\"), ref.nodeId ?? \"\"),\n ),\n limit: 1,\n })) as unknown as DesignNode[];\n return rows[0] ?? null;\n }\n\n /**\n * Upsert the row. Non-ok states keep the last good name/version/modified time\n * (an auth failure must not erase the label); a deleted node also drops its\n * cached render, so a design that no longer exists is never shown as reference.\n */\n private async store(\n projectId: string,\n item: Due,\n meta: DesignMeta,\n now: Date,\n ): Promise<DesignNode> {\n const { ref, cached } = item;\n const stamp = now.toISOString();\n const keep = meta.state !== \"ok\" && cached;\n const values = {\n name: keep ? cached.name : meta.name,\n version: keep ? cached.version : meta.version,\n lastModified: keep ? cached.lastModified : meta.lastModified,\n state: meta.state,\n fetchedAt: stamp,\n updatedAt: stamp,\n };\n if (!cached) {\n return (await this.db.insert(this.tables.designNodes, {\n id: ulid(),\n projectId,\n provider: item.provider.metadata.kind,\n viewerId: item.scope,\n fileKey: ref.fileKey,\n nodeId: ref.nodeId ?? \"\",\n renderPath: null,\n renderVersion: null,\n notifiedVersion: null,\n createdAt: stamp,\n ...values,\n })) as unknown as DesignNode;\n }\n const dropRender = meta.state === \"deleted\" && cached.renderPath !== null;\n if (dropRender) await this.renders.remove(cached);\n return (await this.db.update(this.tables.designNodes, cached.id, {\n ...values,\n ...(dropRender ? { renderPath: null, renderVersion: null } : {}),\n })) as unknown as DesignNode;\n }\n}\n\n/** Never fetched, or cached longer ago than the TTL. */\nfunction isDue(cached: DesignNode | null, now: Date): boolean {\n return !cached || now.getTime() - Date.parse(cached.fetchedAt) >= DESIGN_TTL_MS;\n}\n\nfunction callOptions(logger: Logger | undefined, options: RefreshOptions): DesignCallOptions {\n return {\n ...(logger ? { logger } : {}),\n ...(options.signal ? { signal: options.signal } : {}),\n };\n}\n\nfunction statusFor(state: DesignMeta[\"state\"]): DesignCredentialStatus {\n return state === \"unauthorized\" || state === \"forbidden\" ? state : \"ok\";\n}\n\n/** Run `load` once per key, sharing the in-flight promise between concurrent callers. */\nasync function memo<T>(\n cache: Map<string, Promise<T>>,\n key: string,\n load: () => Promise<T>,\n): Promise<T> {\n const hit = cache.get(key);\n if (hit) return await hit;\n const created = load();\n cache.set(key, created);\n return await created;\n}\n"],"mappings":"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"}
package/dist/models.d.mts CHANGED
@@ -31,8 +31,8 @@ import { ConnectionModel, ConnectionTables } from "./models/connections.mjs";
31
31
  import { DesignModel, DesignTables } from "./models/design.mjs";
32
32
  import { DesignCredentialStatus, DesignResolution, DesignSource, normalizeImportPath, resolveDesigns } from "./models/design-resolve.mjs";
33
33
  import { DesignCredentialModel, DesignCredentialTables, PROJECT_SCOPE } from "./models/design-credentials.mjs";
34
+ import { DESIGN_TTL_MS, DesignChange, DesignChangeKind, credentialDaysLeft, designChange, isDesignStale } from "./models/design-health.mjs";
34
35
  import { DesignNodeModel, DesignNodeTables, RefreshOptions } from "./models/design-nodes.mjs";
35
- import { DESIGN_TTL_MS, credentialDaysLeft, isDesignStale } from "./models/design-health.mjs";
36
36
  import { LabelModel } from "./models/label.mjs";
37
37
  import { PackageUsageInput, PackageUsageModel, PackageUsageTables, UpstreamStory, matchUpstreamStory } from "./models/usage.mjs";
38
38
  import { MemberModel } from "./models/member.mjs";
@@ -45,4 +45,4 @@ import { ContentRefModel } from "./models/content-ref.mjs";
45
45
  import { CredentialProbe, ReencryptResult, UnreadableCredential, probeCredentials, reencryptCredentials } from "./models/credentials.mjs";
46
46
  import { SiteSettingsModel, SiteSettingsTables } from "./models/site-settings.mjs";
47
47
  import { PUBLIC_ORIGIN_KEY, PublicOriginOptions, PublicOriginResolver, PublicOriginSource, ResolvedPublicOrigin, StoredPublicOrigin, createPublicOriginResolver } from "./models/public-origin.mjs";
48
- export { AiBudgetAlertModel, type AiBudgetAlertTables, type AiUsageInput, AiUsageModel, type AiUsageTables, type AttemptClaim, type AttemptLogRecorder, BASELINE_ARCHIVE_FORMAT, BASELINE_ARCHIVE_LIMITS, BASELINE_ARCHIVE_VERSION, BaselineApprovalConflict, BaselineArchiveError, type BaselineExportOptions, type BaselineImportResult, type BaselineManifest, type BaselineManifestEntry, BaselineModel, type BaselineOriginReader, type BaselineStatus, BaselineTransferModel, BaselineWriteConflict, type BuildChangeset, type BuildComponentInput, BuildComponentModel, type BuildComponentTables, type BuildCreateInput, type BuildListFilter, BuildModel, BuildReviewModel, type BuildReviewTables, CaptureAttemptModel, type CaptureAttemptTables, CaptureJobModel, type CaptureJobTables, CaptureLogModel, type CaptureLogTables, CaptureShardModel, type CaptureShardTables, type ChangedComponent, type Changeset, type ChangesetComponent, type ChannelValidation, type CommentCreateInput, CommentModel, type ComponentHistoryRow, type ComponentPresence, ConnectionModel, type ConnectionTables, ContentRefModel, type CredentialProbe, DESIGN_TTL_MS, DesignCredentialModel, type DesignCredentialStatus, type DesignCredentialTables, DesignModel, DesignNodeModel, type DesignNodeTables, type DesignResolution, type DesignSource, type DesignTables, type InheritedRatio, InsightModel, type InsightResultInput, type InsightStartInput, type InsightStartResult, type InsightTables, LabelModel, MemberModel, NO_BASELINE, type NewCaptureJob, type NotificationChannelCreateInput, NotificationChannelModel, type NotificationChannelTables, type NotificationSubscriptionInput, NotificationSubscriptionModel, type NotificationSubscriptionTables, PROJECT_SCOPE, PUBLIC_ORIGIN_KEY, type PackageUsageInput, PackageUsageModel, type PackageUsageTables, type ProjectCreateInput, ProjectGroupMappingModel, ProjectModel, type PublicOriginOptions, type PublicOriginResolver, type PublicOriginSource, RECENT_BUILDS_FOR_INHERITED_RATIO, type ReencryptResult, type RefreshOptions, type ResolvedPublicOrigin, type ReviewStatusInput, SiteSettingsModel, type SiteSettingsTables, type SnapshotCreateInput, SnapshotModel, type StoredPublicOrigin, TERMINAL_JOB_STATUSES, TokenModel, type UnreadableCredential, type UpstreamStory, UserModel, type WebhookCreateInput, WebhookModel, acceptPendingWithoutWork, attemptRowId, averageInheritedRatio, baselineStatus, changesetOf, componentHistory, componentPresence, computeChangeset, createPublicOriginResolver, credentialDaysLeft, deriveReviewStatus, emitAttemptLog, findChangesetBaseline, isBaselineDrifted, isDesignStale, isPublicBuild, isTerminalJob, latestVerdicts, matchUpstreamStory, normalizeImportPath, parseAttemptRowId, probeCredentials, reencryptCredentials, resolveBaselineOrigin, resolveDesigns, worstStatus };
48
+ export { AiBudgetAlertModel, type AiBudgetAlertTables, type AiUsageInput, AiUsageModel, type AiUsageTables, type AttemptClaim, type AttemptLogRecorder, BASELINE_ARCHIVE_FORMAT, BASELINE_ARCHIVE_LIMITS, BASELINE_ARCHIVE_VERSION, BaselineApprovalConflict, BaselineArchiveError, type BaselineExportOptions, type BaselineImportResult, type BaselineManifest, type BaselineManifestEntry, BaselineModel, type BaselineOriginReader, type BaselineStatus, BaselineTransferModel, BaselineWriteConflict, type BuildChangeset, type BuildComponentInput, BuildComponentModel, type BuildComponentTables, type BuildCreateInput, type BuildListFilter, BuildModel, BuildReviewModel, type BuildReviewTables, CaptureAttemptModel, type CaptureAttemptTables, CaptureJobModel, type CaptureJobTables, CaptureLogModel, type CaptureLogTables, CaptureShardModel, type CaptureShardTables, type ChangedComponent, type Changeset, type ChangesetComponent, type ChannelValidation, type CommentCreateInput, CommentModel, type ComponentHistoryRow, type ComponentPresence, ConnectionModel, type ConnectionTables, ContentRefModel, type CredentialProbe, DESIGN_TTL_MS, type DesignChange, type DesignChangeKind, DesignCredentialModel, type DesignCredentialStatus, type DesignCredentialTables, DesignModel, DesignNodeModel, type DesignNodeTables, type DesignResolution, type DesignSource, type DesignTables, type InheritedRatio, InsightModel, type InsightResultInput, type InsightStartInput, type InsightStartResult, type InsightTables, LabelModel, MemberModel, NO_BASELINE, type NewCaptureJob, type NotificationChannelCreateInput, NotificationChannelModel, type NotificationChannelTables, type NotificationSubscriptionInput, NotificationSubscriptionModel, type NotificationSubscriptionTables, PROJECT_SCOPE, PUBLIC_ORIGIN_KEY, type PackageUsageInput, PackageUsageModel, type PackageUsageTables, type ProjectCreateInput, ProjectGroupMappingModel, ProjectModel, type PublicOriginOptions, type PublicOriginResolver, type PublicOriginSource, RECENT_BUILDS_FOR_INHERITED_RATIO, type ReencryptResult, type RefreshOptions, type ResolvedPublicOrigin, type ReviewStatusInput, SiteSettingsModel, type SiteSettingsTables, type SnapshotCreateInput, SnapshotModel, type StoredPublicOrigin, TERMINAL_JOB_STATUSES, TokenModel, type UnreadableCredential, type UpstreamStory, UserModel, type WebhookCreateInput, WebhookModel, acceptPendingWithoutWork, attemptRowId, averageInheritedRatio, baselineStatus, changesetOf, componentHistory, componentPresence, computeChangeset, createPublicOriginResolver, credentialDaysLeft, deriveReviewStatus, designChange, emitAttemptLog, findChangesetBaseline, isBaselineDrifted, isDesignStale, isPublicBuild, isTerminalJob, latestVerdicts, matchUpstreamStory, normalizeImportPath, parseAttemptRowId, probeCredentials, reencryptCredentials, resolveBaselineOrigin, resolveDesigns, worstStatus };
package/dist/models.mjs CHANGED
@@ -34,7 +34,7 @@ import { componentHistory, componentPresence, worstStatus } from "./models/compo
34
34
  import { ConnectionModel } from "./models/connections.mjs";
35
35
  import { DesignModel } from "./models/design.mjs";
36
36
  import { DesignCredentialModel, PROJECT_SCOPE } from "./models/design-credentials.mjs";
37
- import { DESIGN_TTL_MS, credentialDaysLeft, isDesignStale } from "./models/design-health.mjs";
37
+ import { DESIGN_TTL_MS, credentialDaysLeft, designChange, isDesignStale } from "./models/design-health.mjs";
38
38
  import { DesignNodeModel } from "./models/design-nodes.mjs";
39
39
  import { LabelModel } from "./models/label.mjs";
40
40
  import { PackageUsageModel, matchUpstreamStory } from "./models/usage.mjs";
@@ -45,4 +45,4 @@ import { TokenModel } from "./models/token.mjs";
45
45
  import { UserModel } from "./models/user.mjs";
46
46
  import { ContentRefModel } from "./models/content-ref.mjs";
47
47
  import { probeCredentials, reencryptCredentials } from "./models/credentials.mjs";
48
- export { AiBudgetAlertModel, AiUsageModel, BASELINE_ARCHIVE_FORMAT, BASELINE_ARCHIVE_LIMITS, BASELINE_ARCHIVE_VERSION, BaselineApprovalConflict, BaselineArchiveError, BaselineModel, BaselineTransferModel, BaselineWriteConflict, BuildComponentModel, BuildModel, BuildReviewModel, CaptureAttemptModel, CaptureJobModel, CaptureLogModel, CaptureShardModel, CommentModel, ConnectionModel, ContentRefModel, DESIGN_TTL_MS, DesignCredentialModel, DesignModel, DesignNodeModel, InsightModel, LabelModel, MemberModel, NO_BASELINE, NotificationChannelModel, NotificationSubscriptionModel, PROJECT_SCOPE, PUBLIC_ORIGIN_KEY, PackageUsageModel, ProjectGroupMappingModel, ProjectModel, RECENT_BUILDS_FOR_INHERITED_RATIO, SiteSettingsModel, SnapshotModel, TERMINAL_JOB_STATUSES, TokenModel, UserModel, WebhookModel, acceptPendingWithoutWork, attemptRowId, averageInheritedRatio, baselineStatus, changesetOf, componentHistory, componentPresence, computeChangeset, createPublicOriginResolver, credentialDaysLeft, deriveReviewStatus, emitAttemptLog, findChangesetBaseline, isBaselineDrifted, isDesignStale, isPublicBuild, isTerminalJob, latestVerdicts, matchUpstreamStory, normalizeImportPath, parseAttemptRowId, probeCredentials, reencryptCredentials, resolveBaselineOrigin, resolveDesigns, worstStatus };
48
+ export { AiBudgetAlertModel, AiUsageModel, BASELINE_ARCHIVE_FORMAT, BASELINE_ARCHIVE_LIMITS, BASELINE_ARCHIVE_VERSION, BaselineApprovalConflict, BaselineArchiveError, BaselineModel, BaselineTransferModel, BaselineWriteConflict, BuildComponentModel, BuildModel, BuildReviewModel, CaptureAttemptModel, CaptureJobModel, CaptureLogModel, CaptureShardModel, CommentModel, ConnectionModel, ContentRefModel, DESIGN_TTL_MS, DesignCredentialModel, DesignModel, DesignNodeModel, InsightModel, LabelModel, MemberModel, NO_BASELINE, NotificationChannelModel, NotificationSubscriptionModel, PROJECT_SCOPE, PUBLIC_ORIGIN_KEY, PackageUsageModel, ProjectGroupMappingModel, ProjectModel, RECENT_BUILDS_FOR_INHERITED_RATIO, SiteSettingsModel, SnapshotModel, TERMINAL_JOB_STATUSES, TokenModel, UserModel, WebhookModel, acceptPendingWithoutWork, attemptRowId, averageInheritedRatio, baselineStatus, changesetOf, componentHistory, componentPresence, computeChangeset, createPublicOriginResolver, credentialDaysLeft, deriveReviewStatus, designChange, emitAttemptLog, findChangesetBaseline, isBaselineDrifted, isDesignStale, isPublicBuild, isTerminalJob, latestVerdicts, matchUpstreamStory, normalizeImportPath, parseAttemptRowId, probeCredentials, reencryptCredentials, resolveBaselineOrigin, resolveDesigns, worstStatus };
@@ -48,6 +48,8 @@ interface DesignNode {
48
48
  renderPath: string | null;
49
49
  /** File `version` the cached render was taken at; a newer file version refetches. */
50
50
  renderVersion: string | null;
51
+ /** Claim of the last `design:changed` event sent for this node (see `designChange`); null when none. */
52
+ notifiedVersion: string | null;
51
53
  fetchedAt: string;
52
54
  createdAt: string;
53
55
  updatedAt: string;
@@ -112,6 +112,7 @@ const designNodes = defineTable("design_nodes", {
112
112
  state: "state",
113
113
  renderPath: "render_path",
114
114
  renderVersion: "render_version",
115
+ notifiedVersion: "notified_version",
115
116
  fetchedAt: "fetched_at",
116
117
  createdAt: "created_at",
117
118
  updatedAt: "updated_at"