diff --git a/.drive/projects/local-dev/spec.md b/.drive/projects/local-dev/spec.md index bf034d7b4..53df687c4 100644 --- a/.drive/projects/local-dev/spec.md +++ b/.drive/projects/local-dev/spec.md @@ -39,6 +39,27 @@ construction above the Alchemy provider boundary. | D11 | Hot reload / user dev-commands / remote bindings: designed extensions, **not v1** | local-dev.md | | D12 | One dev instance per working directory; no stages; `--fresh` is wholesale local deletion, never `alchemy destroy` | local-dev.md | +## Naming (operator, 2026-07-23): the seam is the LOCAL TARGET, not "dev" + +"dev" names the user-facing feature only — the `prisma-composer dev` +command, the `[dev]` log prefix, the `.prisma-composer/dev/` state dir. +The architectural seam uses the concept's real noun, already established +by ADR-0041 and `@internal/local-target`: + +- `ExtensionDescriptor.localTarget?: () => Promise` + (was `dev`); the descriptor type is `LocalTargetDescriptor` (was + `DevExtensionDescriptor`), its inputs `LocalTargetProvidersInput` etc. +- Core subpath `@prisma/composer/local-target` (was `/dev`): + `resolveLocalTargets` (was `resolveDevDescriptors`), + `localTargetProviders` (was `devProviders`), `DEV_DIR` unchanged (it + names the user-facing dir). +- Extension subpath `@prisma/composer-prisma-cloud/local-target` (was + `/dev`), exporting `localTargetDescriptor()`. +- Hook names inside the descriptor are already precise nouns + (`providers`/`container`/`preflight`/`emulators`/`attach`/`teardown`) + and do not change. Every spec reference to the old names reads as the + new ones; this section is the rename's single source of truth. + ## New/changed surface, by package ### 1. `@internal/s3-protocol` — NEW package (extraction) diff --git a/architecture.config.json b/architecture.config.json index 2a99dee36..2f7d96611 100644 --- a/architecture.config.json +++ b/architecture.config.json @@ -30,6 +30,12 @@ "layer": "core", "plane": "control" }, + { + "glob": "packages/0-framework/1-core/core/src/exports/local-target.ts", + "domain": "framework", + "layer": "core", + "plane": "control" + }, { "glob": "packages/0-framework/1-core/core/src/exports/index.ts", "domain": "framework", @@ -126,6 +132,12 @@ "layer": "lowering", "plane": "control" }, + { + "glob": "packages/1-prisma-cloud/0-lowering/local-target/src/**", + "domain": "prisma-cloud", + "layer": "lowering", + "plane": "control" + }, { "glob": "packages/1-prisma-cloud/1-extensions/target/src/control/**", "domain": "prisma-cloud", @@ -156,6 +168,12 @@ "layer": "extensions", "plane": "control" }, + { + "glob": "packages/1-prisma-cloud/1-extensions/target/src/exports/local-target.ts", + "domain": "prisma-cloud", + "layer": "extensions", + "plane": "control" + }, { "glob": "packages/1-prisma-cloud/1-extensions/target/src/descriptors/**", "domain": "prisma-cloud", @@ -318,6 +336,18 @@ "layer": "extensions", "plane": "shared" }, + { + "glob": "packages/1-prisma-cloud/1-extensions/target/src/preflight-names.ts", + "domain": "prisma-cloud", + "layer": "extensions", + "plane": "control" + }, + { + "glob": "packages/1-prisma-cloud/1-extensions/target/src/local-target/**", + "domain": "prisma-cloud", + "layer": "extensions", + "plane": "control" + }, { "glob": "packages/1-prisma-cloud/2-shared-modules/email/src/contract.ts", "domain": "prisma-cloud", @@ -540,6 +570,12 @@ "layer": "public", "plane": "control" }, + { + "glob": "packages/9-public/composer/src/exports/local-target.ts", + "domain": "public", + "layer": "public", + "plane": "control" + }, { "glob": "packages/9-public/composer/src/exports/report.ts", "domain": "public", @@ -612,6 +648,12 @@ "layer": "public", "plane": "control" }, + { + "glob": "packages/9-public/composer-prisma-cloud/src/exports/local-target.ts", + "domain": "public", + "layer": "public", + "plane": "control" + }, { "glob": "packages/9-public/composer-prisma-cloud/src/exports/index.ts", "domain": "public", @@ -712,7 +754,13 @@ "exceptions": [] } }, - "crossDomainExceptions": [], + "crossDomainExceptions": [ + { + "from": "packages/1-prisma-cloud/1-extensions/target/src/control/**", + "to": "packages/9-public/composer-prisma-cloud/src/exports/local-target.ts", + "reason": "ADR-0041's lazy local-target reference (operator directive; naming, operator 2026-07-23): control/extension.ts's `localTarget` field is a dynamic import of its own published local-target subpath by bare specifier, so no local-target implementation code is bundled into, or loaded by, any deploy path. The specifier resolves at a CONSUMING app's runtime, never as a real build-time dependency between these two packages — verified: dist/control.mjs keeps it as a genuine external dynamic import, never inlined (target's invariant 7 test)." + } + ], "layerOrder": { "framework": ["foundation", "core", "authoring", "tooling"], "prisma-cloud": ["lowering", "extensions", "modules"], diff --git a/dependency-cruiser.config.mjs b/dependency-cruiser.config.mjs index 4b43ce692..54e072673 100644 --- a/dependency-cruiser.config.mjs +++ b/dependency-cruiser.config.mjs @@ -160,10 +160,21 @@ const createPlaneRules = () => { const createSinkAndConsumerRules = () => { forbidden.push({ name: 'public-is-a-sink', - comment: '9-public is a sink (ADR-0028): no internal package imports the published packages', + comment: + '9-public is a sink (ADR-0028): no internal package imports the published packages. ' + + "One sanctioned exception: the prisma-cloud target's control/extension.ts names its own " + + "local-target entry's PUBLISHED subpath in a dynamic import (ADR-0041's lazy local-target " + + 'reference — operator directive; naming, operator 2026-07-23) so the production control ' + + 'bundle stays free of local-target implementation code; the specifier resolves at a ' + + "CONSUMING app's runtime, never as a real build-time dependency between these two packages " + + '(verified: dist/control.mjs keeps it as a genuine external dynamic import, never inlined ' + + "— see target's invariant 7 test).", severity: 'error', from: { path: '^packages/(0-framework|1-prisma-cloud)/' }, - to: { path: '^packages/9-public/' }, + to: { + path: '^packages/9-public/', + pathNot: '^packages/9-public/composer-prisma-cloud/src/exports/local-target\\.ts$', + }, }); forbidden.push({ name: 'examples-import-public-only', diff --git a/examples/storefront-auth/scripts/e2e-verify.sh b/examples/storefront-auth/scripts/e2e-verify.sh index 1b4c6b361..7f4d56e04 100644 --- a/examples/storefront-auth/scripts/e2e-verify.sh +++ b/examples/storefront-auth/scripts/e2e-verify.sh @@ -18,11 +18,13 @@ project_id="$(curl -sS -H "$auth_header" "$api/projects?limit=100" \ # The post-promote endpoint domain is the servable one (the create-time domain # is a placeholder — PRO-200); by the time this script runs, deploy + promote -# have completed, so the service read returns the real domain. -domain="$(curl -sS -H "$auth_header" "$api/projects/$project_id/compute-services?limit=100" \ - | node -e "let d='';process.stdin.on('data',(c)=>{d+=c}).on('end',()=>{const s=(JSON.parse(d).data??[]).find((x)=>x.name==='storefront');console.log(s?.serviceEndpointDomain??'')})")" -[ -n "$domain" ] || { echo "Project $project_id has no 'storefront' compute service with an endpoint domain."; exit 1; } -# serviceEndpointDomain arrives WITH the https:// scheme (see the PRO-200 +# have completed, so the app read returns the real domain. `/v1/apps` is the +# current Management API surface for what used to be `/v1/compute-services` +# (same underlying resources — see cold-start-canary.ts and gotchas.md PRO-217). +domain="$(curl -sS -H "$auth_header" "$api/apps?projectId=$project_id&limit=100" \ + | node -e "let d='';process.stdin.on('data',(c)=>{d+=c}).on('end',()=>{const s=(JSON.parse(d).data??[]).find((x)=>x.name==='storefront');console.log(s?.appEndpointDomain??'')})")" +[ -n "$domain" ] || { echo "Project $project_id has no 'storefront' app with an endpoint domain."; exit 1; } +# appEndpointDomain arrives WITH the https:// scheme (see the PRO-200 # gotcha's captured responses); tolerate either shape. case "$domain" in http://*|https://*) url="$domain" ;; diff --git a/package.json b/package.json index 559a73807..e093d56c4 100644 --- a/package.json +++ b/package.json @@ -35,5 +35,11 @@ "turbo": "^2.10.5", "typescript": "^6.0.3" }, + "pnpm": { + "onlyBuiltDependencies": [ + "prisma", + "@prisma/engines" + ] + }, "version": "0.2.0" } diff --git a/packages/0-framework/1-core/core/package.json b/packages/0-framework/1-core/core/package.json index 6990b12ed..0dbcacb02 100644 --- a/packages/0-framework/1-core/core/package.json +++ b/packages/0-framework/1-core/core/package.json @@ -6,6 +6,7 @@ ".": "./dist/index.mjs", "./config": "./dist/config.mjs", "./deploy": "./dist/deploy.mjs", + "./local-target": "./dist/local-target.mjs", "./testing": "./dist/testing.mjs", "./package.json": "./package.json" }, diff --git a/packages/0-framework/1-core/core/src/__tests__/invariants.test.ts b/packages/0-framework/1-core/core/src/__tests__/invariants.test.ts index 022dcb966..b92e82d3a 100644 --- a/packages/0-framework/1-core/core/src/__tests__/invariants.test.ts +++ b/packages/0-framework/1-core/core/src/__tests__/invariants.test.ts @@ -32,12 +32,47 @@ const leanTokens = [ '"node:', // a node:-scheme import always appears quoted in a bundle ]; -describe('entry map: core splits into authoring + deploy — no runtime entry', () => { - test("package.json exports '.', './deploy', './config', and './testing' (casts/assertions live in @internal/foundation)", () => { +describe('entry map: core splits into authoring + deploy + local-target — no runtime entry', () => { + test("package.json exports '.', './deploy', './config', './local-target', and './testing' (casts/assertions live in @internal/foundation)", () => { const pkg = JSON.parse(fs.readFileSync(path.join(pkgDir, 'package.json'), 'utf8')); // `./package.json` is a conventional manifest export, not a code entry. const codeEntries = Object.keys(pkg.exports).filter((k) => k !== './package.json'); - expect(codeEntries.sort()).toEqual(['.', './config', './deploy', './testing']); + expect(codeEntries.sort()).toEqual([ + '.', + './config', + './deploy', + './local-target', + './testing', + ]); + }); + + // ADR-0041's own firewall: `/deploy` and the root `.` entry must never + // carry anything local-target-flavored — `lower()` is provenance-ignorant + // (REVISED, operator review of #162), so nothing in its own module or the + // authoring barrel should mention the local target's own vocabulary at + // all. "dev" names the user-facing feature only (naming, operator + // 2026-07-23); DEV_DIR is exempt from that check since it's the + // user-facing state dir, not seam vocabulary. + test("'./deploy' and '.' export no local-target-flavored name — /local-target is the only door to it", () => { + const deployText = fs.readFileSync(path.join(srcDir, 'exports', 'deploy.ts'), 'utf8'); + const indexText = fs.readFileSync(path.join(srcDir, 'exports', 'index.ts'), 'utf8'); + const devTokens = [ + 'localTargetProviders', + 'resolveLocalTargets', + 'LocalTargetDescriptor', + 'LocalTargetProvidersInput', + 'DEV_DIR', + ]; + for (const token of devTokens) { + expect({ file: 'exports/deploy.ts', containsToken: deployText.includes(token) }).toEqual({ + file: 'exports/deploy.ts', + containsToken: false, + }); + expect({ file: 'exports/index.ts', containsToken: indexText.includes(token) }).toEqual({ + file: 'exports/index.ts', + containsToken: false, + }); + } }); }); diff --git a/packages/0-framework/1-core/core/src/__tests__/local-target.test.ts b/packages/0-framework/1-core/core/src/__tests__/local-target.test.ts new file mode 100644 index 000000000..abeb8150e --- /dev/null +++ b/packages/0-framework/1-core/core/src/__tests__/local-target.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, test } from 'bun:test'; +import * as Effect from 'effect/Effect'; +import * as Layer from 'effect/Layer'; +import type { + ExtensionDescriptor, + LocalTargetDescriptor, + PrismaAppConfig, +} from '../control/app-config.ts'; +import type { AlchemyStateLayer } from '../control/deploy.ts'; +import { localTargetProviders, resolveLocalTargets } from '../control/local-target.ts'; + +const stateSentinel = (tag: string): AlchemyStateLayer => + ({ __sentinel: tag }) as unknown as AlchemyStateLayer; + +// A build-only extension: every `nodes` entry is `kind: 'build'`, and none +// of `providers`/`application`/`provisions`/`container` are declared — the +// exact shape `nodeBuild()` uses (ADR-0041's build-only exemption). +const buildOnlyExtension = (id = 'test/build-only'): ExtensionDescriptor => ({ + id, + nodes: { node: { kind: 'build', assemble: () => Promise.reject(new Error('unused')) } }, +}); + +const fakeLocalTargetDescriptor = (): LocalTargetDescriptor => ({ + providers: () => Layer.empty, + container: { + ensure: () => { + throw new Error('ensure() must not run here'); + }, + locate: () => { + throw new Error('locate() must not run here'); + }, + remove: () => { + throw new Error('remove() must not run here'); + }, + deserialize: () => { + throw new Error('deserialize() must not run here'); + }, + }, + attach: () => Promise.reject(new Error('unused')), +}); + +// The `localTarget` field is a lazy thunk (ADR-0041's lazy local-target +// reference) — this stand-in resolves it immediately, mirroring the shape +// of a real `() => import('...').then((m) => m.localTargetDescriptor())` +// without an actual dynamic import. A real (non-build) node — `nodes: {}` +// alone is vacuously build-only (isBuildOnlyExtension) and would be skipped +// before its `localTarget` thunk was ever read, defeating the point of this +// fixture. +const localTargetCapableExtension = (id = 'test/dev-pack'): ExtensionDescriptor => ({ + id, + nodes: { + 'fake/resource': Object.assign(() => Effect.succeed({ outputs: {}, entities: [] }), { + kind: 'resource' as const, + }), + }, + localTarget: () => Promise.resolve(fakeLocalTargetDescriptor()), +}); + +// A minimal, non-build-only extension with no `localTarget` descriptor — a +// plain resource node, no providers. +const nonLocalTargetExtension = (id = 'test/pack'): ExtensionDescriptor => ({ + id, + nodes: { + 'fake/resource': Object.assign(() => Effect.succeed({ outputs: {}, entities: [] }), { + kind: 'resource' as const, + }), + }, +}); + +describe('resolveLocalTargets', () => { + test('a local-target-capable extension plus a build-only extension resolves without error — the build-only extension is skipped, never even asked for a thunk', () => { + const localTarget = localTargetCapableExtension(); + const buildOnly = buildOnlyExtension(); + const config: PrismaAppConfig = { + extensions: [localTarget, buildOnly], + state: { extension: localTarget.id, create: () => stateSentinel('config') }, + }; + + return resolveLocalTargets(config).then((resolved) => { + expect([...resolved.keys()]).toEqual(['test/dev-pack']); + }); + }); + + test('an extension with a non-build node and no localTarget thunk throws the pinned no-dev-support message', async () => { + const bare = nonLocalTargetExtension(); + const config: PrismaAppConfig = { + extensions: [bare], + state: { extension: bare.id, create: () => stateSentinel('config') }, + }; + + await expect(resolveLocalTargets(config)).rejects.toThrow( + 'extension "test/pack" has no dev support — it declares no `localTarget` descriptor (ADR-0041).', + ); + }); +}); + +describe('localTargetProviders', () => { + test("merges the resolved descriptors' providers layers without error", async () => { + const localTarget = localTargetCapableExtension(); + const config: PrismaAppConfig = { + extensions: [localTarget], + state: { extension: localTarget.id, create: () => stateSentinel('config') }, + }; + const resolved = await resolveLocalTargets(config); + + expect(localTargetProviders(resolved, new Map(), '/tmp/dev')).toBeDefined(); + }); + + test('an empty resolved map yields the empty layer', () => { + expect(localTargetProviders(new Map(), new Map(), '/tmp/dev')).toBe(Layer.empty); + }); +}); diff --git a/packages/0-framework/1-core/core/src/__tests__/lowering.test.ts b/packages/0-framework/1-core/core/src/__tests__/lowering.test.ts index 2bfdb4d1c..b92ec8efb 100644 --- a/packages/0-framework/1-core/core/src/__tests__/lowering.test.ts +++ b/packages/0-framework/1-core/core/src/__tests__/lowering.test.ts @@ -5,7 +5,11 @@ import * as Layer from 'effect/Layer'; import type { Config, Params } from '../config.ts'; import { number, string } from '../config.ts'; import { containerEnvVarName } from '../container-transport.ts'; -import type { ExtensionDescriptor, PrismaAppConfig } from '../control/app-config.ts'; +import { + type ExtensionDescriptor, + isBuildOnlyExtension, + type PrismaAppConfig, +} from '../control/app-config.ts'; import { type AlchemyStateLayer, type Artifact, @@ -1420,3 +1424,55 @@ describe('mergedProviders', () => { expect(mergedProviders(config)).toBeDefined(); }); }); + +// A build-only extension: every `nodes` entry is `kind: 'build'`, and none +// of `providers`/`application`/`provisions`/`container` are declared — the +// exact shape `nodeBuild()` uses (ADR-0041's build-only exemption). +const buildOnlyExtension = (id = 'test/build-only'): ExtensionDescriptor => ({ + id, + nodes: { node: { kind: 'build', assemble: () => Promise.reject(new Error('unused')) } }, +}); + +describe('isBuildOnlyExtension', () => { + test('true for an extension whose every node is kind: build and declares no providers/application/provisions/container', () => { + expect(isBuildOnlyExtension(buildOnlyExtension())).toBe(true); + }); + + test('false for an extension with a non-build node', () => { + const { descriptor } = fakeExtension(); + expect(isBuildOnlyExtension(descriptor)).toBe(false); + }); + + test('false for an all-build-node extension that also declares providers', () => { + const extension: ExtensionDescriptor = { + ...buildOnlyExtension(), + providers: () => Layer.empty, + }; + expect(isBuildOnlyExtension(extension)).toBe(false); + }); + + test('false for an all-build-node extension that also declares a container', () => { + const extension: ExtensionDescriptor = { + ...buildOnlyExtension(), + container: { + ensure: () => { + throw new Error('unused'); + }, + locate: () => { + throw new Error('unused'); + }, + remove: () => { + throw new Error('unused'); + }, + deserialize: () => { + throw new Error('unused'); + }, + }, + }; + expect(isBuildOnlyExtension(extension)).toBe(false); + }); + + test('true for an extension with no nodes at all (vacuously every node is build)', () => { + expect(isBuildOnlyExtension({ id: 'test/empty', nodes: {} })).toBe(true); + }); +}); diff --git a/packages/0-framework/1-core/core/src/control/app-config.ts b/packages/0-framework/1-core/core/src/control/app-config.ts index a69130923..f80ea4ea2 100644 --- a/packages/0-framework/1-core/core/src/control/app-config.ts +++ b/packages/0-framework/1-core/core/src/control/app-config.ts @@ -64,6 +64,19 @@ export interface ExtensionDescriptor { * in container-transport.ts. */ readonly container?: ContainerDescriptor; + /** + * The extension's LOCAL TARGET counterpart (ADR-0041; naming, operator + * 2026-07-23 — "dev" names the user-facing feature only, the seam takes + * the concept's real noun) — a LAZY reference: an async thunk, never the + * descriptor object itself. This keeps the production control entry's + * static import graph free of local-target implementation code (operator + * directive) — the thunk is one line, dynamically importing the + * extension's own local-target entry by bare specifier + * (e.g. `() => import('@prisma/composer-prisma-cloud/local-target').then((m) => m.localTargetDescriptor())`), + * so nothing local-target-flavored is bundled into, or loaded by, any + * deploy path. + */ + readonly localTarget?: () => Promise; } /** @@ -95,6 +108,72 @@ export interface TeardownInput { readonly stage: string | undefined; } +/** The extension's LOCAL TARGET counterpart (ADR-0041) — the local-target variant OF ExtensionDescriptor, hence the full qualifier. An extension without one is not local-target-capable (cannot back the "dev" feature). */ +export interface LocalTargetDescriptor { + /** Local providers for the SAME resource types this extension's lowering emits. Receives the app identity — unlike deploy's env-arg-free `providers()`, local providers are emulator clients and must know which app they provision for. */ + providers(input: LocalTargetProvidersInput): Layer.Layer; + /** A stable local identity — resolved without any platform call. */ + readonly container: ContainerDescriptor; + /** Value sourcing (secrets/env-params) — runs where deploy's preflight runs. */ + preflight?(input: PreflightInput): Promise; + /** Ensure the emulator daemons this topology's node kinds need are running (idempotent; they persist across sessions). */ + emulators?(input: LocalTargetEmulatorsInput): Promise; + /** The dev session's view of the running app. Core renders it and never learns an emulator's API. */ + attach(input: LocalTargetAttachInput): Promise; + /** `--fresh`: remove every local trace of the dev instance — emulator instances, state, data. */ + teardown?(input: TeardownInput): Promise; +} + +export interface LocalTargetProvidersInput { + /** This extension's resolved local-target container (its `input.appName` is the emulator app namespace). */ + readonly container: ContainerInstance | undefined; + /** Absolute path of the dev state directory (`/.prisma-composer/dev`). */ + readonly devDir: string; +} + +export interface LocalTargetEmulatorsInput { + /** The loaded application graph — inspected for which node kinds need an emulator. */ + readonly graph: Graph; + readonly container: ContainerInstance | undefined; + /** Absolute path of the dev state directory (`/.prisma-composer/dev`). */ + readonly devDir: string; +} + +export interface LocalTargetAttachInput { + readonly container: ContainerInstance | undefined; + readonly devDir: string; +} + +export interface LocalTargetAttachment { + /** Every service's local endpoint, for the front door. */ + endpoints(): Promise; + /** Merged, line-oriented log stream across the app's services (including services that appear after later converges). Ends when `signal` aborts. */ + logs(signal: AbortSignal): AsyncIterable<{ readonly service: string; readonly line: string }>; + /** Stop the app's service instances (emulators and data persist). */ + stopServices(): Promise; +} + +/** `/.prisma-composer/dev` — the dev instance's app-scoped state directory (ADR-0041, ADR-0004's tool-state rule). "dev" names the user-facing feature/dir (naming, operator 2026-07-23) — this constant's name and value are unchanged by the localTarget rename. */ +export const DEV_DIR = '.prisma-composer/dev'; + +/** + * True when an extension only participates in assembly (every `nodes` entry + * is `kind: 'build'`, and it declares none of `providers`/`application`/ + * `provisions`/`container`) — it owns no resources or services, so it has + * nothing to emulate and is exempt from local-target-capability requirements + * (ADR-0041). Shared by `localTargetProviders` and every local-target hook + * iteration. + */ +export function isBuildOnlyExtension(extension: ExtensionDescriptor): boolean { + return ( + Object.values(extension.nodes).every((node) => node.kind === 'build') && + extension.providers === undefined && + extension.application === undefined && + extension.provisions === undefined && + extension.container === undefined + ); +} + /** * What one registry entry can do. The `kind` discriminant is checked at every * lookup site against what the site needs — a resource node looked up against diff --git a/packages/0-framework/1-core/core/src/control/deploy.ts b/packages/0-framework/1-core/core/src/control/deploy.ts index 0f6a49894..6585b96c4 100644 --- a/packages/0-framework/1-core/core/src/control/deploy.ts +++ b/packages/0-framework/1-core/core/src/control/deploy.ts @@ -200,6 +200,8 @@ export interface LowerOptions { readonly stage?: string; /** Alchemy state store for the stack. Defaults to the config's own state layer. */ readonly state?: AlchemyStateLayer; + /** Explicit provider set for the stack. Defaults to the config's own merged `providers()` (`mergedProviders`) — the same override precedence as `state`. The dev stack module passes `localTargetProviders(...)` here (ADR-0041); `lower()` itself learns nothing about the local target. */ + readonly providers?: Layer.Layer; /** * Invoked once per deploy, during apply, with the Deploy operation's result * — the app and every node it deployed, resolved, in topo order. @@ -755,10 +757,11 @@ export function lower(root: ModuleNode, config: PrismaAppConfig, opts: LowerOpti // match what Alchemy.Stack accepts. const stackEffect = Effect.orDie(lowering(root, config, opts)) as Effect.Effect; const containers = deserializeContainers(config.extensions, process.env); + const providers = opts.providers ?? mergedProviders(config); return Alchemy.Stack( opts.name, - { providers: mergedProviders(config), state: resolveStateLayer(opts, config, containers) }, + { providers, state: resolveStateLayer(opts, config, containers) }, stackEffect, ); } diff --git a/packages/0-framework/1-core/core/src/control/local-target.ts b/packages/0-framework/1-core/core/src/control/local-target.ts new file mode 100644 index 000000000..1fff03a99 --- /dev/null +++ b/packages/0-framework/1-core/core/src/control/local-target.ts @@ -0,0 +1,60 @@ +/** The local-target stack's own provider aggregation (ADR-0041; naming, operator 2026-07-23 — the seam is `localTarget`, "dev" names the user-facing feature only) — the local-target counterpart of `deploy.ts`'s `mergedProviders`, kept in its own module so `lower()` learns nothing about it (deploy.ts's REVISED — operator review of #162). */ +import * as Layer from 'effect/Layer'; +import { + type ContainerInstance, + isBuildOnlyExtension, + type LocalTargetDescriptor, + type PrismaAppConfig, +} from './app-config.ts'; +import { LowerError } from './deploy.ts'; + +function noLocalTargetSupportError(id: string): LowerError { + return new LowerError( + `extension "${id}" has no dev support — it declares no \`localTarget\` descriptor (ADR-0041).`, + ); +} + +/** + * Resolves every non-build-only configured extension's lazy `localTarget` + * thunk, once (ADR-0041's lazy local-target reference — operator directive: + * the production control entry carries only the thunk, never the + * descriptor, so resolving it is this module's job, not something a deploy + * path ever does). A build-only extension (`isBuildOnlyExtension`) owns no + * resources or services and is skipped entirely — never even checked for a + * `localTarget` thunk. Every other configured extension must be + * local-target-capable, or the dev command cannot bring the app up at all, + * so a missing thunk throws naming the extension. The generated dev stack + * module calls this once and threads the resolved map through every + * subsequent hook, including `localTargetProviders`. + */ +export async function resolveLocalTargets( + config: PrismaAppConfig, +): Promise> { + const entries = await Promise.all( + config.extensions.flatMap((extension) => { + if (isBuildOnlyExtension(extension)) return []; + if (extension.localTarget === undefined) throw noLocalTargetSupportError(extension.id); + return [extension.localTarget().then((descriptor) => [extension.id, descriptor] as const)]; + }), + ); + return new Map(entries); +} + +/** + * All configured extensions' local-target providers merged, config array + * order (ADR-0041), from the ALREADY-RESOLVED descriptor map + * (`resolveLocalTargets`'s product — this function itself never touches a + * `localTarget` thunk). The generated dev stack module is this + * aggregator's one caller, passing the result as `LowerOptions.providers`. + */ +export function localTargetProviders( + resolved: ReadonlyMap, + containers: ReadonlyMap, + devDir: string, +): Layer.Layer { + const layers = [...resolved.entries()].map(([id, descriptor]) => + descriptor.providers({ container: containers.get(id), devDir }), + ); + const [first, ...rest] = layers; + return first === undefined ? Layer.empty : Layer.mergeAll(first, ...rest); +} diff --git a/packages/0-framework/1-core/core/src/exports/local-target.ts b/packages/0-framework/1-core/core/src/exports/local-target.ts new file mode 100644 index 000000000..574784a62 --- /dev/null +++ b/packages/0-framework/1-core/core/src/exports/local-target.ts @@ -0,0 +1,18 @@ +/** + * Public surface: the local target's own control-plane subpath (ADR-0041; + * naming, operator 2026-07-23) — the dev stack module's orchestration + * point, `localTargetProviders`, plus the seam types re-exported here for + * convenience (they stay DEFINED in `app-config.ts`, since + * `ExtensionDescriptor.localTarget` references them as contract). Nothing + * local-target-flavored is exported from `/deploy` or the root. + */ + +export { + DEV_DIR, + type LocalTargetAttachInput, + type LocalTargetAttachment, + type LocalTargetDescriptor, + type LocalTargetEmulatorsInput, + type LocalTargetProvidersInput, +} from '../control/app-config.ts'; +export { localTargetProviders, resolveLocalTargets } from '../control/local-target.ts'; diff --git a/packages/0-framework/1-core/core/tsdown.config.ts b/packages/0-framework/1-core/core/tsdown.config.ts index 8beaa46b8..2de20a5a0 100644 --- a/packages/0-framework/1-core/core/tsdown.config.ts +++ b/packages/0-framework/1-core/core/tsdown.config.ts @@ -8,6 +8,7 @@ export default defineConfig({ index: 'src/exports/index.ts', deploy: 'src/exports/deploy.ts', config: 'src/exports/app-config.ts', + 'local-target': 'src/exports/local-target.ts', testing: 'src/exports/testing.ts', }, }); diff --git a/packages/1-prisma-cloud/0-lowering/dev-emulators/src/exports/index.ts b/packages/1-prisma-cloud/0-lowering/dev-emulators/src/exports/index.ts index 089b755ba..ff2937866 100644 --- a/packages/1-prisma-cloud/0-lowering/dev-emulators/src/exports/index.ts +++ b/packages/1-prisma-cloud/0-lowering/dev-emulators/src/exports/index.ts @@ -26,3 +26,4 @@ export { registryFilePath, stopDaemon, } from '../daemon.ts'; +export { instanceNameFor, slug } from '../instance-name.ts'; diff --git a/packages/1-prisma-cloud/0-lowering/local-target/package.json b/packages/1-prisma-cloud/0-lowering/local-target/package.json new file mode 100644 index 000000000..51ab5bdc4 --- /dev/null +++ b/packages/1-prisma-cloud/0-lowering/local-target/package.json @@ -0,0 +1,43 @@ +{ + "name": "@internal/local-target", + "version": "0.2.0", + "type": "module", + "exports": { + ".": "./dist/index.mjs", + "./package.json": "./package.json" + }, + "scripts": { + "typecheck": "tsc --noEmit", + "test": "bun test", + "build": "tsdown", + "clean": "rm -rf dist" + }, + "dependencies": { + "@internal/core": "workspace:0.2.0", + "@internal/dev-emulators": "workspace:0.2.0", + "@internal/lowering": "workspace:0.2.0", + "@internal/s3-protocol": "workspace:0.2.0", + "alchemy": "2.0.0-beta.59", + "effect": "4.0.0-beta.93", + "tar": "^7.5.21" + }, + "devDependencies": { + "@internal/tsdown-config": "workspace:0.2.0", + "@prisma/dev": "^0.24.14", + "@types/bun": "^1.3.13", + "@types/node": "^25.9.3", + "tsdown": "^0.22.7", + "typescript": "^6.0.3" + }, + "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "https://github.com/prisma/compose.git", + "directory": "packages/1-prisma-cloud/0-lowering/local-target" + }, + "engines": { + "node": ">=24" + }, + "private": true, + "types": "./dist/index.d.mts" +} diff --git a/packages/1-prisma-cloud/0-lowering/local-target/src/__tests__/artifact-extract.test.ts b/packages/1-prisma-cloud/0-lowering/local-target/src/__tests__/artifact-extract.test.ts new file mode 100644 index 000000000..edc807ae5 --- /dev/null +++ b/packages/1-prisma-cloud/0-lowering/local-target/src/__tests__/artifact-extract.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, test } from 'bun:test'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import * as zlib from 'node:zlib'; +import { packageComputeArtifact } from '@internal/lowering/compute'; +import { extractComputeArtifact } from '../artifact-extract.ts'; + +function makeBundle(files: Record): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'artifact-extract-test-')); + for (const [rel, content] of Object.entries(files)) { + const full = path.join(dir, rel); + fs.mkdirSync(path.dirname(full), { recursive: true }); + fs.writeFileSync(full, content); + } + return dir; +} + +function readAll(dir: string): Record { + const out: Record = {}; + const walk = (sub: string): void => { + for (const entry of fs.readdirSync(path.join(dir, sub), { withFileTypes: true })) { + const rel = sub.length > 0 ? `${sub}/${entry.name}` : entry.name; + if (entry.isDirectory()) walk(rel); + else out[rel] = fs.readFileSync(path.join(dir, rel), 'utf8'); + } + }; + walk(''); + return out; +} + +// Format-drift protection: `packageComputeArtifact` (`@internal/lowering`, +// imported downward — production source) and `extractComputeArtifact` +// (this package — emulation-only) are now maintained in two different +// packages by two different mechanisms (a hand-rolled deterministic writer; +// the maintained `tar` package for reading). This round-trip is what proves +// they still agree on the wire format. +describe('extractComputeArtifact', () => { + test('round-trips packageComputeArtifact output: every packaged file lands byte-identical', () => { + const bundleDir = makeBundle({ + 'main.js': 'export default { run: async () => {} };', + 'nested/asset.txt': 'hello world', + }); + const artifact = packageComputeArtifact({ + id: 'auth', + bundleDir, + appEntry: 'server.js', + address: 'auth', + }); + + const destDir = fs.mkdtempSync(path.join(os.tmpdir(), 'artifact-extract-dest-')); + fs.rmSync(destDir, { recursive: true, force: true }); // extractComputeArtifact creates it + extractComputeArtifact(artifact.path, destDir); + + const extracted = readAll(destDir); + expect(extracted['main.js']).toBe('export default { run: async () => {} };'); + expect(extracted['nested/asset.txt']).toBe('hello world'); + expect(extracted['bootstrap.js']).toContain( + 'await main.run("auth", () => import("./server.js"));', + ); + expect(JSON.parse(extracted['compute.manifest.json'] ?? '{}')).toEqual({ + manifestVersion: '1', + entrypoint: 'bootstrap.js', + address: 'auth', + }); + expect(extracted['bunfig.toml']).toContain('auto = "disable"'); + }); + + test('rejects a tar entry with a non-regular-file type', () => { + // Hand-build a one-entry ustar archive with typeflag '5' (directory) to + // prove extraction rejects anything packageComputeArtifact never writes. + const header = Buffer.alloc(512); + header.write('somedir/', 0, 100, 'utf8'); + header.write('0000644\0', 100, 8, 'utf8'); + header.write('0000000\0', 108, 8, 'utf8'); + header.write('0000000\0', 116, 8, 'utf8'); + header.write('00000000000\0', 124, 12, 'utf8'); + header.write('00000000000\0', 136, 12, 'utf8'); + header.write(' ', 148, 8, 'utf8'); + header.write('5', 156, 1, 'utf8'); // typeflag: directory + header.write('ustar\0', 257, 6, 'utf8'); + header.write('00', 263, 2, 'utf8'); + let sum = 0; + for (const b of header) sum += b; + header.write(`${sum.toString(8).padStart(6, '0')}\0 `, 148, 8, 'utf8'); + const tar = Buffer.concat([header, Buffer.alloc(1024)]); + const gz = zlib.gzipSync(tar); + + const tmpGz = path.join( + fs.mkdtempSync(path.join(os.tmpdir(), 'artifact-extract-bad-')), + 'bad.tar.gz', + ); + fs.writeFileSync(tmpGz, gz); + const destDir = path.join(path.dirname(tmpGz), 'dest'); + + expect(() => extractComputeArtifact(tmpGz, destDir)).toThrow(/has type "Directory"/); + }); +}); diff --git a/packages/1-prisma-cloud/0-lowering/local-target/src/__tests__/postgres-instance-name-drift.test.ts b/packages/1-prisma-cloud/0-lowering/local-target/src/__tests__/postgres-instance-name-drift.test.ts new file mode 100644 index 000000000..63e962a90 --- /dev/null +++ b/packages/1-prisma-cloud/0-lowering/local-target/src/__tests__/postgres-instance-name-drift.test.ts @@ -0,0 +1,109 @@ +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; +import { fileURLToPath } from 'node:url'; +import type { ContainerInstance, LocalTargetProvidersInput } from '@internal/core/config'; +import { ensureDaemon, instanceNameFor, postgresClient } from '@internal/dev-emulators'; +import { Connection, Database } from '@internal/lowering/postgres'; +import * as Effect from 'effect/Effect'; +import * as Redacted from 'effect/Redacted'; +import { LocalConnectionProvider, LocalDatabaseProvider } from '../postgres.ts'; + +/** + * Delta review finding A (#160): `LocalDatabaseProvider` used to re-derive + * the instance name with its OWN slug, which dropped the daemon's + * leading/trailing-dash trim — a database id or app name ending in a + * hyphen (the wire protocol's own segment rule already forbids a LEADING + * non-alphanumeric, so a trailing hyphen is the pathological shape that + * actually reaches these providers) produced a DIFFERENT concatenated name + * here than the one the daemon actually created the server under (a + * doubled dash at the boundary), so `LocalConnectionProvider`'s + * listing-based lookup threw `noRecordedInstanceError` even though the + * server existed. Both providers now import `instanceNameFor` directly + * from `@internal/dev-emulators` — one implementation, so this can no + * longer drift. This test proves it end to end, against the real daemon, + * for exactly the pathological shape that used to fail: an app name AND a + * database id each ending in a hyphen. + */ + +const APP = 'pgdrifttestapp-'; +const DATABASE_ID = 'orders-'; + +function fakeContainer(appName: string): ContainerInstance { + return { input: { appName, stage: undefined }, serialize: () => 'x' }; +} + +// The default, machine-global daemon (the SAME one `postgresClient()` inside +// the providers under test talks to) — this test's whole point is proving +// the providers agree with the REAL daemon, so it must run against the one +// registry those providers actually resolve, not an isolated test seam. +// `ensureDaemon` adopts an already-healthy daemon, so this is cheap and +// idempotent alongside every other suite that also ensures it. +beforeAll(async () => { + const entry = fileURLToPath(import.meta.resolve('@internal/dev-emulators/postgres-main')); + await ensureDaemon('postgres', entry); +}); + +afterAll(async () => { + await postgresClient() + .deleteApp(APP) + .catch(() => undefined); +}); + +describe('instance-name drift (delta review finding A, #160)', () => { + test('a database id and app name with leading/trailing non-alphanumerics: Database ensures, Connection resolves the SAME server', async () => { + const input: LocalTargetProvidersInput = { + container: fakeContainer(APP), + devDir: '/dev/null/unused', + }; + + // 1. Ensure through the real daemon (LocalDatabaseProvider's own reconcile). + const databaseService = await Effect.runPromise( + Database.Provider.pipe(Effect.provide(LocalDatabaseProvider(input))), + ); + const databaseAttributes = await Effect.runPromise( + databaseService.reconcile({ + id: 'db', + instanceId: 'db', + news: { projectId: 'p', name: DATABASE_ID, region: 'us-east-1' }, + olds: undefined, + output: undefined, + session: undefined as never, + bindings: [], + }), + ); + + // The provider-derived id is exactly the daemon's own derivation — no + // second implementation to drift from it. + expect(databaseAttributes.id).toBe(instanceNameFor(APP, DATABASE_ID)); + expect(databaseAttributes.id).toBe('pcdev-pgdrifttestapp-orders'); + // Proves the trim/collapse actually happened — the pre-fix drift left a + // doubled dash at the "pgdrifttestapp-" + "-" + "orders-" boundary. + expect(databaseAttributes.id.includes('--')).toBe(false); + + // 2. Connection-resolve through the listing (LocalConnectionProvider's + // own reconcile) — before the fix, this threw noRecordedInstanceError + // for this exact pathological pair. + const connectionService = await Effect.runPromise( + Connection.Provider.pipe(Effect.provide(LocalConnectionProvider(input))), + ); + const connectionAttributes = await Effect.runPromise( + connectionService.reconcile({ + id: 'conn', + instanceId: 'conn', + news: { databaseId: databaseAttributes.id, name: 'conn' }, + olds: undefined, + output: undefined, + session: undefined as never, + bindings: [], + }), + ); + + expect(connectionAttributes.id).toBe(databaseAttributes.id); + expect(Redacted.value(connectionAttributes.connectionString)).toMatch(/^postgres:\/\//); + + // 3. The daemon's own listing agrees on the same name too — the third + // independent read of the same value. + const listed = await postgresClient().listDatabases(APP); + const entry = listed.find((d) => d.instanceName === databaseAttributes.id); + expect(entry).toBeDefined(); + }, 30_000); +}); diff --git a/packages/1-prisma-cloud/0-lowering/local-target/src/__tests__/postgres-module-resolution.test.ts b/packages/1-prisma-cloud/0-lowering/local-target/src/__tests__/postgres-module-resolution.test.ts new file mode 100644 index 000000000..6021e210d --- /dev/null +++ b/packages/1-prisma-cloud/0-lowering/local-target/src/__tests__/postgres-module-resolution.test.ts @@ -0,0 +1,70 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { resolvePrismaDevModulePath } from '../postgres.ts'; + +/** + * `resolvePrismaDevModulePath`'s two-step resolution (local-dev spec § 4, + * REVISED — operator review of #162): resolve `@prisma/dev` directly from + * the app's own node_modules first; on failure, resolve `prisma` (which + * apps typically depend on, and which carries `@prisma/dev` as its own + * dependency) and resolve `@prisma/dev` from there; both failing throws the + * pinned error. + */ +describe('resolvePrismaDevModulePath', () => { + let cwd: string; + + beforeEach(() => { + // realpath'd — node's own module resolution resolves symlinks (macOS's + // `/tmp` → `/private/tmp`), and the assertions below compare against it. + cwd = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), 'dev-postgres-module-resolution-test-')), + ); + fs.writeFileSync(path.join(cwd, 'package.json'), JSON.stringify({ name: 'app' })); + }); + + afterEach(() => { + fs.rmSync(cwd, { recursive: true, force: true }); + }); + + function writeModule(dir: string, name: string): void { + const modDir = path.join(dir, ...name.split('/')); + fs.mkdirSync(modDir, { recursive: true }); + fs.writeFileSync(path.join(modDir, 'package.json'), JSON.stringify({ name, main: 'index.js' })); + fs.writeFileSync(path.join(modDir, 'index.js'), 'module.exports = {};\n'); + } + + test('resolves @prisma/dev directly when the app depends on it', () => { + writeModule(path.join(cwd, 'node_modules'), '@prisma/dev'); + + const resolved = resolvePrismaDevModulePath(cwd); + + expect(resolved).toBe(path.join(cwd, 'node_modules', '@prisma', 'dev', 'index.js')); + }); + + test("falls back to resolving @prisma/dev from prisma's own dependency tree", () => { + writeModule(path.join(cwd, 'node_modules'), 'prisma'); + writeModule(path.join(cwd, 'node_modules', 'prisma', 'node_modules'), '@prisma/dev'); + + const resolved = resolvePrismaDevModulePath(cwd); + + expect(resolved).toBe( + path.join(cwd, 'node_modules', 'prisma', 'node_modules', '@prisma', 'dev', 'index.js'), + ); + }); + + test('neither @prisma/dev nor prisma installed throws the pinned error', () => { + expect(() => resolvePrismaDevModulePath(cwd)).toThrow( + 'local dev needs @prisma/dev for its local Postgres emulator — add "prisma" to your app\'s devDependencies.', + ); + }); + + test('prisma installed but without @prisma/dev throws the pinned error', () => { + writeModule(path.join(cwd, 'node_modules'), 'prisma'); + + expect(() => resolvePrismaDevModulePath(cwd)).toThrow( + 'local dev needs @prisma/dev for its local Postgres emulator — add "prisma" to your app\'s devDependencies.', + ); + }); +}); diff --git a/packages/1-prisma-cloud/0-lowering/local-target/src/app-name.ts b/packages/1-prisma-cloud/0-lowering/local-target/src/app-name.ts new file mode 100644 index 000000000..ba13b7f38 --- /dev/null +++ b/packages/1-prisma-cloud/0-lowering/local-target/src/app-name.ts @@ -0,0 +1,22 @@ +import type { ContainerInstance } from '@internal/core/config'; + +function containerNotResolvedError(): Error { + return new Error( + "local dev: no container was resolved for this provider — the extension's " + + '`localTarget.container` descriptor did not run before converge.', + ); +} + +/** + * Every local provider's emulator app namespace is its resolved local + * container's `input.appName` — a field already on core's generic + * `ContainerInstance` (no narrowing to a target-specific container class + * needed for it). Narrowing to e.g. the prisma-cloud target's + * `PrismaCloudContainer` is not available here: `@internal/local-target` + * sits below the extensions layer and cannot import it (ADR-0028's layer + * order). + */ +export function appNameOf(container: ContainerInstance | undefined): string { + if (container === undefined) throw containerNotResolvedError(); + return container.input.appName; +} diff --git a/packages/1-prisma-cloud/0-lowering/local-target/src/artifact-extract.ts b/packages/1-prisma-cloud/0-lowering/local-target/src/artifact-extract.ts new file mode 100644 index 000000000..33420649d --- /dev/null +++ b/packages/1-prisma-cloud/0-lowering/local-target/src/artifact-extract.ts @@ -0,0 +1,70 @@ +/** + * Extracts the tar.gz `@internal/lowering`'s `packageComputeArtifact` writes + * (local-dev spec § 4) — emulation-only code, so it lives here rather than + * beside the writer (production source stays physically separated from + * emulation). Reading tar is commodity even though the writer is a + * deterministic subset we own (fixed mtimes, sorted entries): the maintained + * `tar` package (dependency razor) does the parsing; this module keeps only + * the pinned entry filtering (regular files only, reject links/devices, + * reject path escapes) and the directory-level temp-then-rename. + */ +import * as crypto from 'node:crypto'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as tar from 'tar'; + +function unsupportedEntryError(entryPath: string, type: string): Error { + return new Error( + `compute artifact entry "${entryPath}" has type "${type}" — only regular files are ` + + 'supported; this artifact was not produced by packageComputeArtifact.', + ); +} + +function pathEscapeError(entryPath: string): Error { + return new Error( + `compute artifact entry "${entryPath}" escapes the extraction directory — this artifact ` + + 'was not produced by packageComputeArtifact.', + ); +} + +const REGULAR_FILE_TYPES = new Set(['File', 'OldFile', 'ContiguousFile']); + +/** + * Extracts `tarGzPath` (a `packageComputeArtifact` tar.gz) into `destDir`, + * temp-then-rename at the directory level: entries are written into a + * sibling temp directory first, then that directory is renamed onto + * `destDir` — a concurrent unpack of the SAME hash races benignly (last + * rename wins, both write identical bytes), and a reader of `destDir` never + * sees a partial tree. + */ +export function extractComputeArtifact(tarGzPath: string, destDir: string): void { + const parentDir = path.dirname(destDir); + fs.mkdirSync(parentDir, { recursive: true }); + const tmpDir = path.join(parentDir, `.${path.basename(destDir)}.${crypto.randomUUID()}.tmp`); + fs.mkdirSync(tmpDir, { recursive: true }); + try { + tar.x({ + file: tarGzPath, + cwd: tmpDir, + sync: true, + // preservePaths stays at its default (false): tar itself strips a + // leading `/` and refuses a `..`-escaping path — the explicit check + // below still names the entry in a pinned error rather than leaving + // tar's own generic warning as the only signal. + onentry: (entry) => { + if (!REGULAR_FILE_TYPES.has(entry.type)) { + throw unsupportedEntryError(entry.path, entry.type); + } + const resolved = path.resolve(tmpDir, entry.path); + if (resolved !== tmpDir && !resolved.startsWith(`${tmpDir}${path.sep}`)) { + throw pathEscapeError(entry.path); + } + }, + }); + fs.rmSync(destDir, { recursive: true, force: true }); + fs.renameSync(tmpDir, destDir); + } catch (error) { + fs.rmSync(tmpDir, { recursive: true, force: true }); + throw error; + } +} diff --git a/packages/1-prisma-cloud/0-lowering/local-target/src/bucket.ts b/packages/1-prisma-cloud/0-lowering/local-target/src/bucket.ts new file mode 100644 index 000000000..b50ac5bc6 --- /dev/null +++ b/packages/1-prisma-cloud/0-lowering/local-target/src/bucket.ts @@ -0,0 +1,81 @@ +/** + * Local bucket-cluster providers (local-dev spec § 4): both `Bucket` and + * `BucketKey` are clients of the machine-global bucket emulator — already up + * by the time these run, since the extension's `localTarget.emulators` hook + * ensures it before converge. + */ +import * as path from 'node:path'; +import type { LocalTargetProvidersInput } from '@internal/core/config'; +import { bucketsClient } from '@internal/dev-emulators'; +import { Bucket, BucketKey } from '@internal/lowering/buckets'; +import { mintKeyPair } from '@internal/s3-protocol'; +import * as Provider from 'alchemy/Provider'; +import * as Effect from 'effect/Effect'; +import type * as Layer from 'effect/Layer'; +import * as Redacted from 'effect/Redacted'; +import { appNameOf } from './app-name.ts'; + +/** `Bucket` → registers `--` with the bucket emulator, backed by an in-project data root. */ +export function LocalBucketProvider( + input: LocalTargetProvidersInput, +): Layer.Layer> { + const service: Provider.ProviderService = { + list: () => Effect.succeed([]), + reconcile: ({ news }) => + Effect.tryPromise({ + try: async () => { + const app = appNameOf(input.container); + const dir = path.join(input.devDir, 'buckets', news.name); + await bucketsClient().putBucket(app, news.name, dir); + return { id: news.name, name: news.name }; + }, + catch: (cause) => cause, + }), + // Objects belong to the developer; only `--fresh` removes them. + delete: () => Effect.void, + read: ({ output }) => Effect.succeed(output), + }; + return Provider.effect(Bucket, Effect.succeed(service)); +} + +/** + * `BucketKey` → mint-once-stable (the same lifecycle as `ServiceKey`/ + * `S3Credentials`: reuse `output`'s pair when present, mint only on first + * create) — but ALWAYS re-registers the (prior or fresh) pair with the + * emulator, so a bucket emulator whose own state was wiped self-heals on the + * next converge. + */ +export function LocalBucketKeyProvider( + input: LocalTargetProvidersInput, +): Layer.Layer> { + const service: Provider.ProviderService = { + list: () => Effect.succeed([]), + reconcile: ({ news, output }) => + Effect.tryPromise({ + try: async () => { + const app = appNameOf(input.container); + const pair = + output !== undefined + ? { + accessKeyId: output.accessKeyId, + secretAccessKey: Redacted.value(output.secretAccessKey), + } + : mintKeyPair(); + const client = bucketsClient(); + await client.putCredentials(app, pair.accessKeyId, pair.secretAccessKey); + return { + id: news.name, + bucketId: news.bucketId, + accessKeyId: pair.accessKeyId, + secretAccessKey: Redacted.make(pair.secretAccessKey), + endpoint: client.baseUrl, + bucketName: `${app}--${news.name}`, + }; + }, + catch: (cause) => cause, + }), + delete: () => Effect.void, + read: ({ output }) => Effect.succeed(output), + }; + return Provider.effect(BucketKey, Effect.succeed(service)); +} diff --git a/packages/1-prisma-cloud/0-lowering/local-target/src/compute.ts b/packages/1-prisma-cloud/0-lowering/local-target/src/compute.ts new file mode 100644 index 000000000..579de3e20 --- /dev/null +++ b/packages/1-prisma-cloud/0-lowering/local-target/src/compute.ts @@ -0,0 +1,215 @@ +/** + * Local compute-cluster providers (local-dev spec § 4): `ComputeService` and + * `Deployment` become clients of the machine-scoped Compute emulator; + * `EnvironmentVariable` becomes a row in the dev env store; `Project` is a + * total-but-unused identity stand-in (no lowering yields one today). Every + * factory takes `LocalTargetProvidersInput` — the app name is + * `input.container`'s `input.appName` (see `app-name.ts`), `devDir` is + * `input.devDir`; nothing here reads `process.cwd()` or the environment. + */ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import type { LocalTargetProvidersInput } from '@internal/core/config'; +import { computeClient } from '@internal/dev-emulators'; +import { + ComputeService, + Deployment, + type DeploymentAttributes, + EnvironmentVariable, +} from '@internal/lowering/compute'; +import { Project } from '@internal/lowering/postgres'; +import * as Provider from 'alchemy/Provider'; +import * as Effect from 'effect/Effect'; +import type * as Layer from 'effect/Layer'; +import { appNameOf } from './app-name.ts'; +import { extractComputeArtifact } from './artifact-extract.ts'; +import { envStore, secretsStore } from './dev-store.ts'; + +/** + * The env-var key the app's own boot-side `deserialize()` reads for its + * `port` service param — `COMPOSER_
_PORT`. Mirrors + * `@prisma/composer-prisma-cloud`'s serializer.ts `configKey(address, { + * owner: 'service', name: 'port' })` byte-for-byte; duplicated rather than + * imported because `@internal/local-target` sits below the extensions layer + * and cannot import a target's serializer (ADR-0028's layer order) — the two + * encode one shared wire protocol (ADR-0029) and must never diverge. + */ +function servicePortEnvKey(address: string): string { + const segments = address.split('.').filter((s) => s.length > 0); + return ['COMPOSER', ...segments, 'PORT'].join('_').toUpperCase(); +} + +function manifestMissingAddressError(): Error { + return new Error( + 'artifact manifest carries no address — repackage with a current @prisma/composer.', + ); +} + +interface ComputeManifest { + readonly manifestVersion: string; + readonly entrypoint: string; + readonly address?: string; +} + +function isComputeManifest(value: unknown): value is ComputeManifest { + return ( + typeof value === 'object' && + value !== null && + 'manifestVersion' in value && + typeof value.manifestVersion === 'string' && + 'entrypoint' in value && + typeof value.entrypoint === 'string' + ); +} + +/** + * Reads the address the artifact was packaged for out of its own + * `compute.manifest.json` — intrinsic artifact metadata (the SAME manifest + * `bootstrap.js` was baked from), never dev config threaded through the + * platform's `Deployment` primitive (local-dev spec § 4, REVERTED — + * operator review of #162). + */ +function readManifestAddress(artifactDir: string): string { + const manifestPath = path.join(artifactDir, 'compute.manifest.json'); + let parsed: unknown; + try { + parsed = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + } catch { + throw manifestMissingAddressError(); + } + if (!isComputeManifest(parsed) || parsed.address === undefined) { + throw manifestMissingAddressError(); + } + return parsed.address; +} + +async function materializeEnv( + devDir: string, + address: string, + port: number, +): Promise> { + const env: Record = { ...(await envStore(devDir).read()) }; + env[servicePortEnvKey(address)] = JSON.stringify(port); + const secrets = await secretsStore(devDir).read(); + for (const [key, value] of Object.entries(secrets)) env[key] = value; + if (process.env['PATH'] !== undefined) env['PATH'] = process.env['PATH']; + if (process.env['HOME'] !== undefined) env['HOME'] = process.env['HOME']; + return env; +} + +/** + * `ComputeService` → the Compute emulator: reserves (or returns) the + * service's stable port. `delete` is a no-op — instance removal belongs to + * `teardown` (`DELETE /apps/`), not per-resource Alchemy deletes. + */ +export function LocalComputeServiceProvider( + input: LocalTargetProvidersInput, +): Layer.Layer> { + const service: Provider.ProviderService = { + list: () => Effect.succeed([]), + reconcile: ({ news }) => + Effect.tryPromise({ + try: async () => { + const app = appNameOf(input.container); + const { url } = await computeClient().ensureService(app, news.name); + return { id: news.name, name: news.name, endpointDomain: url }; + }, + catch: (cause) => cause, + }), + delete: () => Effect.void, + read: ({ output }) => Effect.succeed(output), + }; + return Provider.effect(ComputeService, Effect.succeed(service)); +} + +/** `EnvironmentVariable` → a key/value row in `/env.json`. Parity with deploy: the poison `DATABASE_URL` rows land here like any other. */ +export function LocalEnvironmentVariableProvider( + input: LocalTargetProvidersInput, +): Layer.Layer> { + const service: Provider.ProviderService = { + list: () => Effect.succeed([]), + reconcile: ({ news }) => + Effect.tryPromise({ + try: async () => { + await envStore(input.devDir).update((current) => ({ + ...current, + [news.key]: news.value, + })); + return { id: news.key, key: news.key }; + }, + catch: (cause) => cause, + }), + delete: ({ output }) => + Effect.tryPromise({ + try: async () => { + await envStore(input.devDir).update((current) => { + const next = { ...current }; + delete next[output.key]; + return next; + }); + }, + catch: (cause) => cause, + }), + }; + return Provider.effect(EnvironmentVariable, Effect.succeed(service)); +} + +/** + * `Deployment` → unpacks the artifact once per hash, fetches the emulator's + * assigned port, materializes the child's full env (env store + secrets + + * the port override + `PATH`/`HOME`), and puts the deployment — the emulator + * (re)starts the child only when the hash or env actually changed. + */ +export function LocalDeploymentProvider( + input: LocalTargetProvidersInput, +): Layer.Layer> { + const service: Provider.ProviderService = { + list: () => Effect.succeed([]), + reconcile: ({ news }) => + Effect.tryPromise({ + try: async (): Promise => { + const app = appNameOf(input.container); + const id = news.computeServiceId; + + const artifactDir = path.join(input.devDir, 'artifacts', news.artifactHash); + if (!fs.existsSync(artifactDir)) { + extractComputeArtifact(news.artifactPath, artifactDir); + } + const address = readManifestAddress(artifactDir); + + const { port } = await computeClient().ensureService(app, id); + const env = await materializeEnv(input.devDir, address, port); + await computeClient().putDeployment(app, id, { + address, + artifactDir, + artifactHash: news.artifactHash, + env, + port, + }); + + return { deploymentId: news.artifactHash, deployedUrl: `http://localhost:${port}` }; + }, + catch: (cause) => cause, + }), + // Content-addressed, cheap to leave unpacked; `--fresh` removes the whole + // dev dir and `teardown` removes the emulator's instance. + delete: () => Effect.void, + }; + return Provider.effect(Deployment, Effect.succeed(service)); +} + +/** + * `Project` — identity only; present so the provider collection stays total. + * No lowering yields a `Project` resource today (mirrors the hosted + * `Project` provider, which is also never exercised — see postgres.ts). + */ +export function LocalProjectProvider( + _input: LocalTargetProvidersInput, +): Layer.Layer> { + const service: Provider.ProviderService = { + list: () => Effect.succeed([]), + reconcile: ({ news }) => Effect.succeed({ id: 'local', name: news.name }), + delete: () => Effect.void, + }; + return Provider.effect(Project, Effect.succeed(service)); +} diff --git a/packages/1-prisma-cloud/0-lowering/local-target/src/dev-store.ts b/packages/1-prisma-cloud/0-lowering/local-target/src/dev-store.ts new file mode 100644 index 000000000..cc05e0504 --- /dev/null +++ b/packages/1-prisma-cloud/0-lowering/local-target/src/dev-store.ts @@ -0,0 +1,118 @@ +/** + * The shared dev-instance store (local-dev spec § 4): the JSON files under + * `/` every local provider reads or writes. Every write is + * temp-then-rename; reads and writes on the SAME file are additionally + * serialized behind one in-process async queue per absolute path, because the + * local providers reconcile concurrently inside the one alchemy child and a + * naive read-modify-write would drop a concurrent writer's key. Postgres + * instance state is NOT here (REVISED — operator review of #162): the + * `postgres-main` daemon owns it now. + */ +import * as crypto from 'node:crypto'; +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; + +function isEnoent(err: unknown): boolean { + return err instanceof Error && 'code' in err && err.code === 'ENOENT'; +} + +// One queue per absolute file path, at module scope — so every caller across +// every provider factory (each of which may construct its own `DevStore` +// handle) serializes through the SAME queue for a given file, not just +// callers sharing one JS object. +const queues = new Map>(); + +function enqueue(filePath: string, task: () => Promise): Promise { + const prior = (queues.get(filePath) ?? Promise.resolve()).then( + () => undefined, + () => undefined, + ); + const result = prior.then(task); + queues.set( + filePath, + result.then( + () => undefined, + () => undefined, + ), + ); + return result; +} + +async function readJson( + filePath: string, + isValid: (value: unknown) => value is T, + empty: T, +): Promise { + let raw: string; + try { + raw = await fs.readFile(filePath, 'utf8'); + } catch (err) { + if (isEnoent(err)) return empty; + throw err; + } + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return empty; // a corrupt/foreign file is treated as absent, never trusted + } + return isValid(parsed) ? parsed : empty; +} + +async function writeJsonAtomic( + filePath: string, + value: unknown, + mode: number | undefined, +): Promise { + const dir = path.dirname(filePath); + await fs.mkdir(dir, { recursive: true }); + const tmpPath = path.join(dir, `.${path.basename(filePath)}.${crypto.randomUUID()}.tmp`); + const json = `${JSON.stringify(value, null, 2)}\n`; + await fs.writeFile(tmpPath, json, mode !== undefined ? { mode } : undefined); + await fs.rename(tmpPath, filePath); + if (mode !== undefined) await fs.chmod(filePath, mode); +} + +export interface DevStore { + /** The current contents — the empty shape when the file has never been written. */ + read(): Promise; + /** Read-modify-write behind this file's queue: `mutate` sees the latest committed contents. */ + update(mutate: (current: T) => T): Promise; +} + +function createStore( + filePath: string, + isValid: (value: unknown) => value is T, + empty: T, + mode?: number, +): DevStore { + return { + read: () => enqueue(filePath, () => readJson(filePath, isValid, empty)), + update: (mutate) => + enqueue(filePath, async () => { + const current = await readJson(filePath, isValid, empty); + const next = mutate(current); + await writeJsonAtomic(filePath, next, mode); + return next; + }), + }; +} + +function isStringRecord(value: unknown): value is Record { + return ( + typeof value === 'object' && + value !== null && + !Array.isArray(value) && + Object.values(value).every((v) => typeof v === 'string') + ); +} + +/** `/env.json` — every `EnvironmentVariable` row, key → value. Last write wins. */ +export function envStore(devDir: string): DevStore> { + return createStore(path.join(devDir, 'env.json'), isStringRecord, {}); +} + +/** `/secrets.json` — platform var name → value (shell-sourced or minted placeholder). Mode 0o600. */ +export function secretsStore(devDir: string): DevStore> { + return createStore(path.join(devDir, 'secrets.json'), isStringRecord, {}, 0o600); +} diff --git a/packages/1-prisma-cloud/0-lowering/local-target/src/exports/index.ts b/packages/1-prisma-cloud/0-lowering/local-target/src/exports/index.ts new file mode 100644 index 000000000..9f0c0a04c --- /dev/null +++ b/packages/1-prisma-cloud/0-lowering/local-target/src/exports/index.ts @@ -0,0 +1,9 @@ +/** `@internal/local-target`'s public surface (local-dev spec § 4): the local deploy target's provider suite — the dev provider bundle plus the dev-instance store and shared plumbing. Implementation lives in `../dev/*`. */ +export * from '../app-name.ts'; +export * from '../bucket.ts'; +export * from '../compute.ts'; +export * from '../dev-store.ts'; +export * from '../postgres.ts'; +export * from '../providers.ts'; +export * from '../resolve-package-entry.ts'; +export * from '../teardown.ts'; diff --git a/packages/1-prisma-cloud/0-lowering/local-target/src/postgres.ts b/packages/1-prisma-cloud/0-lowering/local-target/src/postgres.ts new file mode 100644 index 000000000..0891f0772 --- /dev/null +++ b/packages/1-prisma-cloud/0-lowering/local-target/src/postgres.ts @@ -0,0 +1,120 @@ +/** + * Local postgres-cluster providers (local-dev spec § 4, REVISED — operator + * review of #162): `Database` and `Connection` become clients of the + * `postgres-main` emulator daemon, which hosts `@prisma/dev`'s programmatic + * `startPrismaDevServer` — one named, persistent server per `Database` + * resource. The CLI shell-out is gone: no bin walk-up, no stdout URL + * parsing, no `prisma dev stop/rm` glob teardown. `PgWarm` and + * `PnMigration` are NOT here; the hosted ones run unchanged against + * whichever URL they are handed. + * + * Instance-name derivation is NOT duplicated here (delta review finding A, + * #160): a locally re-derived slug drifted from the daemon's own + * `instanceNameFor` (no leading/trailing-dash trim), so a database id or + * app name with a leading/trailing non-alphanumeric character (e.g. + * `_orders`) produced a DIFFERENT name here than the one the daemon + * actually created the server under — `Connection`'s lookup by that + * drifted name then threw `noRecordedInstanceError` even though the + * server existed. `instanceNameFor` is imported directly from + * `@internal/dev-emulators` instead, so there is exactly one + * implementation. + */ +import { createRequire } from 'node:module'; +import * as path from 'node:path'; +import type { LocalTargetProvidersInput } from '@internal/core/config'; +import { instanceNameFor, postgresClient } from '@internal/dev-emulators'; +import { Connection, Database } from '@internal/lowering/postgres'; +import * as Provider from 'alchemy/Provider'; +import * as Effect from 'effect/Effect'; +import type * as Layer from 'effect/Layer'; +import * as Redacted from 'effect/Redacted'; +import { appNameOf } from './app-name.ts'; + +function noPrismaDevError(): Error { + return new Error( + 'local dev needs @prisma/dev for its local Postgres emulator — add "prisma" to your app\'s devDependencies.', + ); +} + +/** + * Two-step resolution, pinned (local-dev spec § 4): (1) resolve + * `@prisma/dev` directly from the app's own `node_modules`; (2) on failure, + * resolve `prisma` (the CLI apps typically depend on, which itself carries + * `@prisma/dev`) and resolve `@prisma/dev` from THERE. The daemon imports + * the returned path dynamically, so the app stays in charge of its own + * Prisma version. `cwd` is the one place a local provider legitimately + * reads `process.cwd()` — finding the app's own installed version is + * inherently cwd-relative. + */ +export function resolvePrismaDevModulePath(cwd: string): string { + const appRequire = createRequire(path.join(cwd, 'package.json')); + try { + return appRequire.resolve('@prisma/dev'); + } catch { + // fall through to the prisma-CLI-relative resolution + } + try { + const prismaEntry = appRequire.resolve('prisma'); + return createRequire(prismaEntry).resolve('@prisma/dev'); + } catch { + throw noPrismaDevError(); + } +} + +/** + * `Database` → an ensured `postgres-main` server, one per resource. Stores + * the daemon's returned `url` on its own attributes. + */ +export function LocalDatabaseProvider( + input: LocalTargetProvidersInput, +): Layer.Layer> { + const service: Provider.ProviderService = { + list: () => Effect.succeed([]), + reconcile: ({ news }) => + Effect.tryPromise({ + try: async () => { + const app = appNameOf(input.container); + const prismaDevModulePath = resolvePrismaDevModulePath(process.cwd()); + const { url } = await postgresClient().ensureDatabase( + app, + news.name, + prismaDevModulePath, + ); + const attributes = { id: instanceNameFor(app, news.name), name: news.name, url }; + return attributes; + }, + catch: (cause) => cause, + }), + delete: () => Effect.void, + }; + return Provider.effect(Database, Effect.succeed(service)); +} + +function noRecordedInstanceError(databaseId: string): Error { + return new Error( + `no local Postgres instance recorded for databaseId "${databaseId}" — the Database provider ` + + 'did not run; converge is corrupt (try --fresh).', + ); +} + +/** `Connection` → the daemon's live listing, matched by instance name (the Database attributes' `id` IS the instance name). */ +export function LocalConnectionProvider( + input: LocalTargetProvidersInput, +): Layer.Layer> { + const service: Provider.ProviderService = { + list: () => Effect.succeed([]), + reconcile: ({ news }) => + Effect.tryPromise({ + try: async () => { + const app = appNameOf(input.container); + const databases = await postgresClient().listDatabases(app); + const found = databases.find((entry) => entry.instanceName === news.databaseId); + if (found === undefined) throw noRecordedInstanceError(news.databaseId); + return { id: found.instanceName, connectionString: Redacted.make(found.url) }; + }, + catch: (cause) => cause, + }), + delete: () => Effect.void, + }; + return Provider.effect(Connection, Effect.succeed(service)); +} diff --git a/packages/1-prisma-cloud/0-lowering/local-target/src/providers.ts b/packages/1-prisma-cloud/0-lowering/local-target/src/providers.ts new file mode 100644 index 000000000..9db09ab0b --- /dev/null +++ b/packages/1-prisma-cloud/0-lowering/local-target/src/providers.ts @@ -0,0 +1,51 @@ +/** + * The local-target provider bundle (local-dev spec § 4): the SAME + * `Providers` collection tag `providers()` uses, backed by the eight local + * providers instead of the hosted Management-API-backed ones. No + * `ManagementClient`, no credentials layer — this bundle must typecheck + * without either. + */ +import type { LocalTargetProvidersInput } from '@internal/core/config'; +import { Providers } from '@internal/lowering'; +import { Bucket, BucketKey } from '@internal/lowering/buckets'; +import { ComputeService, Deployment, EnvironmentVariable } from '@internal/lowering/compute'; +import { Connection, Database, Project } from '@internal/lowering/postgres'; +import * as Provider from 'alchemy/Provider'; +import * as Layer from 'effect/Layer'; +import { LocalBucketKeyProvider, LocalBucketProvider } from './bucket.ts'; +import { + LocalComputeServiceProvider, + LocalDeploymentProvider, + LocalEnvironmentVariableProvider, + LocalProjectProvider, +} from './compute.ts'; +import { LocalConnectionProvider, LocalDatabaseProvider } from './postgres.ts'; + +export const localTargetProviders = (input: LocalTargetProvidersInput): Layer.Layer => + Layer.effect( + Providers, + Provider.collection([ + Project, + Database, + Connection, + ComputeService, + Deployment, + EnvironmentVariable, + Bucket, + BucketKey, + ]), + ).pipe( + Layer.provide( + Layer.mergeAll( + LocalProjectProvider(input), + LocalDatabaseProvider(input), + LocalConnectionProvider(input), + LocalComputeServiceProvider(input), + LocalDeploymentProvider(input), + LocalEnvironmentVariableProvider(input), + LocalBucketProvider(input), + LocalBucketKeyProvider(input), + ), + ), + Layer.orDie, + ); diff --git a/packages/1-prisma-cloud/0-lowering/local-target/src/resolve-package-entry.ts b/packages/1-prisma-cloud/0-lowering/local-target/src/resolve-package-entry.ts new file mode 100644 index 000000000..2cf37f4ad --- /dev/null +++ b/packages/1-prisma-cloud/0-lowering/local-target/src/resolve-package-entry.ts @@ -0,0 +1,14 @@ +/** + * `import.meta.resolve(specifier)` + `fileURLToPath` in one call (local-dev + * spec § 2's publish note). Lives here, not in the target extension's own + * `src/local-target/emulators.ts`, because that extension's shipped source must stay + * free of `node:`/`bun:` imports (invariant 5, + * `src/__tests__/invariants.test.ts`) — every actual filesystem/runtime + * touch is delegated to `@internal/local-target`, which runs in the CLI + * parent and carries no such restriction. + */ +import { fileURLToPath } from 'node:url'; + +export function resolvePackageEntry(specifier: string): string { + return fileURLToPath(import.meta.resolve(specifier)); +} diff --git a/packages/1-prisma-cloud/0-lowering/local-target/src/teardown.ts b/packages/1-prisma-cloud/0-lowering/local-target/src/teardown.ts new file mode 100644 index 000000000..2c8637f4e --- /dev/null +++ b/packages/1-prisma-cloud/0-lowering/local-target/src/teardown.ts @@ -0,0 +1,16 @@ +/** + * The fs-touching half of `--fresh` teardown (local-dev spec § 5, + * ADR-0041 D12). The target extension's `dev/teardown.ts` orchestrates + * (which app, which emulator clients); this module owns the actual + * filesystem operations, so the extension's own source stays free of + * `node:`/`bun:` imports (its invariant 5). Postgres instance removal is + * NOT here (REVISED — operator review of #162): it is a + * `postgresClient().deleteApp(app)` call, made directly alongside the + * compute/buckets clients — no filesystem or child-process work of its own. + */ +import * as fs from 'node:fs'; + +/** `fs.rm(..., { recursive: true, force: true })` for every given path — tolerates absence. */ +export function removeLocalPaths(paths: readonly string[]): void { + for (const target of paths) fs.rmSync(target, { recursive: true, force: true }); +} diff --git a/packages/1-prisma-cloud/0-lowering/local-target/tsconfig.json b/packages/1-prisma-cloud/0-lowering/local-target/tsconfig.json new file mode 100644 index 000000000..a16ed256c --- /dev/null +++ b/packages/1-prisma-cloud/0-lowering/local-target/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../../../tsconfig.base.json", + "compilerOptions": { + "types": ["bun-types"] + }, + "include": ["src"] +} diff --git a/packages/1-prisma-cloud/0-lowering/local-target/tsdown.config.ts b/packages/1-prisma-cloud/0-lowering/local-target/tsdown.config.ts new file mode 100644 index 000000000..b21e84a8f --- /dev/null +++ b/packages/1-prisma-cloud/0-lowering/local-target/tsdown.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from '@internal/tsdown-config'; + +export default defineConfig({ + entry: { + index: 'src/exports/index.ts', + }, +}); diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/__tests__/artifact.test.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/__tests__/artifact.test.ts index 353f7677b..82020346e 100644 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/__tests__/artifact.test.ts +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/__tests__/artifact.test.ts @@ -57,20 +57,21 @@ describe('packageComputeArtifact', () => { expect(bootstrap).toContain('await main.run("auth", () => import("./server.js"));'); }); - test('writes compute.manifest.json with entrypoint bootstrap.js', () => { + test('writes compute.manifest.json with entrypoint bootstrap.js and the packaged address', () => { const bundleDir = makeBundle({ 'main.js': 'export default {};' }); const artifact = packageComputeArtifact({ id: 'hello', bundleDir, appEntry: 'server.js', - address: '', + address: 'auth', }); const { read } = readTar(fs.readFileSync(artifact.path)); expect(JSON.parse(read('compute.manifest.json'))).toEqual({ manifestVersion: '1', entrypoint: 'bootstrap.js', + address: 'auth', }); }); diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/compute/artifact.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/compute/artifact.ts index 2fb53af6f..de79c3eed 100644 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/compute/artifact.ts +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/compute/artifact.ts @@ -145,7 +145,18 @@ export function packageComputeArtifact(opts: PackageComputeArtifactOptions): Com const entryFile = resolveEntry(opts.bundleDir, opts.bundleEntry); const bootstrap = `import main from "./${entryFile}";\nawait main.run(${JSON.stringify(opts.address)}, () => import(${JSON.stringify(`./${opts.appEntry}`)}));\n`; - const manifest = `${JSON.stringify({ manifestVersion: MANIFEST_VERSION, entrypoint: 'bootstrap.js' }, null, 2)}\n`; + // `address` is intrinsic artifact metadata, not dev config — bootstrap.js + // above already bakes `main.run(address, …)`, so the manifest carrying it + // too is the same fact recorded twice: once for the boot path, once for a + // reader that needs the address WITHOUT executing the artifact (the local + // Deployment provider, which learns nothing else about dev — local-dev + // spec § 4). No version bump — no consumer needs protecting from a new + // field; the platform still reads only `entrypoint`. + const manifest = `${JSON.stringify( + { manifestVersion: MANIFEST_VERSION, entrypoint: 'bootstrap.js', address: opts.address }, + null, + 2, + )}\n`; const files = walkFiles(opts.bundleDir).map((relPath) => ({ relPath, diff --git a/packages/1-prisma-cloud/1-extensions/target/package.json b/packages/1-prisma-cloud/1-extensions/target/package.json index df0c2df3f..b289e7b66 100644 --- a/packages/1-prisma-cloud/1-extensions/target/package.json +++ b/packages/1-prisma-cloud/1-extensions/target/package.json @@ -7,6 +7,7 @@ ".": "./dist/index.mjs", "./connection": "./dist/connection.mjs", "./control": "./dist/control.mjs", + "./local-target": "./dist/local-target.mjs", "./prisma-next": "./dist/prisma-next.mjs", "./testing": "./dist/testing.mjs", "./package.json": "./package.json" @@ -20,7 +21,9 @@ }, "dependencies": { "@internal/core": "workspace:0.2.0", + "@internal/dev-emulators": "workspace:0.2.0", "@internal/foundation": "workspace:0.2.0", + "@internal/local-target": "workspace:0.2.0", "@internal/lowering": "workspace:0.2.0", "@internal/node": "workspace:0.2.0", "@internal/s3-protocol": "workspace:0.2.0", diff --git a/packages/1-prisma-cloud/1-extensions/target/src/__tests__/control-env.test.ts b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/control-env.test.ts index 260a1a1c0..463c7d098 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/__tests__/control-env.test.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/control-env.test.ts @@ -1,4 +1,6 @@ import { describe, expect, test } from 'bun:test'; +import type { LowerContext } from '@internal/core/deploy'; +import * as Effect from 'effect/Effect'; import { prismaCloud } from '../exports/control.ts'; /** Sets env vars for the duration of `fn`, restoring whatever was there before. */ @@ -18,9 +20,28 @@ async function withEnv(values: Record, fn: () => } } -describe('prismaCloud() — env read + validation at construction (config evaluation)', () => { - test('builds a descriptor from PRISMA_WORKSPACE_ID alone', async () => { - await withEnv({ PRISMA_WORKSPACE_ID: 'ws-123', PRISMA_REGION: undefined }, () => { +const SCRUBBED = { + PRISMA_WORKSPACE_ID: undefined, + PRISMA_REGION: undefined, + PRISMA_SERVICE_TOKEN: undefined, +}; + +/** Minimal `LowerContext` for driving one node descriptor's `provision` in isolation. */ +function computeCtx(): LowerContext { + return { + id: 'auth', + application: { projectId: 'shop-project#cloud-id', branchId: undefined }, + } as unknown as LowerContext; +} + +/** The throw under test happens before any yield, so no Alchemy context is ever needed — collapse E/R for `runSync` like `control-lowering.test.ts`'s `run` helper does. */ +function runSync(eff: Effect.Effect): A { + return Effect.runSync(eff as Effect.Effect); +} + +describe('prismaCloud() — constructs with NO environment present (local-dev spec § 5)', () => { + test('succeeds in a fully scrubbed environment — no PRISMA_* var is required at construction', async () => { + await withEnv(SCRUBBED, () => { const descriptor = prismaCloud(); expect(descriptor.id).toBe('@prisma/composer-prisma-cloud'); expect(Object.keys(descriptor.nodes).sort()).toEqual([ @@ -31,34 +52,20 @@ describe('prismaCloud() — env read + validation at construction (config evalua 's3', 's3-store', ]); + // The localTarget descriptor is present unconditionally — an + // extension without one is not local-target-capable, and this one + // always is (ADR-0041). + expect(descriptor.localTarget).toBeDefined(); }); }); - test('an explicit workspaceId option wins — no env needed', async () => { - await withEnv({ PRISMA_WORKSPACE_ID: undefined, PRISMA_REGION: undefined }, () => { + test('an explicit workspaceId option still works — no env needed either way', async () => { + await withEnv(SCRUBBED, () => { expect(() => prismaCloud({ workspaceId: 'ws-explicit' })).not.toThrow(); }); }); - test('accepts a known PRISMA_REGION', async () => { - await withEnv({ PRISMA_WORKSPACE_ID: 'ws-123', PRISMA_REGION: 'eu-west-3' }, () => { - expect(() => prismaCloud()).not.toThrow(); - }); - }); - - test('throws naming PRISMA_WORKSPACE_ID when it is missing', async () => { - await withEnv({ PRISMA_WORKSPACE_ID: undefined, PRISMA_REGION: undefined }, () => { - expect(() => prismaCloud()).toThrow(/PRISMA_WORKSPACE_ID/); - }); - }); - - test('throws naming the bad value when PRISMA_REGION is not a known region', async () => { - await withEnv({ PRISMA_WORKSPACE_ID: 'ws-123', PRISMA_REGION: 'mars-1' }, () => { - expect(() => prismaCloud()).toThrow(/PRISMA_REGION="mars-1"/); - }); - }); - - test('the descriptor carries the registry kinds the router checks', async () => { + test('builds a descriptor from PRISMA_WORKSPACE_ID alone', async () => { await withEnv({ PRISMA_WORKSPACE_ID: 'ws-123', PRISMA_REGION: undefined }, () => { const descriptor = prismaCloud(); expect(descriptor.nodes['postgres']?.kind).toBe('resource'); @@ -71,3 +78,18 @@ describe('prismaCloud() — env read + validation at construction (config evalua }); }); }); + +describe('prismaCloud() — region resolution is deferred to first lowering use, not construction', () => { + test('a bad PRISMA_REGION does not fail construction — only an actual lowering', async () => { + await withEnv({ PRISMA_WORKSPACE_ID: 'ws-123', PRISMA_REGION: 'mars-1' }, () => { + expect(() => prismaCloud()).not.toThrow(); + + const descriptor = prismaCloud(); + const compute = descriptor.nodes['compute']; + if (compute === undefined || compute.kind !== 'service') { + throw new Error('expected a service descriptor for "compute"'); + } + expect(() => runSync(compute.provision(computeCtx()))).toThrow(/PRISMA_REGION="mars-1"/); + }); + }); +}); diff --git a/packages/1-prisma-cloud/1-extensions/target/src/__tests__/control-lowering.test.ts b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/control-lowering.test.ts index 1c1c37685..d0d230cd9 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/__tests__/control-lowering.test.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/control-lowering.test.ts @@ -1655,7 +1655,7 @@ describe("descriptors/compute.ts's provider-param loop is generic over the regis // The three-brand options can't ride the shared registry, so erase the // precise descriptor to the registry's own type — the same assignment // control.ts makes when it registers the real one. - const descriptor: NodeDescriptor = computeDescriptor(o); + const descriptor: NodeDescriptor = computeDescriptor(() => o); if (descriptor.kind !== 'service') throw new Error('expected a service descriptor'); run(descriptor.serialize(ctx, provisioned, config)); diff --git a/packages/1-prisma-cloud/1-extensions/target/src/__tests__/invariants.test.ts b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/invariants.test.ts index c307d5fea..1eb6e5317 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/__tests__/invariants.test.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/invariants.test.ts @@ -50,8 +50,8 @@ function builtEntryGraph(entryFileName: string): string { return parts.join('\n'); } -describe('entry map: authoring + control + prisma-next + testing, no other runtime entry', () => { - test("package.json exports '.', './connection', './control', './prisma-next', and './testing' (cron lives in @internal/cron)", () => { +describe('entry map: authoring + control + local-target + prisma-next + testing, no other runtime entry', () => { + test("package.json exports '.', './connection', './control', './local-target', './prisma-next', and './testing' (cron lives in @internal/cron)", () => { const pkg = JSON.parse(fs.readFileSync(path.join(pkgDir, 'package.json'), 'utf8')); // `./package.json` is a conventional manifest export, not a code entry. const codeEntries = Object.keys(pkg.exports).filter((k) => k !== './package.json'); @@ -59,12 +59,43 @@ describe('entry map: authoring + control + prisma-next + testing, no other runti '.', './connection', './control', + './local-target', './prisma-next', './testing', ]); }); }); +describe("invariant 7 (ADR-0041's lazy local-target reference): the production control entry never reaches local-target implementation", () => { + test("the built './control' bundle graph carries no local-target-implementation token — only the bare-specifier dynamic import to './local-target'", () => { + const graph = builtEntryGraph('control.mjs'); + // Positive marker: the probe genuinely reached the real bundle (the + // hosted-path env read, present regardless of the local target). + expect(graph).toContain('PRISMA_WORKSPACE_ID'); + // The lazy reference itself must survive as a genuine bare-specifier + // dynamic import — not inlined, not rewritten to a relative dist path. + expect(graph).toContain('@prisma/composer-prisma-cloud/local-target'); + for (const token of [ + // Function-shaped tokens check for a trailing "(" — a bare mention of + // the name in a SHARED file's prose doc comment (e.g. container.ts's + // `deserialize` explaining that `dev/container.ts` reuses it) is not a + // leak; an actual call or declaration always has the paren. + 'devContainerDescriptor(', + 'runDevPreflight(', + 'runDevEmulators(', + 'devAttach(', + 'runDevTeardown(', + 'localTargetProviders(', + '@internal/local-target', + ]) { + expect({ token, containsToken: graph.includes(token) }).toEqual({ + token, + containsToken: false, + }); + } + }); +}); + describe('invariant 2: authoring imports stay lean (core + pack)', () => { test('bundling both authoring entries yields no control/execution-plane tokens', async () => { const out = await Bun.build({ @@ -91,7 +122,7 @@ describe('invariant 2: authoring imports stay lean (core + pack)', () => { }); describe('invariant 4: environment touches are confined to the config serializer, the control factory, and the container lifecycle', () => { - test("the process-env token appears only in serializer.ts (param read+stash, reserved-provider-param read+stash — the origin row rides that generic pair — secret double-lookup+stash, env-sourced param double-lookup, readOrigin's stash read), control/extension.ts's prismaCloud() (ADR-0017 — PRISMA_WORKSPACE_ID + optional PRISMA_REGION; the CLI-fed deploy identity now arrives via ctx.container, never env), container.ts (PRISMA_WORKSPACE_ID + PRISMA_SERVICE_TOKEN, ADR-0038's container lifecycle), preflight.ts (shell token + fill-missing lookup), teardown.ts (shell token), and compute.ts (exposes the resolved port as PORT)", () => { + test("the process-env token appears only in serializer.ts (param read+stash, reserved-provider-param read+stash — the origin row rides that generic pair — secret double-lookup+stash, env-sourced param double-lookup, readOrigin's stash read), control/extension.ts's prismaCloud() (ADR-0017 — optional PRISMA_WORKSPACE_ID + optional PRISMA_REGION, neither required — local-dev spec § 5's lazy restructure; the CLI-fed deploy identity now arrives via ctx.container, never env), container.ts (PRISMA_WORKSPACE_ID + PRISMA_SERVICE_TOKEN, ADR-0038's container lifecycle), preflight.ts (shell token + fill-missing lookup), local-target/preflight.ts (dev's own shell-token read — the local-dev value-sourcing policy, ADR-0041), teardown.ts (shell token), and compute.ts (exposes the resolved port as PORT)", () => { const sources = shippedSources(); expect(sources.length).toBeGreaterThan(0); @@ -105,6 +136,7 @@ describe('invariant 4: environment touches are confined to the config serializer { file: 'compute.ts', count: 1 }, { file: 'container.ts', count: 3 }, { file: 'control/extension.ts', count: 2 }, + { file: 'local-target/preflight.ts', count: 2 }, { file: 'preflight.ts', count: 2 }, { file: 'serializer.ts', count: 10 }, { file: 'teardown.ts', count: 1 }, diff --git a/packages/1-prisma-cloud/1-extensions/target/src/__tests__/local-target-preflight.test.ts b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/local-target-preflight.test.ts new file mode 100644 index 000000000..39b252ec1 --- /dev/null +++ b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/local-target-preflight.test.ts @@ -0,0 +1,129 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { Load, module, secret, string } from '@internal/core'; +import { secretsStore } from '@internal/local-target'; +import { PrismaCloudContainer } from '../container.ts'; +import { compute } from '../exports/index.ts'; +import { runDevPreflight } from '../local-target/preflight.ts'; +import { envParam } from '../param.ts'; +import { envSecret } from '../secret.ts'; + +const build = { + extension: '@prisma/composer/node', + type: 'node', + module: 'file:///test/service.ts', + entry: 'server.js', +}; + +const fakeContainer = () => + new PrismaCloudContainer({ appName: 'app', stage: undefined }, 'local', undefined); + +const secretGraph = () => + Load( + module('app', ({ provision }) => { + provision(compute({ name: 'ingest', deps: {}, secrets: { stripeKey: secret() }, build }), { + id: 'ingest', + secrets: { stripeKey: envSecret('STRIPE_SECRET_KEY') }, + }); + }), + ); + +const paramGraph = () => + Load( + module('app', ({ provision }) => { + provision(compute({ name: 'web', deps: {}, params: { appOrigin: string() }, build }), { + id: 'web', + params: { appOrigin: envParam('APP_ORIGIN') }, + }); + }), + ); + +describe('runDevPreflight (local-dev spec S5, ADR-0041 D7)', () => { + let cwd: string; + let previousCwd: string; + let warnings: string[]; + let originalWarn: typeof console.warn; + + beforeEach(() => { + cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'dev-preflight-test-')); + previousCwd = process.cwd(); + process.chdir(cwd); + delete process.env['STRIPE_SECRET_KEY']; + delete process.env['APP_ORIGIN']; + warnings = []; + originalWarn = console.warn; + console.warn = (msg: string) => { + warnings.push(msg); + }; + }); + + afterEach(() => { + process.chdir(previousCwd); + fs.rmSync(cwd, { recursive: true, force: true }); + console.warn = originalWarn; + }); + + test('a missing secret mints a placeholder, persists it, and warns exactly once', async () => { + await runDevPreflight({ graph: secretGraph(), container: fakeContainer(), stage: undefined }); + + const stored = await secretsStore(path.join(cwd, '.prisma-composer', 'dev')).read(); + expect(stored['STRIPE_SECRET_KEY']).toMatch(/^local-placeholder-[0-9a-f]{16}$/); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toBe( + '[dev] STRIPE_SECRET_KEY is not set in this shell — using a local placeholder. Anything ' + + 'that talks to the real service behind it will fail; everything else runs.', + ); + }); + + test('the minted placeholder is stable across two preflight runs', async () => { + await runDevPreflight({ graph: secretGraph(), container: fakeContainer(), stage: undefined }); + const first = (await secretsStore(path.join(cwd, '.prisma-composer', 'dev')).read())[ + 'STRIPE_SECRET_KEY' + ]; + + await runDevPreflight({ graph: secretGraph(), container: fakeContainer(), stage: undefined }); + const second = (await secretsStore(path.join(cwd, '.prisma-composer', 'dev')).read())[ + 'STRIPE_SECRET_KEY' + ]; + + expect(second).toBe(first); + // The pinned policy warns only on the MINT branch — reusing an already- + // persisted placeholder is silent, so the second run adds no warning. + expect(warnings).toHaveLength(1); + }); + + test('a secret set in the shell is used directly, with no placeholder and no warning', async () => { + process.env['STRIPE_SECRET_KEY'] = 'sk_live_from_shell'; + + await runDevPreflight({ graph: secretGraph(), container: fakeContainer(), stage: undefined }); + + const stored = await secretsStore(path.join(cwd, '.prisma-composer', 'dev')).read(); + expect(stored['STRIPE_SECRET_KEY']).toBe('sk_live_from_shell'); + expect(warnings).toEqual([]); + }); + + test('a missing env-sourced param is a hard error listing the name and service, instructing the shell fix', async () => { + const error: unknown = await runDevPreflight({ + graph: paramGraph(), + container: fakeContainer(), + stage: undefined, + }).catch((e: unknown) => e); + + expect(error).toBeInstanceOf(Error); + const message = (error as Error).message; + expect(message).toContain('APP_ORIGIN'); + expect(message).toContain('service "web"'); + expect(message).toContain('Set each in the shell you run `prisma-composer dev` from.'); + }); + + test('an env-sourced param present in the shell is written to secrets.json with no error', async () => { + process.env['APP_ORIGIN'] = 'https://localhost:3000'; + + await runDevPreflight({ graph: paramGraph(), container: fakeContainer(), stage: undefined }); + + const stored = await secretsStore(path.join(cwd, '.prisma-composer', 'dev')).read(); + expect(stored['APP_ORIGIN']).toBe('https://localhost:3000'); + }); +}); diff --git a/packages/1-prisma-cloud/1-extensions/target/src/container.ts b/packages/1-prisma-cloud/1-extensions/target/src/container.ts index 7777865c3..d8eeddaaa 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/container.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/container.ts @@ -67,8 +67,13 @@ function invalidPayloadError(reason: string): Error { ); } -/** Reconstructs a `PrismaCloudContainer` from `serialize()`'s JSON output — real narrowing, no casts. */ -function deserialize(serialized: string): PrismaCloudContainer { +/** + * Reconstructs a `PrismaCloudContainer` from `serialize()`'s JSON output — + * real narrowing, no casts. Exported so `dev/container.ts`'s + * `devContainerDescriptor` can reuse it verbatim (local-dev spec § 5) — the + * dev and deploy container descriptors deserialize the identical wire shape. + */ +export function deserialize(serialized: string): PrismaCloudContainer { let parsed: unknown; try { parsed = JSON.parse(serialized); diff --git a/packages/1-prisma-cloud/1-extensions/target/src/control/extension.ts b/packages/1-prisma-cloud/1-extensions/target/src/control/extension.ts index b00802f23..85422dce7 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/control/extension.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/control/extension.ts @@ -267,14 +267,18 @@ export const PROVIDER_PARAMS: ReadonlyMap ResolvedCloudOptions { + let cached: ResolvedCloudOptions | undefined; + return () => { + cached ??= resolveOptions(opts); + return cached; + }; +} + /** The Prisma Cloud extension descriptor — `prisma-composer.config.ts` lists it under `extensions`. */ export const prismaCloud = (opts: PrismaCloudOptions = {}): ExtensionDescriptor => { - const o = resolveOptions(opts); + const o = lazyOptions(opts); return { id: PRISMA_CLOUD_EXTENSION_ID, @@ -365,5 +385,16 @@ export const prismaCloud = (opts: PrismaCloudOptions = {}): ExtensionDescriptor // bucket descriptor registers under 's3', not 'bucket'. s3: bucketDescriptor(o), }, + + // A lazy reference, not the descriptor (ADR-0041, operator directive): + // this control entry must carry no local-target implementation code, so + // the ONE line here is a dynamic import of this extension's own + // SEPARATE local-target entry (`src/exports/local-target.ts` → + // `@prisma/composer-prisma-cloud/local-target`) — never a static + // import. `resolveLocalTargets` (core) is the only caller. "dev" names + // the user-facing feature only (naming, operator 2026-07-23) — the seam + // itself is `localTarget`. + localTarget: () => + import('@prisma/composer-prisma-cloud/local-target').then((m) => m.localTargetDescriptor()), }; }; diff --git a/packages/1-prisma-cloud/1-extensions/target/src/descriptors/bucket.ts b/packages/1-prisma-cloud/1-extensions/target/src/descriptors/bucket.ts index df219518e..10c539536 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/descriptors/bucket.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/descriptors/bucket.ts @@ -20,7 +20,7 @@ import { * carrier, and its attributes (endpoint, bucketName, accessKeyId, * secretAccessKey) become the four S3Config outputs consumers resolve by name. */ -export function bucketDescriptor(_o: ResolvedCloudOptions): NodeDescriptor { +export function bucketDescriptor(_o: () => ResolvedCloudOptions): NodeDescriptor { const lowering: Lowering = ({ id, application }) => Effect.gen(function* () { validateName(id, 'resource name (from provision id)'); diff --git a/packages/1-prisma-cloud/1-extensions/target/src/descriptors/compute.ts b/packages/1-prisma-cloud/1-extensions/target/src/descriptors/compute.ts index 63d487e61..382ca1cb2 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/descriptors/compute.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/descriptors/compute.ts @@ -54,7 +54,7 @@ export interface ComputeSerialized { * Annotating this `NodeDescriptor` would force s3-store to cast them back. */ export function computeDescriptor( - o: ResolvedCloudOptions, + o: () => ResolvedCloudOptions, ): { readonly kind: 'service' } & ServiceLowering { return { kind: 'service' as const, @@ -68,7 +68,7 @@ export function computeDescriptor( const svc = yield* Prisma.ComputeService(`${id}-svc`, { projectId, name: id, - region: o.region ?? DEFAULT_REGION, + region: o().region ?? DEFAULT_REGION, ...(branchId !== undefined ? { branchId } : {}), }); return { serviceId: svc.id, projectId, endpointDomain: svc.endpointDomain }; @@ -163,7 +163,7 @@ export function computeDescriptor( refsByBrand.set(edge.brand, refs); } } - for (const [brand, entry] of o.providerParams) { + for (const [brand, entry] of o().providerParams) { const raw = 'valueForService' in entry ? entry.valueForService(provisioned, address) diff --git a/packages/1-prisma-cloud/1-extensions/target/src/descriptors/postgres.ts b/packages/1-prisma-cloud/1-extensions/target/src/descriptors/postgres.ts index ead55c521..dc734ae2d 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/descriptors/postgres.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/descriptors/postgres.ts @@ -20,7 +20,7 @@ import { * module provision id, so a resource shared by several consumers is created * exactly once. */ -export function postgresDescriptor(o: ResolvedCloudOptions): NodeDescriptor { +export function postgresDescriptor(o: () => ResolvedCloudOptions): NodeDescriptor { const lowering: Lowering = ({ id, application }) => Effect.gen(function* () { validateName(id, 'resource name (from provision id)'); @@ -28,7 +28,7 @@ export function postgresDescriptor(o: ResolvedCloudOptions): NodeDescriptor { const db = yield* Prisma.Database(`${id}-db`, { projectId: projectIdOf(application), name: id, - region: o.region ?? DEFAULT_REGION, + region: o().region ?? DEFAULT_REGION, ...(branchId !== undefined ? { branchId } : {}), }); const conn = yield* Prisma.Connection(`${id}-conn`, { databaseId: db.id, name: id }); diff --git a/packages/1-prisma-cloud/1-extensions/target/src/descriptors/prisma-next.ts b/packages/1-prisma-cloud/1-extensions/target/src/descriptors/prisma-next.ts index 0bdf38ecc..83e99bf1e 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/descriptors/prisma-next.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/descriptors/prisma-next.ts @@ -24,7 +24,7 @@ import { * target REF identity (hash + sorted invariants): unchanged redeploy is a * no-op, a contract or ref-invariant change re-migrates. */ -export function prismaNextDescriptor(o: ResolvedCloudOptions): NodeDescriptor { +export function prismaNextDescriptor(o: () => ResolvedCloudOptions): NodeDescriptor { const lowering: Lowering = ({ id, node, application }) => Effect.gen(function* () { validateName(id, 'resource name (from provision id)'); @@ -32,7 +32,7 @@ export function prismaNextDescriptor(o: ResolvedCloudOptions): NodeDescriptor { const db = yield* Prisma.Database(`${id}-db`, { projectId: projectIdOf(application), name: id, - region: o.region ?? DEFAULT_REGION, + region: o().region ?? DEFAULT_REGION, ...(branchId !== undefined ? { branchId } : {}), }); const conn = yield* Prisma.Connection(`${id}-conn`, { databaseId: db.id, name: id }); diff --git a/packages/1-prisma-cloud/1-extensions/target/src/descriptors/s3-credentials.ts b/packages/1-prisma-cloud/1-extensions/target/src/descriptors/s3-credentials.ts index 0af35275d..d639f6985 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/descriptors/s3-credentials.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/descriptors/s3-credentials.ts @@ -13,7 +13,7 @@ import type { ResolvedCloudOptions } from './shared.ts'; * `_o` is unused today (the mint needs no region/project) but kept for symmetry * with the other descriptors' signature. */ -export function s3CredentialsDescriptor(_o: ResolvedCloudOptions): NodeDescriptor { +export function s3CredentialsDescriptor(_o: () => ResolvedCloudOptions): NodeDescriptor { const lowering: Lowering = ({ id }) => Effect.gen(function* () { const creds = yield* S3Credentials(`${id}-creds`, {}); diff --git a/packages/1-prisma-cloud/1-extensions/target/src/descriptors/s3-store.ts b/packages/1-prisma-cloud/1-extensions/target/src/descriptors/s3-store.ts index cc9706e32..13a8b1264 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/descriptors/s3-store.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/descriptors/s3-store.ts @@ -28,7 +28,7 @@ export interface S3StoreSerialized extends ComputeSerialized { readonly secretAccessKey: unknown; } -export function s3StoreDescriptor(o: ResolvedCloudOptions): NodeDescriptor { +export function s3StoreDescriptor(o: () => ResolvedCloudOptions): NodeDescriptor { // No `base.kind !== 'service'` check any more: computeDescriptor's return // type says `kind: 'service'`, so the discriminant is a compile-time fact // rather than something to re-test at runtime. diff --git a/packages/1-prisma-cloud/1-extensions/target/src/exports/local-target.ts b/packages/1-prisma-cloud/1-extensions/target/src/exports/local-target.ts new file mode 100644 index 000000000..e907f3a9c --- /dev/null +++ b/packages/1-prisma-cloud/1-extensions/target/src/exports/local-target.ts @@ -0,0 +1,9 @@ +/** + * The extension's local-target control-plane entry (ADR-0041, operator + * directive; naming, operator 2026-07-23) — a SEPARATE entry from + * `./control`, loaded only via the lazy + * `localTarget: () => import('@prisma/composer-prisma-cloud/local-target').then(...)` + * reference `control/extension.ts` carries. Implementation lives in + * `../local-target/descriptor.ts`. + */ +export { localTargetDescriptor } from '../local-target/descriptor.ts'; diff --git a/packages/1-prisma-cloud/1-extensions/target/src/local-target-entry.d.ts b/packages/1-prisma-cloud/1-extensions/target/src/local-target-entry.d.ts new file mode 100644 index 000000000..9c606da9e --- /dev/null +++ b/packages/1-prisma-cloud/1-extensions/target/src/local-target-entry.d.ts @@ -0,0 +1,17 @@ +// `control/extension.ts`'s lazy `localTarget` thunk (ADR-0041's lazy +// local-target reference) names its own published subpath by bare +// specifier — at runtime this resolves against a CONSUMING app's own +// node_modules (this package is only ever inlined into +// @prisma/composer-prisma-cloud, never installed standalone), so it is +// deliberately NOT a real package.json dependency here (that would be a +// genuine build-order cycle: composer-prisma-cloud already depends on this +// package's dist). This shadow exists solely so `tsc` can typecheck the +// dynamic import within this package's own build — the bundler ignores it +// and, finding no real dependency to resolve the specifier against, +// correctly leaves the dynamic import as a genuine external reference +// rather than inlining anything. +declare module '@prisma/composer-prisma-cloud/local-target' { + import type { LocalTargetDescriptor } from '@internal/core/config'; + + export function localTargetDescriptor(): LocalTargetDescriptor; +} diff --git a/packages/1-prisma-cloud/1-extensions/target/src/local-target/attach.ts b/packages/1-prisma-cloud/1-extensions/target/src/local-target/attach.ts new file mode 100644 index 000000000..f84440910 --- /dev/null +++ b/packages/1-prisma-cloud/1-extensions/target/src/local-target/attach.ts @@ -0,0 +1,101 @@ +/** + * The dev session's view of the running app (local-dev spec § 5, ADR-0041 D5) + * — a Compute-emulator client scoped to this app. Core renders `endpoints()`/ + * `logs()`/`stopServices()` and never learns the emulator's HTTP API + * (ADR-0038's opacity pattern). + */ +import type { LocalTargetAttachInput, LocalTargetAttachment } from '@internal/core/config'; +import { computeClient } from '@internal/dev-emulators'; +import { prismaCloudContainerOf } from '../container.ts'; + +const RELIST_INTERVAL_MS = 2000; + +interface LogLine { + readonly service: string; + readonly line: string; +} + +/** + * Merges every listed service's `logs?follow=1` stream into one queue, + * re-listing every 2 s so a service that appears after a later converge gets + * its own follower attached, and re-attaching any follower whose connection + * dropped (an emulator restart shows a gap in that service's stream, never a + * dead session). + */ +async function* mergedLogs(app: string, signal: AbortSignal): AsyncIterable { + const client = computeClient(); + const followed = new Set(); + const queue: LogLine[] = []; + let wake: (() => void) | undefined; + + const push = (item: LogLine): void => { + queue.push(item); + wake?.(); + wake = undefined; + }; + + const follow = (id: string): void => { + if (followed.has(id)) return; + followed.add(id); + void (async () => { + try { + let buffer = ''; + for await (const chunk of client.followLogs(app, id, signal)) { + buffer += chunk; + let newlineAt = buffer.indexOf('\n'); + while (newlineAt !== -1) { + push({ service: id, line: buffer.slice(0, newlineAt) }); + buffer = buffer.slice(newlineAt + 1); + newlineAt = buffer.indexOf('\n'); + } + } + } catch { + // the connection dropped — the next relist re-attaches it below + } finally { + followed.delete(id); + } + })(); + }; + + const relist = async (): Promise => { + const services = await client.listServices(app); + for (const svc of services) follow(svc.id); + }; + + await relist(); + const timer = setInterval(() => void relist(), RELIST_INTERVAL_MS); + signal.addEventListener('abort', () => { + clearInterval(timer); + wake?.(); + wake = undefined; + }); + + try { + while (!signal.aborted) { + const next = queue.shift(); + if (next !== undefined) { + yield next; + continue; + } + await new Promise((resolve) => { + wake = resolve; + }); + } + } finally { + clearInterval(timer); + } +} + +export async function devAttach(input: LocalTargetAttachInput): Promise { + const app = prismaCloudContainerOf(input.container).input.appName; + const client = computeClient(); + + return { + endpoints: async () => { + const services = await client.listServices(app); + return services.map((svc) => ({ address: svc.address, url: svc.url })); + }, + logs: (signal) => mergedLogs(app, signal), + stopServices: () => client.stopApp(app), + }; +} diff --git a/packages/1-prisma-cloud/1-extensions/target/src/local-target/container.ts b/packages/1-prisma-cloud/1-extensions/target/src/local-target/container.ts new file mode 100644 index 000000000..ce62dfd2e --- /dev/null +++ b/packages/1-prisma-cloud/1-extensions/target/src/local-target/container.ts @@ -0,0 +1,24 @@ +/** + * The extension's DEV container lifecycle (local-dev spec § 5) — a purely + * local identity, resolved with no platform call: `projectId` is the literal + * `'local'`, and there is never a Branch. `deserialize` reuses + * `container.ts`'s existing one — the dev and deploy container descriptors + * produce/consume the identical wire shape (ADR-0037's transport), so the + * SAME env var either narrows to a real hosted container or this local one + * depending on which one the CLI resolved. + */ +import type { ContainerDescriptor, LocateContainerInput } from '@internal/core/config'; +import { deserialize, PrismaCloudContainer } from '../container.ts'; + +function resolve(input: LocateContainerInput): PrismaCloudContainer { + return new PrismaCloudContainer({ appName: input.appName, stage: undefined }, 'local', undefined); +} + +export function devContainerDescriptor(): ContainerDescriptor { + return { + ensure: (input) => Promise.resolve(resolve(input)), + locate: (input) => Promise.resolve(resolve(input)), + remove: () => Promise.resolve(), + deserialize, + }; +} diff --git a/packages/1-prisma-cloud/1-extensions/target/src/local-target/descriptor.ts b/packages/1-prisma-cloud/1-extensions/target/src/local-target/descriptor.ts new file mode 100644 index 000000000..1fe2f2a26 --- /dev/null +++ b/packages/1-prisma-cloud/1-extensions/target/src/local-target/descriptor.ts @@ -0,0 +1,58 @@ +/** + * Assembles the extension's local-target descriptor (local-dev spec § 5; + * naming, operator 2026-07-23 — "dev" names the user-facing feature only) + * — the implementation behind `src/exports/local-target.ts`'s public + * `localTargetDescriptor()`. Every `src/local-target/*` implementation file, plus + * `@internal/local-target`, feeds ONLY this entry — never `./control` + * (ADR-0041, operator directive): the production control entry carries + * just the lazy + * `localTarget: () => import('@prisma/composer-prisma-cloud/local-target').then(...)` + * reference, so no local-target implementation code is bundled into, or + * loaded by, any deploy path. + */ +import type { LocalTargetDescriptor } from '@internal/core/config'; +import { blindCast } from '@internal/foundation/casts'; +import { localTargetProviders } from '@internal/local-target'; +import * as Prisma from '@internal/lowering'; +import * as Layer from 'effect/Layer'; +import { PgWarmProvider } from '../pg-warm-resource.ts'; +import { PnMigrationProvider } from '../pn-migration-resource.ts'; +import { S3CredentialsProvider } from '../s3-credentials-resource.ts'; +import { devAttach } from './attach.ts'; +import { devContainerDescriptor } from './container.ts'; +import { runDevEmulators } from './emulators.ts'; +import { runDevPreflight } from './preflight.ts'; +import { runDevTeardown } from './teardown.ts'; + +/** `localTargetProviders()`'s `ProviderCollection` doesn't structurally unify with Alchemy's inferred providers Layer (a @internal/lowering typings gap); it satisfies it at runtime — mirrors `control/extension.ts`'s own `asProvidersLayer`, duplicated rather than shared so this module imports nothing from `./control`. */ +function asProvidersLayer(layer: Layer.Layer): Layer.Layer { + return blindCast< + Layer.Layer, + "the merged local providers layer satisfies Alchemy's ProviderCollection shape at runtime; only the structural type doesn't unify, mirroring control/extension.ts's own asProvidersLayer" + >(layer); +} + +/** + * The extension's local-target descriptor — self-contained, no + * deploy-factory options: it is credential-free by design, so this takes + * nothing and reads no environment. + */ +export function localTargetDescriptor(): LocalTargetDescriptor { + return { + container: devContainerDescriptor(), + providers: (input) => + asProvidersLayer( + Layer.mergeAll( + localTargetProviders(input), + PgWarmProvider(), + PnMigrationProvider(), + S3CredentialsProvider(), + Prisma.ServiceKeyProvider(), + ), + ), + preflight: (input) => runDevPreflight(input), + emulators: (input) => runDevEmulators(input), + attach: (input) => devAttach(input), + teardown: (input) => runDevTeardown(input), + }; +} diff --git a/packages/1-prisma-cloud/1-extensions/target/src/local-target/emulators.ts b/packages/1-prisma-cloud/1-extensions/target/src/local-target/emulators.ts new file mode 100644 index 000000000..38757e7a3 --- /dev/null +++ b/packages/1-prisma-cloud/1-extensions/target/src/local-target/emulators.ts @@ -0,0 +1,55 @@ +/** + * Dev emulator bring-up (local-dev spec § 5, ADR-0041 D4): ensures the + * machine-scoped daemons this topology's node kinds need. Compute is always + * ensured — every app has services; buckets only when the graph uses the + * `s3` resource kind; postgres only when the graph uses the `postgres` or + * `prisma-next` resource kind (REVISED — Postgres is a first-class daemon + * since the programmatic `@prisma/dev` adoption, operator review of #162). + * + * Idempotent: `ensureDaemon` itself adopts an already-healthy daemon, so + * repeated `prisma-composer dev` sessions are cheap. + * + * Entry resolution (spec § 2's publish note): `ensureDaemon` does not + * resolve its own daemon program — it takes the resolved `entry` path from + * its caller. This extension resolves against the PUBLIC + * `@prisma/composer-prisma-cloud/local-target/*-main` subpaths (not the + * private `@internal/dev-emulators` ones), so a published install's `dev` + * command finds its daemon programs in its own dependency tree. Resolution + * itself is delegated to `@internal/local-target` (this extension's own + * source stays free of `node:`/`bun:` imports — invariant 5). + */ +import type { LocalTargetEmulatorsInput } from '@internal/core/config'; +import type { DaemonName } from '@internal/dev-emulators'; +import { ensureDaemon } from '@internal/dev-emulators'; +import { resolvePackageEntry } from '@internal/local-target'; + +function usesBuckets(input: LocalTargetEmulatorsInput): boolean { + return input.graph.nodes.some((n) => n.node.kind === 'resource' && n.node.type === 's3'); +} + +function usesPostgres(input: LocalTargetEmulatorsInput): boolean { + return input.graph.nodes.some( + (n) => + n.node.kind === 'resource' && (n.node.type === 'postgres' || n.node.type === 'prisma-next'), + ); +} + +/** The resolved absolute path to this daemon's published entrypoint. */ +function daemonEntry(name: DaemonName): string { + return resolvePackageEntry(`@prisma/composer-prisma-cloud/local-target/${name}-main`); +} + +export async function runDevEmulators(input: LocalTargetEmulatorsInput): Promise { + const { url: computeUrl } = await ensureDaemon('compute', daemonEntry('compute')); + console.log(`[dev] compute emulator ready at ${computeUrl}`); + + if (usesBuckets(input)) { + const { url: bucketsUrl } = await ensureDaemon('buckets', daemonEntry('buckets')); + console.log(`[dev] buckets emulator ready at ${bucketsUrl}`); + } + + if (usesPostgres(input)) { + const { url: postgresUrl } = await ensureDaemon('postgres', daemonEntry('postgres')); + console.log(`[dev] postgres emulator ready at ${postgresUrl}`); + } +} diff --git a/packages/1-prisma-cloud/1-extensions/target/src/local-target/preflight.ts b/packages/1-prisma-cloud/1-extensions/target/src/local-target/preflight.ts new file mode 100644 index 000000000..54b561b7f --- /dev/null +++ b/packages/1-prisma-cloud/1-extensions/target/src/local-target/preflight.ts @@ -0,0 +1,76 @@ +/** + * Dev preflight (local-dev spec § 5, ADR-0041): the same name walk deploy's + * preflight uses (`preflight-names.ts`), but with dev's value-sourcing policy + * (ADR-0041's D7) instead of deploy's platform-existence check — a secret + * falls back to a minted, persisted placeholder with a warning; an + * env-sourced param falls back to nothing and is a hard error, because params + * feed boot-time schema validation and a placeholder there would produce a + * confusing crash instead of a legible degraded mode. + * + * Control-plane only, runs in the CLI parent (no CliError import — see + * container.ts). Reads no Management API — dev is credential-free. + */ + +import type { PreflightInput } from '@internal/core/config'; +import { DEV_DIR } from '@internal/core/config'; +import { secretsStore } from '@internal/local-target'; +import { collectPreflightNames, type PreflightName } from '../preflight-names.ts'; + +/** `local-placeholder-<16 lowercase hex>` — Web Crypto only, matching the extension's other local mints (ServiceKey, S3Credentials). */ +function mintPlaceholder(): string { + const bytes = crypto.getRandomValues(new Uint8Array(8)); + const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join(''); + return `local-placeholder-${hex}`; +} + +function missingEnvParamsError(missing: readonly PreflightName[]): Error { + const lines = missing.map((m) => ` - ${m.name} (required by service "${m.serviceAddress}")`); + return new Error( + `local dev preflight failed — ${missing.length} env-sourced param(s) are not set in this shell:\n` + + `${lines.join('\n')}\n\n` + + 'Set each in the shell you run `prisma-composer dev` from.', + ); +} + +export async function runDevPreflight(input: PreflightInput): Promise { + // No `node:path` import (invariant 5) — local dev is POSIX-only (spec's + // Windows note), so a plain `/`-join is exact, not an approximation. + const devDir = `${process.cwd()}/${DEV_DIR}`; + const store = secretsStore(devDir); + const { secrets, envParams } = collectPreflightNames(input.graph); + + // Secrets first, persisted immediately — a later env-param failure below + // must not cost an already-minted placeholder its stability. + await store.update((current) => { + const next = { ...current }; + for (const { name } of secrets) { + const shellValue = process.env[name]; + if (shellValue !== undefined && shellValue.length > 0) { + next[name] = shellValue; + continue; + } + if (next[name] !== undefined) continue; // reuse the persisted placeholder + next[name] = mintPlaceholder(); + console.warn( + `[dev] ${name} is not set in this shell — using a local placeholder. Anything that talks ` + + 'to the real service behind it will fail; everything else runs.', + ); + } + return next; + }); + + const resolved: Record = {}; + const missing: PreflightName[] = []; + for (const meta of envParams) { + const shellValue = process.env[meta.name]; + if (shellValue !== undefined && shellValue.length > 0) { + resolved[meta.name] = shellValue; + } else { + missing.push(meta); + } + } + if (Object.keys(resolved).length > 0) { + await store.update((current) => ({ ...current, ...resolved })); + } + if (missing.length > 0) throw missingEnvParamsError(missing); +} diff --git a/packages/1-prisma-cloud/1-extensions/target/src/local-target/teardown.ts b/packages/1-prisma-cloud/1-extensions/target/src/local-target/teardown.ts new file mode 100644 index 000000000..d31674e8e --- /dev/null +++ b/packages/1-prisma-cloud/1-extensions/target/src/local-target/teardown.ts @@ -0,0 +1,40 @@ +/** + * `--fresh` teardown (local-dev spec § 5, ADR-0041 D12, REVISED — operator + * review of #162): wholesale LOCAL deletion — never an `alchemy destroy`. + * Removes this app's records on the three machine-global emulator daemons + * (never the daemons themselves — other apps may be using them; postgres's + * `DELETE /apps/` closes its servers and deletes their persisted data), + * the dev state directory, and the dev stage's `localState()` directory. + * + * Every actual filesystem operation is delegated to `@internal/local-target` + * (this extension's own source stays free of `node:`/`bun:` imports — + * invariant 5); control-plane only, runs in the CLI parent (no CliError + * import — see container.ts). + */ + +import type { TeardownInput } from '@internal/core/config'; +import { DEV_DIR } from '@internal/core/config'; +import { bucketsClient, computeClient, postgresClient } from '@internal/dev-emulators'; +import { removeLocalPaths } from '@internal/local-target'; +import { prismaCloudContainerOf } from '../container.ts'; + +async function tolerateUnreachable(action: () => Promise): Promise { + try { + await action(); + } catch { + // Unreachable or absent daemon — the daemon itself is never stopped by + // `--fresh` (other apps may be using it), and there is nothing left to + // remove on it if it isn't running at all. + } +} + +export async function runDevTeardown(input: TeardownInput): Promise { + const app = prismaCloudContainerOf(input.container).input.appName; + const cwd = process.cwd(); + + await tolerateUnreachable(() => postgresClient().deleteApp(app)); + await tolerateUnreachable(() => computeClient().deleteApp(app)); + await tolerateUnreachable(() => bucketsClient().deleteApp(app)); + + removeLocalPaths([`${cwd}/${DEV_DIR}`, `${cwd}/.alchemy/state/${app}/dev`]); +} diff --git a/packages/1-prisma-cloud/1-extensions/target/src/preflight-names.ts b/packages/1-prisma-cloud/1-extensions/target/src/preflight-names.ts new file mode 100644 index 000000000..7d25f581b --- /dev/null +++ b/packages/1-prisma-cloud/1-extensions/target/src/preflight-names.ts @@ -0,0 +1,50 @@ +/** + * The platform env-var NAMES deploy preflight and dev preflight must both + * check — extracted so the two walks (`runPreflight`, `runDevPreflight`) + * cannot drift (local-dev spec § 5). Deploy checks secrets and env-sourced + * params identically (one platform-existence check per name); dev applies a + * DIFFERENT policy to each (a placeholder for a missing secret, a hard error + * for a missing env-sourced param), so the two lists stay separate here + * rather than merged into one. + */ + +import type { Graph } from '@internal/core'; +import { paramManifest, provisionManifest } from '@internal/core'; +import { isEnvParamSource, paramName } from './param.ts'; +import { secretName } from './secret.ts'; + +export interface PreflightName { + readonly name: string; + readonly serviceAddress: string; +} + +export interface PreflightNames { + /** Every secret slot's bound platform var name, deduped by name (first service wins the reported address). */ + readonly secrets: readonly PreflightName[]; + /** Every env-sourced param binding's platform var name (envParam-bound only — a literal-bound param never touches the platform), deduped the same way. */ + readonly envParams: readonly PreflightName[]; +} + +function dedupedNames(entries: Iterable): readonly PreflightName[] { + const byName = new Map(); + for (const entry of entries) { + if (!byName.has(entry.name)) byName.set(entry.name, entry); + } + return [...byName.values()]; +} + +/** Walks `graph.secrets`/`graph.params` exactly as `runPreflight` did before this extraction. */ +export function collectPreflightNames(graph: Graph): PreflightNames { + const secrets = dedupedNames( + provisionManifest(graph).map((binding) => ({ + name: secretName(binding), + serviceAddress: binding.serviceAddress, + })), + ); + const envParams = dedupedNames( + paramManifest(graph) + .filter((binding) => isEnvParamSource(binding.binding)) + .map((binding) => ({ name: paramName(binding), serviceAddress: binding.serviceAddress })), + ); + return { secrets, envParams }; +} diff --git a/packages/1-prisma-cloud/1-extensions/target/src/preflight.ts b/packages/1-prisma-cloud/1-extensions/target/src/preflight.ts index 4f21d3c73..a76d6b69d 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/preflight.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/preflight.ts @@ -13,7 +13,6 @@ * in the CLI parent, so it builds its own Management API client from env — the * same credential path `container.ts`'s `ensure`/`locate` use. */ -import { paramManifest, provisionManifest } from '@internal/core'; import type { PreflightInput } from '@internal/core/config'; import { blindCast } from '@internal/foundation/casts'; import { @@ -25,8 +24,7 @@ import { import * as Effect from 'effect/Effect'; import * as Layer from 'effect/Layer'; import { prismaCloudContainerOf } from './container.ts'; -import { isEnvParamSource, paramName } from './param.ts'; -import { secretName } from './secret.ts'; +import { collectPreflightNames } from './preflight-names.ts'; type EnvClass = 'production' | 'preview'; @@ -198,16 +196,13 @@ export async function runPreflight( // One check per platform NAME (many slots/services, secret or param, may // bind the same one). Every wired secret is required — the forwarding model // has no optional slot; a param binding is only checked when it is - // env-sourced — a literal-bound param never touches the platform. + // env-sourced — a literal-bound param never touches the platform. Deploy + // treats the two lists identically (unlike dev preflight), so they're + // merged here, secrets first (matching the pre-extraction dedup order). + const collected = collectPreflightNames(input.graph); const names = new Map(); - for (const binding of provisionManifest(input.graph)) { - const name = secretName(binding); - if (!names.has(name)) names.set(name, { name, serviceAddress: binding.serviceAddress }); - } - for (const binding of paramManifest(input.graph)) { - if (!isEnvParamSource(binding.binding)) continue; - const name = paramName(binding); - if (!names.has(name)) names.set(name, { name, serviceAddress: binding.serviceAddress }); + for (const meta of [...collected.secrets, ...collected.envParams]) { + if (!names.has(meta.name)) names.set(meta.name, meta); } if (names.size === 0) return; diff --git a/packages/1-prisma-cloud/1-extensions/target/tsdown.config.ts b/packages/1-prisma-cloud/1-extensions/target/tsdown.config.ts index 4c0dd5453..f7a0a6ff5 100644 --- a/packages/1-prisma-cloud/1-extensions/target/tsdown.config.ts +++ b/packages/1-prisma-cloud/1-extensions/target/tsdown.config.ts @@ -9,6 +9,7 @@ export default defineConfig({ entry: { index: 'src/exports/index.ts', control: 'src/exports/control.ts', + 'local-target': 'src/exports/local-target.ts', 'prisma-next': 'src/exports/prisma-next.ts', testing: 'src/exports/testing.ts', connection: 'src/exports/pg-connection.ts', diff --git a/packages/9-public/composer-prisma-cloud/package.json b/packages/9-public/composer-prisma-cloud/package.json index f1788cffa..3866a1834 100644 --- a/packages/9-public/composer-prisma-cloud/package.json +++ b/packages/9-public/composer-prisma-cloud/package.json @@ -6,6 +6,10 @@ "exports": { ".": "./dist/index.mjs", "./control": "./dist/control.mjs", + "./local-target": "./dist/local-target.mjs", + "./local-target/compute-main": "./dist/compute-main.mjs", + "./local-target/buckets-main": "./dist/buckets-main.mjs", + "./local-target/postgres-main": "./dist/postgres-main.mjs", "./prisma-next": "./dist/prisma-next.mjs", "./testing": "./dist/testing.mjs", "./cron": "./dist/cron/index.mjs", @@ -33,6 +37,7 @@ "dependencies": { "@effect/platform-bun": "4.0.0-beta.97", "@effect/platform-node": "4.0.0-beta.92", + "@internal/dev-emulators": "workspace:0.2.0", "@prisma-next/cli": "0.16.0", "@prisma-next/config-loader": "0.16.0", "@prisma-next/contract": "0.16.0", @@ -55,6 +60,7 @@ "@internal/cron": "workspace:0.2.0", "@internal/email": "workspace:0.2.0", "@internal/foundation": "workspace:0.2.0", + "@internal/local-target": "workspace:0.2.0", "@internal/lowering": "workspace:0.2.0", "@internal/nextjs": "workspace:0.2.0", "@internal/node": "workspace:0.2.0", diff --git a/packages/9-public/composer-prisma-cloud/src/exports/local-target.ts b/packages/9-public/composer-prisma-cloud/src/exports/local-target.ts new file mode 100644 index 000000000..950a37bbe --- /dev/null +++ b/packages/9-public/composer-prisma-cloud/src/exports/local-target.ts @@ -0,0 +1 @@ +export * from '@internal/prisma-cloud/local-target'; diff --git a/packages/9-public/composer-prisma-cloud/tsdown.config.ts b/packages/9-public/composer-prisma-cloud/tsdown.config.ts index c24c90729..acc01c009 100644 --- a/packages/9-public/composer-prisma-cloud/tsdown.config.ts +++ b/packages/9-public/composer-prisma-cloud/tsdown.config.ts @@ -38,12 +38,14 @@ const cronDist = '../../1-prisma-cloud/2-shared-modules/cron/dist'; const storageDist = '../../1-prisma-cloud/2-shared-modules/storage/dist'; const emailDist = '../../1-prisma-cloud/2-shared-modules/email/dist'; const streamsDist = '../../1-prisma-cloud/2-shared-modules/streams/dist'; +const devEmulatorsDist = '../../1-prisma-cloud/0-lowering/dev-emulators/dist'; export default defineConfig([ { ...baseConfig, entry: { index: 'src/exports/index.ts', control: 'src/exports/control.ts', + 'local-target': 'src/exports/local-target.ts', 'prisma-next': 'src/exports/prisma-next.ts', testing: 'src/exports/testing.ts', }, @@ -77,6 +79,31 @@ export default defineConfig([ noExternal: [/^@internal\//], plugins: [externalizeFramework], }, + { + // Re-emitted from @internal/dev-emulators' dist (spec § 2's publish + // note): the daemon programs `ensureDaemon` spawns need a resolvable + // entry inside a PUBLISHED install's own dependency tree — the private + // `@internal/dev-emulators` package is never installed standalone. + // outDir is the DEFAULT `dist` (not a subdirectory): `readOwnVersion()` + // (baked into every one of these programs AND into this package's own + // `local-target.mjs`, which calls `ensureDaemon`) resolves + // `../package.json` relative to its own file — both sides must sit at + // the SAME depth under `dist/` or they read different package.json + // files and disagree on "own version", breaking `ensureDaemon`'s + // staleness check. + ...baseConfig, + dts: false, + entry: { + 'compute-main': `${devEmulatorsDist}/compute-main.mjs`, + 'buckets-main': `${devEmulatorsDist}/buckets-main.mjs`, + 'postgres-main': `${devEmulatorsDist}/postgres-main.mjs`, + }, + exports: false, + clean: false, + skipNodeModulesBundle: false, + noExternal: [/^@internal\//], + plugins: [externalizeFramework], + }, { ...baseConfig, entry: { index: 'src/exports/storage.ts' }, diff --git a/packages/9-public/composer/package.json b/packages/9-public/composer/package.json index 84a8443ab..f42bfdebc 100644 --- a/packages/9-public/composer/package.json +++ b/packages/9-public/composer/package.json @@ -10,6 +10,7 @@ ".": "./dist/index.mjs", "./config": "./dist/config.mjs", "./deploy": "./dist/deploy.mjs", + "./local-target": "./dist/local-target.mjs", "./report": "./dist/report.mjs", "./testing": "./dist/testing.mjs", "./casts": "./dist/casts.mjs", diff --git a/packages/9-public/composer/src/exports/local-target.ts b/packages/9-public/composer/src/exports/local-target.ts new file mode 100644 index 000000000..eba0ca870 --- /dev/null +++ b/packages/9-public/composer/src/exports/local-target.ts @@ -0,0 +1 @@ +export * from '@internal/core/local-target'; diff --git a/packages/9-public/composer/tsdown.config.ts b/packages/9-public/composer/tsdown.config.ts index 4bc4beeb5..bb0c140dc 100644 --- a/packages/9-public/composer/tsdown.config.ts +++ b/packages/9-public/composer/tsdown.config.ts @@ -12,6 +12,7 @@ export default defineConfig([ index: 'src/exports/index.ts', config: 'src/exports/config.ts', deploy: 'src/exports/deploy.ts', + 'local-target': 'src/exports/local-target.ts', report: 'src/exports/report.ts', testing: 'src/exports/testing.ts', casts: 'src/exports/casts.ts', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8441e22ca..c9ab23f58 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -712,6 +712,49 @@ importers: specifier: ^6.0.3 version: 6.0.3 + packages/1-prisma-cloud/0-lowering/local-target: + dependencies: + '@internal/core': + specifier: workspace:0.2.0 + version: link:../../../0-framework/1-core/core + '@internal/dev-emulators': + specifier: workspace:0.2.0 + version: link:../dev-emulators + '@internal/lowering': + specifier: workspace:0.2.0 + version: link:../lowering + '@internal/s3-protocol': + specifier: workspace:0.2.0 + version: link:../s3-protocol + alchemy: + specifier: 2.0.0-beta.59 + version: 2.0.0-beta.59(@effect/platform-bun@4.0.0-beta.97(effect@4.0.0-beta.93))(@effect/platform-node@4.0.0-beta.92(effect@4.0.0-beta.93)(ioredis@5.11.1))(@types/node@25.9.5)(@types/react@19.2.17)(effect@4.0.0-beta.93)(vite@8.1.2(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0))(vitest@4.1.10(@types/node@25.9.5)(vite@8.1.2(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0)))(workerd@1.20260617.1)(ws@8.21.1) + effect: + specifier: 4.0.0-beta.93 + version: 4.0.0-beta.93 + tar: + specifier: ^7.5.21 + version: 7.5.21 + devDependencies: + '@internal/tsdown-config': + specifier: workspace:0.2.0 + version: link:../../../0-framework/0-foundation/tsdown-config + '@prisma/dev': + specifier: ^0.24.14 + version: 0.24.14(typescript@6.0.3) + '@types/bun': + specifier: ^1.3.13 + version: 1.3.14 + '@types/node': + specifier: ^25.9.3 + version: 25.9.5 + tsdown: + specifier: ^0.22.7 + version: 0.22.12(typescript@6.0.3) + typescript: + specifier: ^6.0.3 + version: 6.0.3 + packages/1-prisma-cloud/0-lowering/lowering: dependencies: '@internal/core': @@ -778,9 +821,15 @@ importers: '@internal/core': specifier: workspace:0.2.0 version: link:../../../0-framework/1-core/core + '@internal/dev-emulators': + specifier: workspace:0.2.0 + version: link:../../0-lowering/dev-emulators '@internal/foundation': specifier: workspace:0.2.0 version: link:../../../0-framework/0-foundation/foundation + '@internal/local-target': + specifier: workspace:0.2.0 + version: link:../../0-lowering/local-target '@internal/lowering': specifier: workspace:0.2.0 version: link:../../0-lowering/lowering @@ -1087,6 +1136,9 @@ importers: '@effect/platform-node': specifier: 4.0.0-beta.92 version: 4.0.0-beta.92(effect@4.0.0-beta.93)(ioredis@5.11.1) + '@internal/dev-emulators': + specifier: workspace:0.2.0 + version: link:../../1-prisma-cloud/0-lowering/dev-emulators '@prisma-next/cli': specifier: 0.16.0 version: 0.16.0(typanion@3.14.0)(typescript@6.0.3) @@ -1148,6 +1200,9 @@ importers: '@internal/foundation': specifier: workspace:0.2.0 version: link:../../0-framework/0-foundation/foundation + '@internal/local-target': + specifier: workspace:0.2.0 + version: link:../../1-prisma-cloud/0-lowering/local-target '@internal/lowering': specifier: workspace:0.2.0 version: link:../../1-prisma-cloud/0-lowering/lowering @@ -1196,6 +1251,12 @@ importers: '@types/node': specifier: ^25.9.3 version: 25.9.5 + alchemy: + specifier: 2.0.0-beta.59 + version: 2.0.0-beta.59(@effect/platform-bun@4.0.0-beta.97(effect@4.0.0-beta.97))(@effect/platform-node@4.0.0-beta.92(effect@4.0.0-beta.97)(ioredis@5.11.1))(@types/node@25.9.5)(@types/react@19.2.17)(effect@4.0.0-beta.97)(vite@8.1.2(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0))(vitest@4.1.10(@types/node@25.9.5)(vite@8.1.2(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0)))(workerd@1.20260617.1)(ws@8.21.1) + prisma: + specifier: 7.9.0 + version: 7.9.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@6.0.3) typescript: specifier: ^6.0.3 version: 6.0.3 @@ -1889,6 +1950,10 @@ packages: '@ioredis/commands@1.10.0': resolution: {integrity: sha512-UmeW7z4LfctwoQ5wkhVzgq8tXkreED2xZGpX+Bg+zA+WJFZCT6c062AfCK/Dfk81xZnnwdhJCUMkitihRaoC2Q==} + '@isaacs/fs-minipass@4.0.1': + resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==} + engines: {node: '>=18.0.0'} + '@jridgewell/sourcemap-codec@1.5.5': resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} @@ -2431,15 +2496,33 @@ packages: typescript: optional: true + '@prisma/config@7.9.0': + resolution: {integrity: sha512-CsoK2mhl0u+N4/8V+XroQMOUNIic4isqD+E2HBG8l1yGEKo62CFDu3FHo0FdwItjl6XkW+omA1STSzeN1DAXlg==} + '@prisma/debug@7.2.0': resolution: {integrity: sha512-YSGTiSlBAVJPzX4ONZmMotL+ozJwQjRmZweQNIq/ER0tQJKJynNkRB3kyvt37eOfsbMCXk3gnLF6J9OJ4QWftw==} + '@prisma/debug@7.9.0': + resolution: {integrity: sha512-i0KdVQuKUE6N9NloHs+sUNAk2c9svR3myBndQbA3BoeoArsSpwtNgTdHZL+wBtCLCcdS2OOC/PKhgTe36jkF5A==} + '@prisma/dev@0.24.14': resolution: {integrity: sha512-NhFO49O2JPTdzYiLHvceQn/HiwmcKF/iGV39ko3CpYsoGqS3rz3ko6gzuxFSIeHNwNJeuNcDexyyGeTO3DW80A==} + '@prisma/engines-version@7.9.0-1.e922089b7d7502aff4249d5da3420f6fa55fc6ad': + resolution: {integrity: sha512-2BsPPFksz3CQUXG6af3rVCtJKg6+JJGJTtfgu2fU8DdXhOfkBjulCq8mwybCd6ge0/jhZq2kOtLAbmUDMyI1nA==} + + '@prisma/engines@7.9.0': + resolution: {integrity: sha512-lDWJp/pgSWCLfYsupmmNo96jfsbQnH1yjia8XVM2Kh8nRZhD0bQU2jCHuy3ZTPMLR3apRD3k145ybENalAYjYw==} + + '@prisma/fetch-engine@7.9.0': + resolution: {integrity: sha512-F0XlIgjbE3EywRVR/HpCerNI/dxo40vK66tHcWpsWYwH/Jk9+FsICEzATeMsZ7bdnpZz93hkD4sAb5rKLsCCpA==} + '@prisma/get-platform@7.2.0': resolution: {integrity: sha512-k1V0l0Td1732EHpAfi2eySTezyllok9dXb6UQanajkJQzPUGi3vO2z7jdkz67SypFTdmbnyGYxvEvYZdZsMAVA==} + '@prisma/get-platform@7.9.0': + resolution: {integrity: sha512-4awv6ATdgrHdLms0XKikCyfArn8BrUHZfqg0mtCKrI4+WJe24nmpsdwsypM9ozd03wa846AngY+zSbnngkMrXQ==} + '@prisma/management-api-sdk@1.47.0': resolution: {integrity: sha512-h4h3obs0F7WFUjNhh3vU7de7aESywu8RjO7KNO5/DXZ8QewLmeUjx65nUbQPxcRgDana6c79r5yPRCXSMchfeQ==} @@ -2458,9 +2541,91 @@ packages: engines: {bun: '>=1.3.6'} hasBin: true + '@prisma/studio-core@0.33.0': + resolution: {integrity: sha512-V2fX/nKEymNTrHXwfP26PGjoLStO35Ogu+ex7CFJbLrMYEcZxxZpiSNOs7px23Hk5mzLWvM5RsqG6Ka+rha+wg==} + engines: {node: ^20.19 || ^22.12 || >=24.0, pnpm: '8'} + peerDependencies: + '@types/react': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + '@quansync/fs@1.0.0': resolution: {integrity: sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ==} + '@radix-ui/primitive@1.1.3': + resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==} + + '@radix-ui/react-compose-refs@1.1.2': + resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-primitive@2.1.3': + resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-slot@1.2.3': + resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-toggle@1.1.10': + resolution: {integrity: sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-use-controllable-state@1.2.2': + resolution: {integrity: sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-effect-event@0.0.2': + resolution: {integrity: sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-layout-effect@1.1.1': + resolution: {integrity: sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@react-email/body@0.3.0': resolution: {integrity: sha512-uGo0BOOzjbMUo3lu+BIDWayvn5o6Xyfmnlla5VGf05n8gHMvO1ll7U4FtzWe3hxMLwt53pmc4iE0M+B5slG+Ug==} engines: {node: '>=20.0.0'} @@ -3047,18 +3212,57 @@ packages: '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/d3-array@3.0.3': + resolution: {integrity: sha512-Reoy+pKnvsksN0lQUlcH6dOGjRZ/3WRwXR//m+/8lt1BXeI4xyaUZoqULNjyXXRuh0Mj4LNpkCvhUpQlY3X5xQ==} + + '@types/d3-color@3.1.0': + resolution: {integrity: sha512-HKuicPHJuvPgCD+np6Se9MQvS6OCbJmOjGvylzMJRlDwUXjKTTXs6Pwgk79O09Vj/ho3u1ofXnhFOaEWWPrlwA==} + + '@types/d3-delaunay@6.0.1': + resolution: {integrity: sha512-tLxQ2sfT0p6sxdG75c6f/ekqxjyYR0+LwPrsO1mbC9YDBzPJhs2HbJJRrn8Ez1DBoHRo2yx7YEATI+8V1nGMnQ==} + + '@types/d3-format@3.0.1': + resolution: {integrity: sha512-5KY70ifCCzorkLuIkDe0Z9YTf9RR2CjBX1iaJG+rgM/cPP+sO+q9YdQ9WdhQcgPj1EQiJ2/0+yUkkziTG6Lubg==} + + '@types/d3-geo@3.1.0': + resolution: {integrity: sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==} + + '@types/d3-interpolate@3.0.1': + resolution: {integrity: sha512-jx5leotSeac3jr0RePOH1KdR9rISG91QIE4Q2PYTu4OymLTZfA3SrnURSLzKH48HmXVUru50b8nje4E79oQSQw==} + + '@types/d3-path@3.1.1': + resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==} + + '@types/d3-scale@4.0.2': + resolution: {integrity: sha512-Yk4htunhPAwN0XGlIwArRomOjdoBFXC3+kCxK2Ubg7I9shQlVSJy/pG/Ht5ASN+gdMIalpk8TJ5xV74jFsetLA==} + + '@types/d3-shape@3.1.7': + resolution: {integrity: sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==} + + '@types/d3-time-format@2.1.0': + resolution: {integrity: sha512-/myT3I7EwlukNOX2xVdMzb8FRgNzRMpsZddwst9Ld/VFe6LyJyRp0s32l/V9XoUzk+Gqu56F/oGk6507+8BxrA==} + + '@types/d3-time@3.0.0': + resolution: {integrity: sha512-sZLCdHvBUcNby1cB6Fd3ZBrABbjz3v1Vm90nysCQ6Vt7vd6e/h9Lt7SiJUoEX0l4Dzc7P5llKyhqSi1ycSf1Hg==} + '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/geojson@7946.0.16': + resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==} + '@types/hast@3.0.5': resolution: {integrity: sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==} '@types/linkify-it@5.0.0': resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==} + '@types/lodash@4.17.24': + resolution: {integrity: sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==} + '@types/markdown-it@14.1.2': resolution: {integrity: sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==} @@ -3107,6 +3311,41 @@ packages: resolution: {integrity: sha512-VYNCgUc0nOmC4WJmWw9GkrKdfr8Zl4/rxhC5SvgacBgxiW9W/9NRttUoHHXV8xdII3MaRgkZZVX8Ikzc/Jmjag==} engines: {node: '>=14'} + '@visx/curve@4.0.1-alpha.0': + resolution: {integrity: sha512-jRu61Uz274pV1zyioXmboyrLutYbnKsgjj4njSGCnhdXj5GkZvZbg+ThDb6oOzoAnJOBRLz4rzPlWvNJOzuVMg==} + + '@visx/event@4.0.1-alpha.0': + resolution: {integrity: sha512-EQqCMSv/s8NbFjo+hz3FKsvvYfP+2QslsFJ/24/O5l/W+7UC6J6aAvO0ujVwrTwdYbuQ+vhxKi1xdPdKR/qj1g==} + + '@visx/grid@4.0.1-alpha.0': + resolution: {integrity: sha512-rycutGmTHO+znNdPumheWMglm7YfpffvRwUkVy5zy4WoORIuKTMkDxwnOzHG2xMxU3EE/YCd37xFV5AxA30yeg==} + peerDependencies: + react: ^16.14.0 || ^17.0.0-0 || ^18.0.0-0 || ^19.0.0-0 + + '@visx/group@4.0.1-alpha.0': + resolution: {integrity: sha512-V19l7iQ7jccBv8kao/EByuI6o4xtxzzLV9nqVI1hRvmdzTVsuLpqlwzYCZUXJaTVvUWf8s4D2SQFjGkj/Nw+0w==} + peerDependencies: + react: ^16.14.0 || ^17.0.0-0 || ^18.0.0-0 || ^19.0.0-0 + + '@visx/point@4.0.1-alpha.0': + resolution: {integrity: sha512-ijTfr/Nx09f03vIj9nyTr3z4Xth4Y75427UaogJh6dnIRLMEFHQOwNu791sbfiNj0a+ZXuaE32h0vKrFe4/8Qg==} + + '@visx/responsive@4.0.1-alpha.0': + resolution: {integrity: sha512-o+1zGywQZY0+yOx3Iw87wc4bbPJRr/HnIukTwfOz4UVyj9pB1OQNVHB7OORO1+LBHJceWpB31co/ZV9KHncKrA==} + peerDependencies: + react: ^16.14.0 || ^17.0.0-0 || ^18.0.0-0 || ^19.0.0-0 + + '@visx/scale@4.0.1-alpha.0': + resolution: {integrity: sha512-nzjeE87vFSAXGWFiiNfBpNLAf0Q8Qmf6syvKLjqNi4kGZkdhbUll3E/59YsgWXmjM8+llPLWzGsP+JPvo5eq1A==} + + '@visx/shape@4.0.1-alpha.0': + resolution: {integrity: sha512-62QeiVNmPlterQGwhkEDcbq7M0MqY0lBsK5QKXtM9ZoPZWkuGV3aykA3+Xu20B2FAvyJq4LqJzBc7Sxr+EAdbA==} + peerDependencies: + react: ^16.14.0 || ^17.0.0-0 || ^18.0.0-0 || ^19.0.0-0 + + '@visx/vendor@4.0.0-alpha.0': + resolution: {integrity: sha512-6I+MuqXBcv9jnlcVowHoHKSdk9gXTWkHLKyqBwRWg7LY6A3Ei8SHfubpqGV5rBUSppxMq2RszPJUS6w+H0YgmQ==} + '@vitest/expect@4.1.10': resolution: {integrity: sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==} @@ -3412,10 +3651,17 @@ packages: resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} engines: {node: '>= 20.19.0'} + chownr@3.0.0: + resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} + engines: {node: '>=18'} + ci-info@4.4.0: resolution: {integrity: sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==} engines: {node: '>=8'} + classnames@2.5.1: + resolution: {integrity: sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==} + cli-boxes@3.0.0: resolution: {integrity: sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==} engines: {node: '>=10'} @@ -3501,6 +3747,54 @@ packages: csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + d3-array@3.2.1: + resolution: {integrity: sha512-gUY/qeHq/yNqqoCKNq4vtpFLdoCdvyNpWoC/KNjhGbhDuQpAM9sIQQKkXSNpXa9h5KySs/gzm7R88WkUutgwWQ==} + engines: {node: '>=12'} + + d3-array@3.2.4: + resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} + engines: {node: '>=12'} + + d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + + d3-delaunay@6.0.2: + resolution: {integrity: sha512-IMLNldruDQScrcfT+MWnazhHbDJhcRJyOEBAJfwQnHle1RPh6WDuLvxNArUju2VSMSUuKlY5BGHRJ2cYyoFLQQ==} + engines: {node: '>=12'} + + d3-format@3.1.0: + resolution: {integrity: sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==} + engines: {node: '>=12'} + + d3-geo@3.1.0: + resolution: {integrity: sha512-JEo5HxXDdDYXCaWdwLRt79y7giK8SbhZJbFWXqbRTolCHFI5jRqteLzCsq51NKbUoX0PjBVSohxrx+NoOUujYA==} + engines: {node: '>=12'} + + d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + + d3-path@3.1.0: + resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==} + engines: {node: '>=12'} + + d3-scale@4.0.2: + resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} + engines: {node: '>=12'} + + d3-shape@3.2.0: + resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==} + engines: {node: '>=12'} + + d3-time-format@4.1.0: + resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==} + engines: {node: '>=12'} + + d3-time@3.1.0: + resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==} + engines: {node: '>=12'} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -3510,6 +3804,10 @@ packages: supports-color: optional: true + deepmerge-ts@7.1.5: + resolution: {integrity: sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==} + engines: {node: '>=16.0.0'} + deepmerge@4.3.1: resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} engines: {node: '>=0.10.0'} @@ -3517,6 +3815,9 @@ packages: defu@6.1.7: resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} + delaunator@5.1.0: + resolution: {integrity: sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==} + denque@2.1.0: resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} engines: {node: '>=0.10'} @@ -3570,15 +3871,25 @@ packages: oxc-resolver: optional: true + effect@3.20.0: + resolution: {integrity: sha512-qMLfDJscrNG8p/aw+IkT9W7fgj50Z4wG5bLBy0Txsxz8iUHjDIkOgO3SV0WZfnQbNG2VJYb0b+rDLMrhM4+Krw==} + effect@4.0.0-beta.93: resolution: {integrity: sha512-wNS5MKFa3C42uBfIDik2oJ78lhpoYz2hN4oBR0229BeeDCIrkg/FiOvoiPGdCVlWa7MEKxEL5I0f8AILVHSD9A==} effect@4.0.0-beta.97: resolution: {integrity: sha512-pK03HpQVxGZOWdwDAy/iwvV8u3KYcUf2mOWyWqaut2zau8V2u6ejWP7b4BELjyUIiZWW1fl/s/VJpgZUcTjThg==} + elkjs@0.11.1: + resolution: {integrity: sha512-zxxR9k+rx5ktMwT/FwyLdPCrq7xN6e4VGGHH8hA01vVYKjTFik7nHOxBnAYtrgYUB1RpAiLvA1/U2YraWxyKKg==} + emoji-regex@10.6.0: resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} + empathic@2.0.0: + resolution: {integrity: sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==} + engines: {node: '>=14'} + empathic@2.0.1: resolution: {integrity: sha512-YGRs8knHhKHVShLkFET/rWAU8kmHbOV5LwN938RHI0pljAJ1Gf6SzXsSmRaEzcXTtOOmVqJ5+WtQPL5uigY50Q==} engines: {node: '>=14'} @@ -3631,6 +3942,10 @@ packages: exsolve@1.1.0: resolution: {integrity: sha512-D+42+T12DdIlJM3uepa55qGiL3sYdLBOxIl2ifQCzCHz4c7eiolaHsi3BIqEr7JxBzxv2pYZQX9kw16ziMcEmw==} + fast-check@3.23.2: + resolution: {integrity: sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==} + engines: {node: '>=8.0.0'} + fast-check@4.9.0: resolution: {integrity: sha512-7ms6T7SybUev/PQITciI0yLM2pOSFy5zpG8Ty7tQofcVaQUvrMXp6CBwqF6fThLCLOrfBtuHAtwq6Yu4XPCllg==} engines: {node: '>=12.17.0'} @@ -3823,6 +4138,10 @@ packages: react-devtools-core: optional: true + internmap@2.0.3: + resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} + engines: {node: '>=12'} + interpret@3.1.1: resolution: {integrity: sha512-6xwYfHbajpoF0xLW+iwLkhwgvLoZDfjYfoFNu8ftMoXINzwuymNLd9u/KmwtdT2GbR+/Cz66otEGEVVUHX9QLQ==} engines: {node: '>=10.13.0'} @@ -4006,6 +4325,9 @@ packages: resolution: {integrity: sha512-JtNtbZj8q5BnDMR7trpwvwk3RIrANtIVzEUm8w7amp6xelLgyuq+4WZoTH913XaQAoH/cNdYhaNzBPA2U3xbDw==} engines: {node: '>=22.13.0'} + lodash@4.18.1: + resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} + log-update@6.1.0: resolution: {integrity: sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==} engines: {node: '>=18'} @@ -4074,6 +4396,14 @@ packages: minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + + minizlib@3.1.0: + resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==} + engines: {node: '>= 18'} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -4087,6 +4417,10 @@ packages: multipasta@0.2.8: resolution: {integrity: sha512-ZPWuMKyv0cSO29f7hozp+k6+crZbQijV8ipMvxNxRf2SwtYGTX1ZX89Kd20VV4H9Znonx+EQn+iy1wGQsJ+b+Q==} + mysql2@3.15.3: + resolution: {integrity: sha512-FBrGau0IXmuqg4haEZRBfHNWB5mUARw6hNwPDXXGg0XzVJ50mr/9hb267lvpVMnhZ1FON3qNd4Xfcez1rbFwSg==} + engines: {node: '>= 8.0'} + mysql2@3.22.5: resolution: {integrity: sha512-95uZ2TrPWAZdwpB3vvvDbmEMcNG8yIeNCyu6GUcr/QnWEE/wXm7+mhOCsdQfWQDTV7qYT/PDUZ4U4UPP4AsXqQ==} engines: {node: '>= 8.0'} @@ -4289,6 +4623,10 @@ packages: resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} engines: {node: '>=0.10.0'} + postgres@3.4.7: + resolution: {integrity: sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw==} + engines: {node: '>=12'} + postgres@3.4.9: resolution: {integrity: sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw==} engines: {node: '>=12'} @@ -4298,6 +4636,19 @@ packages: engines: {node: '>=14'} hasBin: true + prisma@7.9.0: + resolution: {integrity: sha512-isQTJEK4pyOlAVzm6kBUDjzgdsgs0A/snpB38ycTHeOHW34qfepP+ClQltgDXqjZBnXALhEtE4duh9L3tN5fHw==} + engines: {node: ^20.19 || ^22.12 || >=24.0} + hasBin: true + peerDependencies: + better-sqlite3: '>=9.0.0' + typescript: '>=5.4.0' + peerDependenciesMeta: + better-sqlite3: + optional: true + typescript: + optional: true + prismjs@1.30.0: resolution: {integrity: sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==} engines: {node: '>=6'} @@ -4322,6 +4673,9 @@ packages: resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==} engines: {node: '>=6'} + pure-rand@6.1.0: + resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==} + pure-rand@8.4.2: resolution: {integrity: sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng==} @@ -4428,6 +4782,9 @@ packages: rfdc@1.4.1: resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + robust-predicates@3.0.3: + resolution: {integrity: sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==} + rolldown-plugin-dts@0.27.9: resolution: {integrity: sha512-d54yt65+ZF/Mk8H6P36As02PAMdaiWRSzVNtJRc1h7nCgUFjuRI4cN2DyTfJyfVpPH6pgy7/2D7YQH1/Rh75Yg==} engines: {node: ^22.18.0 || >=24.11.0} @@ -4494,6 +4851,9 @@ packages: engines: {node: '>=10'} hasBin: true + seq-queue@0.0.5: + resolution: {integrity: sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==} + setimmediate@1.0.5: resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==} @@ -4554,6 +4914,10 @@ packages: resolution: {integrity: sha512-BsTCV265VpTp8tm1wyIm1xqQCS+Q9NHx2Sr+WcnUrgLrQ6yiDIvHYJV5gHxsj1lMBy2zm5twLaZao8Jd+S8JJw==} engines: {bun: '>=1.0.0', deno: '>=2.0.0', node: '>=12.0.0'} + sqlstring@2.3.3: + resolution: {integrity: sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==} + engines: {node: '>= 0.6'} + stack-utils@2.0.6: resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} engines: {node: '>=10'} @@ -4635,6 +4999,10 @@ packages: resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} engines: {node: '>=6'} + tar@7.5.21: + resolution: {integrity: sha512-XdhtCvlMywwxpCW8YEq3lOXBJpUPTR2OHHcwLPO3HwsJqOHa2Ok/oJ7ruGzp+JrKoRPVCzJwAdEjqLW/vNRPHA==} + engines: {node: '>=18'} + terminal-size@4.0.1: resolution: {integrity: sha512-avMLDQpUI9I5XFrklECw1ZEUPJhqzcwSWsyyI8blhRLT+8N1jLJWLWWYQpB2q2xthq8xDvjZPISVh53T/+CLYQ==} engines: {node: '>=18'} @@ -4956,6 +5324,10 @@ packages: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} + yallist@5.0.0: + resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==} + engines: {node: '>=18'} + yaml@2.9.0: resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} engines: {node: '>= 14.6'} @@ -5406,6 +5778,20 @@ snapshots: - rolldown - workerd + '@distilled.cloud/cloudflare-vite-plugin@0.11.3(@distilled.cloud/cloudflare-runtime@0.11.3(@distilled.cloud/cloudflare@0.27.0(effect@4.0.0-beta.97))(@effect/platform-bun@4.0.0-beta.97(effect@4.0.0-beta.97))(@effect/platform-node@4.0.0-beta.92(effect@4.0.0-beta.97)(ioredis@5.11.1))(effect@4.0.0-beta.97))(@distilled.cloud/cloudflare@0.27.0(effect@4.0.0-beta.97))(@effect/platform-bun@4.0.0-beta.97(effect@4.0.0-beta.97))(@effect/platform-node@4.0.0-beta.92(effect@4.0.0-beta.97)(ioredis@5.11.1))(effect@4.0.0-beta.97)(rolldown@1.0.1)(vite@8.1.2(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0))(workerd@1.20260617.1)': + dependencies: + '@distilled.cloud/cloudflare': 0.27.0(effect@4.0.0-beta.97) + '@distilled.cloud/cloudflare-rolldown-plugin': 0.11.3(rolldown@1.0.1)(vite@8.1.2(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0))(workerd@1.20260617.1) + '@distilled.cloud/cloudflare-runtime': 0.11.3(@distilled.cloud/cloudflare@0.27.0(effect@4.0.0-beta.97))(@effect/platform-bun@4.0.0-beta.97(effect@4.0.0-beta.97))(@effect/platform-node@4.0.0-beta.92(effect@4.0.0-beta.97)(ioredis@5.11.1))(effect@4.0.0-beta.97) + effect: 4.0.0-beta.97 + vite: 8.1.2(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0) + optionalDependencies: + '@effect/platform-bun': 4.0.0-beta.97(effect@4.0.0-beta.97) + '@effect/platform-node': 4.0.0-beta.92(effect@4.0.0-beta.97)(ioredis@5.11.1) + transitivePeerDependencies: + - rolldown + - workerd + '@distilled.cloud/cloudflare-vite-plugin@0.11.3(@distilled.cloud/cloudflare-runtime@0.11.3(@distilled.cloud/cloudflare@0.27.0(effect@4.0.0-beta.97))(@effect/platform-bun@4.0.0-beta.97(effect@4.0.0-beta.97))(@effect/platform-node@4.0.0-beta.92(effect@4.0.0-beta.97)(ioredis@5.11.1))(effect@4.0.0-beta.97))(@distilled.cloud/cloudflare@0.27.0(effect@4.0.0-beta.97))(@effect/platform-bun@4.0.0-beta.97(effect@4.0.0-beta.97))(@effect/platform-node@4.0.0-beta.92(effect@4.0.0-beta.97)(ioredis@5.11.1))(effect@4.0.0-beta.97)(rolldown@1.0.1)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0))(workerd@1.20260617.1)': dependencies: '@distilled.cloud/cloudflare': 0.27.0(effect@4.0.0-beta.97) @@ -5557,6 +5943,11 @@ snapshots: effect: 4.0.0-beta.93 vitest: 4.1.10(@types/node@26.1.1)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0)) + '@effect/vitest@4.0.0-beta.92(effect@4.0.0-beta.97)(vitest@4.1.10(@types/node@25.9.5)(vite@8.1.2(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0)))': + dependencies: + effect: 4.0.0-beta.97 + vitest: 4.1.10(@types/node@25.9.5)(vite@8.1.2(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0)) + '@effect/vitest@4.0.0-beta.92(effect@4.0.0-beta.97)(vitest@4.1.10(@types/node@26.1.1)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0)))': dependencies: effect: 4.0.0-beta.97 @@ -5792,6 +6183,10 @@ snapshots: '@ioredis/commands@1.10.0': {} + '@isaacs/fs-minipass@4.0.1': + dependencies: + minipass: 7.1.3 + '@jridgewell/sourcemap-codec@1.5.5': {} '@libsql/client@0.17.4': @@ -6429,8 +6824,19 @@ snapshots: optionalDependencies: typescript: 6.0.3 + '@prisma/config@7.9.0': + dependencies: + c12: 3.3.4 + deepmerge-ts: 7.1.5 + effect: 3.20.0 + empathic: 2.0.0 + transitivePeerDependencies: + - magicast + '@prisma/debug@7.2.0': {} + '@prisma/debug@7.9.0': {} + '@prisma/dev@0.24.14(typescript@6.0.3)': dependencies: '@electric-sql/pglite': 0.4.3 @@ -6451,10 +6857,29 @@ snapshots: transitivePeerDependencies: - typescript + '@prisma/engines-version@7.9.0-1.e922089b7d7502aff4249d5da3420f6fa55fc6ad': {} + + '@prisma/engines@7.9.0': + dependencies: + '@prisma/debug': 7.9.0 + '@prisma/engines-version': 7.9.0-1.e922089b7d7502aff4249d5da3420f6fa55fc6ad + '@prisma/fetch-engine': 7.9.0 + '@prisma/get-platform': 7.9.0 + + '@prisma/fetch-engine@7.9.0': + dependencies: + '@prisma/debug': 7.9.0 + '@prisma/engines-version': 7.9.0-1.e922089b7d7502aff4249d5da3420f6fa55fc6ad + '@prisma/get-platform': 7.9.0 + '@prisma/get-platform@7.2.0': dependencies: '@prisma/debug': 7.2.0 + '@prisma/get-platform@7.9.0': + dependencies: + '@prisma/debug': 7.9.0 + '@prisma/management-api-sdk@1.47.0': dependencies: openapi-fetch: 0.14.0 @@ -6480,10 +6905,85 @@ snapshots: env-paths: 3.0.0 proper-lockfile: 4.1.2 + '@prisma/studio-core@0.33.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/react-toggle': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@types/react': 19.2.17 + '@visx/curve': 4.0.1-alpha.0 + '@visx/event': 4.0.1-alpha.0 + '@visx/grid': 4.0.1-alpha.0(react@19.2.8) + '@visx/group': 4.0.1-alpha.0(react@19.2.8) + '@visx/responsive': 4.0.1-alpha.0(react@19.2.8) + '@visx/scale': 4.0.1-alpha.0 + '@visx/shape': 4.0.1-alpha.0(react@19.2.8) + d3-array: 3.2.4 + d3-shape: 3.2.0 + elkjs: 0.11.1 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + transitivePeerDependencies: + - '@types/react-dom' + '@quansync/fs@1.0.0': dependencies: quansync: 1.0.0 + '@radix-ui/primitive@1.1.3': {} + + '@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.17)(react@19.2.8)': + dependencies: + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-primitive@2.1.3(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.17)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-slot@1.2.3(@types/react@19.2.17)(react@19.2.8)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.17)(react@19.2.8) + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-toggle@1.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.17)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.2.17)(react@19.2.8)': + dependencies: + '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.17)(react@19.2.8) + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-use-effect-event@0.0.2(@types/react@19.2.17)(react@19.2.8)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.17)(react@19.2.8) + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.2.17)(react@19.2.8)': + dependencies: + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.17 + '@react-email/body@0.3.0(react@19.2.8)': dependencies: react: 19.2.8 @@ -6913,16 +7413,50 @@ snapshots: '@types/deep-eql': 4.0.2 assertion-error: 2.0.1 + '@types/d3-array@3.0.3': {} + + '@types/d3-color@3.1.0': {} + + '@types/d3-delaunay@6.0.1': {} + + '@types/d3-format@3.0.1': {} + + '@types/d3-geo@3.1.0': + dependencies: + '@types/geojson': 7946.0.16 + + '@types/d3-interpolate@3.0.1': + dependencies: + '@types/d3-color': 3.1.0 + + '@types/d3-path@3.1.1': {} + + '@types/d3-scale@4.0.2': + dependencies: + '@types/d3-time': 3.0.0 + + '@types/d3-shape@3.1.7': + dependencies: + '@types/d3-path': 3.1.1 + + '@types/d3-time-format@2.1.0': {} + + '@types/d3-time@3.0.0': {} + '@types/deep-eql@4.0.2': {} '@types/estree@1.0.9': {} + '@types/geojson@7946.0.16': {} + '@types/hast@3.0.5': dependencies: '@types/unist': 3.0.3 '@types/linkify-it@5.0.0': {} + '@types/lodash@4.17.24': {} + '@types/markdown-it@14.1.2': dependencies: '@types/linkify-it': 5.0.0 @@ -6976,6 +7510,83 @@ snapshots: '@vercel/detect-agent@1.2.3': {} + '@visx/curve@4.0.1-alpha.0': + dependencies: + '@visx/vendor': 4.0.0-alpha.0 + + '@visx/event@4.0.1-alpha.0': + dependencies: + '@types/react': 19.2.17 + '@visx/point': 4.0.1-alpha.0 + + '@visx/grid@4.0.1-alpha.0(react@19.2.8)': + dependencies: + '@types/react': 19.2.17 + '@visx/curve': 4.0.1-alpha.0 + '@visx/group': 4.0.1-alpha.0(react@19.2.8) + '@visx/point': 4.0.1-alpha.0 + '@visx/scale': 4.0.1-alpha.0 + '@visx/shape': 4.0.1-alpha.0(react@19.2.8) + classnames: 2.5.1 + react: 19.2.8 + + '@visx/group@4.0.1-alpha.0(react@19.2.8)': + dependencies: + '@types/react': 19.2.17 + classnames: 2.5.1 + react: 19.2.8 + + '@visx/point@4.0.1-alpha.0': {} + + '@visx/responsive@4.0.1-alpha.0(react@19.2.8)': + dependencies: + '@types/lodash': 4.17.24 + '@types/react': 19.2.17 + lodash: 4.18.1 + react: 19.2.8 + + '@visx/scale@4.0.1-alpha.0': + dependencies: + '@visx/vendor': 4.0.0-alpha.0 + + '@visx/shape@4.0.1-alpha.0(react@19.2.8)': + dependencies: + '@types/lodash': 4.17.24 + '@types/react': 19.2.17 + '@visx/curve': 4.0.1-alpha.0 + '@visx/group': 4.0.1-alpha.0(react@19.2.8) + '@visx/scale': 4.0.1-alpha.0 + '@visx/vendor': 4.0.0-alpha.0 + classnames: 2.5.1 + lodash: 4.18.1 + react: 19.2.8 + + '@visx/vendor@4.0.0-alpha.0': + dependencies: + '@types/d3-array': 3.0.3 + '@types/d3-color': 3.1.0 + '@types/d3-delaunay': 6.0.1 + '@types/d3-format': 3.0.1 + '@types/d3-geo': 3.1.0 + '@types/d3-interpolate': 3.0.1 + '@types/d3-path': 3.1.1 + '@types/d3-scale': 4.0.2 + '@types/d3-shape': 3.1.7 + '@types/d3-time': 3.0.0 + '@types/d3-time-format': 2.1.0 + d3-array: 3.2.1 + d3-color: 3.1.0 + d3-delaunay: 6.0.2 + d3-format: 3.1.0 + d3-geo: 3.1.0 + d3-interpolate: 3.0.1 + d3-path: 3.1.0 + d3-scale: 4.0.2 + d3-shape: 3.2.0 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + internmap: 2.0.3 + '@vitest/expect@4.1.10': dependencies: '@standard-schema/spec': 1.1.0 @@ -7224,6 +7835,60 @@ snapshots: - vitest - workerd + alchemy@2.0.0-beta.59(@effect/platform-bun@4.0.0-beta.97(effect@4.0.0-beta.97))(@effect/platform-node@4.0.0-beta.92(effect@4.0.0-beta.97)(ioredis@5.11.1))(@types/node@25.9.5)(@types/react@19.2.17)(effect@4.0.0-beta.97)(vite@8.1.2(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0))(vitest@4.1.10(@types/node@25.9.5)(vite@8.1.2(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0)))(workerd@1.20260617.1)(ws@8.21.1): + dependencies: + '@alchemy.run/node-utils': 0.0.5 + '@aws-sdk/credential-providers': 3.1077.0 + '@clack/prompts': 0.11.0 + '@distilled.cloud/aws': 0.27.0(effect@4.0.0-beta.97) + '@distilled.cloud/axiom': 0.27.0(effect@4.0.0-beta.97) + '@distilled.cloud/cloudflare': 0.27.0(effect@4.0.0-beta.97) + '@distilled.cloud/cloudflare-rolldown-plugin': 0.11.3(rolldown@1.0.1)(vite@8.1.2(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0))(workerd@1.20260617.1) + '@distilled.cloud/cloudflare-runtime': 0.11.3(@distilled.cloud/cloudflare@0.27.0(effect@4.0.0-beta.97))(@effect/platform-bun@4.0.0-beta.97(effect@4.0.0-beta.97))(@effect/platform-node@4.0.0-beta.92(effect@4.0.0-beta.97)(ioredis@5.11.1))(effect@4.0.0-beta.97) + '@distilled.cloud/cloudflare-vite-plugin': 0.11.3(@distilled.cloud/cloudflare-runtime@0.11.3(@distilled.cloud/cloudflare@0.27.0(effect@4.0.0-beta.97))(@effect/platform-bun@4.0.0-beta.97(effect@4.0.0-beta.97))(@effect/platform-node@4.0.0-beta.92(effect@4.0.0-beta.97)(ioredis@5.11.1))(effect@4.0.0-beta.97))(@distilled.cloud/cloudflare@0.27.0(effect@4.0.0-beta.97))(@effect/platform-bun@4.0.0-beta.97(effect@4.0.0-beta.97))(@effect/platform-node@4.0.0-beta.92(effect@4.0.0-beta.97)(ioredis@5.11.1))(effect@4.0.0-beta.97)(rolldown@1.0.1)(vite@8.1.2(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0))(workerd@1.20260617.1) + '@distilled.cloud/core': 0.27.0(effect@4.0.0-beta.97) + '@distilled.cloud/neon': 0.27.0(effect@4.0.0-beta.97) + '@distilled.cloud/planetscale': 0.27.0(effect@4.0.0-beta.97) + '@effect/vitest': 4.0.0-beta.92(effect@4.0.0-beta.97)(vitest@4.1.10(@types/node@25.9.5)(vite@8.1.2(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0))) + '@libsql/client': 0.17.4 + '@octokit/rest': 22.0.1 + '@octokit/webhooks': 14.2.0 + '@smithy/node-config-provider': 4.5.4 + '@smithy/shared-ini-file-loader': 4.6.4 + '@smithy/types': 4.16.1 + '@types/aws-lambda': 8.10.162 + aws4fetch: 1.0.20 + capnweb: 0.6.1 + effect: 4.0.0-beta.97 + fast-glob: 3.3.3 + fast-xml-parser: 5.9.3 + ink: 6.8.0(@types/react@19.2.17)(react@19.2.7) + jszip: 3.10.1 + libsodium-wrappers: 0.8.4 + magic-string: 0.30.21 + mysql2: 3.22.5(@types/node@25.9.5) + pathe: 2.0.3 + pg: 8.22.0 + picomatch: 4.0.5 + react: 19.2.7 + rolldown: 1.0.1 + undici: 7.28.0 + yaml: 2.9.0 + optionalDependencies: + '@effect/platform-bun': 4.0.0-beta.97(effect@4.0.0-beta.97) + '@effect/platform-node': 4.0.0-beta.92(effect@4.0.0-beta.97)(ioredis@5.11.1) + vite: 8.1.2(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0) + ws: 8.21.1 + transitivePeerDependencies: + - '@types/node' + - '@types/react' + - bufferutil + - pg-native + - react-devtools-core + - utf-8-validate + - vitest + - workerd + alchemy@2.0.0-beta.59(@effect/platform-bun@4.0.0-beta.97(effect@4.0.0-beta.97))(@effect/platform-node@4.0.0-beta.92(effect@4.0.0-beta.97)(ioredis@5.11.1))(@types/node@26.1.1)(@types/react@19.2.17)(effect@4.0.0-beta.97)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0))(vitest@4.1.10(@types/node@26.1.1)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0)))(workerd@1.20260617.1)(ws@8.21.1): dependencies: '@alchemy.run/node-utils': 0.0.5 @@ -7370,8 +8035,12 @@ snapshots: dependencies: readdirp: 5.0.0 + chownr@3.0.0: {} + ci-info@4.4.0: {} + classnames@2.5.1: {} + cli-boxes@3.0.0: {} cli-cursor@4.0.0: @@ -7437,14 +8106,66 @@ snapshots: csstype@3.2.3: {} + d3-array@3.2.1: + dependencies: + internmap: 2.0.3 + + d3-array@3.2.4: + dependencies: + internmap: 2.0.3 + + d3-color@3.1.0: {} + + d3-delaunay@6.0.2: + dependencies: + delaunator: 5.1.0 + + d3-format@3.1.0: {} + + d3-geo@3.1.0: + dependencies: + d3-array: 3.2.4 + + d3-interpolate@3.0.1: + dependencies: + d3-color: 3.1.0 + + d3-path@3.1.0: {} + + d3-scale@4.0.2: + dependencies: + d3-array: 3.2.4 + d3-format: 3.1.0 + d3-interpolate: 3.0.1 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + + d3-shape@3.2.0: + dependencies: + d3-path: 3.1.0 + + d3-time-format@4.1.0: + dependencies: + d3-time: 3.1.0 + + d3-time@3.1.0: + dependencies: + d3-array: 3.2.4 + debug@4.4.3: dependencies: ms: 2.1.3 + deepmerge-ts@7.1.5: {} + deepmerge@4.3.1: {} defu@6.1.7: {} + delaunator@5.1.0: + dependencies: + robust-predicates: 3.0.3 + denque@2.1.0: {} dependency-cruiser@17.4.3: @@ -7502,6 +8223,11 @@ snapshots: dts-resolver@3.0.0: {} + effect@3.20.0: + dependencies: + '@standard-schema/spec': 1.1.0 + fast-check: 3.23.2 + effect@4.0.0-beta.93: dependencies: '@standard-schema/spec': 1.1.0 @@ -7528,8 +8254,12 @@ snapshots: uuid: 14.0.1 yaml: 2.9.0 + elkjs@0.11.1: {} + emoji-regex@10.6.0: {} + empathic@2.0.0: {} + empathic@2.0.1: {} enhanced-resolve@5.22.1: @@ -7590,6 +8320,10 @@ snapshots: exsolve@1.1.0: {} + fast-check@3.23.2: + dependencies: + pure-rand: 6.1.0 + fast-check@4.9.0: dependencies: pure-rand: 8.4.2 @@ -7797,6 +8531,8 @@ snapshots: - bufferutil - utf-8-validate + internmap@2.0.3: {} + interpret@3.1.1: {} ioredis@5.11.1: @@ -7964,6 +8700,8 @@ snapshots: rfdc: 1.4.1 wrap-ansi: 10.0.0 + lodash@4.18.1: {} + log-update@6.1.0: dependencies: ansi-escapes: 7.3.0 @@ -8037,6 +8775,12 @@ snapshots: minimist@1.2.8: {} + minipass@7.1.3: {} + + minizlib@3.1.0: + dependencies: + minipass: 7.1.3 + ms@2.1.3: {} msgpackr-extract@3.0.4: @@ -8057,6 +8801,18 @@ snapshots: multipasta@0.2.8: {} + mysql2@3.15.3: + dependencies: + aws-ssl-profiles: 1.1.2 + denque: 2.1.0 + generate-function: 2.3.1 + iconv-lite: 0.7.2 + long: 5.3.2 + lru.min: 1.1.4 + named-placeholders: 1.1.6 + seq-queue: 0.0.5 + sqlstring: 2.3.3 + mysql2@3.22.5(@types/node@25.9.5): dependencies: '@types/node': 25.9.5 @@ -8250,10 +9006,29 @@ snapshots: dependencies: xtend: 4.0.2 + postgres@3.4.7: {} + postgres@3.4.9: {} prettier@3.9.5: {} + prisma@7.9.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@6.0.3): + dependencies: + '@prisma/config': 7.9.0 + '@prisma/dev': 0.24.14(typescript@6.0.3) + '@prisma/engines': 7.9.0 + '@prisma/studio-core': 0.33.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + mysql2: 3.15.3 + postgres: 3.4.7 + optionalDependencies: + typescript: 6.0.3 + transitivePeerDependencies: + - '@types/react' + - '@types/react-dom' + - magicast + - react + - react-dom + prismjs@1.30.0: {} process-nextick-args@2.0.1: {} @@ -8275,6 +9050,8 @@ snapshots: punycode.js@2.3.1: {} + pure-rand@6.1.0: {} + pure-rand@8.4.2: {} quansync@1.0.0: {} @@ -8370,6 +9147,8 @@ snapshots: rfdc@1.4.1: {} + robust-predicates@3.0.3: {} + rolldown-plugin-dts@0.27.9(rolldown@1.2.0)(typescript@6.0.3): dependencies: dts-resolver: 3.0.0 @@ -8474,6 +9253,8 @@ snapshots: semver@7.8.5: optional: true + seq-queue@0.0.5: {} + setimmediate@1.0.5: {} sharp@0.34.5: @@ -8555,6 +9336,8 @@ snapshots: sql-escaper@1.3.3: {} + sqlstring@2.3.3: {} + stack-utils@2.0.6: dependencies: escape-string-regexp: 2.0.0 @@ -8621,6 +9404,14 @@ snapshots: tapable@2.3.3: {} + tar@7.5.21: + dependencies: + '@isaacs/fs-minipass': 4.0.1 + chownr: 3.0.0 + minipass: 7.1.3 + minizlib: 3.1.0 + yallist: 5.0.0 + terminal-size@4.0.1: {} tinybench@2.9.0: {} @@ -8909,6 +9700,8 @@ snapshots: xtend@4.0.2: {} + yallist@5.0.0: {} + yaml@2.9.0: {} yoga-layout@3.2.1: {} diff --git a/test/integration/package.json b/test/integration/package.json index a97b66e50..bf52f3a69 100644 --- a/test/integration/package.json +++ b/test/integration/package.json @@ -5,13 +5,15 @@ "type": "module", "scripts": { "typecheck": "tsc --noEmit", - "test": "bun test" + "test": "bun test && bun run test/local-dev.integration.ts" }, "devDependencies": { "@prisma/composer": "workspace:0.2.0", "@prisma/composer-prisma-cloud": "workspace:0.2.0", "@types/bun": "^1.3.13", "@types/node": "^25.9.3", + "alchemy": "2.0.0-beta.59", + "prisma": "7.9.0", "typescript": "^6.0.3" } } diff --git a/test/integration/test/cli.extension-config.test.ts b/test/integration/test/cli.extension-config.test.ts index 3c3b21abb..6e0b24bf4 100644 --- a/test/integration/test/cli.extension-config.test.ts +++ b/test/integration/test/cli.extension-config.test.ts @@ -42,7 +42,16 @@ describe('prisma-composer deploy — real extension-config resolution of prisma- expect(result.stderr).toContain('run your build first'); }, 30_000); - test('without PRISMA_WORKSPACE_ID, fails at the real prismaCloud() env check during config evaluation — proving the /control entry actually resolved and ran', () => { + // Local-dev spec § 5: prismaCloud() now constructs with NO environment + // present (its `dev` field must be buildable credential-free), so + // PRISMA_WORKSPACE_ID's absence no longer surfaces at config evaluation — + // config evaluation is exactly where the OLD eager check used to fire, so + // this proves the restructure through the real CLI path, not just a unit + // test of prismaCloud() in isolation. The pipeline still reaches the same + // "no built entry" failure assemble hits regardless of the env (container + // resolution, which DOES still require PRISMA_WORKSPACE_ID, runs after + // assemble and is never reached here either way). + test('without PRISMA_WORKSPACE_ID, config evaluation still succeeds — deploy fails at the same missing-built-entry point as with the env var present', () => { const env = { ...process.env }; delete env['PRISMA_WORKSPACE_ID']; @@ -54,6 +63,8 @@ describe('prisma-composer deploy — real extension-config resolution of prisma- expect(result.status).not.toBe(0); expect(result.stderr).not.toContain('Cannot resolve'); - expect(result.stderr).toContain('environment variable PRISMA_WORKSPACE_ID is required'); + expect(result.stderr).not.toContain('PRISMA_WORKSPACE_ID'); + expect(result.stderr).toContain('no built entry at'); + expect(result.stderr).toContain('run your build first'); }, 30_000); }); diff --git a/test/integration/test/fixtures/local-dev/bg-service.ts b/test/integration/test/fixtures/local-dev/bg-service.ts new file mode 100644 index 000000000..e65b82e0b --- /dev/null +++ b/test/integration/test/fixtures/local-dev/bg-service.ts @@ -0,0 +1,14 @@ +/** + * A second, dependency-free compute service — its only purpose is proving + * "a changed artifact restarts exactly one service" (S4 proof): only + * `web-service.ts`'s artifact changes between converges, so this one's pid + * must stay stable across both the no-op and the changed-artifact converge. + */ +import node from '@prisma/composer/node'; +import { compute } from '@prisma/composer-prisma-cloud'; + +export default compute({ + name: 'bkg', + deps: {}, + build: node({ module: import.meta.url, entry: 'built/bg-server.mjs' }), +}); diff --git a/test/integration/test/fixtures/local-dev/built/bg-server.mjs b/test/integration/test/fixtures/local-dev/built/bg-server.mjs new file mode 100644 index 000000000..a98f51e31 --- /dev/null +++ b/test/integration/test/fixtures/local-dev/built/bg-server.mjs @@ -0,0 +1,23 @@ +// Hand-written "built output" for bg-service.ts (S4 fixture) -- a +// dependency-free second service that must NOT restart when only web's +// artifact changes. Self-contained (see web-server.mjs's doc comment for +// why this doesn't import the sibling .ts source). + +import node from '@prisma/composer/node'; +import { compute } from '@prisma/composer-prisma-cloud'; + +const service = compute({ + name: 'bkg', + deps: {}, + build: node({ module: import.meta.url, entry: 'bg-server.mjs' }), +}); + +const { port } = service.config(); + +Bun.serve({ + port, + hostname: '0.0.0.0', + fetch: () => new Response('local-dev fixture: bg', { status: 200 }), +}); + +console.log(`[fixture] bg listening on ${port}`); diff --git a/test/integration/test/fixtures/local-dev/built/web-server.mjs b/test/integration/test/fixtures/local-dev/built/web-server.mjs new file mode 100644 index 000000000..bdea5c971 --- /dev/null +++ b/test/integration/test/fixtures/local-dev/built/web-server.mjs @@ -0,0 +1,48 @@ +// Hand-written "built output" for web-service.ts (S4 fixture). A real build +// would bundle web-service.ts's declaration directly into this file (as +// examples/env-param's server.ts does via a real `bun build`); this fixture +// has no bundler, so the SAME compute() shape is declared again here instead +// of importing the sibling .ts source — a relative import to a file outside +// this artifact's own copied bundle would break once the artifact is +// extracted somewhere else (confirmed the hard way: it does). VERSION is +// what the "changed artifact restarts exactly one service" proof edits +// between converges. + +import node from '@prisma/composer/node'; +import { bucket, compute, postgres } from '@prisma/composer-prisma-cloud'; + +const service = compute({ + name: 'web', + deps: { db: postgres(), store: bucket() }, + build: node({ module: import.meta.url, entry: 'web-server.mjs' }), +}); + +const VERSION = 'v1'; + +const { port } = service.config(); +const { db, store } = service.load(); + +Bun.serve({ + port, + hostname: '0.0.0.0', + fetch(request) { + const url = new URL(request.url); + if (url.pathname === '/health') { + return Response.json({ + ok: true, + version: VERSION, + db: typeof db.url === 'string' && db.url.length > 0, + store: typeof store.url === 'string' && store.url.length > 0, + // The port-override row local dev's Deployment provider materializes + // into this process's own env (never persisted to env.json — see + // local-dev spec § 4) — exposed here so a test can assert on it + // directly, through the documented HTTP contract, rather than + // inferring it indirectly from a successful bind. + portEnv: process.env['COMPOSER_WEB_PORT'] ?? null, + }); + } + return new Response('local-dev fixture: web', { status: 200 }); + }, +}); + +console.log(`[fixture] web listening on ${port} (${VERSION})`); diff --git a/test/integration/test/fixtures/local-dev/dev-config.ts b/test/integration/test/fixtures/local-dev/dev-config.ts new file mode 100644 index 000000000..3274e0f04 --- /dev/null +++ b/test/integration/test/fixtures/local-dev/dev-config.ts @@ -0,0 +1,18 @@ +/** + * The dev STACK's own config. Matches the shared + * `test/integration/prisma-composer.config.ts` shape, including `nodeBuild()` + * — the same build-only extension `deploy`'s assemble step routes through + * (`config.extensions[build.extension].nodes[build.type]`) and dev needs to + * accept without throwing (`mergedDevProviders`'s build-only exemption, + * ADR-0041): `isBuildOnlyExtension` recognizes `nodeBuild()` (every `nodes` + * entry is `kind: 'build'`, no `providers`/`application`/`provisions`/ + * `container`) and `mergedDevProviders` skips it rather than throwing. + */ +import { defineConfig } from '@prisma/composer/config'; +import { nodeBuild } from '@prisma/composer/node/control'; +import { prismaCloud, prismaState } from '@prisma/composer-prisma-cloud/control'; + +export default defineConfig({ + extensions: [prismaCloud(), nodeBuild()], + state: prismaState(), +}); diff --git a/test/integration/test/fixtures/local-dev/module.ts b/test/integration/test/fixtures/local-dev/module.ts new file mode 100644 index 000000000..3df8c3371 --- /dev/null +++ b/test/integration/test/fixtures/local-dev/module.ts @@ -0,0 +1,18 @@ +/** + * The local-dev integration fixture's root (S4 proof, local-dev spec § 4/§ 5, + * plan.md's S4 outcome): compute + postgres + bucket, so a `dev: true` + * lowering exercises every one of the eight local providers. Discovered by + * walking up from this file (a real `prisma-composer.config.ts` sits at the + * repo root of this package). + */ +import { module } from '@prisma/composer'; +import { bucket, postgres } from '@prisma/composer-prisma-cloud'; +import bgService from './bg-service.ts'; +import webService from './web-service.ts'; + +export default module('localdevs4fixture', ({ provision }) => { + const db = provision(postgres({ name: 'appdb' })); + const store = provision(bucket({ name: 'files' })); + provision(webService, { deps: { db, store } }); + provision(bgService, {}); +}); diff --git a/test/integration/test/fixtures/local-dev/web-service.ts b/test/integration/test/fixtures/local-dev/web-service.ts new file mode 100644 index 000000000..e78f3e13b --- /dev/null +++ b/test/integration/test/fixtures/local-dev/web-service.ts @@ -0,0 +1,19 @@ +/** + * The local-dev integration fixture's main service (S4 proof, local-dev + * spec § 4/§ 5): a compute service wired to a Postgres and a bucket, so the + * fixture exercises every local provider dev.providers registers. Its + * built entry (`built/web-server.mjs`) is hand-written, not produced by a real + * bundler — assemble() only needs a real file at `entry`, and bun executes a + * `.ts` sibling import natively at runtime, so no build step is needed for + * this fixture to be genuine "built output". (Named `built/`, not `dist/` — + * the repo's `.gitignore` excludes every `dist/` directory, and this one must + * be committed.) + */ +import node from '@prisma/composer/node'; +import { bucket, compute, postgres } from '@prisma/composer-prisma-cloud'; + +export default compute({ + name: 'web', + deps: { db: postgres(), store: bucket() }, + build: node({ module: import.meta.url, entry: 'built/web-server.mjs' }), +}); diff --git a/test/integration/test/local-dev.integration.ts b/test/integration/test/local-dev.integration.ts new file mode 100644 index 000000000..74da2f150 --- /dev/null +++ b/test/integration/test/local-dev.integration.ts @@ -0,0 +1,642 @@ +/** + * The S4 integration proof (local-dev spec, plan.md's S4 outcome; no CLI — + * the `prisma-composer dev` command itself is S5's scope): a fixture + * topology (compute × 2, postgres, bucket) lowered with `dev: true` and + * driven through the real `alchemy` binary against a hand-written stack + * module (S5 owns `generate-dev-stack.ts`; this test hand-writes the + * equivalent per spec § 6's template). + * + * Only `@prisma/composer`/`@prisma/composer-prisma-cloud` (9-public) are + * imported (ADR-0028: examples/website/test import only the published + * surface) — the emulator daemons and the dev-instance store are verified + * through their DOCUMENTED wire protocol and on-disk file contracts (plain + * `fetch()` + `fs.readFileSync`), never by importing `@internal/dev-emulators` + * or `@internal/lowering` directly. + * + * The Compute/buckets/postgres emulators are the real, machine-global daemon + * programs a real `prisma-composer dev` session would spawn (D4) — there is + * no way to + * redirect them to an isolated registry from here: `ensureDaemon`'s own + * `{registryRoot}` override is real, but reaching it would mean importing + * `@internal/dev-emulators` directly, which a test importing only 9-public + * cannot do, and `LocalTargetEmulatorsInput`/`LocalTargetProvidersInput` + * (the public surface) carry no such field by design — the local + * providers are never meant to target anything but the one real registry. + * (A `$HOME` redirect was tried + * and does NOT work: bun's `os.homedir()` does not observe an in-process + * `process.env.HOME` mutation made after startup, and a spawned child's + * `os.homedir()` was confirmed — by checking the real + * `~/.prisma-composer/emulators` after a run — to resolve the real home + * regardless of the overridden env passed to `spawnSync`.) This test + * therefore records whether each daemon was ALREADY running before it acts + * (a baseline check) and only stops the ones it caused to start; every + * app-scoped record is removed at the end regardless, through the + * extension's own `dev.teardown`, and the `postgres-main`-hosted server this + * test created (plus its persisted data) is removed too. + * + * WHY THIS IS A STANDALONE SCRIPT, NOT A `bun:test` FILE: + * + * `bun test` was tried first. Under `bun test` specifically, a daemon + * spawned deep inside the alchemy child's own Effect runtime (originally + * `LocalDatabaseProvider`'s own `prisma dev --name --detach` subprocess, + * pre-REVISION — operator review of #162; the same class of issue applies + * to any detached child a converge causes to spawn, e.g. `ensureDaemon`'s + * own daemon programs) had its stdout silently lost by the calling + * `spawnSync`/`spawn`. This was confirmed NOT a defect in the local + * providers by rigorous empirical isolation: the identical stack file, + * converged with the identical `alchemy` binary and args, succeeds every + * time — "Done: 40 succeeded", full SERVING proof, correct HTTP round-trip — + * under a plain shell, under `bun run