@highstate/backend 0.7.2 → 0.7.3

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 (74) hide show
  1. package/dist/{index.mjs → index.js} +1254 -915
  2. package/dist/library/source-resolution-worker.js +55 -0
  3. package/dist/library/worker/main.js +207 -0
  4. package/dist/{terminal-CqIsctlZ.mjs → library-BW5oPM7V.js} +210 -87
  5. package/dist/shared/index.js +6 -0
  6. package/dist/utils-ByadNcv4.js +102 -0
  7. package/package.json +14 -18
  8. package/src/common/index.ts +3 -0
  9. package/src/common/local.ts +22 -0
  10. package/src/common/pulumi.ts +230 -0
  11. package/src/common/utils.ts +137 -0
  12. package/src/config.ts +40 -0
  13. package/src/index.ts +6 -0
  14. package/src/library/abstractions.ts +83 -0
  15. package/src/library/factory.ts +20 -0
  16. package/src/library/index.ts +2 -0
  17. package/src/library/local.ts +404 -0
  18. package/src/library/source-resolution-worker.ts +96 -0
  19. package/src/library/worker/evaluator.ts +119 -0
  20. package/src/library/worker/loader.ts +93 -0
  21. package/src/library/worker/main.ts +82 -0
  22. package/src/library/worker/protocol.ts +38 -0
  23. package/src/orchestrator/index.ts +1 -0
  24. package/src/orchestrator/manager.ts +165 -0
  25. package/src/orchestrator/operation-workset.ts +483 -0
  26. package/src/orchestrator/operation.ts +647 -0
  27. package/src/preferences/shared.ts +1 -0
  28. package/src/project/abstractions.ts +89 -0
  29. package/src/project/factory.ts +11 -0
  30. package/src/project/index.ts +4 -0
  31. package/src/project/local.ts +412 -0
  32. package/src/project/lock.ts +39 -0
  33. package/src/project/manager.ts +374 -0
  34. package/src/runner/abstractions.ts +146 -0
  35. package/src/runner/factory.ts +22 -0
  36. package/src/runner/index.ts +2 -0
  37. package/src/runner/local.ts +698 -0
  38. package/src/secret/abstractions.ts +59 -0
  39. package/src/secret/factory.ts +22 -0
  40. package/src/secret/index.ts +2 -0
  41. package/src/secret/local.ts +152 -0
  42. package/src/services.ts +133 -0
  43. package/src/shared/index.ts +10 -0
  44. package/src/shared/library.ts +77 -0
  45. package/src/shared/operation.ts +85 -0
  46. package/src/shared/project.ts +62 -0
  47. package/src/shared/resolvers/graph-resolver.ts +111 -0
  48. package/src/shared/resolvers/input-hash.ts +77 -0
  49. package/src/shared/resolvers/input.ts +314 -0
  50. package/src/shared/resolvers/registry.ts +10 -0
  51. package/src/shared/resolvers/validation.ts +94 -0
  52. package/src/shared/state.ts +262 -0
  53. package/src/shared/terminal.ts +13 -0
  54. package/src/state/abstractions.ts +222 -0
  55. package/src/state/factory.ts +22 -0
  56. package/src/state/index.ts +3 -0
  57. package/src/state/local.ts +605 -0
  58. package/src/state/manager.ts +33 -0
  59. package/src/terminal/docker.ts +90 -0
  60. package/src/terminal/factory.ts +20 -0
  61. package/src/terminal/index.ts +3 -0
  62. package/src/terminal/manager.ts +330 -0
  63. package/src/terminal/run.sh.ts +37 -0
  64. package/src/terminal/shared.ts +50 -0
  65. package/src/workspace/abstractions.ts +41 -0
  66. package/src/workspace/factory.ts +14 -0
  67. package/src/workspace/index.ts +2 -0
  68. package/src/workspace/local.ts +54 -0
  69. package/dist/index.d.ts +0 -760
  70. package/dist/library/worker/main.mjs +0 -164
  71. package/dist/runner/source-resolution-worker.mjs +0 -22
  72. package/dist/shared/index.d.ts +0 -85
  73. package/dist/shared/index.mjs +0 -54
  74. package/dist/terminal-Cm2WqcyB.d.ts +0 -1589
