| 1 | package docs |
| 2 | |
| 3 | import ( |
| 4 | "embed" |
| 5 | "time" |
| 6 | |
| 7 | "amadan.net/rastrillo/rastrillo/migrate" |
| 8 | ) |
| 9 | |
| 10 | // migrationFS embeds this app's own numbered migrations — per |
| 11 | // rastrillo SKILL.md's app shape ("Two *migrate.Set values live in |
| 12 | // internal/<app>/migrations.go, beside models.go"; kept in this file |
| 13 | // instead of a separate migrations.go because this task's file list |
| 14 | // is models.go, store.go, store_test.go and a fourth file would only |
| 15 | // split one small declaration across two places for no benefit). |
| 16 | // |
| 17 | //go:embed migrations/*.sql |
| 18 | var migrationFS embed.FS |
| 19 | |
| 20 | // Schema is this app's own migrations — spec §4's documents and |
| 21 | // doc_updates tables, only what this plan needs. It is idempotent |
| 22 | // (CREATE TABLE IF NOT EXISTS today; later plans add further numbered |
| 23 | // files, additive ALTERs, never edits to 0001) per rastrillo's |
| 24 | // migration pattern. |
| 25 | var Schema = migrate.MustFromFS(migrationFS, "docs") |
| 26 | |
| 27 | // Document is a document's row. Its text is deliberately not a |
| 28 | // column: GUID and ClientID are what let Store open the real CRDT |
| 29 | // core (internal/core) and replay doc_updates into it, which is where |
| 30 | // the text actually comes from — see Store.Text. |
| 31 | // |
| 32 | // GUID and ClientID are assigned by the app (Store.Create) and stored |
| 33 | // here; they are never drawn from yrs itself — on wasm the CRDT |
| 34 | // library's entropy is a constant seed, so a value it generates is |
| 35 | // identical in every process, and a repeated client id or GUID |
| 36 | // corrupts the document. This is the Go side of the rule clippy.toml |
| 37 | // enforces in Rust (disallowed-methods on yrs::Doc::new / |
| 38 | // Doc::with_client_id). |
| 39 | // |
| 40 | // OwnerID is the document's owner — Person.ID, never taken from a |
| 41 | // request body (Store.Create's owner parameter comes from the |
| 42 | // signed-in person; see handlers.go's owner helper and rastrillo |
| 43 | // SKILL.md §3/§4's mass-assignment rule). Every read and write is |
| 44 | // scoped by it through store.go's own owned function, deliberately |
| 45 | // not rastrillo's scope.Owned — see that function's doc comment and |
| 46 | // spec §4.2. |
| 47 | type Document struct { |
| 48 | ID int64 `gorm:"primaryKey"` |
| 49 | Sqid string `gorm:"column:sqid;uniqueIndex;not null"` |
| 50 | Title string `gorm:"column:title;not null"` |
| 51 | |
| 52 | OwnerID int64 `gorm:"column:owner_id;not null;index;uniqueIndex:documents_creation,priority:1"` |
| 53 | CreationToken *string `gorm:"column:creation_token;uniqueIndex:documents_creation,priority:2"` |
| 54 | |
| 55 | GUID string `gorm:"column:guid;not null"` |
| 56 | ClientID uint64 `gorm:"column:client_id;not null"` |
| 57 | |
| 58 | CreatedAt time.Time `gorm:"column:created_at;not null"` |
| 59 | UpdatedAt time.Time `gorm:"column:updated_at;not null"` |
| 60 | DeletedAt *time.Time `gorm:"column:deleted_at"` |
| 61 | } |
| 62 | |
| 63 | // Person is this app's own record of someone who has signed in — |
| 64 | // deliberately separate from rastrillo/auth's session Subject, which |
| 65 | // under the magic-link plugin is the verified email address itself, |
| 66 | // not a numeric id (rastrillo SKILL.md §5: "Under auth, never |
| 67 | // sessions.UserID: the Subject is the verified email... map the |
| 68 | // address to your user row's id before scoping"). Store.EnsurePerson |
| 69 | // is the one place that mapping happens, get-or-create, keyed on |
| 70 | // Email. A document's OwnerID is a Person.ID, never an email — so an |
| 71 | // email can change (a future feature) without rewriting every |
| 72 | // document row it ever owned. |
| 73 | type Person struct { |
| 74 | ID int64 `gorm:"primaryKey"` |
| 75 | Email string `gorm:"column:email;uniqueIndex;not null"` |
| 76 | |
| 77 | CreatedAt time.Time `gorm:"column:created_at;not null"` |
| 78 | } |
| 79 | |
| 80 | // DocUpdate is one Yjs update in a document's log, applied in Seq |
| 81 | // order to rebuild the document's CRDT state (Store.replay). |
| 82 | type DocUpdate struct { |
| 83 | ID int64 `gorm:"primaryKey"` |
| 84 | DocID int64 `gorm:"column:doc_id;not null;index"` |
| 85 | Seq int64 `gorm:"column:seq;not null"` |
| 86 | |
| 87 | UpdateBin []byte `gorm:"column:update_bin;not null"` |
| 88 | ContentEncoding int `gorm:"column:content_encoding;not null"` |
| 89 | |
| 90 | CreatedAt time.Time `gorm:"column:created_at;not null"` |
| 91 | } |
| 92 | |
| 93 | // DocSnapshot is one document's entire state encoded as a single Yjs |
| 94 | // update, as of Seq. It is an accelerator for replay and for the update |
| 95 | // endpoint, never the source of truth — doc_updates keeps every row, and |
| 96 | // a snapshot that cannot be read is discarded in favour of replaying |
| 97 | // them (the snapshots design, D2). |
| 98 | // |
| 99 | // ABIVersion records the core that produced State. Without it, "discard |
| 100 | // a snapshot from an older ABI" is unenforceable prose. |
| 101 | type DocSnapshot struct { |
| 102 | ID int64 `gorm:"primaryKey"` |
| 103 | DocID int64 `gorm:"column:doc_id;not null;index"` |
| 104 | Seq int64 `gorm:"column:seq;not null"` |
| 105 | |
| 106 | State []byte `gorm:"column:state;not null"` |
| 107 | ContentEncoding int `gorm:"column:content_encoding;not null"` |
| 108 | ABIVersion uint32 `gorm:"column:abi_version;not null"` |
| 109 | |
| 110 | CreatedAt time.Time `gorm:"column:created_at;not null"` |
| 111 | } |
| 112 | |