Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
*.env
*.env.*
!.env.example
*.lscache

*.lscache

Expand Down
2 changes: 2 additions & 0 deletions .husky/pre-commit
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
pnpm format
git update-index --again
pnpm verify
45 changes: 4 additions & 41 deletions docs/todo/persistence-transport-restructure.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,45 +27,6 @@ name the earlier separate Outbox/Transport project split.
status slice remain under `template/openspec/changes/`; archive them when the
team is ready for accepted specs to become the source of truth.

## Context

The current `ModularTemplate.Persistence` project conflates three concerns:

- Outbox and inbox table definitions and polling machinery
- Rebus transport bridge
- Cross-module context enrollment for shared transactions

This creates a dependency inversion: `ModularTemplate.Persistence` directly references
`ModularTemplate.Identity.Infrastructure` and `ModularTemplate.Operations.Infrastructure`
so it can register their `DbContext` instances. All module contexts share one
`NpgsqlConnection` and are enlisted in a cross-context transaction inside
`CommandTransactionBehavior` solely because the outbox table lives in a separate context.

The fix is to move the outbox tables into each module's own context and schema, which
eliminates the shared platform context and the cross-context transaction machinery.

## Target Layout

```
server/src/
ModularTemplate.SharedKernel/ ← unchanged: domain primitives
ModularTemplate.Infrastructure/ ← platform infrastructure library
β”‚ Persistence/ IUnitOfWork, IModuleDbContext,
β”‚ ModuleUnitOfWork, EF config helpers,
β”‚ stored domain events
β”‚ Outbox/ outbox/inbox DB entities, dispatcher,
β”‚ processor, background services, retry logic,
β”‚ IOutboxWriter, IOutboxDispatcher,
β”‚ IInboxProcessor, IOutboxTransport
β”‚ Transport/ DurableTransportEnvelope,
β”‚ RebusOutboxTransport,
β”‚ RebusDurableTransportHandler,
β”‚ transport config, ASB startup probe
ModularTemplate.Host/ ← registers Infrastructure transport,
β”‚ wires Rebus, background services, unit of work
modules/
ModularTemplate.Identity.Infrastructure/ β†’ references Infrastructure, stamps tables into identity.*
ModularTemplate.Operations.Infrastructure/ β†’ references Infrastructure, stamps tables into operations.*
```

`ModularTemplate.Persistence` is deleted. Its `platform.*` schema (outbox, inbox, domain_events)
Expand Down Expand Up @@ -120,8 +81,10 @@ Once the outbox is co-located with domain data (same context, same schema), the
becomes a standard single-context pipeline behavior:

```

BeginTransaction β†’ invoke next β†’ capture domain events β†’ write outbox rows β†’ commit
```

````

