Projects live at URLs, have owners, and are edited together live

A project is only ever at /p/<id>/<slug>; / is the index of the projects
you own or edit. Every project has an owner, who can name editors;
anyone with the link can view. Every edit saves itself, one request in
flight at a time, as a patch of the leaves that changed, and a websocket
(channels + daphne) carries presence and each committed write to
everyone else in the project. The first write to a leaf wins, and the
loser is told.

Undo is per person: a step undoes only if the leaves it touched still
hold what it left, so it never takes a collaborator's work with it.
Named snapshots replace saving, and restore as an ordinary write.

An empty symbol now survives the leaf round trip with `:nodes {}`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Olive Vaughn 2026-09-29 22:04:03 -04:00
parent c17ee138f2
commit 6a53adb5e0
31 changed files with 1918 additions and 158 deletions

View file

@ -24,7 +24,9 @@ without parsing its leaves: which footage, which analysis, which blocks.
"""
import uuid
from django.conf import settings
from django.db import models
from django.utils import timezone
class Blob(models.Model):
@ -201,10 +203,19 @@ class Project(models.Model):
`schema_version` identifies the stored document format. `seq` counts writes
to this particular project; it is not a format version. Every write bumps
`seq`, and a client that sees `seq > local + 1` refetches once broadcasts exist.
`seq`, and a client that sees `seq > local + 1` refetches.
ANYONE WITH THE LINK CAN VIEW; the owner and the editors can write. Every
project has an owner.
"""
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
owner = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="projects",
)
editors = models.ManyToManyField(
settings.AUTH_USER_MODEL, blank=True, related_name="shared_projects",
)
name = models.CharField(max_length=200, default="untitled")
schema_version = models.PositiveIntegerField(default=2)
seq = models.PositiveBigIntegerField(default=0)
@ -219,10 +230,19 @@ class Project(models.Model):
return f"{self.name} ({self.id})"
def bump(self):
self.seq += 1
self.save(update_fields=["seq", "updated"])
"""The next seq, taken with an UPDATE so that inside a transaction it is
also the write lock: two concurrent saves cannot both get the same one."""
Project.objects.filter(id=self.id).update(
seq=models.F("seq") + 1, updated=timezone.now()
)
self.refresh_from_db(fields=["seq", "updated"])
return self.seq
def can_edit(self, user):
return user.is_authenticated and (
user.id == self.owner_id or self.editors.filter(id=user.id).exists()
)
class Clip(models.Model):
"""Tier 1: the unit of work, and the thing leaf paths are scoped by.
@ -271,6 +291,9 @@ class Leaf(models.Model):
path = models.CharField(max_length=300)
value = models.JSONField()
version = models.PositiveBigIntegerField(default=1)
seq = models.PositiveBigIntegerField(
default=0, help_text="the project seq of the write that last changed it",
)
updated = models.DateTimeField(auto_now=True)
class Meta:
@ -288,7 +311,9 @@ class Leaf(models.Model):
class Revision(models.Model):
"""Tier 1: a snapshot of the authored layer, with a user and a summary.
"""Tier 1: a snapshot of the authored layer, with a user and a summary — a
named snapshot, which is how a person marks a version now that every edit
saves itself.
ON AN EXPLICIT TRIGGER, not on every save. tl snapshots a small annotation
layer; arthur's tier 1 will contain cel polygons, so a snapshot per save bloats
@ -302,6 +327,9 @@ class Revision(models.Model):
author = models.CharField(max_length=200, blank=True)
summary = models.CharField(max_length=500, blank=True)
document = models.JSONField(help_text="every leaf of the project, by path")
blocks = models.JSONField(
default=dict, help_text="each clip's tier-2 block keys, by cid, so a restore can name them",
)
created = models.DateTimeField(auto_now_add=True)
class Meta: