diff --git a/posthog/hogql/database/schema/system.py b/posthog/hogql/database/schema/system.py
index 710f73df4d5b..b8036dc476c9 100644
--- a/posthog/hogql/database/schema/system.py
+++ b/posthog/hogql/database/schema/system.py
@@ -2188,6 +2188,61 @@ def ticket_assignment_join(join_to_add: LazyJoinToAdd, context: HogQLContext, no
)
+canvases: PostgresTable = PostgresTable(
+ name="canvases",
+ postgres_table_name="posthog_canvas",
+ access_scope="canvas",
+ # Mirror the REST API's default filter: soft-deleted canvases are not exposed.
+ predicates=[parse_expr("deleted != true")],
+ description="Canvases (agent-built sandboxed browser apps, filed into channels); one row per canvas (soft-deleted canvases are excluded).",
+ fields={
+ "id": StringDatabaseField(name="id", description="Canvas UUID."),
+ "team_id": IntegerDatabaseField(name="team_id"),
+ "channel_id": StringDatabaseField(
+ name="channel_id", description="Channel the canvas is filed into (tasks Channel UUID)."
+ ),
+ "name": StringDatabaseField(name="name", description="Canvas name."),
+ "template_id": StringDatabaseField(
+ name="template_id", description="Template the canvas was created from, e.g. 'freeform'."
+ ),
+ "context": StringDatabaseField(
+ name="context", description="Author-written context (markdown) passed to generation tasks."
+ ),
+ "generation_task_id": StringDatabaseField(
+ name="generation_task_id",
+ nullable=True,
+ description="Task currently generating this canvas; joins to tasks.id.",
+ ),
+ "pinned_at": DateTimeDatabaseField(
+ name="pinned_at",
+ nullable=True,
+ description="When the canvas was pinned to its channel; NULL if not pinned.",
+ ),
+ "current_source_version_id": StringDatabaseField(
+ name="current_source_version_id",
+ nullable=True,
+ description="The canvas's head source version; NULL until the first publish.",
+ ),
+ "published_build_id": StringDatabaseField(
+ name="published_build_id",
+ nullable=True,
+ description="Build whose artifact the canvas app currently renders; NULL until the first successful build.",
+ ),
+ "created_by_id": IntegerDatabaseField(
+ name="created_by_id", nullable=True, description="User who created the canvas."
+ ),
+ "_deleted": BooleanDatabaseField(name="deleted", hidden=True),
+ "deleted": ExpressionField(
+ name="deleted",
+ expr=ast.Call(name="toInt", args=[ast.Field(chain=["_deleted"])]),
+ description="1 if the canvas has been deleted, 0 otherwise (always 0 here due to the table filter).",
+ ),
+ "created_at": DateTimeDatabaseField(name="created_at", description="When the canvas was created."),
+ "updated_at": DateTimeDatabaseField(name="updated_at", description="When the canvas was last updated."),
+ },
+)
+
+
tags: PostgresTable = PostgresTable(
name="tags",
postgres_table_name="posthog_tag",
@@ -2229,6 +2284,7 @@ class SystemTables(TableNode):
name="business_knowledge_documents", table=business_knowledge_documents
),
"business_knowledge_sources": TableNode(name="business_knowledge_sources", table=business_knowledge_sources),
+ "canvases": TableNode(name="canvases", table=canvases),
"cohort_calculation_history": TableNode(name="cohort_calculation_history", table=cohort_calculation_history),
"cohorts": TableNode(name="cohorts", table=cohorts),
"custom_property_definitions": TableNode(name="custom_property_definitions", table=custom_property_definitions),
diff --git a/posthog/hogql/database/schema/test/test_system_tables.py b/posthog/hogql/database/schema/test/test_system_tables.py
index 58e9bcd20dc2..5b0a16599ec9 100644
--- a/posthog/hogql/database/schema/test/test_system_tables.py
+++ b/posthog/hogql/database/schema/test/test_system_tables.py
@@ -581,6 +581,15 @@ def _create_task(team: Team, label: str):
)
+def _create_canvas(team: Team, label: str):
+ Channel = apps.get_model("tasks", "Channel")
+ Canvas = apps.get_model("canvas", "Canvas")
+
+ with team_scope(team.pk):
+ channel = Channel.objects.create(team=team, name=f"channel_for_canvas_{label}")
+ return Canvas.objects.create(team=team, channel=channel, name=f"canvas_{label}")
+
+
def _create_task_run(team: Team, label: str):
Task = apps.get_model("tasks", "Task")
TaskRun = apps.get_model("tasks", "TaskRun")
@@ -681,6 +690,7 @@ def _create_business_knowledge_chunk(team: Team, label: str):
("business_knowledge_chunks", _create_business_knowledge_chunk),
("business_knowledge_documents", _create_business_knowledge_document),
("business_knowledge_sources", _create_business_knowledge_source),
+ ("canvases", _create_canvas),
("cohorts", _create_cohort),
("cohort_calculation_history", _create_cohort_calculation_history),
("custom_property_definitions", _create_custom_property_definition),
@@ -820,6 +830,39 @@ def test_internal_environments_excluded(self):
assert str(internal_env.pk) not in ids
+class TestSystemTablesCanvasDeletedExclusion(BaseTest):
+ """Verify the canvases system table excludes soft-deleted canvases,
+ mirroring the REST API's default filter."""
+
+ def test_generated_sql_includes_deleted_predicate(self):
+ db = Database.create_for(team=self.team, user=self.user)
+ context = HogQLContext(team_id=self.team.pk, enable_select_queries=True, database=db)
+ query, _ = prepare_and_print_ast(parse_select("SELECT id FROM system.canvases"), context, dialect="clickhouse")
+ assert "system__canvases.deleted" in query
+ assert f"equals(system__canvases.team_id, {self.team.pk})" in query
+
+
+class TestSystemTablesCanvasDeletedExclusionIsolation(NonAtomicBaseTest):
+ """End-to-end check that soft-deleted canvases are never returned via HogQL."""
+
+ CLASS_DATA_LEVEL_SETUP = False
+
+ def test_deleted_canvases_excluded(self):
+ Channel = apps.get_model("tasks", "Channel")
+ Canvas = apps.get_model("canvas", "Canvas")
+
+ with team_scope(self.team.pk):
+ channel = Channel.objects.create(team=self.team, name="canvas-exclusion-channel")
+ live_canvas = Canvas.objects.create(team=self.team, channel=channel, name="live")
+ deleted_canvas = Canvas.objects.create(team=self.team, channel=channel, name="deleted", deleted=True)
+
+ response = execute_hogql_query("SELECT id FROM system.canvases", team=self.team, user=self.user)
+ ids = {str(row[0]) for row in response.results}
+
+ assert str(live_canvas.pk) in ids
+ assert str(deleted_canvas.pk) not in ids
+
+
class TestSystemTablesTaskInternalExclusion(BaseTest):
"""Verify the tasks system table excludes internal tasks (signals pipeline, etc.)
mirroring the REST API's default filter."""
diff --git a/posthog/hogql/database/test/__snapshots__/test_database.ambr b/posthog/hogql/database/test/__snapshots__/test_database.ambr
index 35a63323cd34..b02aed4968db 100644
--- a/posthog/hogql/database/test/__snapshots__/test_database.ambr
+++ b/posthog/hogql/database/test/__snapshots__/test_database.ambr
@@ -3932,6 +3932,145 @@
"row_count": null,
"type": "system"
},
+ "system.canvases": {
+ "certification": null,
+ "fields": {
+ "id": {
+ "chain": null,
+ "fields": null,
+ "hogql_value": "id",
+ "id": null,
+ "name": "id",
+ "schema_valid": true,
+ "table": null,
+ "type": "string"
+ },
+ "channel_id": {
+ "chain": null,
+ "fields": null,
+ "hogql_value": "channel_id",
+ "id": null,
+ "name": "channel_id",
+ "schema_valid": true,
+ "table": null,
+ "type": "string"
+ },
+ "name": {
+ "chain": null,
+ "fields": null,
+ "hogql_value": "name",
+ "id": null,
+ "name": "name",
+ "schema_valid": true,
+ "table": null,
+ "type": "string"
+ },
+ "template_id": {
+ "chain": null,
+ "fields": null,
+ "hogql_value": "template_id",
+ "id": null,
+ "name": "template_id",
+ "schema_valid": true,
+ "table": null,
+ "type": "string"
+ },
+ "context": {
+ "chain": null,
+ "fields": null,
+ "hogql_value": "context",
+ "id": null,
+ "name": "context",
+ "schema_valid": true,
+ "table": null,
+ "type": "string"
+ },
+ "generation_task_id": {
+ "chain": null,
+ "fields": null,
+ "hogql_value": "generation_task_id",
+ "id": null,
+ "name": "generation_task_id",
+ "schema_valid": true,
+ "table": null,
+ "type": "string"
+ },
+ "pinned_at": {
+ "chain": null,
+ "fields": null,
+ "hogql_value": "pinned_at",
+ "id": null,
+ "name": "pinned_at",
+ "schema_valid": true,
+ "table": null,
+ "type": "datetime"
+ },
+ "current_source_version_id": {
+ "chain": null,
+ "fields": null,
+ "hogql_value": "current_source_version_id",
+ "id": null,
+ "name": "current_source_version_id",
+ "schema_valid": true,
+ "table": null,
+ "type": "string"
+ },
+ "published_build_id": {
+ "chain": null,
+ "fields": null,
+ "hogql_value": "published_build_id",
+ "id": null,
+ "name": "published_build_id",
+ "schema_valid": true,
+ "table": null,
+ "type": "string"
+ },
+ "created_by_id": {
+ "chain": null,
+ "fields": null,
+ "hogql_value": "created_by_id",
+ "id": null,
+ "name": "created_by_id",
+ "schema_valid": true,
+ "table": null,
+ "type": "integer"
+ },
+ "deleted": {
+ "chain": null,
+ "fields": null,
+ "hogql_value": "deleted",
+ "id": null,
+ "name": "deleted",
+ "schema_valid": true,
+ "table": null,
+ "type": "integer"
+ },
+ "created_at": {
+ "chain": null,
+ "fields": null,
+ "hogql_value": "created_at",
+ "id": null,
+ "name": "created_at",
+ "schema_valid": true,
+ "table": null,
+ "type": "datetime"
+ },
+ "updated_at": {
+ "chain": null,
+ "fields": null,
+ "hogql_value": "updated_at",
+ "id": null,
+ "name": "updated_at",
+ "schema_valid": true,
+ "table": null,
+ "type": "datetime"
+ }
+ },
+ "id": "system.canvases",
+ "name": "system.canvases",
+ "row_count": null,
+ "type": "system"
+ },
"system.cohort_calculation_history": {
"certification": null,
"fields": {
@@ -12822,6 +12961,145 @@
"row_count": null,
"type": "system"
},
+ "system.canvases": {
+ "certification": null,
+ "fields": {
+ "id": {
+ "chain": null,
+ "fields": null,
+ "hogql_value": "id",
+ "id": null,
+ "name": "id",
+ "schema_valid": true,
+ "table": null,
+ "type": "string"
+ },
+ "channel_id": {
+ "chain": null,
+ "fields": null,
+ "hogql_value": "channel_id",
+ "id": null,
+ "name": "channel_id",
+ "schema_valid": true,
+ "table": null,
+ "type": "string"
+ },
+ "name": {
+ "chain": null,
+ "fields": null,
+ "hogql_value": "name",
+ "id": null,
+ "name": "name",
+ "schema_valid": true,
+ "table": null,
+ "type": "string"
+ },
+ "template_id": {
+ "chain": null,
+ "fields": null,
+ "hogql_value": "template_id",
+ "id": null,
+ "name": "template_id",
+ "schema_valid": true,
+ "table": null,
+ "type": "string"
+ },
+ "context": {
+ "chain": null,
+ "fields": null,
+ "hogql_value": "context",
+ "id": null,
+ "name": "context",
+ "schema_valid": true,
+ "table": null,
+ "type": "string"
+ },
+ "generation_task_id": {
+ "chain": null,
+ "fields": null,
+ "hogql_value": "generation_task_id",
+ "id": null,
+ "name": "generation_task_id",
+ "schema_valid": true,
+ "table": null,
+ "type": "string"
+ },
+ "pinned_at": {
+ "chain": null,
+ "fields": null,
+ "hogql_value": "pinned_at",
+ "id": null,
+ "name": "pinned_at",
+ "schema_valid": true,
+ "table": null,
+ "type": "datetime"
+ },
+ "current_source_version_id": {
+ "chain": null,
+ "fields": null,
+ "hogql_value": "current_source_version_id",
+ "id": null,
+ "name": "current_source_version_id",
+ "schema_valid": true,
+ "table": null,
+ "type": "string"
+ },
+ "published_build_id": {
+ "chain": null,
+ "fields": null,
+ "hogql_value": "published_build_id",
+ "id": null,
+ "name": "published_build_id",
+ "schema_valid": true,
+ "table": null,
+ "type": "string"
+ },
+ "created_by_id": {
+ "chain": null,
+ "fields": null,
+ "hogql_value": "created_by_id",
+ "id": null,
+ "name": "created_by_id",
+ "schema_valid": true,
+ "table": null,
+ "type": "integer"
+ },
+ "deleted": {
+ "chain": null,
+ "fields": null,
+ "hogql_value": "deleted",
+ "id": null,
+ "name": "deleted",
+ "schema_valid": true,
+ "table": null,
+ "type": "integer"
+ },
+ "created_at": {
+ "chain": null,
+ "fields": null,
+ "hogql_value": "created_at",
+ "id": null,
+ "name": "created_at",
+ "schema_valid": true,
+ "table": null,
+ "type": "datetime"
+ },
+ "updated_at": {
+ "chain": null,
+ "fields": null,
+ "hogql_value": "updated_at",
+ "id": null,
+ "name": "updated_at",
+ "schema_valid": true,
+ "table": null,
+ "type": "datetime"
+ }
+ },
+ "id": "system.canvases",
+ "name": "system.canvases",
+ "row_count": null,
+ "type": "system"
+ },
"system.cohort_calculation_history": {
"certification": null,
"fields": {
diff --git a/products/canvas/backend/build_service.py b/products/canvas/backend/build_service.py
index 5378266a7751..fc2c4f117677 100644
--- a/products/canvas/backend/build_service.py
+++ b/products/canvas/backend/build_service.py
@@ -44,6 +44,7 @@
SYNTHETIC_INDEX_HTML,
diagnostic,
has_errors,
+ synthetic_source_project,
validate_relative_path,
validate_source_project,
)
@@ -312,8 +313,6 @@ def current_source_project(canvas: Canvas) -> tuple[dict[str, Any], str | None]:
if canvas.current_source_version_id:
version = CanvasSourceVersion.objects.for_team(canvas.team_id).get(pk=canvas.current_source_version_id)
return read_source_project(version), str(version.id)
- from products.canvas.backend.source import synthetic_source_project # noqa: PLC0415
-
return synthetic_source_project(canvas.legacy_code), None
@@ -424,11 +423,33 @@ def publish_source_project(
key, digest, size = upload_source_project(canvas.team_id, canvas.id, project)
+ # A migrated canvas's pre-relational source must survive its first publish:
+ # it becomes a real parent version here so history (undo/revert) can reach
+ # it — otherwise nulling legacy_code below would discard the only copy.
+ # Same upload-then-commit posture as the main project.
+ legacy_upload: tuple[str, str, int] | None = None
+ if current_id is None and (canvas.legacy_code or "").strip():
+ legacy_upload = upload_source_project(canvas.team_id, canvas.id, synthetic_source_project(canvas.legacy_code))
+
with transaction.atomic(), team_scope(canvas.team_id):
canvas = _claim_canvas_head(
canvas, has_expected_version=has_expected_version, expected_version_id=expected_version_id
)
first_publish = canvas.current_source_version_id is None and not (canvas.legacy_code or "").strip()
+ if (
+ legacy_upload is not None
+ and canvas.current_source_version_id is None
+ and (canvas.legacy_code or "").strip()
+ ):
+ legacy_key, legacy_digest, legacy_size = legacy_upload
+ canvas.current_source_version = CanvasSourceVersion.objects.create(
+ team_id=canvas.team_id,
+ canvas=canvas,
+ source_hash=legacy_digest,
+ source_object_key=legacy_key,
+ source_size=legacy_size,
+ prompt="Imported source",
+ )
version = CanvasSourceVersion.objects.create(
team_id=canvas.team_id,
canvas=canvas,
diff --git a/products/canvas/backend/migrations/0007_soft_delete_home_canvases.py b/products/canvas/backend/migrations/0007_soft_delete_home_canvases.py
new file mode 100644
index 000000000000..8ef59c68ab84
--- /dev/null
+++ b/products/canvas/backend/migrations/0007_soft_delete_home_canvases.py
@@ -0,0 +1,24 @@
+"""Soft-delete the auto-created channel home boards.
+
+The channel home is a native client view now, so the seeded home-board
+canvases are retired. Soft-delete (recoverable) because they were
+system-generated directory listings, not user content; user-created canvases
+are untouched. The `is_home` machinery itself is removed in 0008.
+"""
+
+from django.db import migrations
+
+
+def soft_delete_home_canvases(apps, schema_editor):
+ Canvas = apps.get_model("canvas", "Canvas")
+ Canvas.objects.filter(is_home=True, deleted=False).update(deleted=True)
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("canvas", "0006_require_build_enqueued_at"),
+ ]
+
+ operations = [
+ migrations.RunPython(soft_delete_home_canvases, migrations.RunPython.noop, elidable=False),
+ ]
diff --git a/products/canvas/backend/migrations/0008_remove_home_canvas.py b/products/canvas/backend/migrations/0008_remove_home_canvas.py
new file mode 100644
index 000000000000..38760936bf2f
--- /dev/null
+++ b/products/canvas/backend/migrations/0008_remove_home_canvas.py
@@ -0,0 +1,33 @@
+"""Retire the home-canvas concept (schema side; 0007 soft-deleted the rows).
+
+The partial unique constraint is dropped for real; the column itself is
+removed from Django state only (per safe-migration policy) and given a
+database-side default first, so inserts that no longer mention it keep
+working. A later migration may drop the column physically.
+"""
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("canvas", "0007_soft_delete_home_canvases"),
+ ]
+
+ operations = [
+ migrations.RemoveConstraint(
+ model_name="canvas",
+ name="unique_home_canvas_per_channel",
+ ),
+ migrations.AlterField(
+ model_name="canvas",
+ name="is_home",
+ field=models.BooleanField(default=False, db_default=False),
+ ),
+ migrations.SeparateDatabaseAndState(
+ database_operations=[],
+ state_operations=[
+ migrations.RemoveField(model_name="canvas", name="is_home"),
+ ],
+ ),
+ ]
diff --git a/products/canvas/backend/migrations/max_migration.txt b/products/canvas/backend/migrations/max_migration.txt
index d0da8dfa0911..4b4ca0731778 100644
--- a/products/canvas/backend/migrations/max_migration.txt
+++ b/products/canvas/backend/migrations/max_migration.txt
@@ -1 +1 @@
-0006_require_build_enqueued_at
+0008_remove_home_canvas
diff --git a/products/canvas/backend/models.py b/products/canvas/backend/models.py
index a079fa728d2f..92491e4b12c9 100644
--- a/products/canvas/backend/models.py
+++ b/products/canvas/backend/models.py
@@ -32,8 +32,6 @@ class Canvas(TeamScopedRootMixin, UUIDModel):
generation_task_id = models.UUIDField(null=True, blank=True)
# Set when the canvas is pinned to its channel (shared across users).
pinned_at = models.DateTimeField(null=True, blank=True)
- # A channel's home canvas is the board shown when the channel opens.
- is_home = models.BooleanField(default=False)
current_source_version = models.ForeignKey(
"canvas.CanvasSourceVersion", on_delete=models.SET_NULL, null=True, blank=True, related_name="+"
@@ -56,13 +54,6 @@ class Canvas(TeamScopedRootMixin, UUIDModel):
class Meta:
db_table = "posthog_canvas"
indexes = [models.Index(fields=["channel", "-created_at"], name="canvas_channel_recency")]
- constraints = [
- models.UniqueConstraint(
- fields=["channel"],
- condition=Q(is_home=True, deleted=False),
- name="unique_home_canvas_per_channel",
- )
- ]
class CanvasSourceVersion(TeamScopedRootMixin, UUIDModel):
diff --git a/products/canvas/backend/presentation/serializers.py b/products/canvas/backend/presentation/serializers.py
index cb198cec4e9a..babd99b9c52f 100644
--- a/products/canvas/backend/presentation/serializers.py
+++ b/products/canvas/backend/presentation/serializers.py
@@ -41,7 +41,6 @@ class Meta:
"generation_task_id",
"pinned",
"pinned_at",
- "is_home",
"current_version_id",
"published_build_id",
"created_by",
@@ -67,11 +66,6 @@ class CanvasCreateSerializer(serializers.Serializer):
template_id = serializers.CharField(
required=False, default="freeform", max_length=64, help_text="Canvas template identifier."
)
- is_home = serializers.BooleanField(
- required=False,
- default=False,
- help_text="Create the canvas as the channel's home board (at most one per channel).",
- )
class CanvasUpdateSerializer(serializers.Serializer):
diff --git a/products/canvas/backend/presentation/views.py b/products/canvas/backend/presentation/views.py
index 11773f6cd002..7f6a9e564ed5 100644
--- a/products/canvas/backend/presentation/views.py
+++ b/products/canvas/backend/presentation/views.py
@@ -3,7 +3,6 @@
from django.conf import settings
from django.core.exceptions import ValidationError as DjangoValidationError
-from django.db import IntegrityError, transaction
from django.db.models import QuerySet
from django.utils import timezone
@@ -19,7 +18,6 @@
from posthog.models.user import User
from posthog.storage.object_storage import ObjectStorageError
from posthog.temporal.oauth import SANDBOX_OAUTH_APP_CLIENT_IDS
-from posthog.utils import str_to_bool
from products.canvas.backend import build_service
from products.canvas.backend.models import Canvas, CanvasBuild, CanvasSourceVersion
@@ -111,7 +109,6 @@ class CanvasViewSet(TeamAndOrgViewSetMixin, viewsets.ModelViewSet):
OpenApiParameter(
"channel", OpenApiTypes.UUID, required=False, description="Only return canvases in this channel."
),
- OpenApiParameter("is_home", bool, required=False, description="Filter by channel-home status."),
]
)
def list(self, request: Request, *args: Any, **kwargs: Any) -> Response:
@@ -134,9 +131,6 @@ def safely_get_queryset(self, queryset: QuerySet) -> QuerySet:
except ValueError:
return queryset.none()
queryset = queryset.filter(channel_id=channel_id)
- is_home = self.request.query_params.get("is_home")
- if is_home is not None:
- queryset = queryset.filter(is_home=str_to_bool(is_home))
return queryset.order_by("-created_at")
@extend_schema(
@@ -154,28 +148,18 @@ def create(self, request: Request, *args: Any, **kwargs: Any) -> Response:
# someone else's personal channel must be refused here too.
if not tasks_facade.channel_exists(self.team_id, channel_id, user.id if user else None):
return Response({"detail": "Channel not found in this team."}, status=status.HTTP_400_BAD_REQUEST)
- try:
- # Savepoint so losing the is_home uniqueness race doesn't poison
- # the request's transaction.
- with transaction.atomic():
- canvas = Canvas.objects.create(
- team_id=self.team_id,
- channel_id=channel_id,
- name=payload.validated_data["name"],
- template_id=payload.validated_data["template_id"],
- is_home=payload.validated_data["is_home"],
- created_by=user,
- # A sandbox-created canvas is its task's deliverable: bind
- # the two at birth so the client can show the run on the
- # canvas and nest the task under it — composer-initiated
- # generations have no client-side create to record it.
- generation_task_id=self._sandbox_task_id(request),
- )
- except IntegrityError:
- return Response(
- {"detail": "This channel already has a home canvas.", "code": "home_canvas_exists"},
- status=status.HTTP_409_CONFLICT,
- )
+ canvas = Canvas.objects.create(
+ team_id=self.team_id,
+ channel_id=channel_id,
+ name=payload.validated_data["name"],
+ template_id=payload.validated_data["template_id"],
+ created_by=user,
+ # A sandbox-created canvas is its task's deliverable: bind
+ # the two at birth so the client can show the run on the
+ # canvas and nest the task under it — composer-initiated
+ # generations have no client-side create to record it.
+ generation_task_id=self._sandbox_task_id(request),
+ )
return Response(CanvasSerializer(canvas).data, status=status.HTTP_201_CREATED)
@extend_schema(
diff --git a/products/canvas/backend/tests/test_build_service.py b/products/canvas/backend/tests/test_build_service.py
index 9136127d0927..edc8ca08f74f 100644
--- a/products/canvas/backend/tests/test_build_service.py
+++ b/products/canvas/backend/tests/test_build_service.py
@@ -10,7 +10,7 @@
from posthog.models.scoping import team_scope
from products.canvas.backend import build_service
-from products.canvas.backend.models import Canvas, CanvasBuild
+from products.canvas.backend.models import Canvas, CanvasBuild, CanvasSourceVersion
from products.canvas.backend.source import synthetic_source_project
from products.canvas.backend.tests.test_canvas_api import InMemoryStorage
from products.tasks.backend.models import Channel
@@ -301,3 +301,36 @@ def test_cleanup_prunes_aged_artifacts_but_keeps_published_and_pinned(self):
assert str(aged[2].id) in kept # newest other ready build (instant rollback)
assert str(published.id) not in kept # aged past retention, unprotected
assert pruned == 1
+
+
+class TestLegacySourcePreservation(BuildServiceBaseTest):
+ def test_first_publish_materializes_legacy_code_as_parent_version(self):
+ legacy = "export default function Legacy() { return null }"
+ Canvas.objects.unscoped().filter(id=self.canvas.id).update(legacy_code=legacy)
+ self.canvas.refresh_from_db()
+
+ canvas, version, _build, first_publish = build_service.publish_source_project(
+ self.canvas,
+ project=synthetic_source_project("export default function Rewrite() { return null }"),
+ prompt="rewrite",
+ name=None,
+ has_expected_version=True,
+ expected_version_id=None,
+ task_id=None,
+ created_by_id=None,
+ )
+
+ head = CanvasSourceVersion.objects.unscoped().get(pk=version.id)
+ assert head.parent_version_id is not None
+ legacy_version = CanvasSourceVersion.objects.unscoped().get(pk=head.parent_version_id)
+ assert build_service.read_source_project(legacy_version) == synthetic_source_project(legacy)
+ assert canvas.current_source_version_id == head.id
+ assert canvas.legacy_code is None
+ assert not first_publish
+
+ def test_publish_on_fresh_canvas_creates_single_root_version(self):
+ self._publish()
+
+ versions = CanvasSourceVersion.objects.unscoped().filter(canvas_id=self.canvas.id)
+ assert versions.count() == 1
+ assert versions.get().parent_version_id is None
diff --git a/products/canvas/backend/tests/test_canvas_api.py b/products/canvas/backend/tests/test_canvas_api.py
index eb56dc082e50..1d595661b700 100644
--- a/products/canvas/backend/tests/test_canvas_api.py
+++ b/products/canvas/backend/tests/test_canvas_api.py
@@ -150,19 +150,6 @@ def test_create_rejects_unknown_channel(self):
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
- def test_home_canvas_is_unique_per_channel(self):
- self._create_canvas(name="Home", is_home=True)
- response = self.client.post(
- f"/api/projects/{self.team.id}/canvases/",
- {"name": "Home 2", "channel_id": str(self.channel.id), "is_home": True},
- format="json",
- )
- assert response.status_code == status.HTTP_409_CONFLICT
- assert response.json()["code"] == "home_canvas_exists"
-
- response = self.client.get(f"/api/projects/{self.team.id}/canvases/?is_home=true")
- assert [row["name"] for row in response.json()["results"]] == ["Home"]
-
def test_partial_update_metadata(self):
canvas_id = self._create_canvas()
response = self.client.patch(
diff --git a/products/canvas/frontend/generated/api.schemas.ts b/products/canvas/frontend/generated/api.schemas.ts
index 0f27a14afe0a..bf56caca63b6 100644
--- a/products/canvas/frontend/generated/api.schemas.ts
+++ b/products/canvas/frontend/generated/api.schemas.ts
@@ -79,7 +79,6 @@ export interface CanvasApi {
readonly pinned: boolean
/** @nullable */
readonly pinned_at: string | null
- readonly is_home: boolean
/**
* Id of the live source version — pass as expected_current_version_id on publish. Null before the first publish.
* @nullable
@@ -120,8 +119,6 @@ export interface CanvasCreateApi {
* @maxLength 64
*/
template_id?: string
- /** Create the canvas as the channel's home board (at most one per channel). */
- is_home?: boolean
}
/**
@@ -625,10 +622,6 @@ export type CanvasesListParams = {
* Only return canvases in this channel.
*/
channel?: string
- /**
- * Filter by channel-home status.
- */
- is_home?: boolean
/**
* Number of results to return per page.
*/
diff --git a/products/canvas/frontend/generated/api.zod.ts b/products/canvas/frontend/generated/api.zod.ts
index 220c83277d97..84718bed20d5 100644
--- a/products/canvas/frontend/generated/api.zod.ts
+++ b/products/canvas/frontend/generated/api.zod.ts
@@ -17,8 +17,6 @@ export const canvasesCreateBodyNameMax = 400
export const canvasesCreateBodyTemplateIdDefault = `freeform`
export const canvasesCreateBodyTemplateIdMax = 64
-export const canvasesCreateBodyIsHomeDefault = false
-
export const CanvasesCreateBody = /* @__PURE__ */ zod
.object({
name: zod.string().max(canvasesCreateBodyNameMax).describe('Display name for the canvas.'),
@@ -28,10 +26,6 @@ export const CanvasesCreateBody = /* @__PURE__ */ zod
.max(canvasesCreateBodyTemplateIdMax)
.default(canvasesCreateBodyTemplateIdDefault)
.describe('Canvas template identifier.'),
- is_home: zod
- .boolean()
- .default(canvasesCreateBodyIsHomeDefault)
- .describe("Create the canvas as the channel's home board (at most one per channel)."),
})
.describe('Payload for creating a new, empty canvas in a channel.')
diff --git a/products/canvas/skills/building-react-quill-canvases/SKILL.md b/products/canvas/skills/building-react-quill-canvases/SKILL.md
index 361ee58b0708..1a266cd23677 100644
--- a/products/canvas/skills/building-react-quill-canvases/SKILL.md
+++ b/products/canvas/skills/building-react-quill-canvases/SKILL.md
@@ -61,12 +61,19 @@ text-card-foreground`; borders `border-border`. Never a hardcoded hex or light-o
- Write Unicode glyphs (curly quotes, ellipsis, arrows, emoji) as literal characters in JSX —
`\uXXXX` escapes render verbatim in JSX text.
-## Loading states
+## Loading, error, and empty states
Every data point renders a skeleton in its own `Card` while loading or refreshing: `SkeletonText`
(matching `lines` and text-size `className`) for text/number values, `Skeleton` for blocks/charts.
Drive `isLoading` off the data calls and set it true again on refresh; never show a blank or a
-jumping layout, and handle the empty/error case.
+jumping layout.
+
+A failed query and an empty result are different states — never let one render as the other.
+`.catch` on every `ph.query`/`ph.loadInsight` must set an error state that renders visibly (the
+message plus a Retry button wired to the refresh nonce, as in the starter scaffold), not fall
+through to zeros, an empty chart, or a "no data yet" message. A query that silently swallows its
+error makes real breakage (a missing table, an auth failure, a bad query) look like missing data.
+Reserve the empty state for a query that succeeded with no rows.
## Date window
diff --git a/products/canvas/skills/building-react-quill-canvases/references/starter-scaffold.md b/products/canvas/skills/building-react-quill-canvases/references/starter-scaffold.md
index 35be5b5bb9af..b0e15c9abf89 100644
--- a/products/canvas/skills/building-react-quill-canvases/references/starter-scaffold.md
+++ b/products/canvas/skills/building-react-quill-canvases/references/starter-scaffold.md
@@ -36,6 +36,7 @@ export default function Canvas() {
const [open, setOpen] = useState(false)
const [loading, setLoading] = useState(true)
+ const [error, setError] = useState(null)
const [total, setTotal] = useState(0)
const [series, setSeries] = useState([])
// Refresh plumbing: bump this nonce to re-run the data effect on demand.
@@ -44,6 +45,7 @@ export default function Canvas() {
useEffect(() => {
let cancelled = false
setLoading(true)
+ setError(null)
// Typed query node, computed by PostHog's own runner so the numbers match
// the UI exactly. `event: null` = all events (works on any project).
ph.query({
@@ -62,9 +64,12 @@ export default function Canvas() {
setSeries((s.days ?? []).map((day, i) => ({ day, value: s.data?.[i] ?? 0 })))
setLoading(false)
})
- .catch((error) => {
- if (!cancelled) setLoading(false)
- throw error
+ .catch((err) => {
+ if (cancelled) return
+ setLoading(false)
+ // A failed query must LOOK failed — falling through to zeros or an
+ // empty chart reads as "no data" and hides real breakage.
+ setError(String(err?.message ?? err))
})
return () => {
cancelled = true
@@ -101,6 +106,17 @@ export default function Canvas() {
+ {error && (
+ Couldn't load data: {error}
—
) : (—
) : (