@highstate/backend 0.9.16 → 0.9.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (302) hide show
  1. package/dist/chunk-5WVU2AK4.js +1535 -0
  2. package/dist/chunk-5WVU2AK4.js.map +1 -0
  3. package/dist/chunk-I7BWSAN6.js +49 -0
  4. package/dist/chunk-I7BWSAN6.js.map +1 -0
  5. package/dist/{chunk-RCB4AFGD.js → chunk-VB4YL327.js} +51 -71
  6. package/dist/chunk-VB4YL327.js.map +1 -0
  7. package/dist/database/local/prisma.config.js +26 -0
  8. package/dist/database/local/prisma.config.js.map +1 -0
  9. package/dist/highstate.manifest.json +5 -4
  10. package/dist/index.js +7676 -6634
  11. package/dist/index.js.map +1 -1
  12. package/dist/library/package-resolution-worker.js +8 -6
  13. package/dist/library/package-resolution-worker.js.map +1 -1
  14. package/dist/library/worker/main.js +63 -58
  15. package/dist/library/worker/main.js.map +1 -1
  16. package/dist/shared/index.js +3 -216
  17. package/dist/shared/index.js.map +1 -1
  18. package/package.json +23 -11
  19. package/prisma/backend/_schema/layout.prisma +7 -0
  20. package/prisma/backend/_schema/library.prisma +17 -0
  21. package/prisma/backend/_schema/project.prisma +101 -0
  22. package/prisma/backend/_schema/pulumi.prisma +17 -0
  23. package/prisma/backend/postgresql/main.prisma +17 -0
  24. package/prisma/backend/sqlite/main.prisma +17 -0
  25. package/prisma/backend/sqlite/migrations/20250817070609_initiial/migration.sql +34 -0
  26. package/prisma/backend/sqlite/migrations/20250817104948_add_fields/migration.sql +59 -0
  27. package/prisma/backend/sqlite/migrations/20250818082732_add_models/migration.sql +41 -0
  28. package/prisma/backend/sqlite/migrations/20250818083106_a/migration.sql +19 -0
  29. package/prisma/backend/sqlite/migrations/20250818101945_hi/migration.sql +1 -0
  30. package/prisma/backend/sqlite/migrations/20250819082315_a/migration.sql +5 -0
  31. package/prisma/backend/sqlite/migrations/migration_lock.toml +3 -0
  32. package/prisma/project/api-key.prisma +27 -0
  33. package/prisma/project/artifact.prisma +52 -0
  34. package/prisma/project/custom-status.prisma +46 -0
  35. package/prisma/project/evaluation.prisma +35 -0
  36. package/prisma/project/instance.prisma +160 -0
  37. package/prisma/project/layout.prisma +23 -0
  38. package/prisma/project/lock.prisma +18 -0
  39. package/prisma/project/main.prisma +17 -0
  40. package/prisma/project/migrations/20250816081310_initial/migration.sql +300 -0
  41. package/prisma/project/migrations/20250816082523_test/migration.sql +72 -0
  42. package/prisma/project/migrations/20250818065643_update/migration.sql +42 -0
  43. package/prisma/project/migrations/20250818070758_a/migration.sql +8 -0
  44. package/prisma/project/migrations/20250818070913_a/migration.sql +8 -0
  45. package/prisma/project/migrations/20250818082720_add_motels/migration.sql +11 -0
  46. package/prisma/project/migrations/20250818112523_hello/migration.sql +35 -0
  47. package/prisma/project/migrations/20250819082305_a/migration.sql +14 -0
  48. package/prisma/project/migrations/20250819165004_add_missing_fields/migration.sql +216 -0
  49. package/prisma/project/migrations/20250819171309_a/migration.sql +22 -0
  50. package/prisma/project/migrations/20250820113949_a/migration.sql +66 -0
  51. package/prisma/project/migrations/20250820144256_b/migration.sql +31 -0
  52. package/prisma/project/migrations/20250820145547_a/migration.sql +24 -0
  53. package/prisma/project/migrations/20250820182517_b/migration.sql +2 -0
  54. package/prisma/project/migrations/20250821172324_a/migration.sql +2 -0
  55. package/prisma/project/migrations/20250822081339_a/migration.sql +219 -0
  56. package/prisma/project/migrations/20250822083742_b/migration.sql +1 -0
  57. package/prisma/project/migrations/20250822105134_boom/migration.sql +1 -0
  58. package/prisma/project/migrations/20250822141028_b/migration.sql +1 -0
  59. package/prisma/project/migrations/20250822142342_b/migration.sql +16 -0
  60. package/prisma/project/migrations/20250824072720_a/migration.sql +1 -0
  61. package/prisma/project/migrations/20250824093656_b/migration.sql +21 -0
  62. package/prisma/project/migrations/20250825082518_a/migration.sql +1 -0
  63. package/prisma/project/migrations/20250825085343_b/migration.sql +1 -0
  64. package/prisma/project/migrations/20250825091312_a/migration.sql +1 -0
  65. package/prisma/project/migrations/20250903095431_hi/migration.sql +44 -0
  66. package/prisma/project/migrations/20250903174255_a/migration.sql +24 -0
  67. package/prisma/project/migrations/20250908095205_hi/migration.sql +18 -0
  68. package/prisma/project/migrations/20250909155857_hi/migration.sql +15 -0
  69. package/prisma/project/migrations/migration_lock.toml +3 -0
  70. package/prisma/project/model.prisma +37 -0
  71. package/prisma/project/operation.prisma +148 -0
  72. package/prisma/project/page.prisma +41 -0
  73. package/prisma/project/secret.prisma +42 -0
  74. package/prisma/project/service-account.prisma +36 -0
  75. package/prisma/project/terminal.prisma +90 -0
  76. package/prisma/project/trigger.prisma +31 -0
  77. package/prisma/project/unlock-method.prisma +32 -0
  78. package/prisma/project/worker.prisma +138 -0
  79. package/src/artifact/abstractions.ts +13 -13
  80. package/src/artifact/encryption.ts +31 -15
  81. package/src/artifact/factory.ts +7 -10
  82. package/src/artifact/local.ts +33 -50
  83. package/src/business/api-key.ts +24 -36
  84. package/src/business/artifact.test.ts +978 -0
  85. package/src/business/artifact.ts +136 -215
  86. package/src/business/evaluation.ts +328 -0
  87. package/src/business/index.ts +5 -1
  88. package/src/business/instance-lock.test.ts +1060 -0
  89. package/src/business/instance-lock.ts +387 -77
  90. package/src/business/instance-state.test.ts +735 -0
  91. package/src/business/instance-state.ts +604 -217
  92. package/src/business/operation.test.ts +439 -0
  93. package/src/business/operation.ts +174 -208
  94. package/src/business/project-model.ts +258 -0
  95. package/src/business/project-unlock.ts +172 -112
  96. package/src/business/project.ts +407 -0
  97. package/src/business/secret.test.ts +513 -0
  98. package/src/business/secret.ts +194 -131
  99. package/src/business/settings.test.ts +695 -0
  100. package/src/business/settings.ts +855 -0
  101. package/src/business/terminal-session.ts +90 -0
  102. package/src/business/unit-extra.test.ts +539 -0
  103. package/src/business/unit-extra.ts +160 -0
  104. package/src/business/worker.test.ts +391 -0
  105. package/src/business/worker.ts +250 -114
  106. package/src/common/codebase.ts +65 -0
  107. package/src/common/index.ts +3 -2
  108. package/src/common/logger.ts +5 -0
  109. package/src/common/utils.ts +4 -3
  110. package/src/config.ts +15 -12
  111. package/src/database/_generated/backend/postgresql/client.ts +72 -0
  112. package/src/database/_generated/backend/postgresql/commonInputTypes.ts +350 -0
  113. package/src/database/_generated/backend/postgresql/enums.ts +13 -0
  114. package/src/database/_generated/backend/postgresql/internal/class.ts +320 -0
  115. package/src/database/_generated/backend/postgresql/internal/prismaNamespace.ts +1238 -0
  116. package/src/database/_generated/backend/postgresql/models/Library.ts +1263 -0
  117. package/src/database/_generated/backend/postgresql/models/Project.ts +2175 -0
  118. package/src/database/_generated/backend/postgresql/models/ProjectModelStorage.ts +1263 -0
  119. package/src/database/_generated/backend/postgresql/models/ProjectSpace.ts +1602 -0
  120. package/src/database/_generated/backend/postgresql/models/PulumiBackend.ts +1263 -0
  121. package/src/database/_generated/backend/postgresql/models/UserWorkspaseLayout.ts +1065 -0
  122. package/src/database/_generated/backend/postgresql/models.ts +16 -0
  123. package/src/database/_generated/backend/postgresql/pjtg.ts +182 -0
  124. package/src/database/_generated/backend/sqlite/client.ts +72 -0
  125. package/src/database/_generated/backend/sqlite/commonInputTypes.ts +331 -0
  126. package/src/database/_generated/backend/sqlite/enums.ts +13 -0
  127. package/src/database/_generated/backend/sqlite/internal/class.ts +318 -0
  128. package/src/database/_generated/backend/sqlite/internal/prismaNamespace.ts +1207 -0
  129. package/src/database/_generated/backend/sqlite/models/Library.ts +1261 -0
  130. package/src/database/_generated/backend/sqlite/models/Project.ts +2169 -0
  131. package/src/database/_generated/backend/sqlite/models/ProjectModelStorage.ts +1261 -0
  132. package/src/database/_generated/backend/sqlite/models/ProjectSpace.ts +1599 -0
  133. package/src/database/_generated/backend/sqlite/models/PulumiBackend.ts +1261 -0
  134. package/src/database/_generated/backend/sqlite/models/UserWorkspaseLayout.ts +1063 -0
  135. package/src/database/_generated/backend/sqlite/models.ts +16 -0
  136. package/src/database/_generated/backend/sqlite/pjtg.ts +182 -0
  137. package/src/database/_generated/project/client.ts +204 -0
  138. package/src/database/_generated/project/commonInputTypes.ts +827 -0
  139. package/src/database/_generated/project/enums.ts +104 -0
  140. package/src/database/_generated/project/internal/class.ts +479 -0
  141. package/src/database/_generated/project/internal/prismaNamespace.ts +2974 -0
  142. package/src/database/_generated/project/models/ApiKey.ts +1506 -0
  143. package/src/database/_generated/project/models/Artifact.ts +2051 -0
  144. package/src/database/_generated/project/models/HubModel.ts +1125 -0
  145. package/src/database/_generated/project/models/InstanceCustomStatus.ts +1713 -0
  146. package/src/database/_generated/project/models/InstanceEvaluationState.ts +1312 -0
  147. package/src/database/_generated/project/models/InstanceLock.ts +1268 -0
  148. package/src/database/_generated/project/models/InstanceModel.ts +1125 -0
  149. package/src/database/_generated/project/models/InstanceOperationState.ts +1707 -0
  150. package/src/database/_generated/project/models/InstanceState.ts +4613 -0
  151. package/src/database/_generated/project/models/Operation.ts +1647 -0
  152. package/src/database/_generated/project/models/OperationLog.ts +1455 -0
  153. package/src/database/_generated/project/models/Page.ts +1838 -0
  154. package/src/database/_generated/project/models/Secret.ts +1692 -0
  155. package/src/database/_generated/project/models/ServiceAccount.ts +2165 -0
  156. package/src/database/_generated/project/models/Terminal.ts +2038 -0
  157. package/src/database/_generated/project/models/TerminalSession.ts +1454 -0
  158. package/src/database/_generated/project/models/TerminalSessionLog.ts +1280 -0
  159. package/src/database/_generated/project/models/Trigger.ts +1430 -0
  160. package/src/database/_generated/project/models/UnlockMethod.ts +1220 -0
  161. package/src/database/_generated/project/models/UserCompositeViewport.ts +1280 -0
  162. package/src/database/_generated/project/models/UserProjectViewport.ts +1059 -0
  163. package/src/database/_generated/project/models/Worker.ts +1459 -0
  164. package/src/database/_generated/project/models/WorkerUnitRegistration.ts +1524 -0
  165. package/src/database/_generated/project/models/WorkerVersion.ts +1974 -0
  166. package/src/database/_generated/project/models/WorkerVersionLog.ts +1318 -0
  167. package/src/database/_generated/project/models.ts +35 -0
  168. package/src/database/_generated/project/pjtg.ts +182 -0
  169. package/src/database/abstractions.ts +19 -0
  170. package/src/database/factory.ts +37 -0
  171. package/src/database/index.ts +6 -0
  172. package/src/database/local/backend.ts +134 -0
  173. package/src/database/local/index.ts +3 -0
  174. package/src/database/local/meta.ts +46 -0
  175. package/src/database/local/prisma.config.ts +25 -0
  176. package/src/database/local/project.ts +39 -0
  177. package/src/database/manager.ts +181 -0
  178. package/src/database/migrate.ts +35 -0
  179. package/src/database/prisma.ts +56 -0
  180. package/src/database/well-known.ts +38 -0
  181. package/src/index.ts +4 -4
  182. package/src/library/abstractions.ts +21 -14
  183. package/src/library/factory.ts +1 -1
  184. package/src/library/local.ts +86 -38
  185. package/src/library/package-resolution-worker.ts +1 -1
  186. package/src/library/worker/evaluator.ts +61 -48
  187. package/src/library/worker/loader.lite.ts +14 -1
  188. package/src/library/worker/main.ts +9 -16
  189. package/src/library/worker/protocol.ts +0 -12
  190. package/src/lock/manager.ts +12 -7
  191. package/src/orchestrator/manager.ts +198 -131
  192. package/src/orchestrator/operation-context.ts +357 -0
  193. package/src/orchestrator/operation-plan.destroy.test.md +357 -0
  194. package/src/orchestrator/operation-plan.destroy.test.ts +775 -0
  195. package/src/orchestrator/operation-plan.fixtures.ts +213 -0
  196. package/src/orchestrator/operation-plan.md +198 -0
  197. package/src/orchestrator/operation-plan.refresh.test.md +199 -0
  198. package/src/orchestrator/operation-plan.refresh.test.ts +367 -0
  199. package/src/orchestrator/operation-plan.ts +709 -0
  200. package/src/orchestrator/operation-plan.update.test.md +485 -0
  201. package/src/orchestrator/operation-plan.update.test.ts +1066 -0
  202. package/src/orchestrator/operation-workset.ts +235 -583
  203. package/src/orchestrator/operation.ts +446 -904
  204. package/src/orchestrator/plan-test-builder.ts +267 -0
  205. package/src/project-model/abstractions.ts +118 -0
  206. package/src/project-model/backends/codebase.ts +365 -0
  207. package/src/project-model/backends/database.ts +440 -0
  208. package/src/project-model/errors.ts +81 -0
  209. package/src/project-model/factory.ts +24 -0
  210. package/src/project-model/index.ts +4 -0
  211. package/src/project-model/utils.test.ts +544 -0
  212. package/src/project-model/utils.ts +242 -0
  213. package/src/pubsub/abstractions.ts +10 -1
  214. package/src/pubsub/factory.ts +4 -4
  215. package/src/pubsub/index.ts +1 -0
  216. package/src/pubsub/manager.ts +49 -25
  217. package/src/pubsub/memory.ts +31 -0
  218. package/src/runner/abstractions.ts +38 -26
  219. package/src/runner/artifact-env.ts +17 -6
  220. package/src/runner/factory.ts +6 -6
  221. package/src/runner/force-abort.ts +3 -6
  222. package/src/runner/local.ts +79 -72
  223. package/src/runner/pulumi.ts +26 -63
  224. package/src/services.ts +214 -103
  225. package/src/shared/models/backend/index.ts +3 -1
  226. package/src/shared/models/backend/library.ts +12 -4
  227. package/src/shared/models/backend/project.ts +43 -23
  228. package/src/shared/models/backend/pulumi.ts +14 -0
  229. package/src/shared/models/backend/unlock-method.ts +1 -1
  230. package/src/shared/models/backend/well-known.ts +58 -0
  231. package/src/shared/models/base.ts +40 -109
  232. package/src/shared/models/errors.ts +82 -1
  233. package/src/shared/models/index.ts +3 -2
  234. package/src/shared/models/prisma.ts +36 -0
  235. package/src/shared/models/project/api-key.ts +37 -56
  236. package/src/shared/models/project/artifact.ts +15 -105
  237. package/src/shared/models/project/custom-status.ts +12 -0
  238. package/src/shared/models/project/index.ts +9 -9
  239. package/src/shared/models/project/lock.ts +10 -78
  240. package/src/shared/models/project/model.ts +32 -0
  241. package/src/shared/models/project/operation.ts +222 -99
  242. package/src/shared/models/project/page.ts +37 -48
  243. package/src/shared/models/project/secret.ts +29 -103
  244. package/src/shared/models/project/service-account.ts +12 -17
  245. package/src/shared/models/project/state.ts +100 -390
  246. package/src/shared/models/project/terminal.ts +75 -89
  247. package/src/shared/models/project/trigger.ts +13 -49
  248. package/src/shared/models/project/unlock-method.ts +21 -20
  249. package/src/shared/models/project/worker.ts +89 -88
  250. package/src/shared/resolvers/graph-resolver.ts +62 -26
  251. package/src/shared/resolvers/index.ts +1 -1
  252. package/src/shared/resolvers/input-hash.ts +24 -14
  253. package/src/shared/resolvers/input.ts +48 -6
  254. package/src/shared/resolvers/registry.ts +5 -4
  255. package/src/shared/resolvers/state.ts +12 -1
  256. package/src/shared/resolvers/validation.ts +29 -9
  257. package/src/shared/utils/index.ts +1 -1
  258. package/src/shared/utils/promise-tracker.ts +30 -3
  259. package/src/terminal/abstractions.ts +1 -1
  260. package/src/terminal/docker.ts +3 -3
  261. package/src/terminal/manager.ts +102 -118
  262. package/src/test-utils/database.ts +119 -0
  263. package/src/test-utils/index.ts +2 -0
  264. package/src/test-utils/services.ts +134 -0
  265. package/src/unlock/abstractions.ts +31 -0
  266. package/src/unlock/index.ts +2 -0
  267. package/src/unlock/memory.ts +27 -0
  268. package/src/worker/abstractions.ts +7 -4
  269. package/src/worker/docker.ts +14 -19
  270. package/src/worker/manager.ts +376 -79
  271. package/dist/chunk-RCB4AFGD.js.map +0 -1
  272. package/dist/chunk-WHALQHEZ.js +0 -2017
  273. package/dist/chunk-WHALQHEZ.js.map +0 -1
  274. package/src/business/backend-unlock.ts +0 -10
  275. package/src/common/performance.ts +0 -44
  276. package/src/hotstate/abstractions.ts +0 -48
  277. package/src/hotstate/factory.ts +0 -17
  278. package/src/hotstate/index.ts +0 -3
  279. package/src/hotstate/manager.ts +0 -192
  280. package/src/hotstate/memory.ts +0 -100
  281. package/src/hotstate/validation.ts +0 -101
  282. package/src/project/abstractions.ts +0 -102
  283. package/src/project/factory.ts +0 -11
  284. package/src/project/index.ts +0 -3
  285. package/src/project/local.ts +0 -469
  286. package/src/project/manager.ts +0 -574
  287. package/src/pubsub/local.ts +0 -36
  288. package/src/pubsub/validation.ts +0 -33
  289. package/src/shared/models/project/component.ts +0 -45
  290. package/src/shared/models/project/instance.ts +0 -74
  291. package/src/state/abstractions.ts +0 -450
  292. package/src/state/encryption.ts +0 -59
  293. package/src/state/factory.ts +0 -20
  294. package/src/state/index.ts +0 -6
  295. package/src/state/local/backend.ts +0 -299
  296. package/src/state/local/collection.ts +0 -342
  297. package/src/state/local/index.ts +0 -2
  298. package/src/state/manager.ts +0 -819
  299. package/src/state/repository/index.ts +0 -2
  300. package/src/state/repository/repository.index.ts +0 -193
  301. package/src/state/repository/repository.ts +0 -458
  302. /package/src/{state → database/local}/keyring.ts +0 -0
@@ -1,292 +1,679 @@
1
- import type { HotStateManager } from "../hotstate"
2
- import type { LockManager } from "../lock"
3
- import type { PubSubManager } from "../pubsub"
4
- import type { StateManager } from "../state"
1
+ import type {
2
+ ComponentKind,
3
+ UnitPage,
4
+ UnitTerminal,
5
+ UnitTrigger,
6
+ UnitWorker,
7
+ } from "@highstate/contract"
5
8
  import type { Logger } from "pino"
6
- import type { RunnerBackend } from "../runner"
7
9
  import type { ArtifactService } from "../artifact"
8
- import { randomBytes } from "node:crypto"
9
- import { parseInstanceId } from "@highstate/contract"
10
- import { unique } from "remeda"
10
+ import type { SecretService, UnitExtraService, WorkerService } from "../business"
11
+ import type { PubSubManager } from "../pubsub"
12
+ import type { RunnerBackend } from "../runner"
13
+ import { type InstanceId, parseInstanceId } from "@highstate/contract"
14
+ import { isNonNullish, omit } from "remeda"
15
+ import {
16
+ type DatabaseManager,
17
+ DbNull,
18
+ type InstanceOperationStateCreateManyInput,
19
+ type InstanceOperationStateUpdateInput,
20
+ type InstanceStateInclude,
21
+ type InstanceStateUpdateInput,
22
+ type ProjectTransaction,
23
+ } from "../database"
11
24
  import {
12
- applyStatePatch,
13
- isStableOperationStatus,
14
- isStateEmpty,
15
- type InstanceCustomStatus,
25
+ forSchema,
26
+ type InstanceCustomStatusInput,
27
+ InstanceLockedError,
28
+ type InstanceOperationState,
16
29
  type InstanceState,
17
- type InstanceStatePatch,
18
- type Operation,
30
+ InstanceStateNotFoundError,
31
+ ProjectNotFoundError,
32
+ projectOutputSchema,
33
+ waitAll,
19
34
  } from "../shared"
