Skip to content
Merged
Show file tree
Hide file tree
Changes from 31 commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
6ba74f7
feat(core): add the dev descriptor seam (ADR-0041)
wmadden-electric Jul 22, 2026
9f47c67
feat(lowering): thread the deployment address for local dev
wmadden-electric Jul 22, 2026
7ba751c
feat(lowering): add the local dev provider bundle (local-dev spec S4)
wmadden-electric Jul 22, 2026
c2ed766
refactor(prisma-cloud): extract the preflight name walk (local-dev sp…
wmadden-electric Jul 22, 2026
3dadea1
feat(prisma-cloud): wire the dev descriptor + resolve deploy options …
wmadden-electric Jul 22, 2026
4268b38
test(local-dev): ustar round-trip and dev preflight value-sourcing
wmadden-electric Jul 22, 2026
915ff51
fix(core): keep deploy.ts free of node:path (invariant 5) + update th…
wmadden-electric Jul 22, 2026
f865a7a
fix(lowering): sanitize connection-URL credentials in the prisma dev …
wmadden-electric Jul 22, 2026
5a7891d
test(local-dev): add the S4 integration proof (fixture + real converge)
wmadden-electric Jul 23, 2026
e37caa4
test(local-dev): also remove the emulator daemon log file in afterAll…
wmadden-electric Jul 23, 2026
5a92cc2
docs(drive): take design-branch spec.md/plan.md wholesale, dropping s…
wmadden-electric Jul 23, 2026
9a976d3
feat(core): exempt build-only extensions from mergedDevProviders (ADR…
wmadden-electric Jul 23, 2026
f4599ec
test(integration): move the local-dev proof off bun test; restore nod…
wmadden-electric Jul 23, 2026
40cd813
build(turbo): serialize integration-tests after dev-emulators to avoi…
wmadden-electric Jul 23, 2026
7514398
fix(lowering): teardown's postgres glob slugs the app name like insta…
wmadden-electric Jul 23, 2026
06176a6
test(integration): assert the port-override row reaches the deployed …
wmadden-electric Jul 23, 2026
f47c969
core: rename DevDescriptor to DevExtensionDescriptor, make lower() de…
wmadden-electric Jul 23, 2026
639a0a5
new @internal/local-target package: dissolve lowering's src/dev/ into…
wmadden-electric Jul 23, 2026
6478471
lowering: revert dev leakage into platform primitives, carry address …
wmadden-electric Jul 23, 2026
7cc5dc0
prisma-cloud target: separate control-plane entry for dev, lazy thunk…
wmadden-electric Jul 23, 2026
58127a0
9-public: publish /dev subpaths for @prisma/composer and @prisma/comp…
wmadden-electric Jul 23, 2026
59bc0e3
dependency-cruiser: sanction the target's dynamic import of its own /…
wmadden-electric Jul 23, 2026
cae4fcd
integration test (S4): resolve dev descriptors before building the pr…
wmadden-electric Jul 23, 2026
26fc96b
docs(local-dev): sync spec.md and plan.md from design tip 5c6eb71
wmadden-electric Jul 23, 2026
f7e0b08
local-target: postgres swap to the postgres-main daemon's programmati…
wmadden-electric Jul 23, 2026
5edaa85
target: ensure the postgres-main daemon, teardown it via the daemon c…
wmadden-electric Jul 23, 2026
858bce3
integration test (S4): assert postgres through the daemon, not postgr…
wmadden-electric Jul 23, 2026
57e7374
local-target: unify postgres instance-name slug with the daemon's own…
wmadden-electric Jul 23, 2026
95e4229
rename the dev seam to localTarget — "dev" is the user-facing feature…
wmadden-electric Jul 23, 2026
76eedbe
Merge remote-tracking branch 'bot/main' into claude/local-dev-s4-dev-…
wmadden-electric Jul 23, 2026
b32e349
ci(e2e): follow the Management API rename of compute-services to apps
wmadden-electric Jul 23, 2026
b74f083
docs(local-target): attach app-name's module comment to the function …
wmadden-electric Jul 23, 2026
5539b69
refactor: drop 'dev' from internal directory names (review #162)
wmadden-electric Jul 23, 2026
a34cade
style: reorder imports after the directory rename
wmadden-electric Jul 23, 2026
6f6fafb
chore: point the architecture classification at src/local-target
wmadden-electric Jul 23, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .drive/projects/local-dev/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<LocalTargetDescriptor>`
(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)
Expand Down
50 changes: 49 additions & 1 deletion architecture.config.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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/dev/**",
"domain": "prisma-cloud",
"layer": "extensions",
"plane": "control"
},
{
"glob": "packages/1-prisma-cloud/2-shared-modules/email/src/contract.ts",
"domain": "prisma-cloud",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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"],
Expand Down
15 changes: 13 additions & 2 deletions dependency-cruiser.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
12 changes: 7 additions & 5 deletions examples/storefront-auth/scripts/e2e-verify.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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" ;;
Expand Down
6 changes: 6 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,5 +35,11 @@
"turbo": "^2.10.5",
"typescript": "^6.0.3"
},
"pnpm": {
"onlyBuiltDependencies": [
"prisma",
"@prisma/engines"
]
},
"version": "0.2.0"
}
1 change: 1 addition & 0 deletions packages/0-framework/1-core/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
41 changes: 38 additions & 3 deletions packages/0-framework/1-core/core/src/__tests__/invariants.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
}
});
});

Expand Down
112 changes: 112 additions & 0 deletions packages/0-framework/1-core/core/src/__tests__/local-target.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading
Loading