Skip to content
Merged
Show file tree
Hide file tree
Changes from 33 commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
ed2c747
docs(drive): record the Bundle.watch branch-dependency gap for S5
wmadden-electric Jul 23, 2026
cd686c9
feat(cli): extract the shared deploy/dev pipeline prefix, add the dev…
wmadden-electric Jul 23, 2026
9247550
feat(cli): render the dev stack file (local-dev spec S6)
wmadden-electric Jul 23, 2026
8647e8a
feat(cli): the dev watch loop — debounced, coalesced rebuild trigger
wmadden-electric Jul 23, 2026
f67bd5f
feat(cli): wire runDev — the dev command's pipeline, converge, attach…
wmadden-electric Jul 23, 2026
ffc0d42
docs(drive): sync spec.md from the design branch tip (4ce7160); resol…
wmadden-electric Jul 23, 2026
6c95b09
fix(cli): Ctrl-C actually stops the app's services; slug nested addre…
wmadden-electric Jul 23, 2026
707fe4f
test(local-dev): prove criteria 1/3/6 against examples/store via the …
wmadden-electric Jul 23, 2026
669f13a
fix(cli): watch a file target's parent directory, not the file itself
wmadden-electric Jul 23, 2026
7a16dca
fix(lowering): scope a service's materialized env to its own rows
wmadden-electric Jul 23, 2026
1453340
docs(drive): sync spec.md/plan.md wholesale from the design tip (b2cb…
wmadden-electric Jul 23, 2026
65d7d6d
test(local-dev): prove criteria 4 (bucket) and 5 (placeholder/env-param)
wmadden-electric Jul 23, 2026
e8c21f8
fix(cli): retry the post-converge endpoints() fetch; disable SQL prep…
wmadden-electric Jul 23, 2026
6b9ac00
docs(drive): re-sync spec.md/plan.md with the design tip (147eef8)
wmadden-electric Jul 23, 2026
2695a93
test(lowering): lock in the address string-prefix boundary for scoped…
wmadden-electric Jul 23, 2026
cd4d14f
test(local-dev-store): prove criterion 2 (rebuild restarts exactly on…
wmadden-electric Jul 23, 2026
a80146c
fix(ci): diagnosable proving-script failures; warm the prisma dev cache
wmadden-electric Jul 23, 2026
f385158
feat(cli): swap the dev watch engine to chokidar v4
wmadden-electric Jul 23, 2026
6bd7a35
chore: nudge PR sync
wmadden-electric Jul 23, 2026
e05cfaa
test(cli): give chokidar's async watch setup a grace window in the de…
wmadden-electric Jul 23, 2026
2e55ea2
fix(cli): avoid lint:casts false positive on chokidar's import, prove…
wmadden-electric Jul 23, 2026
d14d6e6
test(local-target): relocate the scopedEnvRows test next to the code …
wmadden-electric Jul 23, 2026
285e9c9
docs(drive): take spec.md/plan.md wholesale from the design tip (7840…
wmadden-electric Jul 23, 2026
83d48c9
feat(cli): resolve dev descriptors once up front, thread them through…
wmadden-electric Jul 23, 2026
8a0eb71
feat(cli): emit the REVISED dev stack template — the one orchestratio…
wmadden-electric Jul 23, 2026
62191b6
feat(core,prisma-cloud): session resume — startServices() on DevAttac…
wmadden-electric Jul 23, 2026
d16f4c7
test(integration): postgres through the daemon; pinned warm-restart H…
wmadden-electric Jul 23, 2026
da5da76
fix(local-target): slug the daemon-facing database id — dotted names …
wmadden-electric Jul 23, 2026
8d0f135
rename ripples: the S5 surfaces adopt the localTarget seam names
wmadden-electric Jul 23, 2026
904021b
Merge remote-tracking branch 'origin/main' into s5-update
wmadden-electric Jul 23, 2026
dea33c3
test: follow the local-target directory rename in the scoped-env test
wmadden-electric Jul 23, 2026
7d20359
test(integration): mask credentialed URLs in the harness's failure dump
wmadden-electric Jul 23, 2026
c1e152b
fix(dev-emulators): retry postgres start when ANY requested port is r…
wmadden-electric Jul 23, 2026
1644803
test(integration): keep the fixture secret's value out of assertion e…
wmadden-electric Jul 23, 2026
8ed02ea
fix(dev-emulators): recover when a failed postgres start leaves its n…
wmadden-electric Jul 23, 2026
cca301e
fix(cli): expose watch readiness instead of guessing at chokidar's at…
wmadden-electric Jul 23, 2026
f9ab62d
fix(cli): watch file targets through their parent directory
wmadden-electric Jul 23, 2026
54afa31
fix(dev-emulators): aux-port refusals retry even for existing databases
wmadden-electric Jul 23, 2026
b06f972
fix(dev-emulators): the postgres daemon survives background-work reje…
wmadden-electric Jul 23, 2026
d786ef1
fix(cli): retry every attach admin call, not just endpoints()
wmadden-electric Jul 23, 2026
93267e5
fix(dev-emulators): retry emulator admin calls on transient socket er…
wmadden-electric Jul 23, 2026
2f323fd
fix(cli): harden the dev session against review-found edge cases
wmadden-electric Jul 23, 2026
486e306
fix(dev-emulators): close postgres servers sequentially, never concur…
wmadden-electric Jul 23, 2026
13e7de7
fix(cli): handle chokidar error events instead of dying on them
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
19 changes: 19 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,25 @@ jobs:
# under CI concurrency.
- name: Build packages
run: pnpm build
# `@prisma/dev` (the ORM's local-Postgres server — the local-dev
# integration proofs' postgres-main daemon hosts it programmatically
# via `startPrismaDevServer`) downloads its database engine binaries
# on its first-ever invocation on a machine. A dev workstation
# accumulates that cache across sessions; a CI runner starts genuinely
# cold every time. Isolating the download in its own step (the `prisma`
# CLI drives the same `@prisma/dev` engine cache) gives it a clear,
# attributable failure (network egress to the engine host, disk
# space, ...) instead of surfacing buried inside a 5-service
# `alchemy deploy` three provider frames deep — which is exactly what
# made the failure this step's own commit message references
# undiagnosable. The instance itself is disposable; only its download
# populates the shared engine cache every later invocation (the
# integration proofs') reuses.
- name: Warm the prisma dev engine cache
working-directory: test/integration
run: |
pnpm exec prisma dev --name ci-engine-warmup --detach
pnpm exec prisma dev rm ci-engine-warmup --force
- name: Test
env:
STATE_TEST_DATABASE_URL: postgres://postgres:postgres@localhost:5432/postgres
Expand Down
2 changes: 2 additions & 0 deletions packages/0-framework/1-core/core/src/control/app-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,8 @@ export interface LocalTargetAttachInput {
}

export interface LocalTargetAttachment {
/** Start every stopped service from its last deployment (the session-resume signal — a no-op converge cannot start anything). */
startServices(): Promise<void>;
/** Every service's local endpoint, for the front door. */
endpoints(): Promise<readonly { readonly address: string; readonly url: string }[]>;
/** Merged, line-oriented log stream across the app's services (including services that appear after later converges). Ends when `signal` aborts. */
Expand Down
1 change: 1 addition & 0 deletions packages/0-framework/3-tooling/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"@internal/core": "workspace:0.2.0",
"@internal/foundation": "workspace:0.2.0",
"c12": "^3.3.4",
"chokidar": "^4.0.3",
"clipanion": "^3.2.1"
},
"devDependencies": {
Expand Down
35 changes: 35 additions & 0 deletions packages/0-framework/3-tooling/cli/src/__tests__/main.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ describe('parseArgs() (clipanion-backed)', () => {
name: undefined,
stage: undefined,
production: false,
fresh: false,
});
});

Expand All @@ -21,6 +22,7 @@ describe('parseArgs() (clipanion-backed)', () => {
name: 'ci-run',
stage: 'prod',
production: false,
fresh: false,
},
);
expect(parseArgs(['deploy', '--stage', 'prod', 'src/service.ts', '--name', 'ci-run'])).toEqual({
Expand All @@ -29,6 +31,7 @@ describe('parseArgs() (clipanion-backed)', () => {
name: 'ci-run',
stage: 'prod',
production: false,
fresh: false,
});
});

Expand All @@ -39,6 +42,7 @@ describe('parseArgs() (clipanion-backed)', () => {
name: undefined,
stage: undefined,
production: true,
fresh: false,
});
});