No `NpgsqlConnection` sharing, no `UseTransactionAsync` across contexts.
The behavior lives in `ModularTemplate.Infrastructure.Outbox` and receives `IModuleDbContext` (a marker
Expand All @@ -134,7 +97,7 @@ Each module's `DbContext.OnModelCreating` calls:
```csharp
modelBuilder.ApplyOutboxConfiguration("identity"); // or "operations"
modelBuilder.ApplyConfigurationsFromAssembly(typeof(IdentityDbContext).Assembly);
```
````

Each module's `Add*Infrastructure` registration:

Expand Down
6 changes: 3 additions & 3 deletions scripts/bootstrap-template.js
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,8 @@ const manifest = {
"playwright-report",
"test-results",
],
ignoredRelativePaths: [".husky/_", ".npmignore"],
ignoredFileExtensions: [".lscache"],
ignoredRelativePaths: [".husky/_", ".npmignore"],
placeholders(names) {
return [
["@modular-template", names.npmScope],
Expand All @@ -70,8 +70,8 @@ const manifest = {
const textFileExtensions = new Set(manifest.textFileExtensions);
const textFileNames = new Set(manifest.textFileNames);
const ignoredSegments = new Set(manifest.ignoredSegments);
const ignoredRelativePaths = new Set(manifest.ignoredRelativePaths);
const ignoredFileExtensions = new Set(manifest.ignoredFileExtensions);
const ignoredRelativePaths = new Set(manifest.ignoredRelativePaths);
function usage() {
console.log(
`Usage: node scripts/bootstrap-template.js --product-name "Acme Desk" --output ../acme-desk [--dry-run]`,
Expand Down Expand Up @@ -183,7 +183,7 @@ function shouldExclude(src) {
return false;
}

if (ignoredFileExtensions.has(path.extname(src))) {
if (ignoredFileExtensions.has(path.extname(relative))) {
return true;
}

Expand Down
51 changes: 44 additions & 7 deletions scripts/bootstrap-template.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,18 @@ test("keeps manifest text and ignore rules focused on bootstrap inputs", () => {
),
true,
);
assert.equal(
shouldExclude(
path.join(
manifest.source,
"server",
"src",
"ModularTemplate.Host",
"ModularTemplate.Host.csproj.lscache",
),
),
true,
);
assert.equal(
shouldExclude(
path.join(
Expand Down Expand Up @@ -189,14 +201,27 @@ server/src/modules/NorthStar.Identity.Infrastructure/Migrations/
test("bootstraps a generated sample with renamed manifests and product CI files", async () => {
await withTempDir(async (tempDir) => {
const outputRoot = path.join(tempDir, "north-star");
const localCacheArtifact = path.join(
manifest.source,
"server",
"src",
"ModularTemplate.Host",
"ModularTemplate.Host.csproj.lscache",
);

await execFileAsync(process.execPath, [
bootstrapScript,
"--product-name",
"North Star",
"--output",
outputRoot,
]);
try {
await writeFile(localCacheArtifact, "ModularTemplate", "utf8");

await execFileAsync(process.execPath, [
bootstrapScript,
"--product-name",
"North Star",
"--output",
outputRoot,
]);
} finally {
await rm(localCacheArtifact, { force: true });
}

const packageJson = JSON.parse(
await readFile(path.join(outputRoot, "package.json"), "utf8"),
Expand Down Expand Up @@ -299,6 +324,18 @@ test("bootstraps a generated sample with renamed manifests and product CI files"
await assert.rejects(readFile(path.join(outputRoot, "README.md")), {
code: "ENOENT",
});
await assert.rejects(
readFile(
path.join(
outputRoot,
"server",
"src",
"NorthStar.Host",
"NorthStar.Host.csproj.lscache",
),
),
{ code: "ENOENT" },
);
assert.equal(
await readFile(path.join(outputRoot, "NorthStar.slnx"), "utf8").then(
() => true,
Expand Down
16 changes: 13 additions & 3 deletions scripts/clean-template-artifacts.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@ const generatedDirectoryNames = new Set([
"test-results",
]);

async function removeGeneratedDirectories(root) {
const generatedFileExtensions = new Set([".lscache"]);

async function removeGeneratedArtifacts(root) {
const entries = await readdir(root, { withFileTypes: true });

for (const entry of entries) {
Expand All @@ -28,15 +30,23 @@ async function removeGeneratedDirectories(root) {
continue;
}

if (
entry.isFile() &&
generatedFileExtensions.has(path.extname(entry.name))
) {
await rm(fullPath, { force: true });
continue;
}

if (!entry.isDirectory()) {
continue;
}

const entryStat = await lstat(fullPath);
if (!entryStat.isSymbolicLink()) {
await removeGeneratedDirectories(fullPath);
await removeGeneratedArtifacts(fullPath);
}
}
}

await removeGeneratedDirectories(templateRoot);
await removeGeneratedArtifacts(templateRoot);
98 changes: 57 additions & 41 deletions scripts/package-smoke.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,9 @@ async function assertPackExcludesGeneratedArtifacts(tarballPath) {
(entry) =>
/package\/template\/(?:.*\/)?(?:\.pnpm-store|bin|coverage|dist|node_modules|obj|playwright-report|test-results)\//.test(
entry,
) || entry.startsWith("package/template/.husky/_/"),
) ||
entry.startsWith("package/template/.husky/_/") ||
entry.endsWith(".lscache"),
);

assert.deepEqual(forbiddenEntries, []);
Expand All @@ -92,48 +94,62 @@ test("packed CLI bootstraps a product from the published payload", async () => {
"generated during package smoke test\n",
"utf8",
);
await writeFile(
path.join(repoRoot, "template", "package-smoke.csproj.lscache"),
"generated local cache artifact\n",
"utf8",
);

const tarballPath = await packPublishedPayload(tempDir);
await assertPackExcludesGeneratedArtifacts(tarballPath);
const outputRoot = path.join(tempDir, "package-desk");
try {
const tarballPath = await packPublishedPayload(tempDir);
await assertPackExcludesGeneratedArtifacts(tarballPath);
const outputRoot = path.join(tempDir, "package-desk");

await execFileAsync(
"pnpm",
[
"dlx",
tarballPath,
"--",
"--product-name",
"Package Desk",
"--output",
outputRoot,
],
{
cwd: tempDir,
env: commandEnv(),
maxBuffer: 1024 * 1024 * 8,
},
);
await execFileAsync(
"pnpm",
[
"dlx",
tarballPath,
"--",
"--product-name",
"Package Desk",
"--output",
outputRoot,
],
{
cwd: tempDir,
env: commandEnv(),
maxBuffer: 1024 * 1024 * 8,
},
);

const packageJson = JSON.parse(
await readFile(path.join(outputRoot, "package.json"), "utf8"),
);
assert.equal(packageJson.name, "package-desk");
await assert.rejects(stat(path.join(outputRoot, ".npmignore")), {
code: "ENOENT",
});
await stat(path.join(outputRoot, "PackageDesk.slnx"));
await stat(path.join(outputRoot, ".github", "workflows", "verify.yml"));
await stat(
path.join(
outputRoot,
"server",
"src",
"modules",
"PackageDesk.Identity.Infrastructure",
"Migrations",
"IdentityDbContextModelSnapshot.cs",
),
);
const packageJson = JSON.parse(
await readFile(path.join(outputRoot, "package.json"), "utf8"),
);
assert.equal(packageJson.name, "package-desk");
await assert.rejects(stat(path.join(outputRoot, ".npmignore")), {
code: "ENOENT",
});
await stat(path.join(outputRoot, "PackageDesk.slnx"));
await stat(path.join(outputRoot, ".github", "workflows", "verify.yml"));
await stat(
path.join(
outputRoot,
"server",
"src",
"modules",
"PackageDesk.Identity.Infrastructure",
"Migrations",
"IdentityDbContextModelSnapshot.cs",
),
);
} finally {
await rm(
path.join(repoRoot, "template", "package-smoke.csproj.lscache"),
{
force: true,
},
);
}
});
});
6 changes: 6 additions & 0 deletions scripts/verify-bootstrap.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ const ignoredSegments = new Set([
"test-results",
]);

const ignoredFileExtensions = new Set([".lscache"]);

function parseArgs(argv) {
const args = {
full: false,
Expand Down Expand Up @@ -160,6 +162,10 @@ async function walk(root) {
continue;
}

if (ignoredFileExtensions.has(path.extname(entry.name))) {
continue;
}

results.push(fullPath);
if (entry.isDirectory()) {
results.push(...(await walk(fullPath)));
Expand Down
2 changes: 1 addition & 1 deletion template/.config/dotnet-tools.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"isRoot": true,
"tools": {
"dotnet-ef": {
"version": "10.0.7",
"version": "10.0.8",
"commands": ["dotnet-ef"],
"rollForward": false
}
Expand Down
3 changes: 2 additions & 1 deletion template/.husky/pre-commit
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
pnpm format:check
pnpm format
git update-index --again
pnpm scripts:lint
pnpm frontend:typecheck
pnpm api-client:check
Loading