Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 11 additions & 6 deletions cmd/migrate.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import (
func newMigrateCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "migrate",
Short: "Upgrade a Trokky project from v0.1.x packages to v2",
Short: "Upgrade a Trokky project to the current packages (v0.1.x split packages, or a pre-3.0 Studio mount)",
Long: `Rewrite a Trokky site project from the old split v0.1.x npm packages to the
consolidated v2 packages.

Expand All @@ -25,13 +25,18 @@ project's JS/TS/Astro sources (@trokky/core -> @trokky/trokky,
@trokky/trokky/adapters/*, @trokky/mail-adapter-* -> @trokky/trokky/mail/*,
@trokky/fields -> @trokky/studio, ...) and updates the dependency sections of
every package.json, pinning @trokky/trokky, @trokky/studio and @trokky/client
to ^2.0.0. node_modules, dist, build, .git, .astro and symlinks are never
to ^3.0.0. node_modules, dist, build, .git, .astro and symlinks are never
touched.

It also reports what it cannot fix by itself: imports of package internals,
Astro's build-time inlining of import.meta.env.TROKKY_API_URL, and — most
importantly — structure entries marked type: 'singleton' whose schema does not
set singleton: true. That divergence is a data-loss trap: 'trokky restore'
It also reports what it cannot fix by itself: the pre-3.0 Studio mounting
(studio.enabled/path/structure in the config, studioPath in mount(), the
dist/server/assets.js import), which since 3.0 the server refuses at boot and
which is replaced by two lines in server.ts — import { studioRouter } from
'@trokky/studio/express' and app.use('/studio', studioRouter({ apiPath: '/api' }))
placed before your own catch-all; imports of package internals; Astro's
build-time inlining of import.meta.env.TROKKY_API_URL; and — most importantly —
structure entries marked type: 'singleton' whose schema does not set
singleton: true. That divergence is a data-loss trap: 'trokky restore'
POSTs such a document instead of doing an id-preserving PUT, which regenerates
the document id and orphans the structure entry. Fix those warnings before you
restore into the migrated site.
Expand Down
8 changes: 4 additions & 4 deletions internal/migrate/e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -191,9 +191,9 @@ func TestE2EWrite(t *testing.T) {
t.Fatalf("rewritten package.json is not valid JSON: %v\n%s", err, pkgSrc)
}
wantDeps := map[string]string{
"@trokky/trokky": "^2.0.0",
"@trokky/studio": "^2.0.0",
"@trokky/client": "^2.0.0",
"@trokky/trokky": "^3.0.0",
"@trokky/studio": "^3.0.0",
"@trokky/client": "^3.0.0",
"express": "^4.19.0",
}
for k, v := range wantDeps {
Expand All @@ -217,7 +217,7 @@ func TestE2EWrite(t *testing.T) {
strings.Index(pkgSrc, `"scripts"`), strings.Index(pkgSrc, `"dependencies"`); !(i < j && j < k && k < d) {
t.Errorf("key order changed (name=%d type=%d scripts=%d dependencies=%d):\n%s", i, j, k, d, pkgSrc)
}
if !strings.Contains(pkgSrc, "\n \"@trokky/trokky\": \"^2.0.0\",") {
if !strings.Contains(pkgSrc, "\n \"@trokky/trokky\": \"^3.0.0\",") {
t.Errorf("2-space indentation was not preserved:\n%s", pkgSrc)
}

Expand Down
4 changes: 2 additions & 2 deletions internal/migrate/packagejson.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ import (
"strings"
)

// TargetVersion is the version range the consolidated v2 packages are pinned to.
const TargetVersion = "^2.0.0"
// TargetVersion is the version range the consolidated packages are pinned to.
const TargetVersion = "^3.0.0"

// consolidatedPackages are the packages that survive into v2 and must be moved
// to TargetVersion wherever they appear.
Expand Down
38 changes: 19 additions & 19 deletions internal/migrate/packagejson_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,8 @@ func TestRewritePackageJSONBasic(t *testing.T) {
},
"dependencies": {
"express": "^4.19.2",
"@trokky/trokky": "^2.0.0",
"@trokky/studio": "^2.0.0",
"@trokky/trokky": "^3.0.0",
"@trokky/studio": "^3.0.0",
"zod": "^3.23.8"
},
"devDependencies": {
Expand All @@ -66,9 +66,9 @@ func TestRewritePackageJSONBasic(t *testing.T) {

wantChanges := []string{
"removed @trokky/core",
"added @trokky/trokky ^2.0.0",
"added @trokky/trokky ^3.0.0",
"removed @trokky/express",
"@trokky/studio -> ^2.0.0",
"@trokky/studio -> ^3.0.0",
"removed @trokky/types",
}
if len(changes) != len(wantChanges) {
Expand Down Expand Up @@ -99,7 +99,7 @@ func TestRewritePackageJSONInsertsAtFirstRemoval(t *testing.T) {
want := `{
"dependencies": {
"a": "1",
"@trokky/trokky": "^2.0.0",
"@trokky/trokky": "^3.0.0",
"b": "2",
"c": "3"
}
Expand Down Expand Up @@ -128,15 +128,15 @@ func TestRewritePackageJSONNoAddWhenNothingRemoved(t *testing.T) {
want := `{
"name": "web",
"dependencies": {
"@trokky/client": "^2.0.0",
"@trokky/client": "^3.0.0",
"astro": "^4.15.0"
}
}
`
if string(out) != want {
t.Errorf("got:\n%s\nwant:\n%s", out, want)
}
if len(changes) != 1 || changes[0] != "@trokky/client -> ^2.0.0" {
if len(changes) != 1 || changes[0] != "@trokky/client -> ^3.0.0" {
t.Errorf("changes = %v, want only the client bump", changes)
}
if strings.Contains(string(out), "@trokky/trokky") {
Expand Down Expand Up @@ -164,19 +164,19 @@ func TestRewritePackageJSONAddsToDevDependenciesOnly(t *testing.T) {
}
want := `{
"dependencies": {
"@trokky/client": "^2.0.0",
"@trokky/client": "^3.0.0",
"astro": "^4.15.0"
},
"devDependencies": {
"@trokky/trokky": "^2.0.0",
"@trokky/trokky": "^3.0.0",
"typescript": "^5.5.0"
}
}
`
if string(out) != want {
t.Errorf("got:\n%s\nwant:\n%s", out, want)
}
wantChanges := []string{"@trokky/client -> ^2.0.0", "removed @trokky/types", "added @trokky/trokky ^2.0.0"}
wantChanges := []string{"@trokky/client -> ^3.0.0", "removed @trokky/types", "added @trokky/trokky ^3.0.0"}
if len(changes) != len(wantChanges) {
t.Fatalf("changes = %v, want %v", changes, wantChanges)
}
Expand Down Expand Up @@ -205,7 +205,7 @@ func TestRewritePackageJSONAddsOnceWhenBothSectionsRemove(t *testing.T) {
}
want := `{
"dependencies": {
"@trokky/trokky": "^2.0.0",
"@trokky/trokky": "^3.0.0",
"express": "^4.19.2"
},
"devDependencies": {
Expand Down Expand Up @@ -238,7 +238,7 @@ func TestRewritePackageJSONNoAddWhenTrokkyAlreadyPresentElsewhere(t *testing.T)
want := `{
"dependencies": {},
"devDependencies": {
"@trokky/trokky": "^2.0.0"
"@trokky/trokky": "^3.0.0"
}
}
`
Expand Down Expand Up @@ -290,7 +290,7 @@ func TestRewritePackageJSONCollapsesEmptiedSection(t *testing.T) {
want := `{
"name": "site",
"dependencies": {
"@trokky/trokky": "^2.0.0"
"@trokky/trokky": "^3.0.0"
},
"devDependencies": {},
"private": true
Expand Down Expand Up @@ -320,7 +320,7 @@ func TestRewritePackageJSONCollapsesLastSectionWithoutComma(t *testing.T) {
}
want := "{\n" +
" \"dependencies\": {\n" +
" \"@trokky/trokky\": \"^2.0.0\"\n" +
" \"@trokky/trokky\": \"^3.0.0\"\n" +
" },\n" +
" \"peerDependencies\": {}\n" +
"}\n"
Expand All @@ -344,14 +344,14 @@ func TestRewritePackageJSONExistingTrokkyBumped(t *testing.T) {
}
want := `{
"dependencies": {
"@trokky/trokky": "^2.0.0"
"@trokky/trokky": "^3.0.0"
}
}
`
if string(out) != want {
t.Errorf("got:\n%s\nwant:\n%s", out, want)
}
if len(changes) != 2 || changes[0] != "@trokky/trokky -> ^2.0.0" || changes[1] != "removed @trokky/core" {
if len(changes) != 2 || changes[0] != "@trokky/trokky -> ^3.0.0" || changes[1] != "removed @trokky/core" {
t.Errorf("changes = %v", changes)
}
}
Expand Down Expand Up @@ -395,7 +395,7 @@ func TestRewritePackageJSONPreservesIndentation(t *testing.T) {
want := "{\n" +
" \"name\": \"x\",\n" +
" \"dependencies\": {\n" +
" \"@trokky/trokky\": \"^2.0.0\",\n" +
" \"@trokky/trokky\": \"^3.0.0\",\n" +
" \"express\": \"^4.19.2\"\n" +
" },\n" +
" \"private\": true\n" +
Expand All @@ -422,7 +422,7 @@ func TestRewritePackageJSONPreservesIndentation(t *testing.T) {
want := "{\n" +
"\t\"name\": \"x\",\n" +
"\t\"dependencies\": {\n" +
"\t\t\"@trokky/trokky\": \"^2.0.0\",\n" +
"\t\t\"@trokky/trokky\": \"^3.0.0\",\n" +
"\t\t\"express\": \"^4.19.2\"\n" +
"\t},\n" +
"\t\"private\": true\n" +
Expand Down Expand Up @@ -511,7 +511,7 @@ func TestRewritePackageJSONNoTrailingNewline(t *testing.T) {
if err != nil {
t.Fatal(err)
}
want := "{\n \"dependencies\": {\n \"@trokky/trokky\": \"^2.0.0\"\n }\n}"
want := "{\n \"dependencies\": {\n \"@trokky/trokky\": \"^3.0.0\"\n }\n}"
if string(out) != want {
t.Errorf("got %q, want %q", out, want)
}
Expand Down
8 changes: 4 additions & 4 deletions internal/migrate/run_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -186,8 +186,8 @@ export const app = createTrokky({ adapter: filesystemData(), mail: mailConsole()
wantPkg := `{
"name": "site",
"dependencies": {
"@trokky/trokky": "^2.0.0",
"@trokky/studio": "^2.0.0",
"@trokky/trokky": "^3.0.0",
"@trokky/studio": "^3.0.0",
"express": "^4.19.2"
}
}
Expand Down Expand Up @@ -269,7 +269,7 @@ func TestResultReport(t *testing.T) {
res := &Result{
Files: []FileChange{
{Path: "src/server.ts", Kind: KindSource, Hits: map[string]int{"@trokky/core": 2, "@trokky/express": 1}},
{Path: "package.json", Kind: KindPackageJSON, Changes: []string{"removed @trokky/core", "added @trokky/trokky ^2.0.0"}},
{Path: "package.json", Kind: KindPackageJSON, Changes: []string{"removed @trokky/core", "added @trokky/trokky ^3.0.0"}},
},
Hits: map[string]int{"@trokky/core": 2, "@trokky/express": 1},
Scanned: 2,
Expand All @@ -287,7 +287,7 @@ func TestResultReport(t *testing.T) {
"src/server.ts",
"@trokky/express x1, @trokky/core x2",
"package.json",
"removed @trokky/core, added @trokky/trokky ^2.0.0",
"removed @trokky/core, added @trokky/trokky ^3.0.0",
"Specifiers rewritten by mapping:",
"@trokky/express",
"-> @trokky/trokky",
Expand Down
50 changes: 48 additions & 2 deletions internal/migrate/warnings.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ import (
// look at. Everything here comes from a text scan of the sources: we never
// execute or type-check the project.
type Warning struct {
// Kind is one of "internal-import", "astro-env", "singleton-divergence"
// or "singleton-near-miss".
// Kind is one of "internal-import", "astro-env", "singleton-divergence",
// "singleton-near-miss" or "studio-mount".
Kind string
// File is relative to the scanned root, slash-separated. It is empty when
// the warning is not tied to a file.
Expand Down Expand Up @@ -499,6 +499,51 @@ func warnIsSchemaFile(rel, src string) bool {
// ScanWarnings walks root and returns every warning found, sorted by file and
// line. Skipped directories (node_modules, .git, dist, build, .astro) and
// symlinks are not visited.
// ---------------------------------------------------------------------------
// studio mounting (Trokky 3.0)
// ---------------------------------------------------------------------------

// studioMountPatterns are the pre-3.0 ways a project told the server to serve the
// Studio. Since 3.0 the server refuses them at boot, so they are reported here
// with the replacement rather than rewritten: the fix is two lines in the server
// file whose right place (before the site's own catch-all) a text rewrite cannot
// know.
var studioMountPatterns = []struct {
re *regexp.Regexp
message string
}{
{regexp.MustCompile(`studioPath\s*:`),
"mount() no longer takes studioPath. Mount the Studio yourself: import { studioRouter } from '@trokky/studio/express' and app.use('/studio', studioRouter({ apiPath: '/api' })) before any catch-all"},
{regexp.MustCompile(`getMountedStudioPath\s*\(`),
"getMountedStudioPath() is gone; the Studio path is wherever you app.use() studioRouter()"},
{regexp.MustCompile(`\.\s*studioPath\b`),
"getInfo()/getMountedPaths() no longer report studioPath; keep your own constant for the mount path"},
{regexp.MustCompile(`studio\s*:\s*\{[^}]*\b(enabled|path|requireAuth|apiUrl)\s*:`),
"the studio config block may only hold branding, fields, settings and session; remove enabled/path/requireAuth/apiUrl (serving the Studio is server.ts's job now)"},
{regexp.MustCompile(`studio\s*:\s*\{[^}]*\bstructure\b`),
"move structure out of the studio block to the top level of the config: the server enforces singletons from it"},
{regexp.MustCompile(`@trokky/studio/dist/server/assets\.js`),
"'@trokky/studio/dist/server/assets.js' is no longer exported; use studioRouter() from '@trokky/studio/express'"},
}

// ScanStudioMount reports every pre-3.0 Studio mounting idiom in src.
func ScanStudioMount(rel, src string) []Warning {
var out []Warning
for _, p := range studioMountPatterns {
loc := p.re.FindStringIndex(src)
if loc == nil {
continue
}
out = append(out, Warning{
Kind: "studio-mount",
File: rel,
Line: 1 + strings.Count(src[:loc[0]], "\n"),
Message: p.message,
})
}
return out
}

// ScanSummary records what the singleton check actually examined, so that a clean run can
// be told apart from a run that found nothing to look at. Reporting only "no warnings" makes
// those two identical, and the second one loses documents on restore.
Expand Down Expand Up @@ -550,6 +595,7 @@ func ScanWarningsSummary(root string) ([]Warning, ScanSummary, error) {
src := string(data)

out = append(out, ScanInternalImports(rel, src)...)
out = append(out, ScanStudioMount(rel, src)...)
if astro {
out = append(out, ScanAstroEnv(rel, src)...)
}
Expand Down
58 changes: 56 additions & 2 deletions internal/migrate/warnings_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ func TestIsAstroProject(t *testing.T) {
{"astro file", map[string]string{"src/pages/index.astro": "---\n---\n<h1/>\n"}, true},
{"package.json dep", map[string]string{"package.json": `{"dependencies":{"astro":"^4.0.0"}}`}, true},
{"package.json devDep", map[string]string{"package.json": `{"devDependencies":{"astro":"^4.0.0"}}`}, true},
{"plain node project", map[string]string{"package.json": `{"dependencies":{"trokky":"^2.0.0"}}`, "server.ts": "//\n"}, false},
{"plain node project", map[string]string{"package.json": `{"dependencies":{"trokky":"^3.0.0"}}`, "server.ts": "//\n"}, false},
{"astro only in node_modules", map[string]string{"node_modules/x/a.astro": "x"}, false},
}

Expand Down Expand Up @@ -175,7 +175,7 @@ func TestScanWarningsAstroEnvOnlyForAstroProjects(t *testing.T) {

// Same source, no astro markers anywhere: not our problem.
plainRoot := warnWriteTree(t, map[string]string{
"package.json": `{"dependencies":{"trokky":"^2.0.0"}}`,
"package.json": `{"dependencies":{"trokky":"^3.0.0"}}`,
"src/page.ts": warnAstroPage,
})
got, err = ScanWarnings(plainRoot)
Expand Down Expand Up @@ -384,3 +384,57 @@ func TestScanWarningsMissingRoot(t *testing.T) {
t.Fatal("expected an error for a missing root")
}
}

func TestScanStudioMountReportsEveryPre30Idiom(t *testing.T) {
src := `import { startServer } from '@trokky/trokky/express'
const server = await startServer(config)
trokky.mount(app, { apiPath: '/api', studioPath: '/studio' })
console.log(info.studioPath)
const p = trokky.getMountedStudioPath()
const { getStudioHTML } = await import('@trokky/studio/dist/server/assets.js')
`
got := ScanStudioMount("server.ts", src)
kinds := map[string]bool{}
for _, w := range got {
if w.Kind != "studio-mount" {
t.Errorf("kind = %q, want studio-mount", w.Kind)
}
if w.File != "server.ts" || w.Line == 0 {
t.Errorf("bad location %s:%d", w.File, w.Line)
}
kinds[w.Message[:20]] = true
}
if len(got) != 4 {
t.Fatalf("expected 4 warnings (studioPath, getMountedStudioPath, .studioPath, dist import), got %d: %+v", len(got), got)
}
if got[0].Line != 3 {
t.Errorf("first warning line = %d, want 3 (the studioPath mount option)", got[0].Line)
}
}

func TestScanStudioMountFlagsOldConfigKeys(t *testing.T) {
cfg := `export default {
studio: {
enabled: true,
path: '/studio',
structure,
},
}`
got := ScanStudioMount("trokky.config.ts", cfg)
if len(got) != 2 {
t.Fatalf("expected the removed-keys warning and the structure warning, got %d: %+v", len(got), got)
}
if !strings.Contains(got[0].Message, "enabled/path/requireAuth/apiUrl") || !strings.Contains(got[1].Message, "top level") {
t.Errorf("unexpected messages: %+v", got)
}
}

func TestScanStudioMountIsQuietOnTheNewShape(t *testing.T) {
src := `import { studioRouter } from '@trokky/studio/express'
server.app.use('/studio', studioRouter({ apiPath: '/api' }))
export default { structure, studio: { branding: { title: 'CMS' } } }
`
if got := ScanStudioMount("server.ts", src); len(got) != 0 {
t.Errorf("new shape must not warn: %+v", got)
}
}
Loading
Loading