@highstate/backend 0.9.15 → 0.9.16

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 (144) hide show
  1. package/dist/chunk-RCB4AFGD.js +159 -0
  2. package/dist/chunk-RCB4AFGD.js.map +1 -0
  3. package/dist/chunk-WHALQHEZ.js +2017 -0
  4. package/dist/chunk-WHALQHEZ.js.map +1 -0
  5. package/dist/highstate.manifest.json +3 -3
  6. package/dist/index.js +6158 -2178
  7. package/dist/index.js.map +1 -1
  8. package/dist/library/worker/main.js +47 -155
  9. package/dist/library/worker/main.js.map +1 -1
  10. package/dist/shared/index.js +159 -41
  11. package/package.json +25 -7
  12. package/src/artifact/abstractions.ts +46 -0
  13. package/src/artifact/encryption.ts +69 -0
  14. package/src/artifact/factory.ts +36 -0
  15. package/src/artifact/index.ts +3 -0
  16. package/src/artifact/local.ts +142 -0
  17. package/src/business/api-key.ts +65 -0
  18. package/src/business/artifact.ts +288 -0
  19. package/src/business/backend-unlock.ts +10 -0
  20. package/src/business/index.ts +9 -0
  21. package/src/business/instance-lock.ts +124 -0
  22. package/src/business/instance-state.ts +292 -0
  23. package/src/business/operation.ts +251 -0
  24. package/src/business/project-unlock.ts +242 -0
  25. package/src/business/secret.ts +187 -0
  26. package/src/business/worker.ts +161 -0
  27. package/src/common/index.ts +2 -1
  28. package/src/common/performance.ts +44 -0
  29. package/src/common/tree.ts +33 -0
  30. package/src/common/utils.ts +40 -1
  31. package/src/config.ts +14 -10
  32. package/src/hotstate/abstractions.ts +48 -0
  33. package/src/hotstate/factory.ts +17 -0
  34. package/src/{secret → hotstate}/index.ts +1 -0
  35. package/src/hotstate/manager.ts +192 -0
  36. package/src/hotstate/memory.ts +100 -0
  37. package/src/hotstate/validation.ts +101 -0
  38. package/src/index.ts +2 -1
  39. package/src/library/abstractions.ts +10 -23
  40. package/src/library/factory.ts +2 -2
  41. package/src/library/local.ts +89 -102
  42. package/src/library/worker/evaluator.ts +9 -42
  43. package/src/library/worker/loader.lite.ts +41 -0
  44. package/src/library/worker/main.ts +14 -65
  45. package/src/library/worker/protocol.ts +8 -24
  46. package/src/lock/abstractions.ts +6 -0
  47. package/src/lock/factory.ts +15 -0
  48. package/src/{workspace → lock}/index.ts +1 -0
  49. package/src/lock/manager.ts +82 -0
  50. package/src/lock/memory.ts +19 -0
  51. package/src/orchestrator/manager.ts +129 -82
  52. package/src/orchestrator/operation-workset.ts +168 -77
  53. package/src/orchestrator/operation.ts +967 -284
  54. package/src/project/abstractions.ts +20 -7
  55. package/src/project/factory.ts +1 -1
  56. package/src/project/index.ts +0 -1
  57. package/src/project/local.ts +73 -17
  58. package/src/project/manager.ts +272 -131
  59. package/src/pubsub/abstractions.ts +13 -0
  60. package/src/pubsub/factory.ts +19 -0
  61. package/src/pubsub/index.ts +3 -0
  62. package/src/pubsub/local.ts +36 -0
  63. package/src/pubsub/manager.ts +100 -0
  64. package/src/pubsub/validation.ts +33 -0
  65. package/src/runner/abstractions.ts +135 -68
  66. package/src/runner/artifact-env.ts +160 -0
  67. package/src/runner/factory.ts +20 -5
  68. package/src/runner/force-abort.ts +117 -0
  69. package/src/runner/local.ts +281 -371
  70. package/src/{common → runner}/pulumi.ts +86 -37
  71. package/src/services.ts +193 -35
  72. package/src/shared/index.ts +3 -11
  73. package/src/shared/models/backend/index.ts +3 -0
  74. package/src/shared/models/backend/project.ts +63 -0
  75. package/src/shared/models/backend/unlock-method.ts +20 -0
  76. package/src/shared/models/base.ts +151 -0
  77. package/src/shared/models/errors.ts +5 -0
  78. package/src/shared/models/index.ts +4 -0
  79. package/src/shared/models/project/api-key.ts +62 -0
  80. package/src/shared/models/project/artifact.ts +113 -0
  81. package/src/shared/models/project/component.ts +45 -0
  82. package/src/shared/models/project/index.ts +14 -0
  83. package/src/shared/{project.ts → models/project/instance.ts} +12 -0
  84. package/src/shared/models/project/lock.ts +91 -0
  85. package/src/shared/{operation.ts → models/project/operation.ts} +28 -7
  86. package/src/shared/models/project/page.ts +57 -0
  87. package/src/shared/models/project/secret.ts +112 -0
  88. package/src/shared/models/project/service-account.ts +22 -0
  89. package/src/shared/models/project/state.ts +432 -0
  90. package/src/shared/models/project/terminal.ts +99 -0
  91. package/src/shared/models/project/trigger.ts +56 -0
  92. package/src/shared/models/project/unlock-method.ts +31 -0
  93. package/src/shared/models/project/worker.ts +105 -0
  94. package/src/shared/resolvers/graph-resolver.ts +28 -0
  95. package/src/shared/resolvers/index.ts +5 -0
  96. package/src/shared/resolvers/input-hash.ts +53 -15
  97. package/src/shared/resolvers/input.ts +1 -9
  98. package/src/shared/resolvers/registry.ts +3 -2
  99. package/src/shared/resolvers/state.ts +2 -2
  100. package/src/shared/resolvers/validation.ts +61 -20
  101. package/src/shared/{async-batcher.ts → utils/async-batcher.ts} +13 -1
  102. package/src/shared/utils/hash.ts +6 -0
  103. package/src/shared/utils/index.ts +3 -0
  104. package/src/shared/utils/promise-tracker.ts +23 -0
  105. package/src/state/abstractions.ts +330 -101
  106. package/src/state/encryption.ts +59 -0
  107. package/src/state/factory.ts +3 -5
  108. package/src/state/index.ts +3 -0
  109. package/src/state/keyring.ts +22 -0
  110. package/src/state/local/backend.ts +299 -0
  111. package/src/state/local/collection.ts +342 -0
  112. package/src/state/local/index.ts +2 -0
  113. package/src/state/manager.ts +804 -18
  114. package/src/state/repository/index.ts +2 -0
  115. package/src/state/repository/repository.index.ts +193 -0
  116. package/src/state/repository/repository.ts +458 -0
  117. package/src/terminal/{shared.ts → abstractions.ts} +3 -3
  118. package/src/terminal/docker.ts +18 -14
  119. package/src/terminal/factory.ts +3 -3
  120. package/src/terminal/index.ts +1 -1
  121. package/src/terminal/manager.ts +131 -79
  122. package/src/terminal/run.sh.ts +21 -11
  123. package/src/worker/abstractions.ts +42 -0
  124. package/src/worker/docker.ts +83 -0
  125. package/src/worker/factory.ts +20 -0
  126. package/src/worker/index.ts +3 -0
  127. package/src/worker/manager.ts +139 -0
  128. package/dist/chunk-KTGKNSKM.js +0 -979
  129. package/dist/chunk-KTGKNSKM.js.map +0 -1
  130. package/dist/chunk-WXDYCRTT.js +0 -234
  131. package/dist/chunk-WXDYCRTT.js.map +0 -1
  132. package/src/library/worker/loader.ts +0 -114
  133. package/src/preferences/shared.ts +0 -1
  134. package/src/project/lock.ts +0 -39
  135. package/src/secret/abstractions.ts +0 -59
  136. package/src/secret/factory.ts +0 -22
  137. package/src/secret/local.ts +0 -152
  138. package/src/shared/state.ts +0 -247
  139. package/src/shared/terminal.ts +0 -14
  140. package/src/state/local.ts +0 -612
  141. package/src/workspace/abstractions.ts +0 -41
  142. package/src/workspace/factory.ts +0 -14
  143. package/src/workspace/local.ts +0 -54
  144. /package/src/shared/{library.ts → models/backend/library.ts} +0 -0