Expand Down Expand Up @@ -77,6 +81,37 @@ describe('parseArgs() (clipanion-backed)', () => {
name: '',
stage: undefined,
production: false,
fresh: false,
});
});

test('parses a bare dev invocation', () => {
expect(parseArgs(['dev', 'src/service.ts'])).toEqual({
command: 'dev',
entry: 'src/service.ts',
name: undefined,
stage: undefined,
production: false,
fresh: false,
});
});

test('parses dev --name and --fresh', () => {
expect(parseArgs(['dev', 'src/service.ts', '--name', 'ci-run', '--fresh'])).toEqual({
command: 'dev',
entry: 'src/service.ts',
name: 'ci-run',
stage: undefined,
production: false,
fresh: true,
});
});

test('throws UsageError when dev is passed --stage', () => {
expect(() => parseArgs(['dev', 'src/service.ts', '--stage', 'prod'])).toThrow(UsageError);
});

test('throws UsageError when dev is passed --production', () => {
expect(() => parseArgs(['dev', 'src/service.ts', '--production'])).toThrow(UsageError);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { describe, expect, test } from 'bun:test';
import { renderDevStackFile } from '../generate-dev-stack.ts';

describe('renderDevStackFile()', () => {
test('renders the config + app imports (relative), resolved dev providers, localState(), no report', () => {
const content = renderDevStackFile({
entryPath: '/repo/app/module.ts',
cwd: '/repo/app',
configPath: '/repo/app/prisma-composer.config.ts',
name: 'app',
assembled: {
bundles: {
web: { dir: '/repo/app/modules/web/dist/bundle', entry: 'server.js' },
},
},
});

expect(content).toContain("import { lower } from '@prisma/composer/deploy';");
expect(content).toContain("import { deserializeContainers } from '@prisma/composer/config';");
expect(content).toContain(
"import { DEV_DIR, localTargetProviders, resolveLocalTargets } from '@prisma/composer/local-target';",
);
expect(content).toContain("import { localState } from 'alchemy/State/LocalState';");
expect(content).toContain('import config from "../../prisma-composer.config.ts";');
expect(content).toContain('import app from "../../module.ts";');
// The one orchestration point (spec § 3 REVISED): containers
// deserialized, `localTarget` thunks resolved by top-level await,
// providers + state passed explicitly — and no `dev` flag, which no
// longer exists on LowerOptions.
expect(content).toContain(
'const containers = deserializeContainers(config.extensions, process.env);',
);
expect(content).toContain('const resolved = await resolveLocalTargets(config);');
expect(content).toContain('const devDir = path.join(process.cwd(), DEV_DIR);');
expect(content).toContain('lower(app, config, {');
expect(content).toContain('name: "app"');
expect(content).toContain('providers: localTargetProviders(resolved, containers, devDir)');
expect(content).toContain('state: localState()');
expect(content).not.toContain('dev: true');
expect(content).toContain(
'"web": { dir: "/repo/app/modules/web/dist/bundle", entry: "server.js" }',
);
// Dev prints its own front door — no report hook in the generated module.
expect(content).not.toContain('report:');
expect(content).not.toContain('deploymentReport');
});

test('the header comment names the --stage dev reproduction command', () => {
const content = renderDevStackFile({
entryPath: '/repo/app/module.ts',
cwd: '/repo/app',
configPath: '/repo/app/prisma-composer.config.ts',
name: 'app',
assembled: { bundles: {} },
});

expect(content).toContain('alchemy deploy .prisma-composer/dev/alchemy.run.ts --stage dev');
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { describe, expect, test } from 'bun:test';
import { renderFrontDoor } from '../run-dev.ts';

describe('renderFrontDoor()', () => {
test('starts with "[dev] ready:", then orders by address depth (fewest dots first), then lexicographic', () => {
const lines = renderFrontDoor([
{ address: 'storefront.web', url: 'http://localhost:3002' },
{ address: 'web', url: 'http://localhost:3000' },
{ address: 'api', url: 'http://localhost:3001' },
{ address: 'storefront.admin', url: 'http://localhost:3003' },
]);

expect(lines).toEqual([
'[dev] ready:',
'[dev] api http://localhost:3001',
'[dev] web http://localhost:3000',
'[dev] storefront.admin http://localhost:3003',
'[dev] storefront.web http://localhost:3002',
]);
});

test('an empty endpoint list still prints the ready line alone', () => {
expect(renderFrontDoor([])).toEqual(['[dev] ready:']);
});
});
123 changes: 123 additions & 0 deletions packages/0-framework/3-tooling/cli/src/dev/__tests__/watch.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
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 { startWatch, watchTargetsFrom } from '../watch.ts';

function tempDir(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), 'cli-watch-'));
}

function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}

describe('watchTargetsFrom()', () => {
test('a bundle with no watch field is reported unwatchable, not a target', () => {
const { targets, unwatchable } = watchTargetsFrom({
web: { dir: '/tmp/web', entry: 'server.js' },
});
expect(targets).toEqual([]);
expect(unwatchable).toEqual(['web']);
});
});

describe('startWatch()', () => {
test('debounces a burst of changes across several files into one callback, 300ms after the last change', async () => {
const dir = tempDir();
const fileA = path.join(dir, 'a.txt');
const fileB = path.join(dir, 'b.txt');
fs.writeFileSync(fileA, 'a');
fs.writeFileSync(fileB, 'b');

let calls = 0;
const stop = startWatch(
[
{ address: 'a', paths: [fileA] },
{ address: 'b', paths: [fileB] },
],
() => {
calls += 1;
},
);

try {
// A burst across both files, all inside the 300ms debounce window.
fs.writeFileSync(fileA, 'a2');
await sleep(50);
fs.writeFileSync(fileB, 'b2');
await sleep(50);
fs.writeFileSync(fileA, 'a3');

// Still inside the debounce window from the last write — no callback yet.
await sleep(100);
expect(calls).toBe(0);

// Past the 300ms debounce from the last write.
await sleep(300);
expect(calls).toBe(1);
} finally {
stop();
fs.rmSync(dir, { recursive: true, force: true });
}
}, 10_000);

test('a stopped watch fires no further callbacks', async () => {
const dir = tempDir();
const file = path.join(dir, 'a.txt');
fs.writeFileSync(file, 'a');

let calls = 0;
const stop = startWatch([{ address: 'a', paths: [file] }], () => {
calls += 1;
});
stop();

fs.writeFileSync(file, 'a2');
await sleep(500);
expect(calls).toBe(0);
fs.rmSync(dir, { recursive: true, force: true });
}, 10_000);

test('survives a delete-then-recreate rebuild of the watched file (rm -rf && bun build --outfile)', async () => {
// A single-file `fs.watch` bound to the original inode goes silently
// dead after the file is unlinked and a new one created at the same
// path — exactly what `rm -rf dist && bun build --outfile dist/x.mjs`
// does on every rebuild. Found live against examples/store's catalog
// service during the S5 proving pass; the fix at the time was a
// hand-rolled parent-directory watch, since replaced by chokidar
// (design tip 74272d8), which absorbs this class of rename/inode-swap
// itself. The regression this test guards against is unchanged, so its
// assertions stay exactly as they were — only the engine underneath
// changed.
const dir = tempDir();
const file = path.join(dir, 'server.mjs');
fs.writeFileSync(file, 'v1');

let calls = 0;
const stop = startWatch([{ address: 'catalog', paths: [file] }], () => {
calls += 1;
});
// chokidar's own watch setup is asynchronous (it returns synchronously,
// then finishes attaching OS-level watches shortly after) — a grace
// window before the first change, not a change to what's asserted.
await sleep(100);

try {
fs.rmSync(file);
fs.writeFileSync(file, 'v2');
await sleep(400);
expect(calls).toBe(1);

Check failure on line 110 in packages/0-framework/3-tooling/cli/src/dev/__tests__/watch.test.ts

View workflow job for this annotation

GitHub Actions / Test

error: expect(received).toBe(expected)

Expected: 1 Received: 0 at <anonymous> (/home/runner/work/composer/composer/packages/0-framework/3-tooling/cli/src/dev/__tests__/watch.test.ts:110:21)

// A SECOND delete+recreate — the exact case that broke a
// file-bound (not directory-bound) watch.
fs.rmSync(file);
fs.writeFileSync(file, 'v3');
await sleep(400);
expect(calls).toBe(2);
} finally {
stop();
fs.rmSync(dir, { recursive: true, force: true });
}
}, 10_000);
});
Loading
Loading