From f8b02912489645ec493a1811ca0350a34ed69bfd Mon Sep 17 00:00:00 2001 From: Yang Zhang Date: Fri, 21 Aug 2026 23:11:52 -0700 Subject: [PATCH 1/3] feat(workflow): add the published-copy columns A public workflow follows the author's latest content today: every save reaches the Hub immediately. Pinning a version as the public copy needs somewhere to keep that copy, which is what these columns are. `is_public` stays the on/off switch. `published_content` is the pin: NULL means the workflow follows the author's latest, which is what every workflow does today, so the migration changes nothing anyone can see. `published_name` and `published_description` travel with it because a pin has to hold everything on show, and `workflow_version` stores no metadata at all -- only content deltas. `published_version_id` names the version row holding that copy, so the revision panel can mark it and the author can restore it. The copy is materialized rather than replayed from `workflow_version` because those rows are reverse JSON-Patch deltas: serving a pinned workflow would mean folding every newer patch back from the author's current content on each public read, and a computed value is something the fulltext index cannot cover. A CHECK constraint makes "private but pinned" unrepresentable, and a PGroonga index mirrors the latest-content one so public search can match the frozen copy. Adding columns changes the arity of the generated positional constructor, so the three copy-producing paths (clone, duplicate, restore-a-version) now build their POJO with setters -- which is also what stops a later column from silently shifting a null into the wrong field. Part of #7828. --- .../user/workflow/WorkflowResource.scala | 31 ++++--- .../workflow/WorkflowVersionResource.scala | 11 +-- .../workflow/PublishedCopySchemaSpec.scala | 93 +++++++++++++++++++ sql/changelog.xml | 5 + sql/texera_ddl.sql | 31 ++++++- sql/updates/41.sql | 93 +++++++++++++++++++ 6 files changed, 244 insertions(+), 20 deletions(-) create mode 100644 amber/src/test/scala/org/apache/texera/web/resource/dashboard/user/workflow/PublishedCopySchemaSpec.scala create mode 100644 sql/updates/41.sql diff --git a/amber/src/main/scala/org/apache/texera/web/resource/dashboard/user/workflow/WorkflowResource.scala b/amber/src/main/scala/org/apache/texera/web/resource/dashboard/user/workflow/WorkflowResource.scala index 2d438e8dc75..e7fecc06c07 100644 --- a/amber/src/main/scala/org/apache/texera/web/resource/dashboard/user/workflow/WorkflowResource.scala +++ b/amber/src/main/scala/org/apache/texera/web/resource/dashboard/user/workflow/WorkflowResource.scala @@ -143,6 +143,21 @@ object WorkflowResource { case class WorkflowIDs(wids: List[Integer], pid: Option[Integer]) + /** + * A workflow POJO for the copy-producing paths (clone, duplicate, restore-a-version). + * + * Built with setters rather than the positional constructor, so that adding a column cannot + * silently shift a null into the wrong field -- as adding the published-copy columns would. + */ + def newUnpublishedWorkflow(name: String, description: String, content: String): Workflow = { + val workflow = new Workflow() + workflow.setName(name) + workflow.setDescription(description) + workflow.setContent(content) + workflow.setIsPublic(false) + workflow + } + private def updateWorkflowField( workflow: Workflow, sessionUser: SessionUser, @@ -504,14 +519,10 @@ class WorkflowResource extends LazyLogging { for (wid <- workflowIDs.wids) { val oldWorkflow: Workflow = workflowDao.fetchOneByWid(wid) val newWorkflow = createWorkflow( - new Workflow( - null, + newUnpublishedWorkflow( oldWorkflow.getName + "_copy", oldWorkflow.getDescription, - assignNewOperatorIds(oldWorkflow.getContent), - null, - null, - false + assignNewOperatorIds(oldWorkflow.getContent) ), sessionUser ) @@ -554,14 +565,10 @@ class WorkflowResource extends LazyLogging { } val oldWorkflow: Workflow = workflowDao.fetchOneByWid(wid) val newWorkflow: DashboardWorkflow = createWorkflow( - new Workflow( - null, + newUnpublishedWorkflow( oldWorkflow.getName + "_clone", oldWorkflow.getDescription, - assignNewOperatorIds(oldWorkflow.getContent), - null, - null, - false + assignNewOperatorIds(oldWorkflow.getContent) ), sessionUser ) diff --git a/amber/src/main/scala/org/apache/texera/web/resource/dashboard/user/workflow/WorkflowVersionResource.scala b/amber/src/main/scala/org/apache/texera/web/resource/dashboard/user/workflow/WorkflowVersionResource.scala index e0664b7c1d4..de1f880a834 100644 --- a/amber/src/main/scala/org/apache/texera/web/resource/dashboard/user/workflow/WorkflowVersionResource.scala +++ b/amber/src/main/scala/org/apache/texera/web/resource/dashboard/user/workflow/WorkflowVersionResource.scala @@ -30,7 +30,8 @@ import org.apache.texera.dao.jooq.generated.tables.daos.{WorkflowDao, WorkflowVe import org.apache.texera.dao.jooq.generated.tables.pojos.{Workflow, WorkflowVersion} import org.apache.texera.web.resource.dashboard.user.workflow.WorkflowResource.{ DashboardWorkflow, - assignNewOperatorIds + assignNewOperatorIds, + newUnpublishedWorkflow } import org.apache.texera.web.resource.dashboard.user.workflow.WorkflowVersionResource._ import org.jooq.DSLContext @@ -428,14 +429,10 @@ class WorkflowVersionResource { val newWorkflow: DashboardWorkflow = try { workflowResource.createWorkflow( - new Workflow( - null, + newUnpublishedWorkflow( newWorkflowName, workflowVersion.getDescription, - assignNewOperatorIds(workflowVersion.getContent), - null, - null, - false + assignNewOperatorIds(workflowVersion.getContent) ), sessionUser ) diff --git a/amber/src/test/scala/org/apache/texera/web/resource/dashboard/user/workflow/PublishedCopySchemaSpec.scala b/amber/src/test/scala/org/apache/texera/web/resource/dashboard/user/workflow/PublishedCopySchemaSpec.scala new file mode 100644 index 00000000000..08a87869c56 --- /dev/null +++ b/amber/src/test/scala/org/apache/texera/web/resource/dashboard/user/workflow/PublishedCopySchemaSpec.scala @@ -0,0 +1,93 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.texera.web.resource.dashboard.user.workflow + +import org.apache.texera.dao.MockTexeraDB +import org.apache.texera.dao.jooq.generated.tables.daos.WorkflowDao +import org.apache.texera.dao.jooq.generated.tables.pojos.Workflow +import org.jooq.exception.DataAccessException +import org.scalatest.BeforeAndAfterAll +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers + +/** + * The columns a pinned public copy lives in, and the constraint that keeps them honest. + * + * Nothing writes them yet: this covers what the migration alone guarantees -- that a workflow + * public today keeps behaving as it does, and that a private workflow can never carry a pin. + */ +class PublishedCopySchemaSpec + extends AnyFlatSpec + with Matchers + with BeforeAndAfterAll + with MockTexeraDB { + + private var workflowDao: WorkflowDao = _ + + override protected def beforeAll(): Unit = { + initializeDBAndReplaceDSLContext() + workflowDao = new WorkflowDao(getDSLContext.configuration()) + } + + /** A workflow as it exists before anything pins it: content only, nothing frozen. */ + private def insertWorkflow(name: String, isPublic: Boolean): Workflow = { + val workflow = new Workflow() + workflow.setName(name) + workflow.setDescription("a workflow") + workflow.setContent("""{"operators":[]}""") + workflow.setIsPublic(isPublic) + workflowDao.insert(workflow) + workflowDao.fetchOneByWid(workflow.getWid) + } + + behavior of "the published-copy columns" + + it should "leave every workflow following the author's latest" in { + // The migration adds columns and no backfill, so a workflow that was public before it ran shows + // exactly what it showed: nothing is frozen, which is the state the rest of the feature calls + // "following". + val stored = insertWorkflow("migration_changes_nothing", isPublic = true) + + stored.getPublishedContent shouldBe null + stored.getPublishedName shouldBe null + stored.getPublishedDescription shouldBe null + stored.getPublishedVersionId shouldBe null + } + + it should "let a public workflow carry a frozen copy" in { + val stored = insertWorkflow("public_may_be_pinned", isPublic = true) + stored.setPublishedContent("""{"operators":[]}""") + stored.setPublishedName("frozen name") + stored.setPublishedDescription("frozen description") + + workflowDao.update(stored) + + workflowDao.fetchOneByWid(stored.getWid).getPublishedName shouldBe "frozen name" + } + + it should "refuse a private workflow that carries a frozen copy" in { + // A pin only means something while the workflow is public. The database rejects the other case, + // so no code path can leave one behind -- unpublishing has to clear the copy. + val stored = insertWorkflow("private_cannot_be_pinned", isPublic = false) + stored.setPublishedContent("""{"operators":[]}""") + + a[DataAccessException] should be thrownBy workflowDao.update(stored) + } +} diff --git a/sql/changelog.xml b/sql/changelog.xml index 3debbad196e..2f4a389bf6c 100644 --- a/sql/changelog.xml +++ b/sql/changelog.xml @@ -114,6 +114,11 @@ + + + + +