@@ -0,0 +1,69 @@
1
+ import type { EncryptionBackend, StateManager } from "../state"
2
+ import type { ArtifactBackend } from "./abstractions"
3
+
4
+ const nonceSize = 24
5
+
6
+ export class EncryptionArtifactBackend implements ArtifactBackend {
7
+ constructor(
8
+ private readonly artifactBackend: ArtifactBackend,
9
+ private readonly stateManager: StateManager,
10
+ ) {}
11
+
12
+ async store(
13
+ projectId: string,
14
+ hash: string,
15
+ chunkSize: number,
16
+ content: AsyncIterable<Uint8Array>,
17
+ ): Promise<void> {
18
+ const encryptionBackend = this.stateManager.getEncryptionBackend(projectId)
19
+ const encryptedContent = this.getEncryptedContent(encryptionBackend, content)
20
+
21
+ await this.artifactBackend.store(projectId, hash, chunkSize + nonceSize, encryptedContent)
22
+ }
23
+
24
+ private async *getEncryptedContent(
25
+ encryptionBackend: EncryptionBackend,
26
+ content: AsyncIterable<Uint8Array>,
27
+ ): AsyncIterable<Uint8Array> {
28
+ for await (const chunk of content) {
29
+ yield await encryptionBackend.encrypt(chunk)
30
+ }
31
+ }
32
+
33
+ async retrieve(
34
+ projectId: string,
35
+ hash: string,
36
+ chunkSize: number,
37
+ ): Promise<AsyncIterable<Uint8Array> | null> {
38
+ const encryptionBackend = this.stateManager.getEncryptionBackend(projectId)
39
+
40
+ const encryptedContent = await this.artifactBackend.retrieve(
41
+ projectId,
42
+ hash,
43
+ chunkSize + nonceSize,
44
+ )
45
+
46
+ if (encryptedContent === null) {
47
+ return null
48
+ }
49
+
50
+ return this.getDecryptedContent(encryptionBackend, encryptedContent)
51
+ }
52
+
53
+ private async *getDecryptedContent(
54
+ encryptionBackend: EncryptionBackend,
55
+ encryptedContent: AsyncIterable<Uint8Array>,
56
+ ): AsyncIterable<Uint8Array> {
57
+ for await (const chunk of encryptedContent) {
58
+ yield await encryptionBackend.decrypt(chunk)
59
+ }
60
+ }
61
+
62
+ async delete(projectId: string, hash: string): Promise<void> {
63
+ return await this.artifactBackend.delete(projectId, hash)
64
+ }
65
+
66
+ async exists(projectId: string, hash: string): Promise<boolean> {
67
+ return await this.artifactBackend.exists(projectId, hash)
68
+ }
69
+ }
@@ -0,0 +1,36 @@
1
+ import type { ArtifactBackend } from "./abstractions"
2
+ import type { Logger } from "pino"
3
+ import type { StateManager } from "../state"
4
+ import { z } from "zod"
5
+ import { LocalArtifactBackend, localArtifactBackendConfig } from "./local"
6
+ import { EncryptionArtifactBackend } from "./encryption"
7
+
8
+ export const artifactBackendConfig = z.object({
9
+ HIGHSTATE_ARTIFACT_BACKEND_TYPE: z.enum(["local"]).default("local"),
10
+ HIGHSTATE_ARTIFACT_ENABLE_ENCRYPTION: z.coerce.boolean().default(true),
11
+ ...localArtifactBackendConfig.shape,
12
+ })
13
+
14
+ export async function createArtifactBackend(
15
+ config: z.infer<typeof artifactBackendConfig> & Record<string, unknown>,
16
+ stateManager: StateManager,
17
+ logger: Logger,
18
+ ): Promise<ArtifactBackend> {
19
+ let backend: ArtifactBackend
20
+
21
+ const fileExtension = config.HIGHSTATE_ARTIFACT_ENABLE_ENCRYPTION ? ".enc" : ".tgz"
22
+
23
+ switch (config.HIGHSTATE_ARTIFACT_BACKEND_TYPE) {
24
+ case "local": {
25
+ backend = await LocalArtifactBackend.create(config, fileExtension, stateManager, logger)
26
+ }
27
+ }
28
+
29
+ if (config.HIGHSTATE_ARTIFACT_ENABLE_ENCRYPTION) {
30
+ backend = new EncryptionArtifactBackend(backend, stateManager)
31
+ } else {
32
+ logger.warn("artifact encryption is disabled, this is not recommended")
33
+ }
34
+
35
+ return backend
36
+ }
@@ -0,0 +1,3 @@
1
+ export * from "./abstractions"
2
+ export * from "./factory"
3
+ export * from "../business/artifact"
@@ -0,0 +1,142 @@
1
+ import type { Logger } from "pino"
2
+ import type { StateManager } from "../state"
3
+ import { createReadStream, createWriteStream } from "node:fs"
4
+ import { access, mkdir, readdir, rmdir, unlink } from "node:fs/promises"
5
+ import { join, resolve } from "node:path"
6
+ import { z } from "zod"
7
+ import { type ArtifactBackend } from "./abstractions"
8
+
9
+ export const localArtifactBackendConfig = z.object({
10
+ HIGHSTATE_ARTIFACT_BACKEND_LOCAL_DIR: z.string().optional(),
11
+ })
12
+
13
+ /**
14
+ * A local artifact backend that stores artifacts in the filesystem.
15
+ *
16
+ * The default artifact location is `~/.highstate/artifacts`.
17
+ *
18
+ * File structure:
19
+ * - `{artifactDir}/{first2chars}/{hash}` - actual artifact content files
20
+ */
21
+ export class LocalArtifactBackend implements ArtifactBackend {
22
+ constructor(
23
+ private readonly storageDir: string,
24
+ private readonly fileExtension: string,
25
+ private readonly stateManager: StateManager,
26
+ private readonly logger: Logger,
27
+ ) {
28
+ this.logger.debug({ msg: "initialized", baseDir: storageDir })
29
+ }
30
+
31
+ async store(
32
+ projectId: string,
33
+ hash: string,
34
+ chunkSize: number,
35
+ content: AsyncIterable<Uint8Array>,
36
+ ): Promise<void> {
37
+ const [baseDir, fileName] = this.getArtifactPath(projectId, hash)
38
+ await mkdir(baseDir, { recursive: true })
39
+
40
+ // check if the artifact already exists
41
+ try {
42
+ await access(fileName)
43
+ this.logger.debug({ msg: "artifact already exists", hash })
44
+
45
+ return
46
+ } catch {
47
+ // artifact does not exist, continue with storing
48
+ }
49
+
50
+ const file = createWriteStream(fileName, { highWaterMark: chunkSize })
51
+ this.logger.debug({ msg: "opened file for writing", hash, fileName })
52
+
53
+ for await (const chunk of content) {
54
+ file.write(chunk)
55
+ }
56
+
57
+ file.end()
58
+ this.logger.info({ msg: "artifact stored", hash, fileName })
59
+ }
60
+
61
+ async retrieve(
62
+ projectId: string,
63
+ hash: string,
64
+ chunkSize: number,
65
+ ): Promise<AsyncIterable<Uint8Array> | null> {
66
+ const [, fileName] = this.getArtifactPath(projectId, hash)
67
+
68
+ try {
69
+ return Promise.resolve(createReadStream(fileName, { highWaterMark: chunkSize }))
70
+ } catch (error) {
71
+ this.logger.debug({ msg: "artifact retrieval failed", hash, error })
72
+
73
+ return null
74
+ }
75
+ }
76
+
77
+ async delete(projectId: string, hash: string): Promise<void> {
78
+ const [baseDir, fileName] = this.getArtifactPath(projectId, hash)
79
+
80
+ try {
81
+ await unlink(fileName)
82
+ await this.deleteDirectoryIfEmpty(baseDir)
83
+
84
+ this.logger.info({ msg: "artifact deleted", hash, fileName })
85
+ } catch (error) {
86
+ this.logger.error({ msg: "artifact deletion failed", hash, fileName, error })
87
+ }
88
+ }
89
+
90
+ async deleteDirectoryIfEmpty(dirPath: string): Promise<void> {
91
+ try {
92
+ const files = await readdir(dirPath)
93
+ if (files.length === 0) {
94
+ await rmdir(dirPath)
95
+ this.logger.info({ msg: "deleted empty directory", dirPath })
96
+ } else {
97
+ this.logger.debug({ msg: "directory not empty, skipping deletion", dirPath, files })
98
+ }
99
+ } catch (error) {
100
+ this.logger.error({ msg: "failed to delete directory", dirPath, error })
101
+ }
102
+ }
103
+
104
+ async exists(projectId: string, hash: string): Promise<boolean> {
105
+ const [, fileName] = this.getArtifactPath(projectId, hash)
106
+
107
+ try {
108
+ await access(fileName)
109
+ return true
110
+ } catch {
111
+ return false
112
+ }
113
+ }
114
+
115
+ private getArtifactPath(projectId: string, hash: string): [baseDir: string, fileName: string] {
116
+ const hashedProjectId = this.stateManager.getHashedProjectId(projectId)
117
+ const baseDir = resolve(this.storageDir, hashedProjectId, hash.substring(0, 2))
118
+ const fileName = join(baseDir, `${hash}${this.fileExtension}`)
119
+
120
+ return [baseDir, fileName]
121
+ }
122
+
123
+ static async create(
124
+ config: z.infer<typeof localArtifactBackendConfig>,
125
+ fileExtension: string,
126
+ stateManager: StateManager,
127
+ logger: Logger,
128
+ ): Promise<LocalArtifactBackend> {
129
+ const childLogger = logger.child({
130
+ backend: "ArtifactBackend",
131
+ service: "LocalArtifactBackend",
132
+ })
133
+
134
+ let location = config.HIGHSTATE_ARTIFACT_BACKEND_LOCAL_DIR
135
+ if (!location) {
136
+ location = resolve(process.env.HOME!, ".highstate", "artifacts")
137
+ }
138
+
139
+ await mkdir(location, { recursive: true })
140
+ return new LocalArtifactBackend(location, fileExtension, stateManager, childLogger)
141
+ }
142
+ }
@@ -0,0 +1,65 @@
1
+ import type { LockManager } from "../lock"
2
+ import type { StateManager } from "../state"
3
+ import { randomBytes } from "node:crypto"
4
+ import { AccessError, type ProjectApiKey } from "../shared"
5
+
6
+ export class ApiKeyService {
7
+ constructor(
8
+ private readonly stateManager: StateManager,
9
+ private readonly lockManager: LockManager,
10
+ ) {}
11
+
12
+ /**
13
+ * Regenerates the token for an API key in a project.
14
+ *
15
+ * @param projectId The ID of the project containing the API key.
16
+ * @param apiKeyId The ID of the API key to regenerate.
17
+ */
18
+ async regenerateToken(projectId: string, apiKeyId: string): Promise<ProjectApiKey> {
19
+ return await this.lockManager.acquire([["api-key", projectId, apiKeyId]], async () => {
20
+ const apiKey = await this.stateManager.getApiKeyRepository(projectId).get(apiKeyId)
21
+ if (!apiKey) {
22
+ throw new Error(`API key with ID "${apiKeyId}" not found in project "${projectId}"`)
23
+ }
24
+
25
+ const batch = this.stateManager.batch()
26
+
27
+ // delete the old token from the index
28
+ await this.stateManager
29
+ .getApiKeyTokenIndexRepository(projectId)
30
+ .indexRepository.delete(apiKey.token, batch)
31
+
32
+ // generate a new token
33
+ apiKey.token = randomBytes(32).toString("hex")
34
+
35
+ // update the API key with the new token and update the index
36
+ await Promise.all([
37
+ this.stateManager.getApiKeyRepository(projectId).putItem(apiKey, batch),
38
+ this.stateManager
39
+ .getApiKeyTokenIndexRepository(projectId)
40
+ .indexRepository.put(apiKey.token, apiKey.id, batch),
41
+ ])
42
+
43
+ await batch.write()
44
+
45
+ return apiKey
46
+ })
47
+ }
48
+
49
+ /**
50
+ * Retrieves an API key by its token for a specific project.
51
+ *
52
+ * @param projectId The ID of the project containing the API key.
53
+ * @param token The token of the API key to retrieve.
54
+ * @returns The ProjectApiKey object if found.
55
+ * @throws AccessError if the token is not valid for the project.
56
+ */
57
+ async getApiKeyByToken(projectId: string, token: string): Promise<ProjectApiKey> {
58
+ const apiKey = await this.stateManager.getApiKeyTokenIndexRepository(projectId).get(token)
59
+ if (!apiKey || apiKey.token !== token) {
60
+ throw new AccessError(`Token is not valid for project "${projectId}"`)
61
+ }
62
+
63
+ return apiKey
64
+ }
65
+ }
@@ -0,0 +1,288 @@
1
+ import type { Logger } from "pino"
2
+ import type { StateManager } from "../state"
3
+ import type { ArtifactBackend } from "../artifact/abstractions"
4
+ import type { LockManager } from "../lock"
5
+ import { v7 as uuidv7 } from "uuid"
6
+ import { unique } from "remeda"
7
+ import { compareArtifactUsage, type Artifact, type ArtifactUsage, type ObjectMeta } from "../shared"
8
+
9
+ const artifactChunkSize = 1024 * 1024 // 1 MB
10
+
11
+ /**
12
+ * The service which handles the storage, retrieval, and management of artifacts in the system.
13
+ */
14
+ export class ArtifactService {
15
+ constructor(
16
+ private readonly stateManager: StateManager,
17
+ private readonly artifactBackend: ArtifactBackend,
18
+ private readonly lockManager: LockManager,
19
+ private readonly logger: Logger,
20
+ ) {}
21
+
22
+ /**
23
+ * Stores an artifact in the backend and indexes it in the state manager.
24
+ * If the artifact already exists, it does nothing.
25
+ *
26
+ * Returns the artifact ID of the stored artifact.
27
+ *
28
+ * @param projectId The project ID to store the artifact under.
29
+ * @param hash The unique hash of the artifact content.
30
+ * @param meta The metadata for the artifact, including name, size, and other properties.
31
+ * @param size The total size of the artifact in bytes.
32
+ * @param chunkSize The size of each chunk in bytes.
33
+ * @param content An async iterable providing the artifact content in chunks.
34
+ * @param usages The list of usages for this artifact. Can be provided later.
35
+ */
36
+ async store(
37
+ projectId: string,
38
+ hash: string,
39
+ size: number,
40
+ meta: ObjectMeta,
41
+ content: AsyncIterable<Uint8Array>,
42
+ usages: ArtifactUsage[] = [],
43
+ ): Promise<string> {
44
+ return await this.lockManager.acquire(["artifact-hash", projectId, hash], async () => {
45
+ const existingArtifact = await this.stateManager
46
+ .getArtifactHashIndexRepository(projectId)
47
+ .get(hash)
48
+
49
+ if (existingArtifact) {
50
+ const fileExists = await this.artifactBackend.exists(projectId, hash)
51
+ if (fileExists) {
52
+ this.logger.debug(`artifact with hash "%s" already exists`, hash)
53
+ return existingArtifact.id
54
+ }
55
+
56
+ // TODO: make this check configurable
57
+ this.logger.warn(
58
+ `artifact with hash "%s" exists in index but not in backend, re-uploading`,
59
+ hash,
60
+ )
61
+ }
62
+
63
+ await this.artifactBackend.store(projectId, hash, artifactChunkSize, content)
64
+
65
+ const artifact: Artifact = {
66
+ id: uuidv7(),
67
+ hash,
68
+ size,
69
+ usages,
70
+ meta,
71
+ chunkSize: artifactChunkSize,
72
+ }
73
+
74
+ const batch = this.stateManager.batch()
75
+
76
+ await Promise.all([
77
+ this.stateManager.getArtifactRepository(projectId).putItem(artifact, batch),
78
+ this.stateManager
79
+ .getArtifactHashIndexRepository(projectId)
80
+ .indexRepository.put(hash, artifact.id, batch),
81
+ ])
82
+
83
+ await batch.write()
84
+
85
+ this.logger.info(
86
+ { projectId },
87
+ `stored artifact with hash "%s" and ID "%s"`,
88
+ hash,
89
+ artifact.id,
90
+ )
91
+
92
+ return artifact.id
93
+ })
94
+ }
95
+
96
+ /**
97
+ * Add usages for artifacts in the project.
98
+ *
99
+ * @param projectId The project ID to which the artifacts belong.
100
+ * @param artifactIds The IDs of the artifacts to track usages for.
101
+ * @param usages The list of usages to track for the artifacts.
102
+ * @returns
103
+ */
104
+ async addUsages(
105
+ projectId: string,
106
+ artifactIds: string[],
107
+ usages: ArtifactUsage[],
108
+ ): Promise<void> {
109
+ if (artifactIds.length === 0 || usages.length === 0) {
110
+ return
111
+ }
112
+
113
+ await this.lockManager.acquire(
114
+ artifactIds.map(id => ["artifact", projectId, id] as const),
115
+ () => this._addUsages(projectId, artifactIds, usages),
116
+ )
117
+ }
118
+
119
+ private async _addUsages(
120
+ projectId: string,
121
+ artifactIds: string[],
122
+ usages: ArtifactUsage[],
123
+ ): Promise<void> {
124
+ this.logger.debug({
125
+ msg: "tracking artifact usages",
126
+ projectId,
127
+ artifactIds,
128
+ usages,
129
+ })
130
+
131
+ const artifacts = await this.stateManager
132
+ .getArtifactRepository(projectId)
133
+ .getManyRecord(artifactIds)
134
+
135
+ const artifactsToUpdate: Artifact[] = []
136
+
137
+ for (const artifactId of artifactIds) {
138
+ const artifact = artifacts[artifactId]
139
+ if (!artifact) {
140
+ this.logger.warn({
141
+ msg: "artifact not found during usage addition",
142
+ projectId,
143
+ artifactId,
144
+ })
145
+ continue
146
+ }
147
+
148
+ // add new usages to the artifact
149
+ for (const usage of usages) {
150
+ if (!artifact.usages.some(u => compareArtifactUsage(u, usage))) {
151
+ artifact.usages.push(usage)
152
+ }
153
+ }
154
+
155
+ artifactsToUpdate.push(artifact)
156
+ }
157
+
158
+ await this.stateManager.getArtifactRepository(projectId).putManyItems(artifactsToUpdate)
159
+ }
160
+
161
+ /**
162
+ * Removes usages for artifacts in the project.
163
+ *
164
+ * If an artifact has no usages left, it will be deleted immediately.
165
+ *
166
+ * @param projectId The project ID to which the artifacts belong.
167
+ * @param artifactIds The IDs of the artifacts to remove usages for.
168
+ * @param usages The list of usages to remove from the artifacts.
169
+ */
170
+ async removeUsages(
171
+ projectId: string,
172
+ artifactIds: string[],
173
+ usages: ArtifactUsage[],
174
+ ): Promise<void> {
175
+ if (artifactIds.length === 0 || usages.length === 0) {
176
+ return
177
+ }
178
+
179
+ await this.lockManager.acquire(
180
+ artifactIds.map(id => ["artifact", projectId, id] as const),
181
+ () => this._removeUsages(projectId, artifactIds, usages),
182
+ )
183
+ }
184
+
185
+ private async _removeUsages(
186
+ projectId: string,
187
+ artifactIds: string[],
188
+ usages: ArtifactUsage[],
189
+ ): Promise<void> {
190
+ this.logger.debug({
191
+ msg: "removing artifact usages",
192
+ projectId,
193
+ artifactIds,
194
+ usages,
195
+ })
196
+
197
+ const artifacts = await this.stateManager
198
+ .getArtifactRepository(projectId)
199
+ .getManyRecord(artifactIds)
200
+
201
+ const artifactsToUpdate: Artifact[] = []
202
+ const artifactsToDelete: Artifact[] = []
203
+
204
+ for (const artifactId of artifactIds) {
205
+ const artifact = artifacts[artifactId]
206
+ if (!artifact) {
207
+ this.logger.warn({
208
+ msg: "artifact not found during usage removal",
209
+ projectId,
210
+ artifactId,
211
+ })
212
+ continue
213
+ }
214
+
215
+ // remove specified usages from the artifact
216
+ artifact.usages = artifact.usages.filter(
217
+ u => !usages.some(usage => compareArtifactUsage(u, usage)),
218
+ )
219
+
220
+ if (artifact.usages.length === 0) {
221
+ // if no usages left, mark for deletion
222
+ artifactsToDelete.push(artifact)
223
+ } else {
224
+ // otherwise, update the artifact metadata
225
+ artifactsToUpdate.push(artifact)
226
+ }
227
+ }
228
+
229
+ if (artifactsToUpdate.length > 0) {
230
+ await this.stateManager.getArtifactRepository(projectId).putManyItems(artifactsToUpdate)
231
+ }
232
+
233
+ if (artifactsToDelete.length > 0) {
234
+ await this.removeArtifacts(projectId, artifactsToDelete)
235
+ }
236
+ }
237
+
238
+ /**
239
+ * Updates the usage of artifacts which still have it and removes the usage from artifacts which no longer have it.
240
+ *
241
+ * @param projectId The project ID to which the artifacts belong.
242
+ * @param usage The usage to track for the artifacts.
243
+ * @param oldArtifactIds The IDs of the artifacts that previously had this usage.
244
+ * @param newArtifactIds The IDs of the artifacts that now have this usage.
245
+ */
246
+ async updateUsage(
247
+ projectId: string,
248
+ usage: ArtifactUsage,
249
+ oldArtifactIds: string[],
250
+ newArtifactIds: string[],
251
+ ): Promise<void> {
252
+ const removedArtifactIds = oldArtifactIds.filter(id => !newArtifactIds.includes(id))
253
+ const allArtifactIds = unique([...oldArtifactIds, ...newArtifactIds])
254
+
255
+ await this.lockManager.acquire(
256
+ allArtifactIds.map(id => ["artifact", projectId, id] as const),
257
+ async () => {
258
+ await this._addUsages(projectId, newArtifactIds, [usage])
259
+ await this._removeUsages(projectId, removedArtifactIds, [usage])
260
+ },
261
+ )
262
+ }
263
+
264
+ private async removeArtifacts(projectId: string, artifacts: Artifact[]): Promise<void> {
265
+ const batch = this.stateManager.batch()
266
+
267
+ await Promise.all([
268
+ this.stateManager.getArtifactRepository(projectId).deleteMany(
269
+ artifacts.map(a => a.id),
270
+ batch,
271
+ ),
272
+ this.stateManager.getArtifactHashIndexRepository(projectId).indexRepository.deleteMany(
273
+ artifacts.map(a => a.hash),
274
+ batch,
275
+ ),
276
+ ])
277
+
278
+ await Promise.allSettled(
279
+ artifacts.map(artifact => this.artifactBackend.delete(projectId, artifact.hash)),
280
+ )
281
+
282
+ this.logger.info({
283
+ msg: "deleted dangling artifacts",
284
+ projectId,
285
+ artifactHashes: artifacts.map(a => a.hash),
286
+ })
287
+ }
288
+ }
@@ -0,0 +1,10 @@
1
+ import type { BackendUnlockMethod } from "../shared"
2
+ import type { StateBackend } from "../state/abstractions"
3
+
4
+ export class BackendUnlockService {
5
+ constructor(private readonly stateBackend: StateBackend) {}
6
+
7
+ getUnlockMethods(): Promise<BackendUnlockMethod[]> {}
8
+
9
+ unlock(): Promise<string> {}
10
+ }
@@ -0,0 +1,9 @@
1
+ export * from "./api-key"
2
+ export * from "./artifact"
3
+ export * from "./backend-unlock"
4
+ export * from "./instance-lock"
5
+ export * from "./instance-state"
6
+ export * from "./operation"
7
+ export * from "./project-unlock"
8
+ export * from "./secret"
9
+ export * from "./worker"