Add migrations.go: Schema, BootSchema and Models for rastrillo migration
Declares the *migrate.Set pair SKILL.md documents (Schema for generate/check, BootSchema for what Apply runs at boot) plus the Models list the CLI's loader reads by name from this package. rastrillo migration generate produced internal/sheets/migrations/0001_create_sheets.sql from the six sheets models; migration check now passes. Verified the generated SQL by hand against the three constraints the models carry that a generic diff could miss: cells' composite primary key (tab_id, row_id, col_id), the unique (tab_id, position) indexes on rows and cols, and the unique index on formats.digest. All four are in the SQL as CREATE TABLE .. PRIMARY KEY (...) / CREATE UNIQUE INDEX — nothing needed hand-writing via `migration new`. Event moves from store/events.go into models.go (as sheets.Event), with store/events.go now `type Event = sheets.Event`. This is forced, not a tidiness call: the CLI's loader (cmd/rastrillo/migration.go, appPackage) requires exactly one non-test package under internal/ and reads Schema/BootSchema/Models from it by name, and package sheets cannot import package store (store already imports sheets for the other five models, so the reverse would cycle) — so Event's struct has to live somewhere package sheets can see it, or `migration check` can never catch it drifting. The alias keeps every existing store.Event call site, production and test, compiling unchanged — same trade b4d3e25 made for the plaintext-columns test, just no longer optional now that a production tool needs the same reach. internal/guards moved to internal/guardstest (test files only, no behaviour change) because the CLI's appPackage requires exactly one non-"test"-suffixed directory under internal/, and internal/guards plus internal/sheets made two; `rastrillo migration generate .` otherwise fails with "expected exactly one app package under ./internal, found [guards sheets]". Confirmed with `go test ./internal/guardstest/...` and full `make test`, both green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01APtNStXDZ5H2fAc1ZuGqtS
13 files changed,
+158
−28
internal/{guards => guardstest}/calc_isolation_test.go+0 −0internal/{guards => guardstest}/excelize_confinement_test.go+0 −0internal/{guards => guardstest}/forbidden_imports_test.go+0 −0internal/{guards => guardstest}/linecap_test.go+0 −0internal/{guards => guardstest}/repo_test.go+0 −0internal/{guards => guardstest}/sealtest_containment_test.go+0 −0internal/sheets/migrations.go+58 −0internal/sheets/migrations/0001_create_sheets.sql+28 −0internal/sheets/migrations/schema.sql+31 −0internal/sheets/models.go+21 −0internal/sheets/models_test.go+9 −16internal/sheets/store/events.go+10 −11internal/sheets/xlsx/oracle_test.go+1 −1
Intent
diff --git a/internal/guards/calc_isolation_test.go b/internal/guardstest/calc_isolation_test.go| similarity index 100% |
| rename from internal/guards/calc_isolation_test.go |
| rename to internal/guardstest/calc_isolation_test.go |
diff --git a/internal/guards/excelize_confinement_test.go b/internal/guardstest/excelize_confinement_test.go| similarity index 100% |
| rename from internal/guards/excelize_confinement_test.go |
| rename to internal/guardstest/excelize_confinement_test.go |
diff --git a/internal/guards/forbidden_imports_test.go b/internal/guardstest/forbidden_imports_test.go| similarity index 100% |
| rename from internal/guards/forbidden_imports_test.go |
| rename to internal/guardstest/forbidden_imports_test.go |
diff --git a/internal/guards/linecap_test.go b/internal/guardstest/linecap_test.go| similarity index 100% |
| rename from internal/guards/linecap_test.go |
| rename to internal/guardstest/linecap_test.go |
diff --git a/internal/guards/repo_test.go b/internal/guardstest/repo_test.go| similarity index 100% |
| rename from internal/guards/repo_test.go |
| rename to internal/guardstest/repo_test.go |
diff --git a/internal/guards/sealtest_containment_test.go b/internal/guardstest/sealtest_containment_test.go| similarity index 100% |
| rename from internal/guards/sealtest_containment_test.go |
| rename to internal/guardstest/sealtest_containment_test.go |
diff --git a/internal/sheets/migrations.go b/internal/sheets/migrations.go| new file mode 100644 |
| index 0000000..7bfb392 |
| --- /dev/null |
| +++ b/internal/sheets/migrations.go |
| @@ -0,0 +1,58 @@ |
| +package sheets |
| + |
| +import ( |
| + "embed" |
| + |
| + "github.com/carlosframework/rastrillo/migrate" |
| +) |
| + |
| +// migrationsFS embeds internal/sheets/migrations/*.sql — this package's |
| +// generated migrations plus the schema.sql summary `rastrillo migration |
| +// generate` maintains beside them. schema.sql matches the glob but is |
| +// never parsed as a step (migrate.FromFS excludes it by name), so the |
| +// go:embed directive below needs at least one *.sql file to exist before |
| +// this package can compile at all; that is why an empty schema.sql was |
| +// committed ahead of the first `rastrillo migration generate` run rather |
| +// than left for it to create. |
| +// |
| +//go:embed migrations/*.sql |
| +var migrationsFS embed.FS |
| + |
| +// Schema is Sheets' own migration set: what `rastrillo migration |
| +// generate`/`check` diff Models against. Never hand-edit a file under |
| +// migrations/ once it has shipped — add a new one instead |
| +// (`rastrillo migration new <name>`). |
| +var Schema = migrate.MustFromFS(migrationsFS, "sheets") |
| + |
| +// BootSchema is everything migrate.Apply runs at boot, in apply order — |
| +// the shape SKILL.md documents as `migrate.Merge(subsystemSchemas..., |
| +// Schema)`. There is no framework subsystem to merge in here (this app |
| +// uses none of rastrillo's sessions/auth/jobs pieces), so BootSchema is |
| +// Schema alone; migrate.Merge is still the call, not a bare assignment, |
| +// so a future subsystem's schema has one obvious place to be added |
| +// without anyone needing to notice BootSchema didn't previously go |
| +// through Merge at all. |
| +var BootSchema = migrate.Merge(Schema) |
| + |
| +// Models lists every GORM model `rastrillo migration generate`/`check` |
| +// diff Schema against — Sheet, Tab, Row, Col, Cell and Format from |
| +// models.go, plus Event. |
| +// |
| +// Event is declared in models.go, not internal/sheets/store/events.go |
| +// where the rest of its behaviour (AppendInput, AppendRecalc, the Kind |
| +// consts) lives, and store/events.go keeps only a type alias back to it |
| +// (type Event = sheets.Event). That split is forced by this file, not a |
| +// tidiness preference: the CLI's loader (cmd/rastrillo/migration.go, |
| +// appPackage) requires exactly one non-test package under internal/ and |
| +// reads Schema, BootSchema and Models from it by name — there is no |
| +// second package generate/check will ever look at. package store already |
| +// imports package sheets for Sheet/Tab/Row/Col/Cell/Format, so the |
| +// reverse import sheets -> store this file would otherwise need to add |
| +// Event here cycles. A model check cannot see can never be caught |
| +// drifting, which would leave exactly one of Sheets' seven tables outside |
| +// the gate this file exists to build — so Event moved, and the alias |
| +// keeps every existing store.Event call site, production and test, |
| +// compiling unchanged. |
| +var Models = []any{ |
| + Sheet{}, Tab{}, Row{}, Col{}, Cell{}, Format{}, Event{}, |
| +} |
diff --git a/internal/sheets/migrations/0001_create_sheets.sql b/internal/sheets/migrations/0001_create_sheets.sql| new file mode 100644 |
| index 0000000..b556c40 |
| --- /dev/null |
| +++ b/internal/sheets/migrations/0001_create_sheets.sql |
| @@ -0,0 +1,28 @@ |
| +CREATE TABLE `sheets` (`id` integer PRIMARY KEY AUTOINCREMENT,`title_sealed` blob,`created_by` integer,`created_at` datetime,`updated_at` datetime,`deleted_at` datetime); |
| + |
| +CREATE INDEX `idx_sheets_deleted_at` ON `sheets`(`deleted_at`); |
| + |
| +CREATE TABLE `tabs` (`id` integer PRIMARY KEY AUTOINCREMENT,`sheet_id` integer,`name_sealed` blob,`position` integer,`frozen_rows` integer,`frozen_cols` integer); |
| + |
| +CREATE INDEX `idx_tabs_sheet_id` ON `tabs`(`sheet_id`); |
| + |
| +CREATE TABLE `rows` (`id` integer PRIMARY KEY AUTOINCREMENT,`tab_id` integer,`position` integer,`height` integer); |
| + |
| +CREATE UNIQUE INDEX `idx_row_tab_pos` ON `rows`(`tab_id`,`position`); |
| + |
| +CREATE TABLE `cols` (`id` integer PRIMARY KEY AUTOINCREMENT,`tab_id` integer,`position` integer,`width` integer); |
| + |
| +CREATE UNIQUE INDEX `idx_col_tab_pos` ON `cols`(`tab_id`,`position`); |
| + |
| +CREATE TABLE `cells` (`tab_id` integer,`row_id` integer,`col_id` integer,`input_sealed` blob,`cached_sealed` blob,`input_version` integer,`cache_version` integer,`format_id` integer,`updated_by` integer,`updated_at` datetime,PRIMARY KEY (`tab_id`,`row_id`,`col_id`)); |
| + |
| +CREATE TABLE `formats` (`id` integer PRIMARY KEY AUTOINCREMENT,`digest` text,`number_fmt` text,`bold` numeric,`italic` numeric,`fill_intent_h` real,`fill_intent_c` real,`fill_hex` text,`align` text); |
| + |
| +CREATE UNIQUE INDEX `idx_formats_digest` ON `formats`(`digest`); |
| + |
| +CREATE TABLE `events` (`id` integer PRIMARY KEY AUTOINCREMENT,`kind` text,`tab_id` integer,`actor_id` integer,`payload_sealed` blob,`trigger_id` integer,`derived_count` integer,`created_at` datetime); |
| + |
| +CREATE INDEX `idx_events_tab_id` ON `events`(`tab_id`); |
| + |
| +CREATE INDEX `idx_events_kind` ON `events`(`kind`); |
| + |
diff --git a/internal/sheets/migrations/schema.sql b/internal/sheets/migrations/schema.sql| new file mode 100644 |
| index 0000000..878400f |
| --- /dev/null |
| +++ b/internal/sheets/migrations/schema.sql |
| @@ -0,0 +1,31 @@ |
| +-- Generated by rastrillo migration generate; DO NOT EDIT. |
| +-- The schema every migration in this directory adds up to. |
| + |
| +CREATE TABLE `cells` (`tab_id` integer,`row_id` integer,`col_id` integer,`input_sealed` blob,`cached_sealed` blob,`input_version` integer,`cache_version` integer,`format_id` integer,`updated_by` integer,`updated_at` datetime,PRIMARY KEY (`tab_id`,`row_id`,`col_id`)); |
| + |
| +CREATE TABLE `cols` (`id` integer PRIMARY KEY AUTOINCREMENT,`tab_id` integer,`position` integer,`width` integer); |
| + |
| +CREATE TABLE `events` (`id` integer PRIMARY KEY AUTOINCREMENT,`kind` text,`tab_id` integer,`actor_id` integer,`payload_sealed` blob,`trigger_id` integer,`derived_count` integer,`created_at` datetime); |
| + |
| +CREATE TABLE `formats` (`id` integer PRIMARY KEY AUTOINCREMENT,`digest` text,`number_fmt` text,`bold` numeric,`italic` numeric,`fill_intent_h` real,`fill_intent_c` real,`fill_hex` text,`align` text); |
| + |
| +CREATE TABLE `rows` (`id` integer PRIMARY KEY AUTOINCREMENT,`tab_id` integer,`position` integer,`height` integer); |
| + |
| +CREATE TABLE `sheets` (`id` integer PRIMARY KEY AUTOINCREMENT,`title_sealed` blob,`created_by` integer,`created_at` datetime,`updated_at` datetime,`deleted_at` datetime); |
| + |
| +CREATE TABLE `tabs` (`id` integer PRIMARY KEY AUTOINCREMENT,`sheet_id` integer,`name_sealed` blob,`position` integer,`frozen_rows` integer,`frozen_cols` integer); |
| + |
| +CREATE UNIQUE INDEX `idx_col_tab_pos` ON `cols`(`tab_id`,`position`); |
| + |
| +CREATE INDEX `idx_events_kind` ON `events`(`kind`); |
| + |
| +CREATE INDEX `idx_events_tab_id` ON `events`(`tab_id`); |
| + |
| +CREATE UNIQUE INDEX `idx_formats_digest` ON `formats`(`digest`); |
| + |
| +CREATE UNIQUE INDEX `idx_row_tab_pos` ON `rows`(`tab_id`,`position`); |
| + |
| +CREATE INDEX `idx_sheets_deleted_at` ON `sheets`(`deleted_at`); |
| + |
| +CREATE INDEX `idx_tabs_sheet_id` ON `tabs`(`sheet_id`); |
| + |
diff --git a/internal/sheets/models.go b/internal/sheets/models.go| index 0f7ec93..9a97fb4 100644 |
| --- a/internal/sheets/models.go |
| +++ b/internal/sheets/models.go |
| @@ -80,3 +80,24 @@ type Format struct { |
| FillHex string // retained from XLSX import for lossless re-export |
| Align string |
| } |
| + |
| +// Event is the append-only log: live-sync fan-out, version history, undo |
| +// and audit. Its behaviour (AppendInput, AppendRecalc, the Kind consts) |
| +// lives in package store, which is where it conceptually belongs — store |
| +// owns persistence. The struct lives here instead, aliased back as |
| +// store.Event, purely because of what migrations.go needs: `rastrillo |
| +// migration check` diffs this package's Models against Schema, and |
| +// package sheets cannot import store (store already imports sheets for |
| +// Sheet/Tab/Row/Col/Cell/Format, so the reverse import would cycle). A |
| +// model the check can't see is a model it can never catch drifting, which |
| +// defeats the point of this file. See migrations.go and store/events.go. |
| +type Event struct { |
| + ID uint64 `gorm:"primaryKey"` |
| + Kind string `gorm:"index;size:16"` |
| + TabID uint64 `gorm:"index"` |
| + ActorID uint64 |
| + PayloadSealed []byte // seal purpose "event.payload"; empty for recalc |
| + TriggerID uint64 // recalc only: the input event that caused it |
| + DerivedCount int // recalc only: how many cells the pass touched |
| + CreatedAt time.Time |
| +} |
diff --git a/internal/sheets/models_test.go b/internal/sheets/models_test.go| index 7ec7c65..e79300f 100644 |
| --- a/internal/sheets/models_test.go |
| +++ b/internal/sheets/models_test.go |
| @@ -6,7 +6,6 @@ import ( |
| "testing" |
| "amadan.net/oficina/sheets/internal/sheets" |
| - "amadan.net/oficina/sheets/internal/sheets/store" |
| ) |
| // The models must carry no column that could hold a readable cell value. |
| @@ -25,20 +24,14 @@ import ( |
| // |
| // Add "Title string" to Sheet and this test fails; that is the point. |
| // |
| -// This lives in package sheets_test, not package sheets, specifically so |
| -// it can also walk store.Event: Event carries sealed user content |
| -// (PayloadSealed) but is declared in internal/sheets/store/events.go, not |
| -// models.go, so a version of this test confined to package sheets (and |
| -// therefore unable to import store, which itself imports sheets) could |
| -// never see it. Without Event in this walk, "schema lives in models.go, |
| -// nowhere else" was true of the file layout but not of what this gate |
| -// actually checked — someone could add a plaintext-shaped field straight |
| -// to Event and every assertion here would keep passing. Moving Event |
| -// into models.go instead (making sheets.Event) was the other option, but |
| -// it would mean renaming store.Event at every call site across store's |
| -// production code and tests for a schema-location tidiness win the |
| -// package-based fix achieves without touching Event's existing callers |
| -// at all. |
| +// Event (sealed PayloadSealed) is included via sheets.Event, not |
| +// store.Event: the two are the same type (store.Event is now a type |
| +// alias — see models.go's doc comment on Event and store/events.go), |
| +// but this file no longer needs to import store to reach it, so it |
| +// doesn't. This test still lives in package sheets_test rather than |
| +// package sheets — no functional need since the migrations.go work, |
| +// only inertia — but there is no reason to move it now that it compiles |
| +// either way. |
| func TestNoPlaintextValueColumns(t *testing.T) { |
| forbidden := map[string]bool{ |
| "Title": true, |
| @@ -55,7 +48,7 @@ func TestNoPlaintextValueColumns(t *testing.T) { |
| sheets.Col{}, |
| sheets.Cell{}, |
| sheets.Format{}, |
| - store.Event{}, |
| + sheets.Event{}, |
| } |
| byteSliceType := reflect.TypeOf([]byte(nil)) |
diff --git a/internal/sheets/store/events.go b/internal/sheets/store/events.go| index 656f235..bce4af5 100644 |
| --- a/internal/sheets/store/events.go |
| +++ b/internal/sheets/store/events.go |
| @@ -4,10 +4,10 @@ package store |
| import ( |
| "encoding/json" |
| - "time" |
| "gorm.io/gorm" |
| + "amadan.net/oficina/sheets/internal/sheets" |
| "amadan.net/oficina/sheets/internal/sheets/seal" |
| ) |
| @@ -20,16 +20,15 @@ const ( |
| // Event is the append-only log. It does four jobs — live-sync fan-out, |
| // version history, undo, and audit — which is why the storage model pays. |
| -type Event struct { |
| - ID uint64 `gorm:"primaryKey"` |
| - Kind string `gorm:"index;size:16"` |
| - TabID uint64 `gorm:"index"` |
| - ActorID uint64 |
| - PayloadSealed []byte // seal purpose "event.payload"; empty for recalc |
| - TriggerID uint64 // recalc only: the input event that caused it |
| - DerivedCount int // recalc only: how many cells the pass touched |
| - CreatedAt time.Time |
| -} |
| +// |
| +// The struct itself is declared as sheets.Event, not here: see that |
| +// type's doc comment in models.go for why (migrations.go's Models list |
| +// has to see it, and package sheets cannot import this package back). |
| +// This alias means every existing call site — store.Event{...}, |
| +// &store.Event{}, AutoMigrate(&store.Event{}) in tests — keeps compiling |
| +// unchanged; a type alias makes store.Event and sheets.Event the exact |
| +// same type, not merely convertible ones. |
| +type Event = sheets.Event |
| // InputEvent is a person's edit, before sealing. |
| type InputEvent struct { |
diff --git a/internal/sheets/xlsx/oracle_test.go b/internal/sheets/xlsx/oracle_test.go| index e3cd739..7234756 100644 |
| --- a/internal/sheets/xlsx/oracle_test.go |
| +++ b/internal/sheets/xlsx/oracle_test.go |
| @@ -22,7 +22,7 @@ |
| // |
| // This lives in internal/sheets/xlsx, not internal/sheets/calc, for one |
| // mechanical reason and one architectural one: |
| -// - internal/guards/excelize_confinement_test.go fails any file outside |
| +// - internal/guardstest/excelize_confinement_test.go fails any file outside |
| // internal/sheets/xlsx (any depth) that imports excelize, with no |
| // exemption for _test.go files. A file in calc that imported excelize |
| // — even test-only — would fail that guard. |