chore: initialize from backend template 0a6dd0e
This commit is contained in:
@@ -0,0 +1,236 @@
|
||||
# Fileserver Production Capability Foundation Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Replace the list-materializing CSV demo boundary with the Phase 1 framework-free publication contract and a bounded, staged local CSV R1 provider without claiming crash-safe R2 guarantees.
|
||||
|
||||
**Architecture:** `application-core` owns typed publication requests, rows, cells, producer/sink callbacks, opaque references, and receipts. `adapter:outbound:fileserver` owns CSV encoding, spreadsheet-formula mitigation, staging, digest/count limits, and local atomic publication. The legacy `FileExportPort` remains temporarily for compatibility and is explicitly documented as deprecated R0/R1 behavior.
|
||||
|
||||
**Tech Stack:** Java 21, JUnit 5, AssertJ, Spring Boot configuration properties, JDK NIO filesystem and SHA-256.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Add the framework-free publication contract
|
||||
|
||||
**Files:**
|
||||
- Create: `src/application-core/src/main/java/dev/caskeleton/application/filepublication/FilePublicationPort.java`
|
||||
- Create: `src/application-core/src/main/java/dev/caskeleton/application/filepublication/FilePublishRequest.java`
|
||||
- Create: `src/application-core/src/main/java/dev/caskeleton/application/filepublication/FilePublishOperationId.java`
|
||||
- Create: `src/application-core/src/main/java/dev/caskeleton/application/filepublication/FileDestinationId.java`
|
||||
- Create: `src/application-core/src/main/java/dev/caskeleton/application/filepublication/LogicalFileName.java`
|
||||
- Create: `src/application-core/src/main/java/dev/caskeleton/application/filepublication/SourceRevision.java`
|
||||
- Create: `src/application-core/src/main/java/dev/caskeleton/application/filepublication/ExportSchema.java`
|
||||
- Create: `src/application-core/src/main/java/dev/caskeleton/application/filepublication/TabularCell.java`
|
||||
- Create: `src/application-core/src/main/java/dev/caskeleton/application/filepublication/TabularRow.java`
|
||||
- Create: `src/application-core/src/main/java/dev/caskeleton/application/filepublication/TabularRowProducer.java`
|
||||
- Create: `src/application-core/src/main/java/dev/caskeleton/application/filepublication/TabularRowSink.java`
|
||||
- Create: `src/application-core/src/main/java/dev/caskeleton/application/filepublication/FilePublishReceipt.java`
|
||||
- Create: `src/application-core/src/main/java/dev/caskeleton/application/filepublication/PublishedFileReference.java`
|
||||
- Create: `src/application-core/src/main/java/dev/caskeleton/application/filepublication/FileVersion.java`
|
||||
- Test: `src/application-core/src/test/java/dev/caskeleton/application/filepublication/FilePublicationContractTest.java`
|
||||
|
||||
- [ ] **Step 1: Write the failing contract test**
|
||||
|
||||
```java
|
||||
@Test
|
||||
void requestRejectsPathLikeLogicalNamesAndSchemaRejectsDuplicateColumns() {
|
||||
assertThatThrownBy(() -> new LogicalFileName("../report.csv"))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
new ExportSchema(
|
||||
"worklog-v1",
|
||||
1,
|
||||
List.of(
|
||||
new ExportSchema.Column(
|
||||
"id", ExportSchema.CellType.INTEGER, false,
|
||||
ExportSchema.FormulaPolicy.REJECT, 64),
|
||||
new ExportSchema.Column(
|
||||
"id", ExportSchema.CellType.TEXT, false,
|
||||
ExportSchema.FormulaPolicy.MITIGATE, 128))))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify RED**
|
||||
|
||||
Run: `cd src && ./gradlew :application-core:test --tests '*FilePublicationContractTest' --console=plain`
|
||||
|
||||
Expected: compilation failure because the `filepublication` contract does not exist.
|
||||
|
||||
- [ ] **Step 3: Implement immutable validated values**
|
||||
|
||||
The contract must expose this shape and no `Path`, `File`, stream, Spring, or provider type:
|
||||
|
||||
```java
|
||||
public interface FilePublicationPort {
|
||||
FilePublishReceipt publish(FilePublishRequest request, TabularRowProducer producer);
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
public interface TabularRowProducer {
|
||||
void produce(TabularRowSink sink);
|
||||
}
|
||||
|
||||
public interface TabularRowSink {
|
||||
void write(TabularRow row);
|
||||
void checkpoint();
|
||||
}
|
||||
```
|
||||
|
||||
`TabularCell` is a sealed interface with nested records for text, integer, decimal, boolean, date,
|
||||
instant, and null. `ExportSchema` owns ordered columns, cell type, nullability, formula policy, and
|
||||
per-cell byte bounds. Records reject null/blank IDs, path separators in `LogicalFileName`, duplicate
|
||||
column names, empty schemas, and non-positive limits.
|
||||
|
||||
- [ ] **Step 4: Verify GREEN**
|
||||
|
||||
Run: `cd src && ./gradlew :application-core:test --tests '*FilePublicationContractTest' --console=plain`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
### Task 2: Add streaming CSV encoding and staged local publication
|
||||
|
||||
**Files:**
|
||||
- Create: `src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/StreamingCsvEncoder.java`
|
||||
- Create: `src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/LocalFilePublicationAdapter.java`
|
||||
- Create: `src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/LocalFilePublicationPolicy.java`
|
||||
- Test: `src/adapter/outbound/fileserver/src/test/java/dev/caskeleton/adapter/outbound/fileserver/LocalFilePublicationAdapterTest.java`
|
||||
|
||||
- [ ] **Step 1: Write the failing streaming publication tests**
|
||||
|
||||
```java
|
||||
@Test
|
||||
void publishesRowsThroughTheSinkAndReturnsAnOpaqueReceipt() {
|
||||
AtomicInteger calls = new AtomicInteger();
|
||||
FilePublishReceipt receipt =
|
||||
adapter.publish(
|
||||
request(),
|
||||
sink -> {
|
||||
calls.incrementAndGet();
|
||||
sink.write(new TabularRow(List.of(new IntegerCell(1), new TextCell("=cmd"))));
|
||||
});
|
||||
|
||||
assertThat(calls).hasValue(1);
|
||||
assertThat(receipt.reference().value()).doesNotContain(tempDir.toString());
|
||||
assertThat(Files.readString(publishedFile(receipt), UTF_8)).contains("1,'=cmd");
|
||||
}
|
||||
|
||||
@Test
|
||||
void abortsBeforeFinalPublicationWhenTheByteLimitIsExceeded() {
|
||||
assertThatThrownBy(
|
||||
() -> adapter.publish(request(), sink -> sink.write(oversizedRow())))
|
||||
.isInstanceOf(FilePublicationException.class);
|
||||
assertThat(finalArtifacts()).isEmpty();
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify RED**
|
||||
|
||||
Run: `cd src && ./gradlew :adapter:outbound:fileserver:test --tests '*LocalFilePublicationAdapterTest' --console=plain`
|
||||
|
||||
Expected: compilation failure because the staged provider does not exist.
|
||||
|
||||
- [ ] **Step 3: Implement the minimum staged provider**
|
||||
|
||||
`LocalFilePublicationPolicy` validates a fixed destination ID, base directory, maximum rows,
|
||||
maximum encoded bytes, and the only initial format profile `csv-rfc4180-v1`.
|
||||
|
||||
`LocalFilePublicationAdapter` must:
|
||||
|
||||
```text
|
||||
validate request/schema before producer invocation
|
||||
create a private .staging directory
|
||||
exclusive-create an operation-scoped .part file
|
||||
write header and each row directly through StreamingCsvEncoder
|
||||
enforce schema/cell/row/byte limits at each sink call
|
||||
prefix dangerous spreadsheet text with a single quote when policy is MITIGATE
|
||||
compute SHA-256 and counts while writing
|
||||
flush and FileChannel.force(true)
|
||||
move staging to the final operation-scoped file with ATOMIC_MOVE
|
||||
delete staging on pre-publish failure
|
||||
return an opaque reference and never an absolute path
|
||||
```
|
||||
|
||||
The first release is labelled local R1. Existing final artifacts cause a typed conflict; durable
|
||||
operation journals, crash reconciliation, replace semantics, and SFTP/NFS remain unimplemented and
|
||||
must not be advertised.
|
||||
|
||||
- [ ] **Step 4: Verify GREEN**
|
||||
|
||||
Run: `cd src && ./gradlew :adapter:outbound:fileserver:test --tests '*LocalFilePublicationAdapterTest' --console=plain`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
### Task 3: Add opt-in R1 composition and truthful documentation
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/FileExportProperties.java`
|
||||
- Modify: `src/adapter/outbound/fileserver/src/main/java/dev/caskeleton/adapter/outbound/fileserver/FileExportConfig.java`
|
||||
- Create: `src/adapter/outbound/fileserver/src/test/java/dev/caskeleton/adapter/outbound/fileserver/FilePublicationConfigTest.java`
|
||||
- Modify: `src/adapter/outbound/fileserver/README.md`
|
||||
- Modify: `src/adapter/outbound/fileserver/CLAUDE.md`
|
||||
|
||||
- [ ] **Step 1: Write the failing composition test**
|
||||
|
||||
```java
|
||||
@Test
|
||||
void disabledConfigurationCreatesNoPublicationPort() {
|
||||
contextRunner
|
||||
.withUserConfiguration(FileExportConfig.class)
|
||||
.run(context -> assertThat(context).doesNotHaveBean(FilePublicationPort.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void enabledConfigurationCreatesExactlyOneLocalR1PublicationPort() {
|
||||
contextRunner
|
||||
.withUserConfiguration(FileExportConfig.class)
|
||||
.withPropertyValues(
|
||||
"ca-skeleton.fileserver.enabled=true",
|
||||
"ca-skeleton.fileserver.destination-id=local-export",
|
||||
"ca-skeleton.fileserver.base-directory=" + tempDir)
|
||||
.run(context -> assertThat(context).hasSingleBean(FilePublicationPort.class));
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify RED**
|
||||
|
||||
Run: `cd src && ./gradlew :adapter:outbound:fileserver:test --tests '*FilePublicationConfigTest' --console=plain`
|
||||
|
||||
Expected: FAIL because the new port is not composed.
|
||||
|
||||
- [ ] **Step 3: Wire only the local R1 provider**
|
||||
|
||||
Add validated destination ID, row limit, byte limit, and format-profile settings. Contribute
|
||||
`FilePublicationPort` only when explicitly enabled. Keep `FileExportPort` as a deprecated compatibility
|
||||
bean and document that it materializes caller rows and is not R2 evidence.
|
||||
|
||||
- [ ] **Step 4: Verify module and architecture gates**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd src
|
||||
./gradlew :application-core:test :adapter:outbound:fileserver:check --console=plain
|
||||
./gradlew verifyCleanArchitectureDependencies --console=plain
|
||||
./gradlew :app-bootstrap:test --tests '*CleanArchitectureTest' --console=plain
|
||||
```
|
||||
|
||||
Expected: all commands PASS.
|
||||
|
||||
### Task 4: Record the unfinished R2 boundary
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/superpowers/specs/2026-07-26-fileserver-production-capability-design.md`
|
||||
|
||||
- [ ] **Step 1: Update implementation status without weakening completion criteria**
|
||||
|
||||
Record Phase 0–1/local R1 foundation as implemented. Keep Phase 2 durable journal/reconciliation,
|
||||
Phase 3 operations, Phase 4 SFTP, Phase 5 NFS/HA/bootstrap, and Phase 6 optional operations marked
|
||||
unimplemented. The document must still say that local R1 is not Fileserver R2.
|
||||
|
||||
- [ ] **Step 2: Verify documentation structure**
|
||||
|
||||
Run: `rg -n 'R1|R2|구현 상태|미구현' docs/superpowers/specs/2026-07-26-fileserver-production-capability-design.md`
|
||||
|
||||
Expected: explicit R1 implementation and remaining R2 gaps are both present.
|
||||
Reference in New Issue
Block a user