migrate-global.test.ts 6.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168
  1. import { describe, expect } from "bun:test"
  2. import { Project } from "@/project/project"
  3. import { Database } from "@kirincode-ai/core/database/database"
  4. import { eq } from "drizzle-orm"
  5. import { SessionTable } from "@kirincode-ai/core/session/sql"
  6. import { ProjectTable } from "@kirincode-ai/core/project/sql"
  7. import { AbsolutePath } from "@kirincode-ai/core/schema"
  8. import { ProjectV2 } from "@kirincode-ai/core/project"
  9. import { SessionID } from "../../src/session/schema"
  10. import { $ } from "bun"
  11. import { tmpdirScoped } from "../fixture/fixture"
  12. import { LayerNode } from "@kirincode-ai/core/effect/layer-node"
  13. import { CrossSpawnSpawner } from "@kirincode-ai/core/cross-spawn-spawner"
  14. import { Effect } from "effect"
  15. import { testEffect } from "../lib/effect"
  16. const it = testEffect(LayerNode.compile(LayerNode.group([Project.node, Database.node, CrossSpawnSpawner.node])))
  17. function legacySessionID() {
  18. // Global-session migration covers persisted IDs from before prefixed session IDs.
  19. return crypto.randomUUID() as SessionID
  20. }
  21. function seed(opts: { id: SessionID; dir: string; project: ProjectV2.ID }) {
  22. const now = Date.now()
  23. return Database.Service.use(({ db }) =>
  24. db
  25. .insert(SessionTable)
  26. .values({
  27. id: opts.id,
  28. project_id: opts.project,
  29. slug: opts.id,
  30. directory: opts.dir,
  31. title: "test",
  32. version: "0.0.0-test",
  33. time_created: now,
  34. time_updated: now,
  35. })
  36. .run()
  37. .pipe(Effect.orDie),
  38. )
  39. }
  40. function ensureGlobal() {
  41. return Database.Service.use(({ db }) =>
  42. db
  43. .insert(ProjectTable)
  44. .values({
  45. id: ProjectV2.ID.global,
  46. worktree: AbsolutePath.make("/"),
  47. time_created: Date.now(),
  48. time_updated: Date.now(),
  49. sandboxes: [],
  50. })
  51. .onConflictDoNothing()
  52. .run()
  53. .pipe(Effect.orDie),
  54. )
  55. }
  56. describe("migrateFromGlobal", () => {
  57. it.live("migrates global sessions on first project creation", () =>
  58. Effect.gen(function* () {
  59. // 1. Start with git init but no commits — creates "global" project row
  60. const tmp = yield* tmpdirScoped()
  61. yield* Effect.promise(() => $`git init`.cwd(tmp).quiet())
  62. yield* Effect.promise(() => $`git config user.name "Test"`.cwd(tmp).quiet())
  63. yield* Effect.promise(() => $`git config user.email "test@opencode.test"`.cwd(tmp).quiet())
  64. yield* Effect.promise(() => $`git config commit.gpgsign false`.cwd(tmp).quiet())
  65. const projects = yield* Project.Service
  66. const { project: pre } = yield* projects.fromDirectory(tmp)
  67. expect(pre.id).toBe(ProjectV2.ID.global)
  68. // 2. Seed a session under "global" with matching directory
  69. const id = legacySessionID()
  70. yield* seed({ id, dir: tmp, project: ProjectV2.ID.global })
  71. // 3. Make a commit so the project gets a real ID
  72. yield* Effect.promise(() => $`git commit --allow-empty -m "root"`.cwd(tmp).quiet())
  73. const { project: real } = yield* projects.fromDirectory(tmp)
  74. expect(real.id).not.toBe(ProjectV2.ID.global)
  75. // 4. The session should have been migrated to the real project ID
  76. const row = yield* Database.Service.use(({ db }) =>
  77. db.select().from(SessionTable).where(eq(SessionTable.id, id)).get().pipe(Effect.orDie),
  78. )
  79. expect(row).toBeDefined()
  80. expect(row!.project_id).toBe(real.id)
  81. }),
  82. )
  83. it.live("migrates global sessions even when project row already exists", () =>
  84. Effect.gen(function* () {
  85. // 1. Create a repo with a commit — real project ID created immediately
  86. const tmp = yield* tmpdirScoped({ git: true })
  87. const projects = yield* Project.Service
  88. const { project } = yield* projects.fromDirectory(tmp)
  89. expect(project.id).not.toBe(ProjectV2.ID.global)
  90. // 2. Ensure "global" project row exists (as it would from a prior no-git session)
  91. yield* ensureGlobal()
  92. // 3. Seed a session under "global" with matching directory.
  93. // This simulates a session created before git init that wasn't
  94. // present when the real project row was first created.
  95. const id = legacySessionID()
  96. yield* seed({ id, dir: tmp, project: ProjectV2.ID.global })
  97. // 4. Call fromDirectory again — project row already exists,
  98. // so the current code skips migration entirely. This is the bug.
  99. yield* projects.fromDirectory(tmp)
  100. const row = yield* Database.Service.use(({ db }) =>
  101. db.select().from(SessionTable).where(eq(SessionTable.id, id)).get().pipe(Effect.orDie),
  102. )
  103. expect(row).toBeDefined()
  104. expect(row!.project_id).toBe(project.id)
  105. }),
  106. )
  107. it.live("does not claim sessions with empty directory", () =>
  108. Effect.gen(function* () {
  109. const tmp = yield* tmpdirScoped({ git: true })
  110. const projects = yield* Project.Service
  111. const { project } = yield* projects.fromDirectory(tmp)
  112. expect(project.id).not.toBe(ProjectV2.ID.global)
  113. yield* ensureGlobal()
  114. // Legacy sessions may lack a directory value.
  115. // Without a matching origin directory, they should remain global.
  116. const id = legacySessionID()
  117. yield* seed({ id, dir: "", project: ProjectV2.ID.global })
  118. yield* projects.fromDirectory(tmp)
  119. const row = yield* Database.Service.use(({ db }) =>
  120. db.select().from(SessionTable).where(eq(SessionTable.id, id)).get().pipe(Effect.orDie),
  121. )
  122. expect(row).toBeDefined()
  123. expect(row!.project_id).toBe(ProjectV2.ID.global)
  124. }),
  125. )
  126. it.live("does not steal sessions from unrelated directories", () =>
  127. Effect.gen(function* () {
  128. const tmp = yield* tmpdirScoped({ git: true })
  129. const projects = yield* Project.Service
  130. const { project } = yield* projects.fromDirectory(tmp)
  131. expect(project.id).not.toBe(ProjectV2.ID.global)
  132. yield* ensureGlobal()
  133. // Seed a session under "global" but for a DIFFERENT directory
  134. const id = legacySessionID()
  135. yield* seed({ id, dir: "/some/other/dir", project: ProjectV2.ID.global })
  136. yield* projects.fromDirectory(tmp)
  137. const row = yield* Database.Service.use(({ db }) =>
  138. db.select().from(SessionTable).where(eq(SessionTable.id, id)).get().pipe(Effect.orDie),
  139. )
  140. expect(row).toBeDefined()
  141. // Should remain under "global" — not stolen
  142. expect(row!.project_id).toBe(ProjectV2.ID.global)
  143. }),
  144. )
  145. })