20
35
 
21
- export class InstanceStateService {
22
- constructor(
23
- private readonly stateManager: StateManager,
24
- private readonly hotStateManager: HotStateManager,
25
- private readonly pubsubManager: PubSubManager,
26
- private readonly lockManager: LockManager,
27
- private readonly runnerBackend: RunnerBackend,
28
- private readonly artifactManager: ArtifactService,
29
- private readonly logger: Logger,
30
- ) {}
31
-
36
+ export type GetProjectInstancesOptions = {
32
37
  /**
33
- * Gets the current states of all instances in a project.
38
+ * Whether to include evaluation states in the result.
34
39
  *
35
- * @param projectId The ID of the project for which to retrieve instance states.
40
+ * By default, this is false.
36
41
  */
37
- async getInstanceStates(projectId: string): Promise<InstanceState[]> {
38
- const entries = await this.hotStateManager.hgetall(["instance-states", projectId])
42
+ includeEvaluationState?: boolean
39
43
 
40
- return entries.map(([, value]) => value)
41
- }
44
+ /**
45
+ * Include last operation state in the result.
46
+ *
47
+ * By default, this is false.
48
+ */
49
+ includeLastOperationState?: boolean
42
50
 
43
- async getInstanceState(projectId: string, instanceId: string): Promise<InstanceState | null> {
44
- return await this.hotStateManager.hget(["instance-states", projectId], instanceId)
45
- }
51
+ /**
52
+ * Include the instance ID of the parent instance in the result.
53
+ *
54
+ * By default, this is false.
55
+ */
56
+ includeParentInstanceId?: boolean
46
57
 
47
- async updateInstanceStates(
48
- projectId: string,
49
- states: InstanceState[],
50
- persistent = false,
51
- ): Promise<InstanceState[]> {
52
- const batch = this.stateManager.batch()
53
- const promises: Promise<void>[] = []
58
+ /**
59
+ * Whether to include `terminalIds` and `pageIds` in the result.
60
+ *
61
+ * By default, this is false.
62
+ */
63
+ includeExtra?: boolean
54
64
 
55
- for (const state of states) {
56
- promises.push(this.hotStateManager.hset(["instance-states", projectId], state.id, state))
65
+ /**
66
+ * Whether to load custom statuses for each instance.
67
+ *
68
+ * By default, this is false.
69
+ */
70
+ loadCustomStatuses?: boolean
71
+ }
57
72
 
58
- promises.push(
59
- this.pubsubManager.publish(["instance-state", projectId], { type: "updated", state }),
60
- )
73
+ export type ForgetInstanceStateOptions = {
74
+ /**
75
+ * Whether to delete terminals and their sessions associated with the instance.
76
+ *
77
+ * If `false`, the terminals will be marked as deleted and no new sessions will be allowed to be created,
78
+ * but existing sessions will remain to provide history and logs.
79
+ *
80
+ * By default, this is false.
81
+ */
82
+ clearTerminalData?: boolean
61
83
 
62
- if (persistent) {
63
- promises.push(this.stateManager.getInstanceStateRepository(projectId).putItem(state, batch))
64
- }
65
- }
84
+ /**
85
+ * Whether to delete the secrets associated with the instance.
86
+ *
87
+ * By default, this is false.
88
+ */
89
+ deleteSecrets?: boolean
90
+ }
66
91
 
67
- await Promise.allSettled(promises)
68
- await batch.write()
92
+ export type InstanceStatePatch = Pick<
93
+ Partial<InstanceState>,
94
+ | "status"
95
+ | "message"
96
+ | "statusFields"
97
+ | "parentId"
98
+ | "lastOperationState"
99
+ | "inputHash"
100
+ | "outputHash"
101
+ | "dependencyOutputHash"
102
+ | "model"
103
+ | "resolvedInputs"
104
+ | "currentResourceCount"
105
+ >
106
+
107
+ export type UpdateOperationStateOptions = {
108
+ /**
109
+ * The operation state to update.
110
+ */
111
+ operationState: InstanceOperationStateUpdateInput
69
112
 
70
- this.logger.debug({ projectId, states }, `instance states updated for project "%s"`, projectId)
113
+ /**
114
+ * Instance state patch to update or function to compute patch from current state.
115
+ */
116
+ instanceState?: InstanceStatePatch
71
117
 
72
- return states
118
+ /**
119
+ * Unit-specific extra data to update.
120
+ */
121
+ unitExtra?: {
122
+ /**
123
+ * Unit pages to update.
124
+ */
125
+ pages: UnitPage[]
126
+
127
+ /**
128
+ * Unit terminals to update.
129
+ */
130
+ terminals: UnitTerminal[]
131
+
132
+ /**
133
+ * Unit triggers to update.
134
+ */
135
+ triggers: UnitTrigger[]
136
+
137
+ /**
138
+ * Unit workers to update.
139
+ */
140
+ workers: UnitWorker[]
141
+
142
+ /**
143
+ * Unit secrets to update.
144
+ */
145
+ secrets: Record<string, unknown>
73
146
  }
147
+ }
74
148
 
75
- async patchInstanceState(projectId: string, patch: InstanceStatePatch): Promise<InstanceState> {
76
- return await this.lockManager.acquire(["instance-state", projectId, patch.id], async () => {
77
- const state = await this.hotStateManager.hget(["instance-states", projectId], patch.id)
149
+ export function includeForInstanceState(
150
+ options: GetProjectInstancesOptions = {},
151
+ ): InstanceStateInclude {
152
+ return {
153
+ secrets: {
154
+ select: { name: true },
155
+ },
78
156
 
79
- if (!state) {
80
- throw new Error(`Instance state with ID "${patch.id}" not found in project "${projectId}"`)
81
- }
157
+ parent: options.includeParentInstanceId ? { select: { instanceId: true } } : undefined,
82
158
 
83
- const patchedState = await this.applyStatePatch(
84
- projectId,
85
- state,
86
- patch,
87
- patch.operationStatus !== undefined,
88
- )
159
+ operationStates: options.includeLastOperationState
160
+ ? { take: 1, orderBy: { startedAt: "desc" } }
161
+ : undefined,
89
162
 
90
- this.logger.debug(
91
- { projectId, instanceId: patch.id, patch },
92
- `instance state updated for instance "%s" in project "%s"`,
93
- patch.id,
94
- projectId,
95
- )
163
+ evaluationState: options.includeEvaluationState,
96
164
 
97
- return patchedState
98
- })
165
+ terminals: options.includeExtra ? { select: { id: true } } : undefined,
166
+ pages: options.includeExtra ? { select: { id: true } } : undefined,
167
+
168
+ customStatuses: options.loadCustomStatuses
169
+ ? {
170
+ orderBy: [{ order: "asc" }, { createdAt: "asc" }],
171
+ }
172
+ : undefined,
99
173
  }
174
+ }
100
175
 
101
- async patchOperationInstanceState(
102
- projectId: string,
103
- operation: Operation,
104
- patch: InstanceStatePatch,
105
- ): Promise<InstanceState> {
106
- return await this.lockManager.acquire(["instance-state", projectId, patch.id], async () => {
107
- const state = await this.hotStateManager.hget(["instance-states", projectId], patch.id)
176
+ export function mapInstanceStateResult(
177
+ instance: InstanceState & {
178
+ secrets: { name: string | null }[]
179
+ parent?: { instanceId: InstanceId } | null
180
+ operationStates?: InstanceOperationState[]
181
+ terminals?: { id: string }[]
182
+ pages?: { id: string }[]
183
+ customStatuses: InstanceState["customStatuses"]
184
+ },
185
+ ): InstanceState {
186
+ return {
187
+ ...omit(instance, [
188
+ "secrets",
189
+ "parent",
190
+ "operationStates",
191
+ "terminals",
192
+ "pages",
193
+ "customStatuses",
194
+ ]),
195
+
196
+ secretNames: instance.secrets.map(secret => secret.name).filter(isNonNullish),
197
+ parentInstanceId: instance.parent ? (instance.parent?.instanceId ?? null) : undefined,
198
+ lastOperationState: instance.operationStates?.[0],
199
+ evaluationState: instance.evaluationState ?? undefined,
200
+ terminalIds: instance.terminals ? instance.terminals.map(terminal => terminal.id) : undefined,
201
+ pageIds: instance.pages ? instance.pages.map(page => page.id) : undefined,
202
+ customStatuses: instance.customStatuses ?? undefined,
203
+ }
204
+ }
108
205
 
109
- const patchedState = await this.applyStatePatch(
110
- projectId,
111
- state,
112
- patch,
113
- operation.type !== "preview",
114
- )
206
+ export class InstanceStateService {
207
+ constructor(
208
+ private readonly database: DatabaseManager,
209
+ private readonly pubsubManager: PubSubManager,
210
+ private readonly runnerBackend: RunnerBackend,
211
+ private readonly workerService: WorkerService,
212
+ private readonly artifactService: ArtifactService,
213
+ private readonly unitExtraService: UnitExtraService,
214
+ private readonly secretService: SecretService,
215
+ private readonly logger: Logger,
216
+ ) {}
115
217
 
116
- this.logger.debug(
117
- { projectId, instanceId: patch.id, patch },
118
- `instance state updated for instance "%s" in project "%s"`,
119
- patch.id,
120
- projectId,
121
- )
218
+ /**
219
+ * Gets the aggregates of all instances in the project.
220
+ *
221
+ * @param projectId The ID of the project for which to retrieve instances.
222
+ * @param options Options to customize the retrieval of instances.
223
+ */
224
+ async getInstanceStates(
225
+ projectId: string,
226
+ options: GetProjectInstancesOptions = {},
227
+ ): Promise<InstanceState[]> {
228
+ const database = await this.database.forProject(projectId)
122
229
 
123
- return patchedState
230
+ // load instance states from the database
231
+ const queryResult = await database.instanceState.findMany({
232
+ include: includeForInstanceState(options),
124
233
  })
234
+
235
+ // aggregate the results from the database
236
+ return queryResult.map(mapInstanceStateResult)
125
237
  }
126
238
 
127
- async updateStateSecretNames(
239
+ /**
240
+ * Marks an instance state as deleted and cleans up associated resources.
241
+ *
242
+ * This operation:
243
+ * - only allows deletion of instances with operation state null or "destroyed";
244
+ * - prevents deletion of instances that have active locks;
245
+ * - marks the instance state as "deleted" (never actually removes the record);
246
+ * - handles terminals: deletes data if requested, otherwise marks as "unavailable";
247
+ * - handles secrets: deletes if requested, otherwise ignores them;
248
+ * - deletes artifact references for the instance;
249
+ * - recursively handles child instances using parentId relationship;
250
+ * - performs worker cleanup and synchronization;
251
+ * - performs artifact garbage collection.
252
+ *
253
+ * @param projectId The ID of the project containing the instance.
254
+ * @param instanceId The ID of the instance whose state is to be marked as deleted.
255
+ * @param options Configuration options for terminal and secret handling.
256
+ */
257
+ async forgetInstanceState(
128
258
  projectId: string,
129
- instanceId: string,
130
- secretNamesToAdd: string[],
131
- secretNamesToRemove: string[],
132
- invalidateState = true,
259
+ instanceId: InstanceId,
260
+ { deleteSecrets = false, clearTerminalData = false }: ForgetInstanceStateOptions = {},
133
261
  ): Promise<void> {
134
- await this.lockManager.acquire(["instance-state", projectId, instanceId], async () => {
135
- const state = await this.hotStateManager.hget(["instance-states", projectId], instanceId)
262
+ const database = await this.database.forProject(projectId)
263
+ const project = await this.database.backend.project.findUnique({
264
+ where: { id: projectId },
265
+ select: forSchema(projectOutputSchema),
266
+ })
136
267
 
137
- const secretNameSet = new Set(state?.secretNames ?? [])
138
- for (const name of secretNamesToAdd) {
139
- secretNameSet.add(name)
140
- }
268
+ if (!project) {
269
+ throw new ProjectNotFoundError(projectId)
270
+ }
141
271
 
142
- for (const name of secretNamesToRemove) {
143
- secretNameSet.delete(name)
144
- }
272
+ // collect instances to process cleanup after transaction
273
+ const unitInstancesToCleanup: { id: string; instanceId: InstanceId }[] = []
274
+ const updatedStateIds: string[] = []
145
275
 
146
- const patch: InstanceStatePatch = {
147
- id: instanceId,
148
- secretNames: Array.from(secretNameSet),
276
+ await database.$transaction(async tx => {
277
+ const state = await tx.instanceState.findUnique({
278
+ where: { instanceId },
279
+ select: { id: true, kind: true, instanceId: true, lock: { select: { stateId: true } } },
280
+ })
281
+
282
+ if (!state) {
283
+ throw new InstanceStateNotFoundError(projectId, instanceId)
149
284
  }
150
285
 
151
- if (state?.operationStatus && invalidateState) {
152
- // invalidate the input hash nonce if the operation status is present
153
- // if not, the invalidation makes no sense
154
- patch.operationStatus = {
155
- inputHashNonce: randomBytes(4).readInt32LE(),
156
- }
286
+ if (state.lock) {
287
+ throw new InstanceLockedError(projectId, instanceId)
157
288
  }
158
289
 
159
- await this.applyStatePatch(projectId, state, patch, true)
290
+ await this.processInstanceDeletion(
291
+ tx,
292
+ projectId,
293
+ state,
294
+ { deleteSecrets, clearTerminalData },
295
+ unitInstancesToCleanup,
296
+ updatedStateIds,
297
+ )
160
298
  })
299
+
300
+ // publish state events for all updated instances
301
+ for (const updatedStateId of updatedStateIds) {
302
+ void this.pubsubManager.publish(["instance-state", projectId], {
303
+ type: "patched",
304
+ stateId: updatedStateId,
305
+ patch: {
306
+ status: "undeployed",
307
+ statusFields: null,
308
+ inputHash: null,
309
+ outputHash: null,
310
+ dependencyOutputHash: null,
311
+ message: null,
312
+ currentResourceCount: null,
313
+ model: null,
314
+ resolvedInputs: null,
315
+ secretNames: deleteSecrets ? [] : undefined,
316
+ terminalIds: clearTerminalData ? [] : undefined,
317
+ pageIds: [],
318
+ customStatuses: [],
319
+ triggerIds: [],
320
+ evaluationState: null,
321
+ },
322
+ })
323
+ }
324
+
325
+ // process side effects
326
+ try {
327
+ await waitAll([
328
+ this.workerService.cleanupWorkerUsageAndSync(projectId),
329
+ this.artifactService.collectGarbage(projectId),
330
+ ...unitInstancesToCleanup.map(async ({ id, instanceId }) => {
331
+ const [instanceType, instanceName] = parseInstanceId(instanceId)
332
+
333
+ await this.runnerBackend.deleteState({
334
+ projectId: project.id,
335
+ stateId: id,
336
+ libraryId: project.libraryId,
337
+ instanceName,
338
+ instanceType,
339
+ })
340
+ }),
341
+ ])
342
+ } catch (error) {
343
+ this.logger.warn(
344
+ { error, projectId, instanceId },
345
+ "failed to perform side effects after forgetting instance state",
346
+ )
347
+ }
161
348
  }
162
349
 
163
350
  /**
164
- * Completely deletes the instance state from the system including all related resources.
165
- *
166
- * @param projectId The ID of the project from which to delete the instance state.
167
- * @param instanceId The ID of the instance whose state is to be deleted.
351
+ * Processes the deletion of an instance within a transaction.
352
+ * Handles validation, deletion logic, and recursive child deletion.
168
353
  */
169
- async deleteInstanceState(projectId: string, instanceId: string): Promise<void> {
170
- await this.lockManager.acquire(["instance-state", projectId, instanceId], async () => {
171
- const [instanceType, instanceName] = parseInstanceId(instanceId)
172
- const state = await this.getInstanceState(projectId, instanceId)
173
-
174
- const batch = this.stateManager.batch()
175
-
176
- await Promise.allSettled([
177
- // remove from the state
178
- this.stateManager.getInstanceStateRepository(projectId).delete(instanceId, batch),
179
-
180
- // clear related resources
181
- this.stateManager.getInstanceLockRepository(projectId).delete(instanceId, batch),
182
-
183
- this.stateManager
184
- .getPageRepository(projectId)
185
- .deleteMany(Object.values(state?.extra?.pageIds ?? {}), batch),
354
+ private async processInstanceDeletion(
355
+ tx: ProjectTransaction,
356
+ projectId: string,
357
+ state: { id: string; kind: ComponentKind; instanceId: InstanceId },
358
+ options: ForgetInstanceStateOptions,
359
+ unitInstancesToCleanup: { id: string; instanceId: InstanceId }[],
360
+ updatedStateIds: string[],
361
+ ): Promise<void> {
362
+ const { deleteSecrets = false, clearTerminalData = false } = options
363
+
364
+ // always mark instance state as undeployed (never actually delete the record)
365
+ await tx.instanceState.update({
366
+ where: { id: state.id },
367
+ data: {
368
+ status: "undeployed",
369
+ statusFields: DbNull,
370
+ inputHash: null,
371
+ outputHash: null,
372
+ dependencyOutputHash: null,
373
+ message: null,
374
+ currentResourceCount: null,
375
+ model: DbNull,
376
+ resolvedInputs: DbNull,
377
+ },
378
+ })
186
379
 
187
- this.stateManager
188
- .getTriggerRepository(projectId)
189
- .deleteMany(Object.values(state?.extra?.triggerIds ?? {}), batch),
380
+ // handle terminals
381
+ if (clearTerminalData) {
382
+ await tx.terminal.deleteMany({ where: { stateId: state.id } })
383
+ } else {
384
+ await tx.terminal.updateMany({
385
+ where: { stateId: state.id },
386
+ data: { status: "unavailable" },
387
+ })
388
+ }
190
389
 
191
- this.stateManager
192
- .getTerminalRepository(projectId)
193
- .deleteMany(Object.values(state?.extra?.terminalIds ?? {}), batch),
194
- ])
390
+ // handle secrets
391
+ if (deleteSecrets) {
392
+ await tx.secret.deleteMany({
393
+ where: { stateId: state.id },
394
+ })
395
+ }
195
396
 
196
- await Promise.allSettled([
197
- // commit the state batch
198
- batch.write(),
397
+ // delete custom statuses for this instance
398
+ await tx.instanceCustomStatus.deleteMany({
399
+ where: { stateId: state.id },
400
+ })
199
401
 
200
- // update hotstate
201
- this.hotStateManager.hdel(["instance-states", projectId], instanceId),
402
+ // delete other related resources
403
+ await tx.page.deleteMany({ where: { stateId: state.id } })
404
+ await tx.trigger.deleteMany({ where: { stateId: state.id } })
405
+
406
+ // remove artifact references for this instance
407
+ await tx.instanceState.update({
408
+ where: { id: state.id },
409
+ data: {
410
+ artifacts: {
411
+ set: [],
412
+ },
413
+ },
414
+ })
202
415
 
203
- // publish the instance state deletion event
204
- this.pubsubManager.publish(["instance-state", projectId], { type: "deleted", instanceId }),
416
+ // collect unit instances for Pulumi cleanup (to be done outside transaction)
417
+ if (state.kind === "unit") {
418
+ unitInstancesToCleanup.push({ id: state.id, instanceId: state.instanceId })
419
+ }
205
420
 
206
- // remove all artifact references
207
- this.artifactManager.removeUsages(
421
+ // track this instance as updated
422
+ updatedStateIds.push(state.id)
423
+
424
+ this.logger.info({ projectId }, `marked state "%s" as deleted`, state.id)
425
+
426
+ // recursively handle child instances using parentId
427
+ if (state.kind === "composite") {
428
+ const childStates = await tx.instanceState.findMany({
429
+ where: {
430
+ parentId: state.id,
431
+ status: { not: "undeployed" }, // don't process undeployed children
432
+ },
433
+ select: { id: true, kind: true, instanceId: true },
434
+ })
435
+
436
+ // recursively delete child states (within the same transaction)
437
+ for (const child of childStates) {
438
+ await this.processInstanceDeletion(
439
+ tx,
208
440
  projectId,
209
- unique([
210
- ...Object.values(state?.extra?.ownedArtifactIds ?? []).flat(),
211
- ...(state?.extra?.usedArtifactIds ?? []),
212
- ]),
213
- [{ type: "instance", instanceId }],
214
- ),
215
-
216
- // clear pulumi state
217
- this.runnerBackend.deleteState({ projectId, instanceName, instanceType }),
218
- ])
219
- })
441
+ child,
442
+ options,
443
+ unitInstancesToCleanup,
444
+ updatedStateIds,
445
+ )
446
+ }
447
+ }
220
448
  }
221
449
 
450
+ /**
451
+ * Replaces or adds a custom status for an instance in a project.
452
+ *
453
+ * @param projectId The ID of the project containing the instance.
454
+ * @param serviceAccoundtId The ID of the service account owning the instance.
455
+ * @param stateId The ID of the instance state to update.
456
+ * @param status The custom status to replace or add.
457
+ */
222
458
  async updateCustomStatus(
223
459
  projectId: string,
224
- instanceId: string,
225
- status: InstanceCustomStatus,
460
+ stateId: string,
461
+ serviceAccountId: string,
462
+ status: InstanceCustomStatusInput,
226
463
  ): Promise<void> {
227
- await this.lockManager.acquire(["instance-state", projectId, instanceId], async () => {
228
- const state = await this.hotStateManager.hget(["instance-states", projectId], instanceId)
229
- if (!state) {
230
- throw new Error(
231
- `Instance state with ID "${instanceId}" not found in project "${projectId}"`,
232
- )
233
- }
464
+ const database = await this.database.forProject(projectId)
465
+
466
+ const customStatuses = await database.$transaction(async tx => {
467
+ await tx.instanceCustomStatus.upsert({
468
+ where: {
469
+ stateId_serviceAccountId_name: {
470
+ stateId,
471
+ serviceAccountId,
472
+ name: status.name,
473
+ },
474
+ },
475
+ create: {
476
+ stateId: stateId,
477
+ serviceAccountId,
478
+ name: status.name,
479
+ meta: status.meta,
480
+ value: status.value,
481
+ message: status.message ?? null,
482
+ order: status.order ?? 50,
483
+ },
484
+ update: {
485
+ meta: status.meta,
486
+ value: status.value,
487
+ message: status.message ?? null,
488
+ order: status.order ?? 50,
489
+ },
490
+ })
491
+
492
+ return await tx.instanceCustomStatus.findMany({
493
+ where: { stateId, serviceAccountId },
494
+ orderBy: [{ order: "asc" }, { createdAt: "asc" }],
495
+ })
496
+ })
234
497
 
235
- const existingStatus = state.extra?.customStatuses?.find(s => s.name === status.name)
236
- if (existingStatus) {
237
- Object.assign(existingStatus, status)
238
- } else {
239
- state.extra ??= {}
240
- state.extra.customStatuses ??= []
241
- state.extra.customStatuses?.push(status)
242
- }
498
+ void this.pubsubManager.publish(["instance-state", projectId], {
499
+ type: "patched",
500
+ stateId,
501
+ patch: { customStatuses },
502
+ })
503
+ }
504
+
505
+ /**
506
+ * Removes a custom status from an instance in a project.
507
+ *
508
+ * @param projectId The ID of the project containing the instance.
509
+ * @param stateId The ID of the instance state to update.
510
+ * @param serviceAccountId The ID of the service account owning the instance.
511
+ * @param statusName The name of the custom status to remove.
512
+ */
513
+ async removeCustomStatus(
514
+ projectId: string,
515
+ stateId: string,
516
+ serviceAccountId: string,
517
+ statusName: string,
518
+ ): Promise<void> {
519
+ const database = await this.database.forProject(projectId)
520
+
521
+ const customStatuses = await database.$transaction(async tx => {
522
+ await tx.instanceCustomStatus.deleteMany({
523
+ where: {
524
+ stateId,
525
+ serviceAccountId,
526
+ name: statusName,
527
+ },
528
+ })
529
+
530
+ return await tx.instanceCustomStatus.findMany({
531
+ where: { stateId, serviceAccountId },
532
+ orderBy: [{ order: "asc" }, { createdAt: "asc" }],
533
+ })
534
+ })
243
535
 
244
- await this.applyStatePatch(projectId, state, { id: instanceId, extra: state.extra })
536
+ void this.pubsubManager.publish(["instance-state", projectId], {
537
+ type: "patched",
538
+ stateId,
539
+ patch: { customStatuses },
245
540
  })
246
541
  }
247
542
 
248
- private async applyStatePatch(
543
+ /**
544
+ * Creates the provided operation states.
545
+ * Also updates the instance state if provided.
546
+ *
547
+ * @param projectId The ID of the project containing the instances.
548
+ * @param operationStates The tuples of operation state data to create and instance state patch to apply.
549
+ */
550
+ async createOperationStates(
249
551
  projectId: string,
250
- state: InstanceState | null,
251
- patch: InstanceStatePatch,
252
- persistent = true,
253
- ): Promise<InstanceState> {
254
- const patchedState = applyStatePatch(state, patch)
255
-
256
- const promises: Promise<void>[] = [
257
- this.hotStateManager.hset(["instance-states", projectId], patchedState.id, patchedState),
258
- ]
259
-
260
- const shouldPersist =
261
- persistent &&
262
- (!patchedState.operationStatus ||
263
- isStableOperationStatus(patchedState.operationStatus?.status))
264
-
265
- const repo = this.stateManager.getInstanceStateRepository(projectId)
266
-
267
- if (isStateEmpty(patchedState)) {
268
- promises.push(
269
- this.pubsubManager.publish(["instance-state", projectId], {
270
- type: "deleted",
271
- instanceId: patchedState.id,
552
+ operationStates: [
553
+ opState: InstanceOperationStateCreateManyInput,
554
+ instanceState: InstanceStatePatch,
555
+ ][],
556
+ ): Promise<Partial<InstanceState>[]> {
557
+ const database = await this.database.forProject(projectId)
558
+
559
+ const patches = await database.$transaction(async tx => {
560
+ return await Promise.all(
561
+ operationStates.map(async ([opState, instanceState]) => {
562
+ const operationState = await tx.instanceOperationState.create({
563
+ data: opState,
564
+ })
565
+
566
+ const state = await tx.instanceState.update({
567
+ where: { id: opState.stateId },
568
+ data: instanceState as InstanceStateUpdateInput,
569
+ })
570
+
571
+ return { ...state, lastOperationState: operationState }
272
572
  }),
273
573
  )
574
+ })
575
+
576
+ // publish patches after transaction
577
+ for (const patch of patches) {
578
+ void this.pubsubManager.publish(["instance-state", projectId], {
579
+ type: "patched",
580
+ stateId: patch.id,
581
+ patch,
582
+ })
583
+ }
584
+
585
+ return patches
586
+ }
587
+
588
+ /**
589
+ * Updates the operation state and instance state for a specific instance.
590
+ *
591
+ * @param projectId The ID of the project containing the instance.
592
+ * @param stateId The ID of the instance state to update.
593
+ * @param operationId The ID of the operation (required if operationState is provided).
594
+ * @param options Update options containing operation state, instance state, and unit-specific data.
595
+ * @return The instance state patch containing the updated operation state and instance state fields.
596
+ */
597
+ async updateOperationState(
598
+ projectId: string,
599
+ stateId: string,
600
+ operationId: string,
601
+ options: UpdateOperationStateOptions,
602
+ ): Promise<Partial<InstanceState>> {
603
+ const { operationState, instanceState, unitExtra } = options
604
+ const database = await this.database.forProject(projectId)
605
+
606
+ const result = await database.$transaction(async tx => {
607
+ let unitExtraData = null
608
+
609
+ // update operation state
610
+ const updatedOperationState = await tx.instanceOperationState.update({
611
+ where: {
612
+ operationId_stateId: {
613
+ operationId,
614
+ stateId,
615
+ },
616
+ },
617
+ data: operationState,
618
+ })
619
+
620
+ const project = await this.database.backend.project.findUnique({
621
+ where: { id: projectId },
622
+ select: { libraryId: true },
623
+ })
624
+
625
+ if (!project) {
626
+ throw new ProjectNotFoundError(projectId)
627
+ }
274
628
 
275
- if (shouldPersist) {
276
- promises.push(repo.delete(patchedState.id))
629
+ // update unit-specific data if provided
630
+ if (unitExtra) {
631
+ const [pageIds, terminalIds, triggerIds, secretNames] = await Promise.all([
632
+ this.unitExtraService.processUnitPages(tx, stateId, unitExtra.pages),
633
+ this.unitExtraService.processUnitTerminals(tx, stateId, unitExtra.terminals),
634
+ this.unitExtraService.processUnitTriggers(tx, stateId, unitExtra.triggers),
635
+ this.secretService.updateInstanceSecretsCore(
636
+ tx,
637
+ project.libraryId,
638
+ stateId,
639
+ unitExtra.secrets,
640
+ ),
641
+ this.workerService.updateUnitRegistrations(tx, projectId, stateId, unitExtra.workers),
642
+ ])
643
+
644
+ unitExtraData = { pageIds, terminalIds, triggerIds, secretNames }
277
645
  }
278
- } else {
279
- promises.push(
280
- this.pubsubManager.publish(["instance-state", projectId], { type: "patched", patch }),
281
- )
282
646
 
283
- if (shouldPersist) {
284
- promises.push(repo.putItem(patchedState))
647
+ // update instance state if provided
648
+ if (instanceState) {
649
+ await tx.instanceState.update({
650
+ where: { id: stateId },
651
+ data: instanceState as InstanceStateUpdateInput,
652
+ })
285
653
  }
654
+
655
+ return { updatedOperationState, unitExtraData }
656
+ })
657
+
658
+ // build patch combining operation state, instance state, and unit extra data
659
+ const patch: Partial<InstanceState> = {
660
+ ...instanceState,
661
+ ...result.unitExtraData,
662
+ lastOperationState: result.updatedOperationState,
286
663
  }
287
664
 
288
- await Promise.allSettled(promises)
665
+ // emit the patch after transaction
666
+ void this.pubsubManager.publish(["instance-state", projectId], {
667
+ type: "patched",
668
+ stateId,
669
+ patch,
670
+ })
671
+
672
+ this.logger.debug(
673
+ { projectId, stateId, operationId, options },
674
+ "updated operation state for instance",
675
+ )
289
676
 
290
- return patchedState
677
+ return patch
291
678
  }
292
679
  }