feat(authoring): rename document types, stock and templates natively #3

Merged
james.maes merged 1 commits from feat/definition-rename-20261106 into develop 2026-11-06 15:32:52 +00:00
8 changed files with 171 additions and 10 deletions

No files matched your search

+2 -2
View File
@@ -1,6 +1,6 @@
# QBit Document Generation
> **Paused, unreleased work in progress — 2026-11-04.** Implementation has stopped. This is a source checkpoint, not a production-ready release or a complete installable package set.
> **Paused, unreleased work in progress — 2026-11-04.** Implementation has stopped apart from the scoped definition-rename addition of 2026-11-06 (see [status](docs/RELEASE-STATUS.md)). This is a source checkpoint, not a production-ready release or a complete installable package set.
Start with [current status and build prerequisites](docs/RELEASE-STATUS.md) and [known issues](docs/KNOWN-ISSUES.md). These documents supersede older release-readiness statements below.
@@ -20,7 +20,7 @@ The QBit owns document types, stock, table bindings, template drafts, immutable
- Matching durable retained-file receipts and strict audit callbacks before success or printing. Request tables contain receipt/provenance fields, never document bytes or context bodies.
- Reprint through current retained-version authorization, without rerendering. Queue acceptance is reported as `QUEUED`, never as physical printing.
- Stable request keys prevent blind duplicate execution. Incomplete work requires reconciliation; this implementation does not claim exactly-once processing.
- Native definition/source editing, immutable retained previews and optimistic draft/publication forms in a Document Generation workspace. See [authoring](docs/AUTHORING.md).
- Native definition/source editing, display-name renames for types, stock and templates, immutable retained previews and optimistic draft/publication forms in a Document Generation workspace. See [authoring](docs/AUTHORING.md).
- Durable generation-owned queue/items, independent strict audit, bounded admission/cursors and conservative recovery. Native source record/batch/status/download/reprint processes call the same services. See [queue](docs/DURABLE-QUEUE.md).
- Explicit opt-in adapter to existing core Jobs with cooperative checkpoints; no additional scheduler. See [Jobs](docs/JOBS.md) for verification state and host requirements.
+2
View File
@@ -12,6 +12,8 @@ The QBit adds a Document Generation workspace using standard QQQ tables and proc
Select a template and use **Edit template source**. PDF source uses the existing HTML code editor and FreeMarker HTML/CSS syntax; ZPL source uses the JSON editor and the renderer's restricted scene schema. These are separate source documents sharing the same pinned context script and assets. This is a source editor, not a visual layout designer or arbitrary raw ZPL terminal.
Use **Rename** on a document type, stock size or template to change only its display name. The form loads the current name; the saved name is trimmed, must be nonblank, at most 200 characters and free of control characters. Identity, stock geometry, bindings, drafts, published revisions and queued pins are untouched, and ordinary table editing of these rows stays denied. The action requires `document-generation.configure` at discovery and again at save, and the native update keeps the host's configured field-level audit.
Use **Select or clear published context script** to attach a context-tested immutable script publication. Search by project name in the ordinary QQQ selector. The optional scripting adapter checks current project/execute permissions and successful context testing of the exact published build, then obtains its artifact/hash pin server-side. No internal reference needs to be typed or copied from the IDE. Selection changes only the draft; publish the template explicitly. A newer script publication never changes an existing template or queued revision. Clearing requires the explicit checkbox, and source editing preserves the current selection even if a client submits a forged internal pin. Discovery is intentionally bounded; narrow the project search when necessary.
An edit captures the current draft fingerprint. Saving under a row lock compares the original fingerprint, so stale editors receive an error rather than overwriting another save. Publication performs isolated validation, then locks and compares the draft again before atomically inserting its immutable revision and changing the active revision. If validation overlapped a draft edit, publication refuses the changed draft. The unversioned three-argument Java `saveDraft` remains an explicit overwrite API for trusted integrations; native source forms use `saveSources` with the fingerprint and preserve their owner-resolved context pin.
+3 -3
View File
@@ -1,6 +1,6 @@
# Document Generation — current status
Updated: 2026-11-04. **Work stopped at the user's request. Unreleased work in progress; not a production 1.0 release.**
Updated: 2026-11-06. **Unreleased work in progress; not a production 1.0 release.** The 2026-11-04 stop was lifted only for the definition-rename change below; no other implementation is in progress.
## Purpose
@@ -16,10 +16,10 @@ Use Java 21 and the included Maven wrapper. Component example: `./mvnw -pl found
## Evidence retained
Foundation: 225 tests passed with zero failures/errors/skips, Checkstyle clean and coverage gate met. Renderer bridge: 52 tests passed; remote transport: 19 passed. A clean consumer resolved the foundation/remote modules. The patched OpenHTML module passed 15 tests. These are component results, not a complete released-system acceptance.
Foundation: 225 tests passed with zero failures/errors/skips, Checkstyle clean and coverage gate met. The definition-rename change (native `document_generation_{type,stock,template}_author_rename` actions, see [authoring](AUTHORING.md)) was first qualified by GG Forge as a local patch (37 rename-related tests on H2, MySQL and PostgreSQL 17) and then re-verified on this branch with `./mvnw -pl foundation -am verify`: 232 tests passed with zero failures/errors/skips, Checkstyle clean and coverage gate met, including the Docker MySQL 8.4 and PostgreSQL 17 suites. No schema change. Renderer bridge: 52 tests passed; remote transport: 19 passed. A clean consumer resolved the foundation/remote modules. The patched OpenHTML module passed 15 tests. These are component results, not a complete released-system acceptance.
## Source and release state
The starting source was extracted from Website-Backend commit b6fb23825a87412397dec703b9617148ae7eb601, then adapted for standalone packaging. This repository preserves the resulting source and existing licenses. It does not declare a published artifact cohort, completed deployment or complete acceptance.
See [known issues](KNOWN-ISSUES.md) and the README for the existing API and integration documentation. No further implementation, tests, releases or deployment should be started without explicit user instruction. The outstanding dependency order is Core, Collaboration, Scripting/Record Files, then the integrated Generation bridges and matching UI.
See [known issues](KNOWN-ISSUES.md) and the README for the existing API and integration documentation. No further implementation, tests, releases or deployment should be started without explicit user instruction; the definition rename was added under such an instruction so a published release can replace GG Forge's local `0.1.0-forge.ee782b7d6ea8-rename1` pin. The outstanding dependency order is Core, Collaboration, Scripting/Record Files, then the integrated Generation bridges and matching UI.
@@ -91,8 +91,32 @@ public class DocumentDefinitionService
return (GenerationStore.insert(GenerationTables.TYPE, new QRecord().withValue("name", text(name, 200))).getValueInteger("id"));
}
/***************************************************************************
** Rename a definition without changing its identity, geometry or revisions.
** Native UpdateAction retains the host's ordinary field-change audit path.
***************************************************************************/
public void rename(String table, Integer id, String name) throws QException
{
GenerationSecurity.require("configure");
if(table == null || !List.of(GenerationTables.TYPE, GenerationTables.STOCK, GenerationTables.TEMPLATE).contains(table))
{
throw new QBadRequestException("Only document types, stock and templates can be renamed.");
}
String label = text(name, 200).strip();
if(label.codePoints().anyMatch(Character::isISOControl))
{
throw new QBadRequestException("Document names cannot contain control characters.");
}
GenerationStore.atomic(List.of(table), () ->
{
GenerationStore.get(table, id);
GenerationStore.update(table, new QRecord().withValue("id", id).withValue("name", label));
return (null);
});
}
/*******************************************************************************
** Stock definitions are immutable; changing a draft selects a new stock row.
** Stock geometry is immutable; changing a draft selects a new stock row.
*******************************************************************************/
public Integer createStock(String name, GenerationPorts.Stock stock) throws QException
{
@@ -68,7 +68,7 @@ public final class GenerationAuthoringProcesses
public static final String FORMAT = "document_generation_formats";
public static final String SOURCES = "document_generation_source_tables";
public static final String PREVIEW_SOURCE = "document_generation_preview_source";
private static final List<String> OPERATIONS = List.of("create", "edit", "publish", "preview", "asset", "catalog", "context", "removeAsset", "replaceAsset", "naming", "packingSlip");
private static final List<String> OPERATIONS = List.of("create", "rename", "edit", "publish", "preview", "asset", "catalog", "context", "removeAsset", "replaceAsset", "naming", "packingSlip");
/*******************************************************************************
** Metadata namespace.
@@ -127,6 +127,10 @@ public final class GenerationAuthoringProcesses
register(instance, GenerationTables.TEMPLATE, "packingSlip", "New packing slip");
}
register(instance, GenerationTables.TEMPLATE, "edit", "Edit layout");
for(String table : List.of(GenerationTables.TYPE, GenerationTables.STOCK, GenerationTables.TEMPLATE))
{
register(instance, table, "rename", "Rename");
}
register(instance, GenerationTables.TEMPLATE, "naming", "Edit output filename");
register(instance, GenerationTables.TEMPLATE, "publish", "Validate and publish draft");
register(instance, GenerationTables.TEMPLATE, "preview", "Preview draft");
@@ -262,6 +266,10 @@ public final class GenerationAuthoringProcesses
*******************************************************************************/
private static List<String> fields(String table, String operation)
{
if("rename".equals(operation))
{
return (List.of("name"));
}
if("naming".equals(operation))
{
return (List.of("outputBaseName", "outputNameContextKey"));
@@ -372,9 +380,16 @@ public final class GenerationAuthoringProcesses
{
if(input.getRecords() == null || input.getRecords().size() != 1)
{
throw new QBadRequestException("Select one document template.");
throw new QBadRequestException("Select one document definition.");
}
Integer id = input.getRecords().getFirst().getValueInteger("id");
if("rename".equals(operation))
{
QRecord record = GenerationStore.get(input.getProcess().getTableName(), id);
output.addValue("templateId", id);
output.addValue("name", record.getValueString("name"));
return;
}
var draft = new DocumentDefinitionService(GenerationProcesses.config(QContext.getQInstance())).loadDraft(id);
output.addValue("templateId", id);
output.addValue("expectedFingerprint", draft.fingerprint());
@@ -495,6 +510,11 @@ public final class GenerationAuthoringProcesses
service.saveOutputNaming(id, input.getValueString("outputBaseName"), input.getValueString("outputNameContextKey"), input.getValueString("expectedFingerprint"));
state = "OUTPUT_NAMING_SAVED";
}
else if("rename".equals(operation))
{
service.rename(table, id, input.getValueString("name"));
state = "RENAMED";
}
else if("edit".equals(operation))
{
service.saveSources(id, input.getValueInteger("stockId"), input.getValueString("pdfSource"), input.getValueString("zplSource"),
@@ -245,7 +245,7 @@ final class GenerationStore
}
/*******************************************************************************
** Updates are limited to drafts, active publication references and job state.
** Updates allow definition names, drafts, publication references and job state.
** Batch completion is a monotonic marker only; its accepted pins never change.
*******************************************************************************/
static void update(String table, QRecord row) throws QException
@@ -256,7 +256,11 @@ final class GenerationStore
&& row.getValues().keySet().equals(java.util.Set.of("id", "completed"))
&& row.getValueInteger("id") != null && row.getValueInteger("id") > 0
&& Boolean.TRUE.equals(row.getValue("completed"));
if(!GenerationTables.TEMPLATE.equals(table) && !GenerationTables.REQUEST.equals(table) && !batchCompletion)
boolean definitionName = List.of(GenerationTables.TYPE, GenerationTables.STOCK).contains(table)
&& row.getValues().keySet().equals(java.util.Set.of("id", "name"))
&& row.getValueInteger("id") != null && row.getValueInteger("id") > 0
&& row.getValueString("name") != null && !row.getValueString("name").isBlank() && row.getValueString("name").length() <= 200;
if(!GenerationTables.TEMPLATE.equals(table) && !GenerationTables.REQUEST.equals(table) && !batchCompletion && !definitionName)
{
throw (new QException("Immutable Document Generation record."));
}
@@ -120,6 +120,30 @@ class DocumentPublicationJdbcTest
/***************************************************************************
** Native DML renames commit without changing stock geometry or publication.
***************************************************************************/
@Test
void definitionRenamesPreservePublishedSnapshot() throws Exception
{
Integer revisionId = definitions.publish(template);
var before = GenerationStore.get(GenerationTables.TEMPLATE, template);
var published = new java.util.HashMap<>(GenerationStore.get(GenerationTables.REVISION, revisionId).getValues());
Integer stockId = before.getValueInteger("stockId");
Integer typeId = before.getValueInteger("typeId");
var stock = new java.util.HashMap<>(GenerationStore.get(GenerationTables.STOCK, stockId).getValues());
definitions.rename(GenerationTables.TYPE, typeId, "Readable type");
definitions.rename(GenerationTables.STOCK, stockId, "A4 paper");
definitions.rename(GenerationTables.TEMPLATE, template, "Readable document");
stock.put("name", "A4 paper");
assertThat(GenerationStore.get(GenerationTables.STOCK, stockId).getValues()).isEqualTo(stock);
assertThat(GenerationStore.get(GenerationTables.REVISION, revisionId).getValues()).isEqualTo(published);
assertThat(value("SELECT published_revision_id FROM document_generation_template WHERE id=" + template)).isEqualTo(revisionId);
assertThat(GenerationStore.get(GenerationTables.TYPE, typeId).getValueString("name")).isEqualTo("Readable type");
assertThat(GenerationStore.get(GenerationTables.TEMPLATE, template).getValueString("name")).isEqualTo("Readable document");
}
/***************************************************************************
** Drop the test context, never reuse another fixture's transaction.
***************************************************************************/
@@ -807,6 +807,93 @@ class GenerationProcessesTest
return (output);
}
/***************************************************************************
** Rename forms change only the display name and preserve published bytes/pins.
***************************************************************************/
@Test
void renameDefinitionsPreservesPublishedRevisionAndStockGeometry() throws Exception
{
QRecord original = GenerationStore.get(GenerationTables.TEMPLATE, template);
int revisionId = original.getValueInteger("publishedRevisionId");
Map<String, java.io.Serializable> revision = new HashMap<>(GenerationStore.get(GenerationTables.REVISION, revisionId).getValues());
for(String table : List.of(GenerationTables.TYPE, GenerationTables.STOCK, GenerationTables.TEMPLATE))
{
int id = table.equals(GenerationTables.TYPE) ? original.getValueInteger("typeId")
: table.equals(GenerationTables.STOCK) ? original.getValueInteger("stockId") : template;
Map<String, java.io.Serializable> before = new HashMap<>(GenerationStore.get(table, id).getValues());
var process = instance.getProcess(GenerationAuthoringProcesses.name(table, "rename"));
assertThat(process.getFrontendStep("edit").getFormFields()).extracting(field -> field.getName()).containsExactly("name");
var input = author(table, "rename", id);
assertThat(input.getValueString("name")).isEqualTo(before.get("name"));
input.addValue("name", " Readable label ");
saveAuthor(input);
before.put("name", "Readable label");
assertThat(GenerationStore.get(table, id).getValues()).isEqualTo(before);
}
assertThat(GenerationStore.get(GenerationTables.REVISION, revisionId).getValues()).isEqualTo(revision);
}
/***************************************************************************
** Renames use ordinary QQQ field auditing rather than an untracked SQL write.
***************************************************************************/
@Test
void renameEmitsConfiguredNativeFieldAudit() throws Exception
{
QContext.getQSession().getUser().setFullName("Synthetic Alice");
new com.kingsrook.qqq.backend.core.model.audits.AuditsMetaDataProvider()
.defineAll(instance, instance.getTable(GenerationTables.TEMPLATE).getBackendName(), null);
instance.getTable(GenerationTables.TEMPLATE).setAuditRules(
new com.kingsrook.qqq.backend.core.model.metadata.audits.QAuditRules()
.withAuditLevel(com.kingsrook.qqq.backend.core.model.metadata.audits.AuditLevel.FIELD));
new QInstanceEnricher(instance).enrich();
new DocumentDefinitionService(config).rename(GenerationTables.TEMPLATE, template, "Readable document");
var query = new com.kingsrook.qqq.backend.core.actions.tables.QueryAction();
var auditQuery = new com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput("auditDetail");
auditQuery.withPermissionBypass("Test fixture inspects native audit output after the authorized rename.");
var details = query.execute(auditQuery).getRecords();
assertThat(details).anyMatch(row -> "name".equals(row.getValueString("fieldName"))
&& row.getValueString("message").contains("Readable document"));
}
/***************************************************************************
** Invalid names and unowned immutable rows cannot be changed through rename.
***************************************************************************/
@Test
void renameRejectsInvalidNamesAndCannotChangeStockDimensions() throws Exception
{
var service = new DocumentDefinitionService(config);
for(String name : List.of(" ", "x".repeat(201), "bad\nname"))
{
assertThatThrownBy(() -> service.rename(GenerationTables.TEMPLATE, template, name)).isInstanceOf(QException.class);
}
assertThatThrownBy(() -> service.rename(GenerationTables.TEMPLATE, template, null)).isInstanceOf(QException.class);
assertThatThrownBy(() -> service.rename(GenerationTables.REVISION, 1, "changed")).isInstanceOf(QException.class);
assertThatThrownBy(() -> service.rename(null, 1, "changed")).isInstanceOf(QException.class);
int stockId = GenerationStore.get(GenerationTables.TEMPLATE, template).getValueInteger("stockId");
assertThatThrownBy(() -> GenerationStore.update(GenerationTables.STOCK,
new QRecord().withValue("id", stockId).withValue("name", "bad").withValue("widthMicrons", 1)))
.isInstanceOf(QException.class).hasMessageContaining("Immutable");
}
/***************************************************************************
** Configure permission and read-only credential ceilings apply at save time.
***************************************************************************/
@Test
void renameRechecksConfigureAndCredentialCeiling() throws Exception
{
var input = author(GenerationTables.TEMPLATE, "rename", template);
input.addValue("name", "Blocked");
QContext.getQSession().withCredentialScope(new com.kingsrook.qqq.backend.core.model.session.QCredentialScope().withReadOnly(true));
assertThatThrownBy(() -> saveAuthor(input)).isInstanceOf(com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException.class);
QContext.getQSession().setCredentialScope(null);
QContext.getQSession().getPermissions().remove("document-generation.configure");
assertThatThrownBy(() -> saveAuthor(input)).isInstanceOf(com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException.class);
}
/*******************************************************************************
** Users can create types/stock/bindings/templates and edit source entirely in QQQ.
*******************************************************************************/