@@ -0,0 +1,90 @@
1
+ import type { TerminalRunOptions, TerminalBackend } from "./shared"
2
+ import type { Logger } from "pino"
3
+ import { Readable } from "node:stream"
4
+ import { tmpdir } from "node:os"
5
+ import { resolve } from "node:path"
6
+ import { mkdir, writeFile } from "node:fs/promises"
7
+ import { z } from "zod"
8
+ import spawn from "nano-spawn"
9
+ import { runScript } from "./run.sh"
10
+
11
+ export const dockerTerminalBackendConfig = z.object({
12
+ HIGHSTATE_BACKEND_TERMINAL_DOCKER_BINARY: z.string().default("docker"),
13
+ HIGHSTATE_BACKEND_TERMINAL_DOCKER_USE_SUDO: z.coerce.boolean().default(false),
14
+ HIGHSTATE_BACKEND_TERMINAL_DOCKER_HOST: z.string().optional(),
15
+ })
16
+
17
+ export class DockerTerminalBackend implements TerminalBackend {
18
+ constructor(
19
+ private readonly binary: string,
20
+ private readonly useSudo: boolean,
21
+ private readonly host: string | undefined,
22
+ private readonly logger: Logger,
23
+ ) {}
24
+
25
+ async run({ factory, stdin, stdout, screenSize, signal }: TerminalRunOptions): Promise<void> {
26
+ const hsTempDir = resolve(tmpdir(), "highstate")
27
+ await mkdir(hsTempDir, { recursive: true })
28
+
29
+ const runScriptPath = resolve(hsTempDir, "run.sh")
30
+ await writeFile(runScriptPath, runScript, { mode: 0o755 })
31
+
32
+ const args = [
33
+ "run",
34
+ "-i",
35
+ "-v",
36
+ `${runScriptPath}:/run.sh:ro`,
37
+ factory.image,
38
+ "/bin/bash",
39
+ "/run.sh",
40
+ ]
41
+
42
+ const initData = {
43
+ command: factory.command,
44
+ cwd: factory.cwd,
45
+ env: {
46
+ ...factory.env,
47
+ TERM: "xterm-256color",
48
+ },
49
+ files: factory.files ?? {},
50
+ screenSize,
51
+ }
52
+
53
+ const initDataStream = Readable.from(JSON.stringify(initData) + "\n")
54
+
55
+ if (this.useSudo) {
56
+ args.unshift(this.binary)
57
+ }
58
+
59
+ const process = spawn(this.useSudo ? "sudo" : this.binary, args, {
60
+ env: {
61
+ DOCKER_HOST: this.host,
62
+ },
63
+ signal,
64
+ })
65
+
66
+ const childProcess = await process.nodeChildProcess
67
+
68
+ initDataStream.pipe(childProcess.stdin!, { end: false })
69
+ initDataStream.on("end", () => stdin.pipe(childProcess.stdin!))
70
+
71
+ childProcess.stdout!.pipe(stdout)
72
+ childProcess.stderr!.pipe(stdout)
73
+
74
+ this.logger.info({ pid: childProcess.pid }, "process started")
75
+
76
+ await process
77
+ }
78
+
79
+ static create(
80
+ config: z.infer<typeof dockerTerminalBackendConfig>,
81
+ logger: Logger,
82
+ ): DockerTerminalBackend {
83
+ return new DockerTerminalBackend(
84
+ config.HIGHSTATE_BACKEND_TERMINAL_DOCKER_BINARY,
85
+ config.HIGHSTATE_BACKEND_TERMINAL_DOCKER_USE_SUDO,
86
+ config.HIGHSTATE_BACKEND_TERMINAL_DOCKER_HOST,
87
+ logger.child({ backend: "TerminalBackend", service: "DockerTerminalBackend" }),
88
+ )
89
+ }
90
+ }
@@ -0,0 +1,20 @@
1
+ import type { Logger } from "pino"
2
+ import type { TerminalBackend } from "./shared"
3
+ import { z } from "zod"
4
+ import { DockerTerminalBackend, dockerTerminalBackendConfig } from "./docker"
5
+
6
+ export const terminalBackendConfig = z.object({
7
+ HIGHSTATE_BACKEND_TERMINAL_TYPE: z.enum(["docker"]).default("docker"),
8
+ ...dockerTerminalBackendConfig.shape,
9
+ })
10
+
11
+ export function createTerminalBackend(
12
+ config: z.infer<typeof terminalBackendConfig>,
13
+ logger: Logger,
14
+ ): TerminalBackend {
15
+ switch (config.HIGHSTATE_BACKEND_TERMINAL_TYPE) {
16
+ case "docker": {
17
+ return DockerTerminalBackend.create(config, logger)
18
+ }
19
+ }
20
+ }
@@ -0,0 +1,3 @@
1
+ export * from "./shared"
2
+ export * from "./factory"
3
+ export * from "./manager"
@@ -0,0 +1,330 @@
1
+ import type { ScreenSize, TerminalBackend } from "./shared"
2
+ import type { InstanceTerminal, TerminalSession } from "../shared"
3
+ import type { Logger } from "pino"
4
+ import type { StateBackend, TerminalHistoryEntry } from "../state"
5
+ import type { RunnerBackend } from "../runner"
6
+ import { PassThrough, type Stream, type Writable } from "node:stream"
7
+ import { EventEmitter, on } from "node:events"
8
+ import { parseInstanceId } from "@highstate/contract"
9
+ import { nanoid } from "nanoid"
10
+ import { createAsyncBatcher, isAbortErrorLike } from "../common"
11
+
12
+ type ManagedTerminal = {
13
+ readonly session: TerminalSession
14
+ readonly abortController: AbortController
15
+ readonly stdin: PassThrough
16
+ readonly stdout: PassThrough
17
+
18
+ refCount: number
19
+ started: boolean
20
+ start(screenSize: ScreenSize): void
21
+ }
22
+
23
+ type TerminalEvents = {
24
+ [sessionId: string]: [terminal: TerminalSession]
25
+ }
26
+
27
+ const notAttachedTerminalLifetime = 5 * 60 * 1000 // 5 minutes
28
+
29
+ export class TerminalManager {
30
+ private readonly managedTerminals = new Map<string, ManagedTerminal>()
31
+ private readonly existingSessions = new Map<string, TerminalSession>()
32
+ private readonly terminalEE = new EventEmitter<TerminalEvents>()
33
+
34
+ private constructor(
35
+ private readonly terminalBackend: TerminalBackend,
36
+ private readonly stateBackend: StateBackend,
37
+ private readonly runnerBackend: RunnerBackend,
38
+ private readonly logger: Logger,
39
+ ) {
40
+ void this.markFinishedSessions()
41
+ }
42
+
43
+ public isSessionActive(sessionId: string): boolean {
44
+ return this.managedTerminals.has(sessionId)
45
+ }
46
+
47
+ public async *watchSession(
48
+ projectId: string,
49
+ instanceId: string,
50
+ sessionId: string,
51
+ signal?: AbortSignal,
52
+ ): AsyncIterable<TerminalSession> {
53
+ const managedTerminal = this.managedTerminals.get(sessionId)
54
+
55
+ // if terminal session is not active, just fetch it, return and stop watching
56
+ if (!managedTerminal) {
57
+ const session = await this.stateBackend.getTerminalSession(projectId, instanceId, sessionId)
58
+
59
+ if (!session) {
60
+ throw new Error(`Terminal session "${sessionId}" not found`)
61
+ }
62
+
63
+ yield session
64
+ return
65
+ }
66
+
67
+ yield managedTerminal.session
68
+
69
+ for await (const [terminalSession] of on(this.terminalEE, sessionId, {
70
+ signal,
71
+ }) as AsyncIterable<[TerminalSession]>) {
72
+ yield terminalSession
73
+
74
+ if (terminalSession.finishedAt) {
75
+ // terminal session is finished, stop watching
76
+ return
77
+ }
78
+ }
79
+ }
80
+
81
+ public async createSession(
82
+ projectId: string,
83
+ instanceId: string,
84
+ terminalName: string,
85
+ ): Promise<TerminalSession> {
86
+ const [instanceType, instanceName] = parseInstanceId(instanceId)
87
+
88
+ const factory = await this.runnerBackend.getTerminalFactory(
89
+ { projectId, instanceType, instanceName },
90
+ terminalName,
91
+ )
92
+
93
+ if (!factory) {
94
+ throw new Error(
95
+ `Terminal factory for instance "${instanceId}" with name "${terminalName}" not found`,
96
+ )
97
+ }
98
+
99
+ const terminalSession: TerminalSession = {
100
+ id: nanoid(),
101
+ projectId,
102
+ instanceId,
103
+ terminalName,
104
+ terminalTitle: factory.title,
105
+ createdAt: new Date(),
106
+ }
107
+
108
+ await this.stateBackend.putTerminalSession(projectId, instanceId, terminalSession)
109
+ this.logger.info({ msg: "terminal session created", id: terminalSession.id })
110
+
111
+ this.createManagedTerminal(factory, terminalSession)
112
+
113
+ return terminalSession
114
+ }
115
+
116
+ public async getOrCreateSession(
117
+ projectId: string,
118
+ instanceId: string,
119
+ terminalName: string,
120
+ ): Promise<TerminalSession> {
121
+ const existingSession = this.existingSessions.get(`${projectId}:${instanceId}.${terminalName}`)
122
+
123
+ if (existingSession) {
124
+ return existingSession
125
+ }
126
+
127
+ return await this.createSession(projectId, instanceId, terminalName)
128
+ }
129
+
130
+ public close(sessionId: string): void {
131
+ this.logger.info({ msg: "closing terminal session", id: sessionId })
132
+
133
+ const managedTerminal = this.managedTerminals.get(sessionId)
134
+
135
+ if (managedTerminal) {
136
+ managedTerminal.abortController.abort()
137
+ }
138
+ }
139
+
140
+ public attach(
141
+ sessionId: string,
142
+ stdin: Stream,
143
+ stdout: Writable,
144
+ screenSize: ScreenSize,
145
+ signal: AbortSignal,
146
+ ): void {
147
+ const terminal = this.managedTerminals.get(sessionId)
148
+ if (!terminal) {
149
+ throw new Error(`Terminal session "${sessionId}" not found, check if it's still active`)
150
+ }
151
+
152
+ const handleStdin = (data: unknown) => {
153
+ terminal.stdin.write(data)
154
+ }
155
+
156
+ const handleStdout = (data: unknown) => {
157
+ stdout.write(data)
158
+ }
159
+
160
+ terminal.stdout.on("data", handleStdout)
161
+ stdin.on("data", handleStdin)
162
+
163
+ terminal.refCount += 1
164
+
165
+ this.logger.info(
166
+ "terminal attached (sessionId: %s, refCount: %d)",
167
+ sessionId,
168
+ terminal.refCount,
169
+ )
170
+
171
+ signal.addEventListener("abort", () => {
172
+ terminal.refCount -= 1
173
+ terminal.stdout.off("data", handleStdout)
174
+ stdin.off("data", handleStdin)
175
+
176
+ this.logger.info(
177
+ "terminal detached (sessionId: %s, refCount: %d)",
178
+ sessionId,
179
+ terminal.refCount,
180
+ )
181
+ })
182
+
183
+ if (!terminal.started) {
184
+ terminal.start(screenSize)
185
+ terminal.started = true
186
+ }
187
+ }
188
+
189
+ private async updateActiveTerminalSessions(): Promise<void> {
190
+ await this.stateBackend.putActiveTerminalSessions(
191
+ Array.from(this.managedTerminals.values()).map(t => t.session),
192
+ )
193
+ }
194
+
195
+ private createManagedTerminal(
196
+ factory: InstanceTerminal,
197
+ terminalSession: TerminalSession,
198
+ ): ManagedTerminal {
199
+ const managedTerminal: ManagedTerminal = {
200
+ session: terminalSession,
201
+ abortController: new AbortController(),
202
+ stdin: new PassThrough(),
203
+ stdout: new PassThrough(),
204
+
205
+ refCount: 0,
206
+ started: false,
207
+
208
+ start: (screenSize: ScreenSize) => {
209
+ void this.terminalBackend
210
+ .run({
211
+ factory,
212
+ stdin: managedTerminal.stdin,
213
+ stdout: managedTerminal.stdout,
214
+ screenSize,
215
+ signal: managedTerminal.abortController.signal,
216
+ })
217
+ .catch(error => {
218
+ if (isAbortErrorLike(error)) {
219
+ return
220
+ }
221
+
222
+ this.logger.error({
223
+ msg: "managed terminal failed",
224
+ id: managedTerminal.session.id,
225
+ error: error as unknown,
226
+ })
227
+ })
228
+ .finally(() => {
229
+ this.logger.info({ msg: "managed terminal closed", id: managedTerminal.session.id })
230
+ this.managedTerminals.delete(managedTerminal.session.id)
231
+ this.existingSessions.delete(
232
+ `${managedTerminal.session.projectId}:${managedTerminal.session.instanceId}.${managedTerminal.session.terminalName}`,
233
+ )
234
+
235
+ managedTerminal.session.finishedAt = new Date()
236
+ this.terminalEE.emit(managedTerminal.session.id, managedTerminal.session)
237
+
238
+ void this.stateBackend.putTerminalSession(
239
+ managedTerminal.session.projectId,
240
+ managedTerminal.session.instanceId,
241
+ managedTerminal.session,
242
+ )
243
+
244
+ void this.updateActiveTerminalSessions()
245
+ })
246
+
247
+ setTimeout(
248
+ () => this.closeTerminalIfNotAttached(managedTerminal),
249
+ notAttachedTerminalLifetime,
250
+ )
251
+
252
+ this.logger.info({ msg: "managed terminal created", id: managedTerminal.session.id })
253
+ },
254
+ }
255
+
256
+ managedTerminal.stdout.on("data", data => {
257
+ const entry = String(data)
258
+
259
+ void this.persistHistory.call([terminalSession.id, entry])
260
+ })
261
+
262
+ this.managedTerminals.set(managedTerminal.session.id, managedTerminal)
263
+ this.existingSessions.set(
264
+ `${managedTerminal.session.projectId}:${managedTerminal.session.instanceId}.${managedTerminal.session.terminalName}`,
265
+ managedTerminal.session,
266
+ )
267
+
268
+ void this.updateActiveTerminalSessions()
269
+
270
+ return managedTerminal
271
+ }
272
+
273
+ private closeTerminalIfNotAttached(terminal: ManagedTerminal) {
274
+ if (!this.managedTerminals.has(terminal.session.id)) {
275
+ // Already closed
276
+ return
277
+ }
278
+
279
+ if (terminal.refCount <= 0) {
280
+ this.logger.info({
281
+ msg: "terminal not attached for too long, closing",
282
+ id: terminal.session.id,
283
+ })
284
+
285
+ terminal.abortController.abort()
286
+ this.managedTerminals.delete(terminal.session.id)
287
+ this.existingSessions.delete(
288
+ `${terminal.session.projectId}:${terminal.session.instanceId}.${terminal.session.terminalName}`,
289
+ )
290
+ return
291
+ }
292
+
293
+ setTimeout(() => this.closeTerminalIfNotAttached(terminal), notAttachedTerminalLifetime)
294
+ }
295
+
296
+ static create(
297
+ terminalBackend: TerminalBackend,
298
+ stateBackend: StateBackend,
299
+ runnerBackend: RunnerBackend,
300
+ logger: Logger,
301
+ ): TerminalManager {
302
+ return new TerminalManager(
303
+ terminalBackend,
304
+ stateBackend,
305
+ runnerBackend,
306
+ logger.child({ service: "TerminalManager" }),
307
+ )
308
+ }
309
+
310
+ private persistHistory = createAsyncBatcher(async (entries: TerminalHistoryEntry[]) => {
311
+ this.logger.trace({ msg: "persisting history entries", count: entries.length })
312
+
313
+ // TODO: buffer entries and save them as lines
314
+
315
+ await this.stateBackend.appendTerminalSessionHistory(entries)
316
+ })
317
+
318
+ private async markFinishedSessions(): Promise<void> {
319
+ const sessions = await this.stateBackend.getActiveTerminalSessions()
320
+
321
+ for (const session of sessions) {
322
+ await this.stateBackend.putTerminalSession(session.projectId, session.instanceId, {
323
+ ...session,
324
+ finishedAt: new Date(),
325
+ })
326
+ }
327
+
328
+ await this.updateActiveTerminalSessions()
329
+ }
330
+ }
@@ -0,0 +1,37 @@
1
+ export const runScript = `set -e -o pipefail
2
+ read -r data
3
+
4
+ # Extract env and files as key-value pairs, and command as an array
5
+ envKeys=($(jq -r '.env | keys[]' <<<"$data"))
6
+ filesKeys=($(jq -r '.files | keys[]' <<<"$data"))
7
+ commandArr=($(jq -r '.command[]' <<<"$data"))
8
+ cols=$(jq -r '.screenSize.cols' <<<"$data")
9
+ rows=$(jq -r '.screenSize.rows' <<<"$data")
10
+
11
+ # Set environment variables
12
+ for key in "\${envKeys[@]}"; do
13
+ value=$(jq -r ".env[\\"$key\\"]" <<<"$data")
14
+ export "$key=$value"
15
+ done
16
+
17
+ # Create files
18
+ for key in "\${filesKeys[@]}"; do
19
+ isBinary=$(jq -r ".files[\\"$key\\"].isBinary // false" <<<"$data")
20
+ content=$(jq -r ".files[\\"$key\\"].content" <<<"$data")
21
+ mode=$(jq -r ".files[\\"$key\\"].mode // 0" <<<"$data")
22
+
23
+ if [ "$isBinary" = "true" ]; then
24
+ echo "$content" | base64 -d > "$key"
25
+ else
26
+ echo "$content" > "$key"
27
+ fi
28
+
29
+ if [ "$mode" -ne 0 ]; then
30
+ chmod $(printf "%o" "$mode") "$key"
31
+ fi
32
+ done
33
+
34
+ # Execute the command, keeping stdin/stdout open and spawnin a new TTY
35
+ cmd=$(printf "%q " "\${commandArr[@]}")
36
+ exec script -q -c "stty cols $cols rows $rows; $cmd" /dev/null
37
+ `
@@ -0,0 +1,50 @@
1
+ import type { Stream } from "node:stream"
2
+ import type { InstanceTerminal } from "../shared"
3
+
4
+ export type ScreenSize = {
5
+ /**
6
+ * The number of columns.
7
+ */
8
+ cols: number
9
+
10
+ /**
11
+ * The number of rows.
12
+ */
13
+ rows: number
14
+ }
15
+
16
+ export type TerminalRunOptions = {
17
+ /**
18
+ * The factory to use.
19
+ */
20
+ factory: InstanceTerminal
21
+
22
+ /**
23
+ * The input stream.
24
+ */
25
+ stdin: Stream
26
+
27
+ /**
28
+ * The output stream.
29
+ */
30
+ stdout: NodeJS.WritableStream
31
+
32
+ /**
33
+ * The size of the screen to set before running the terminal.
34
+ */
35
+ screenSize: ScreenSize
36
+
37
+ /**
38
+ * The signal to abort the terminal.
39
+ */
40
+ signal?: AbortSignal
41
+ }
42
+
43
+ export interface TerminalBackend {
44
+ /**
45
+ * Creates a new terminal and runs it.
46
+ *
47
+ * @param options The options.
48
+ */
49
+ run(options: TerminalRunOptions): Promise<void>
50
+ }
@@ -0,0 +1,41 @@
1
+ export interface WorkspaceBackend {
2
+ /**
3
+ * Gets the layout of the workspace.
4
+ */
5
+ getWorkspaceLayout(): Promise<unknown>
6
+
7
+ /**
8
+ * Sets the layout of the workspace.
9
+ */
10
+ setWorkspaceLayout(layout: unknown): Promise<void>
11
+
12
+ /**
13
+ * Gets the viewport of the project.
14
+ *
15
+ * @param projectId The ID of the project.
16
+ */
17
+ getProjectViewport(projectId: string): Promise<unknown>
18
+
19
+ /**
20
+ * Sets the viewport of the project.
21
+ *
22
+ * @param projectId The ID of the project.
23
+ */
24
+ setProjectViewport(projectId: string, viewport: unknown): Promise<void>
25
+
26
+ /**
27
+ * Gets the viewport of the composite instance.
28
+ *
29
+ * @param projectId The ID of the project.
30
+ * @param instanceId The ID of the instance.
31
+ */
32
+ getInstanceViewport(projectId: string, instanceId: string): Promise<unknown>
33
+
34
+ /**
35
+ * Sets the viewport of the composite instance.
36
+ *
37
+ * @param projectId The ID of the project.
38
+ * @param instanceId The ID of the instance.
39
+ */
40
+ setInstanceViewport(projectId: string, instanceId: string, viewport: unknown): Promise<void>
41
+ }
@@ -0,0 +1,14 @@
1
+ import type { WorkspaceBackend } from "./abstractions"
2
+ import { z } from "zod"
3
+ import { LocalWorkspaceBackend, localWorkspaceBackendConfig } from "./local"
4
+
5
+ export const workspaceBackendConfig = z.object({
6
+ HIGHSTATE_BACKEND_WORKSPACE_TYPE: z.enum(["local"]).default("local"),
7
+ ...localWorkspaceBackendConfig.shape,
8
+ })
9
+
10
+ export function createWorkspaceBackend(
11
+ config: z.infer<typeof workspaceBackendConfig>,
12
+ ): Promise<WorkspaceBackend> {
13
+ return LocalWorkspaceBackend.create(config)
14
+ }
@@ -0,0 +1,2 @@
1
+ export * from "./abstractions"
2
+ export * from "./factory"
@@ -0,0 +1,54 @@
1
+ import type { ClassicLevel } from "classic-level"
2
+ import type { WorkspaceBackend } from "./abstractions"
3
+ import { homedir } from "node:os"
4
+ import { resolve } from "node:path"
5
+ import { z } from "zod"
6
+
7
+ export const localWorkspaceBackendConfig = z.object({
8
+ HIGHSTATE_BACKEND_WORKSPACE_LOCAL_DIR: z.string().optional(),
9
+ })
10
+
11
+ export class LocalWorkspaceBackend implements WorkspaceBackend {
12
+ constructor(private readonly db: ClassicLevel<string, unknown>) {}
13
+
14
+ async getWorkspaceLayout(): Promise<unknown> {
15
+ return await this.db.get("layout")
16
+ }
17
+
18
+ async setWorkspaceLayout(layout: unknown): Promise<void> {
19
+ await this.db.put("layout", layout)
20
+ }
21
+
22
+ async getProjectViewport(projectId: string): Promise<unknown> {
23
+ return await this.db.get(`viewports/projects/${projectId}`)
24
+ }
25
+
26
+ async setProjectViewport(projectId: string, viewport: unknown): Promise<void> {
27
+ await this.db.put(`viewports/projects/${projectId}`, viewport)
28
+ }
29
+
30
+ async getInstanceViewport(projectId: string, instanceId: string): Promise<unknown> {
31
+ return await this.db.get(`viewports/instances/${projectId}/${instanceId}`)
32
+ }
33
+
34
+ async setInstanceViewport(
35
+ projectId: string,
36
+ instanceId: string,
37
+ viewport: unknown,
38
+ ): Promise<void> {
39
+ await this.db.put(`viewports/instances/${projectId}/${instanceId}`, viewport)
40
+ }
41
+
42
+ static async create(config: z.infer<typeof localWorkspaceBackendConfig>) {
43
+ let location = config.HIGHSTATE_BACKEND_WORKSPACE_LOCAL_DIR
44
+ if (!location) {
45
+ const home = homedir()
46
+ location = resolve(home, ".highstate/workspace")
47
+ }
48
+
49
+ const { ClassicLevel } = await import("classic-level")
50
+ const db = new ClassicLevel<string, unknown>(location, { valueEncoding: "json" })
51
+
52
+ return new LocalWorkspaceBackend(db)
53
+ }
54
+ }