@highstate/backend 0.9.14 → 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 +6146 -2174
  7. package/dist/index.js.map +1 -1
  8. package/dist/library/worker/main.js +51 -159
  9. package/dist/library/worker/main.js.map +1 -1
  10. package/dist/shared/index.js +159 -43
  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 +14 -47
  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 +131 -82
  52. package/src/orchestrator/operation-workset.ts +188 -77
  53. package/src/orchestrator/operation.ts +975 -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 -372
  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} +43 -8
  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 +74 -13
  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 +7 -2
  99. package/src/shared/resolvers/state.ts +12 -0
  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 +134 -80
  122. package/src/terminal/run.sh.ts +22 -10
  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-C2TJAQAD.js +0 -937
  129. package/dist/chunk-C2TJAQAD.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 -270
  139. package/src/shared/terminal.ts +0 -13
  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
@@ -1,22 +1,20 @@
1
1
  import type { StateBackend } from "./abstractions"
2
2
  import type { Logger } from "pino"
3
- import type { LocalPulumiHost } from "../common"
4
3
  import { z } from "zod"
5
4
  import { LocalStateBackend, localStateBackendConfig } from "./local"
6
5
 
7
6
  export const stateBackendConfig = z.object({
8
- HIGHSTATE_BACKEND_STATE_TYPE: z.enum(["local"]).default("local"),
7
+ HIGHSTATE_STATE_BACKEND_TYPE: z.enum(["local"]).default("local"),
9
8
  ...localStateBackendConfig.shape,
10
9
  })
11
10
 
12
11
  export function createStateBackend(
13
12
  config: z.infer<typeof stateBackendConfig>,
14
- localPulumiHost: LocalPulumiHost,
15
13
  logger: Logger,
16
14
  ): Promise<StateBackend> {
17
- switch (config.HIGHSTATE_BACKEND_STATE_TYPE) {
15
+ switch (config.HIGHSTATE_STATE_BACKEND_TYPE) {
18
16
  case "local": {
19
- return LocalStateBackend.create(config, localPulumiHost, logger)
17
+ return LocalStateBackend.create(config, logger)
20
18
  }
21
19
  }
22
20
  }
@@ -1,3 +1,6 @@
1
1
  export * from "./abstractions"
2
2
  export * from "./factory"
3
3
  export * from "./manager"
4
+ export * from "./repository"
5
+ export * from "./keyring"
6
+ export * from "./encryption"
@@ -0,0 +1,22 @@
1
+ import type { Logger } from "pino"
2
+ import { AsyncEntry, findCredentialsAsync } from "@napi-rs/keyring"
3
+ import { generateIdentity } from "age-encryption"
4
+
5
+ const serviceName = "io.highstate.backend"
6
+ const accountName = "identity"
7
+
8
+ export async function getOrCreateBackendIdentity(logger: Logger): Promise<string> {
9
+ const credentials = await findCredentialsAsync(serviceName)
10
+ const entry = credentials.find(entry => entry.account === accountName)
11
+
12
+ if (entry) {
13
+ logger.debug("found existing backend identity in keyring")
14
+ return entry.password
15
+ }
16
+
17
+ const newIdentity = await generateIdentity()
18
+ await new AsyncEntry(serviceName, accountName).setPassword(newIdentity)
19
+
20
+ logger.info("created new backend identity and stored it in the OS keyring")
21
+ return newIdentity
22
+ }
@@ -0,0 +1,299 @@
1
+ import type { AbstractLevel } from "abstract-level"
2
+ import type { Logger } from "pino"
3
+ import { resolve } from "node:path"
4
+ import { z } from "zod"
5
+ import {
6
+ CollectionBackend,
7
+ type StateBackend,
8
+ type StateBatch,
9
+ type StateCollection,
10
+ type StateSnapshot,
11
+ } from "../abstractions"
12
+ import { LocalStateCollection } from "./collection"
13
+
14
+ export const localStateBackendConfig = z.object({
15
+ HIGHSTATE_STATE_BACKEND_LOCAL_DIR: z.string().optional(),
16
+ })
17
+
18
+ /**
19
+ * A state backend that stores the state in a local LevelDB database.
20
+ *
21
+ * The default state location is `~/.highstate/state.db`.
22
+ *
23
+ * Storage Structure:
24
+ *
25
+ * ## System Collections
26
+ * - `backend/id` - static id for the backend
27
+ * - `backend/encryptedMasterKey` - encrypted master key for the backend
28
+ * - `backend/projects/{projectId}` - project metadata
29
+ * - `backend/projectMasterKeys/{projectId}` - encrypted master keys for data encryption/decryption
30
+ * - `backend/projectHashNamespaces/{projectId}` - UUID namespaces for ID hashing per project
31
+ * - `backend/projectUnlockSuites/{projectId}` - unlock suites for project unlocking
32
+ *
33
+ * ## Project Collections
34
+ * - `projects/{projectId}/instanceStates/{instanceId}` - instance states
35
+ * - `projects/{projectId}/instanceLocks/{instanceId}` - instance locks for operation coordination
36
+ * - `projects/{projectId}/terminalSessions/{sessionId}` - terminal sessions (sessionId is UUIDv7)
37
+ * - `projects/{projectId}/activeTerminalSessions` - index of active terminal sessions
38
+ * - `projects/{projectId}/instances/{instanceId}/terminalSessions/{sessionId}` - terminal session index per instance
39
+ * - `projects/{projectId}/terminals/{terminalId}` - project terminals
40
+ * - `projects/{projectId}/terminalSpecs/{terminalId}` - terminal spec data
41
+ * - `projects/{projectId}/pages/{pageId}` - project pages
42
+ * - `projects/{projectId}/pageContents/{pageId}` - page content data
43
+ * - `projects/{projectId}/artifacts/{artifactId}` - project artifacts
44
+ * - `projects/{projectId}/artifactHashIndex/{hash}` - artifact hash to ID mapping
45
+ * - `projects/{projectId}/triggers/{triggerId}` - instance triggers
46
+ * - `projects/{projectId}/operations/{operationId}` - project operations
47
+ * - `projects/{projectId}/activeOperations/{operationId}` - index of active operations
48
+ * - `projects/{projectId}/unlockMethods/{methodId}` - unlock methods for the project
49
+ * - `projects/{projectId}/compositeInstances/{instanceId}` - evaluated composite instances
50
+ * - `projects/{projectId}/secrets/{secretId}` - project secrets
51
+ * - `projects/{projectId}/secretContents/{secretId}` - secret content data
52
+ * - `projects/{projectId}/serviceAccounts/{serviceAccountId}` - project service accounts
53
+ * - `projects/{projectId}/apiKeys/{apiKeyId}` - project API keys
54
+ * - `projects/{projectId}/apiKeyTokenIndex/{tokenHash}` - maps API key token hashes to API key IDs
55
+ * - `projects/{projectId}/workers/{workerId}` - project workers
56
+ * - `projects/{projectId}/workerRegistrations/{registrationId}` - worker registrations
57
+ * - `projects/{projectId}/workers/{workerId}/registrations/{registrationId}` - index of registrations per worker
58
+ * - `projects/{projectId}/workers/{workerId}/logs/{logId}` - worker logs (logId is UUIDv7)
59
+ *
60
+ * ## Operation Collections
61
+ * - `projects/{projectId}/operations/{operationId}/logs/{logId}` - operation logs (logId is UUIDv7)
62
+ * - `projects/{projectId}/operations/{operationId}/instances/{instanceId}/logs/{logId}` - instance-specific log index
63
+ *
64
+ * ## Terminal Collections
65
+ * - `projects/{projectId}/terminalSessions/{sessionId}/lines/{lineId}` - terminal session history (lineId is UUIDv7)
66
+ *
67
+ * ## Collection Metadata
68
+ * Each collection sublevel contains a special `_count` key that tracks the total number of documents
69
+ * in that collection for efficient pagination and querying without full iteration.
70
+ */
71
+ export class LocalStateBackend implements StateBackend {
72
+ constructor(private readonly db: AbstractLevel<string, unknown>) {}
73
+
74
+ async getEncryptedBackendMasterKey(): Promise<string | null> {
75
+ const encryptedMasterKey = await this.db.get("backend/encryptedMasterKey")
76
+
77
+ return encryptedMasterKey ?? null
78
+ }
79
+
80
+ async setEncryptedBackendMasterKey(encryptedMasterKey: string): Promise<void> {
81
+ await this.db.put("backend/encryptedMasterKey", encryptedMasterKey)
82
+ }
83
+
84
+ async getStaticBackendId(): Promise<string | null> {
85
+ const ns = await this.db.get("backend/id")
86
+
87
+ return ns ?? null
88
+ }
89
+
90
+ async setStaticBackendId(namespace: string): Promise<void> {
91
+ await this.db.put("backend/id", namespace)
92
+ }
93
+
94
+ createStateBatch(): StateBatch {
95
+ const batch = this.db.batch()
96
+
97
+ return {
98
+ [CollectionBackend]: batch,
99
+ [Symbol.asyncDispose]: () => batch.close(),
100
+ write: () => batch.write(),
101
+ }
102
+ }
103
+
104
+ createStateSnapshot(): StateSnapshot {
105
+ const snapshot = this.db.snapshot()
106
+
107
+ return {
108
+ [CollectionBackend]: snapshot,
109
+ [Symbol.asyncDispose]: () => snapshot.close(),
110
+ }
111
+ }
112
+
113
+ createProjectCollection(): StateCollection {
114
+ return LocalStateCollection.create(this.db, "backend/projects")
115
+ }
116
+
117
+ createProjectMasterKeyCollection(): StateCollection {
118
+ return LocalStateCollection.create(this.db, "backend/projectMasterKeys")
119
+ }
120
+
121
+ createProjectIdHashNamespaceCollection(): StateCollection {
122
+ return LocalStateCollection.create(this.db, "backend/projectHashNamespaces")
123
+ }
124
+
125
+ createProjectUnlockSuiteCollection(): StateCollection {
126
+ return LocalStateCollection.create(this.db, "backend/projectUnlockSuites")
127
+ }
128
+
129
+ createTerminalSessionCollection(projectId: string): StateCollection {
130
+ return LocalStateCollection.create(this.db, `projects/${projectId}/terminalSessions`)
131
+ }
132
+
133
+ createActiveTerminalSessionIndexCollection(projectId: string): StateCollection {
134
+ return LocalStateCollection.create(this.db, `projects/${projectId}/activeTerminalSessions`)
135
+ }
136
+
137
+ createInstanceTerminalSessionIndexCollection(
138
+ projectId: string,
139
+ instanceId: string,
140
+ ): StateCollection {
141
+ return LocalStateCollection.create(
142
+ this.db,
143
+ `projects/${projectId}/instances/${instanceId}/terminalSessions`,
144
+ )
145
+ }
146
+
147
+ createTerminalCollection(projectId: string): StateCollection {
148
+ return LocalStateCollection.create(this.db, `projects/${projectId}/terminals`)
149
+ }
150
+
151
+ createTerminalSpecCollection(projectId: string): StateCollection {
152
+ return LocalStateCollection.create(this.db, `projects/${projectId}/terminalSpecs`)
153
+ }
154
+
155
+ createServiceAccountCollection(projectId: string): StateCollection {
156
+ return LocalStateCollection.create(this.db, `projects/${projectId}/serviceAccounts`)
157
+ }
158
+
159
+ createApiKeyCollection(projectId: string): StateCollection {
160
+ return LocalStateCollection.create(this.db, `projects/${projectId}/apiKeys`)
161
+ }
162
+
163
+ createApiKeyTokenIndexCollection(projectId: string): StateCollection {
164
+ return LocalStateCollection.create(this.db, `projects/${projectId}/apiKeyTokenIndex`)
165
+ }
166
+
167
+ createWorkerCollection(projectId: string): StateCollection {
168
+ return LocalStateCollection.create(this.db, `projects/${projectId}/workers`)
169
+ }
170
+
171
+ createWorkerRegistrationCollection(projectId: string): StateCollection {
172
+ return LocalStateCollection.create(this.db, `projects/${projectId}/workerRegistrations`)
173
+ }
174
+
175
+ createWorkerRegistrationIndexCollection(projectId: string, workerId: string): StateCollection {
176
+ return LocalStateCollection.create(
177
+ this.db,
178
+ `projects/${projectId}/workers/${workerId}/registrations`,
179
+ )
180
+ }
181
+
182
+ createWorkerLogCollection(projectId: string, workerId: string): StateCollection {
183
+ return LocalStateCollection.create(this.db, `projects/${projectId}/workers/${workerId}/logs`)
184
+ }
185
+
186
+ createPageCollection(projectId: string): StateCollection {
187
+ return LocalStateCollection.create(this.db, `projects/${projectId}/pages`)
188
+ }
189
+
190
+ createPageContentCollection(projectId: string): StateCollection {
191
+ return LocalStateCollection.create(this.db, `projects/${projectId}/pageContents`)
192
+ }
193
+
194
+ createArtifactCollection(projectId: string): StateCollection {
195
+ return LocalStateCollection.create(this.db, `projects/${projectId}/artifacts`)
196
+ }
197
+
198
+ createArtifactHashIndexCollection(projectId: string): StateCollection {
199
+ return LocalStateCollection.create(this.db, `projects/${projectId}/artifactHashIndex`)
200
+ }
201
+
202
+ createTriggerCollection(projectId: string): StateCollection {
203
+ return LocalStateCollection.create(this.db, `projects/${projectId}/triggers`)
204
+ }
205
+
206
+ createUnlockMethodCollection(projectId: string): StateCollection {
207
+ return LocalStateCollection.create(this.db, `projects/${projectId}/unlockMethods`)
208
+ }
209
+
210
+ createOperationCollection(projectId: string): StateCollection {
211
+ return LocalStateCollection.create(this.db, `projects/${projectId}/operations`)
212
+ }
213
+
214
+ createActiveOperationIndexCollection(projectId: string): StateCollection {
215
+ return LocalStateCollection.create(this.db, `projects/${projectId}/activeOperations`)
216
+ }
217
+
218
+ createSecretCollection(projectId: string): StateCollection {
219
+ return LocalStateCollection.create(this.db, `projects/${projectId}/secrets`)
220
+ }
221
+
222
+ createSecretContentCollection(projectId: string): StateCollection {
223
+ return LocalStateCollection.create(this.db, `projects/${projectId}/secretContents`)
224
+ }
225
+
226
+ createSecretIndexCollection(projectId: string): StateCollection {
227
+ return LocalStateCollection.create(this.db, `projects/${projectId}/secretIndex`)
228
+ }
229
+
230
+ createOperationLogCollection(projectId: string, operationId: string): StateCollection {
231
+ return LocalStateCollection.create(
232
+ this.db,
233
+ `projects/${projectId}/operations/${operationId}/logs`,
234
+ )
235
+ }
236
+
237
+ createInstanceLogIndexCollection(
238
+ projectId: string,
239
+ operationId: string,
240
+ instanceId: string,
241
+ ): StateCollection {
242
+ return LocalStateCollection.create(
243
+ this.db,
244
+ `projects/${projectId}/operations/${operationId}/instances/${instanceId}/logs`,
245
+ )
246
+ }
247
+
248
+ createTerminalSessionLineCollection(projectId: string, sessionId: string): StateCollection {
249
+ return LocalStateCollection.create(
250
+ this.db,
251
+ `projects/${projectId}/terminalSessions/${sessionId}/lines`,
252
+ )
253
+ }
254
+
255
+ createCompositeInstanceCollection(projectId: string): StateCollection {
256
+ return LocalStateCollection.create(this.db, `projects/${projectId}/compositeInstances`)
257
+ }
258
+
259
+ createInstanceStateCollection(projectId: string): StateCollection {
260
+ return LocalStateCollection.create(this.db, `projects/${projectId}/instanceStates`)
261
+ }
262
+
263
+ createInstanceLockCollection(projectId: string): StateCollection {
264
+ return LocalStateCollection.create(this.db, `projects/${projectId}/instanceLocks`)
265
+ }
266
+
267
+ createUserLayoutCollection(): StateCollection {
268
+ return LocalStateCollection.create(this.db, "backend/userLayouts")
269
+ }
270
+
271
+ createProjectViewportCollection(projectId: string): StateCollection {
272
+ return LocalStateCollection.create(this.db, `projects/${projectId}/viewports`)
273
+ }
274
+
275
+ createInstanceViewportCollection(projectId: string, instanceId: string): StateCollection {
276
+ return LocalStateCollection.create(
277
+ this.db,
278
+ `projects/${projectId}/instances/${instanceId}/viewports`,
279
+ )
280
+ }
281
+
282
+ static async create(
283
+ config: z.infer<typeof localStateBackendConfig>,
284
+ logger: Logger,
285
+ ): Promise<StateBackend> {
286
+ const childLogger = logger.child({ backend: "StateBackend", service: "LocalStateBackend" })
287
+
288
+ let location = config.HIGHSTATE_STATE_BACKEND_LOCAL_DIR
289
+ if (!location) {
290
+ location = resolve(process.env.HOME!, ".highstate", "state.db")
291
+ childLogger.debug({ location }, "using default local state backend location")
292
+ }
293
+
294
+ const { ClassicLevel } = await import("classic-level")
295
+ const db = new ClassicLevel<string, string>(location)
296
+
297
+ return new LocalStateBackend(db as unknown as AbstractLevel<string, unknown>)
298
+ }
299
+ }
@@ -0,0 +1,342 @@
1
+ import type {
2
+ AbstractChainedBatch,
3
+ AbstractIteratorOptions,
4
+ AbstractLevel,
5
+ AbstractSnapshot,
6
+ AbstractSublevel,
7
+ } from "abstract-level"
8
+ import type { CollectionQueryResult } from "../../shared"
9
+ import { BetterLock } from "better-lock"
10
+ import {
11
+ type StateCollection,
12
+ type CollectionQuery,
13
+ type StateSnapshot,
14
+ type StateBatch,
15
+ CollectionBackend,
16
+ } from "../abstractions"
17
+
18
+ type Sublevel = AbstractSublevel<
19
+ AbstractLevel<string, unknown>,
20
+ string | Buffer<ArrayBufferLike> | Uint8Array<ArrayBufferLike>,
21
+ string,
22
+ Uint8Array<ArrayBufferLike>
23
+ >
24
+
25
+ type ChainedBatch = AbstractChainedBatch<AbstractLevel<string, unknown>, string, Uint8Array>
26
+
27
+ function getSnapshot(snapshot: StateSnapshot | undefined) {
28
+ if (snapshot && CollectionBackend in snapshot) {
29
+ return snapshot[CollectionBackend] as AbstractSnapshot
30
+ }
31
+
32
+ return undefined
33
+ }
34
+
35
+ function getBatch(batch: StateBatch | undefined) {
36
+ if (batch && CollectionBackend in batch) {
37
+ return batch[CollectionBackend] as AbstractChainedBatch<
38
+ AbstractLevel<string, unknown>,
39
+ string,
40
+ Uint8Array
41
+ >
42
+ }
43
+
44
+ return undefined
45
+ }
46
+
47
+ function getOrCreateBatch(db: AbstractLevel<string, unknown>, batch?: StateBatch) {
48
+ const explicitBatch = getBatch(batch)
49
+ if (explicitBatch) {
50
+ return { chainedBatch: explicitBatch, explicit: true }
51
+ }
52
+
53
+ const chainedBatch = db.batch() as unknown as AbstractChainedBatch<
54
+ AbstractLevel<string, unknown>,
55
+ string,
56
+ Uint8Array
57
+ >
58
+
59
+ return { chainedBatch, explicit: false }
60
+ }
61
+
62
+ /**
63
+ * LocalStateCollection implements StateCollection using LevelDB sublevel for binary data storage.
64
+ * It operates on raw binary data without any knowledge of the data structure.
65
+ */
66
+ export class LocalStateCollection implements StateCollection {
67
+ private static readonly countKey = "_count"
68
+
69
+ // the lock for operations that modify the collection by first reading the item
70
+ // used to update the count key atomically
71
+ private readonly readWriteLock = new BetterLock()
72
+
73
+ constructor(
74
+ private readonly db: AbstractLevel<string, unknown>,
75
+ private readonly sublevel: Sublevel,
76
+ ) {}
77
+
78
+ async get(key: string, snapshot?: StateSnapshot): Promise<Uint8Array | undefined> {
79
+ return await this.sublevel.get(key, { snapshot: getSnapshot(snapshot) })
80
+ }
81
+
82
+ async getMany(keys: string[], snapshot?: StateSnapshot): Promise<(Uint8Array | undefined)[]> {
83
+ return await this.sublevel.getMany(keys, { snapshot: getSnapshot(snapshot) })
84
+ }
85
+
86
+ async getAll(snapshot?: StateSnapshot): Promise<Array<[string, Uint8Array]>> {
87
+ const result: Array<[string, Uint8Array]> = []
88
+ for await (const [key, value] of this.sublevel.iterator({ snapshot: getSnapshot(snapshot) })) {
89
+ // skip count key
90
+ if (key === LocalStateCollection.countKey) continue
91
+
92
+ result.push([key, value])
93
+ }
94
+
95
+ return result
96
+ }
97
+
98
+ async put(key: string, value: Uint8Array, batch?: StateBatch): Promise<void> {
99
+ await this.readWriteLock.acquire(key, async () => {
100
+ // check if this is a new item
101
+ const existing = await this.sublevel.get(key)
102
+ const isNewItem = !existing
103
+
104
+ const { chainedBatch, explicit } = getOrCreateBatch(this.db, batch)
105
+
106
+ chainedBatch.put(key, value, { sublevel: this.sublevel })
107
+
108
+ // update count if this is a new item
109
+ if (isNewItem) {
110
+ await this.incrementCount(1, chainedBatch)
111
+ }
112
+
113
+ if (!explicit) {
114
+ // if we created a new batch, we need to commit it
115
+ await chainedBatch.write()
116
+ }
117
+ })
118
+ }
119
+
120
+ async putMany(entries: Array<[string, Uint8Array]>, batch?: StateBatch): Promise<void> {
121
+ const keys = entries.map(([key]) => key)
122
+
123
+ await this.readWriteLock.acquire(keys, async () => {
124
+ // check which items are new
125
+ let newItemsCount = 0
126
+
127
+ for (const [key] of entries) {
128
+ const existing = await this.sublevel.get(key)
129
+ if (!existing) {
130
+ newItemsCount++
131
+ }
132
+ }
133
+
134
+ const { chainedBatch, explicit } = getOrCreateBatch(this.db, batch)
135
+
136
+ for (const [key, value] of entries) {
137
+ chainedBatch.put(key, value, { sublevel: this.sublevel })
138
+ }
139
+
140
+ // update count for new items
141
+ if (newItemsCount > 0) {
142
+ await this.incrementCount(newItemsCount, chainedBatch)
143
+ }
144
+
145
+ if (!explicit) {
146
+ // if we created a new batch, we need to commit it
147
+ await chainedBatch.write()
148
+ }
149
+ })
150
+ }
151
+
152
+ async delete(key: string, batch?: StateBatch): Promise<void> {
153
+ await this.readWriteLock.acquire(key, async () => {
154
+ // check if item exists before deleting
155
+ const existing = await this.sublevel.get(key)
156
+
157
+ const { chainedBatch, explicit } = getOrCreateBatch(this.db, batch)
158
+
159
+ chainedBatch.del(key, { sublevel: this.sublevel })
160
+
161
+ // update count if item existed
162
+ if (existing) {
163
+ await this.incrementCount(-1, chainedBatch)
164
+ }
165
+
166
+ if (!explicit) {
167
+ // if we created a new batch, we need to commit it
168
+ await chainedBatch.write()
169
+ }
170
+ })
171
+ }
172
+
173
+ async deleteMany(keys: string[], batch?: StateBatch): Promise<void> {
174
+ await this.readWriteLock.acquire(keys, async () => {
175
+ // check which items exist before deleting
176
+ const existingValues = await this.sublevel.getMany(keys)
177
+ let deletedItemsCount = 0
178
+
179
+ for (const value of existingValues) {
180
+ if (value) {
181
+ deletedItemsCount++
182
+ }
183
+ }
184
+
185
+ const { chainedBatch, explicit } = getOrCreateBatch(this.db, batch)
186
+
187
+ for (const id of keys) {
188
+ chainedBatch.del(id, { sublevel: this.sublevel })
189
+ }
190
+
191
+ // update count for deleted items
192
+ if (deletedItemsCount > 0) {
193
+ await this.incrementCount(-deletedItemsCount, chainedBatch)
194
+ }
195
+
196
+ if (!explicit) {
197
+ // if we created a new batch, we need to commit it
198
+ await chainedBatch.write()
199
+ }
200
+ })
201
+ }
202
+
203
+ async query(
204
+ query: CollectionQuery,
205
+ snapshot?: StateSnapshot,
206
+ ): Promise<CollectionQueryResult<[string, Uint8Array]>> {
207
+ const { cursor, count = 20, sort = "asc" } = query
208
+ const limit = Math.min(count, 100) // cap at 100
209
+
210
+ const items: [string, Uint8Array][] = []
211
+ let nextCursor: string | undefined
212
+
213
+ const iteratorOptions: AbstractIteratorOptions<string, Uint8Array> = {
214
+ limit: limit + 1, // +1 to account "_count" key
215
+ reverse: sort === "desc",
216
+ snapshot: getSnapshot(snapshot),
217
+ }
218
+
219
+ if (cursor && sort === "asc") {
220
+ iteratorOptions.gt = cursor // start after the cursor
221
+ } else if (cursor && sort === "desc") {
222
+ iteratorOptions.lt = cursor // start before the cursor
223
+ }
224
+
225
+ for await (const [key, value] of this.sublevel.iterator(iteratorOptions)) {
226
+ // skip count key
227
+ if (key === LocalStateCollection.countKey) continue
228
+
229
+ items.push([key, value])
230
+
231
+ // if we've collected enough documents, the last key becomes the next cursor
232
+ if (items.length === limit) {
233
+ nextCursor = key
234
+ break
235
+ }
236
+ }
237
+
238
+ // get total count for the collection
239
+ const total = await this.getTotalCount(snapshot)
240
+
241
+ return {
242
+ items,
243
+ total,
244
+ nextCursor,
245
+ }
246
+ }
247
+
248
+ async getTotalCount(snapshot?: StateSnapshot): Promise<number> {
249
+ const countData = await this.sublevel.get(LocalStateCollection.countKey, {
250
+ snapshot: getSnapshot(snapshot),
251
+ })
252
+
253
+ if (!countData) {
254
+ // if count doesn't exist, recalculate it
255
+ return await this.readWriteLock.acquire(LocalStateCollection.countKey, async () => {
256
+ return await this.recalculateCount()
257
+ })
258
+ }
259
+
260
+ // decode the count data and return it as an integer
261
+ // if the data is not a valid number, recalculate it
262
+ const count = new TextDecoder().decode(countData)
263
+ const countValue = parseInt(count, 10)
264
+ if (isNaN(countValue)) {
265
+ return await this.readWriteLock.acquire(LocalStateCollection.countKey, async () => {
266
+ return await this.recalculateCount()
267
+ })
268
+ }
269
+
270
+ return countValue
271
+ }
272
+
273
+ /**
274
+ * Helper method to increment the count by a specific amount.
275
+ *
276
+ * MUST be used within a lock to ensure atomicity.
277
+ */
278
+ private async incrementCount(amount: number, batch: ChainedBatch): Promise<void> {
279
+ await this.readWriteLock.acquire(LocalStateCollection.countKey, async () => {
280
+ const currentCount = await this.sublevel.get(LocalStateCollection.countKey)
281
+ if (!currentCount) {
282
+ await this.recalculateCount()
283
+ return
284
+ }
285
+
286
+ const count = new TextDecoder().decode(currentCount)
287
+ const currentCountValue = parseInt(count, 10)
288
+ if (isNaN(currentCountValue)) {
289
+ await this.recalculateCount()
290
+ return
291
+ }
292
+
293
+ const newCount = currentCountValue + amount
294
+ const countData = new TextEncoder().encode(newCount.toString())
295
+
296
+ batch.put(LocalStateCollection.countKey, countData, { sublevel: this.sublevel })
297
+ })
298
+ }
299
+
300
+ /**
301
+ * Helper method to recalculate the count from scratch by iterating through all keys.
302
+ */
303
+ private async recalculateCount(): Promise<number> {
304
+ // calculate the count by iterating through all keys
305
+ let count = 0
306
+ for await (const key of this.sublevel.keys()) {
307
+ if (key !== LocalStateCollection.countKey) {
308
+ count++
309
+ }
310
+ }
311
+
312
+ // store the calculated count
313
+ const countData = new TextEncoder().encode(count.toString())
314
+ await this.sublevel.put(LocalStateCollection.countKey, countData)
315
+
316
+ return count
317
+ }
318
+
319
+ iterate(snapshot?: StateSnapshot): AsyncIterable<[string, Uint8Array]> {
320
+ const sublevel = this.sublevel
321
+ const snapshotOption = getSnapshot(snapshot)
322
+
323
+ return {
324
+ async *[Symbol.asyncIterator](): AsyncGenerator<[string, Uint8Array], void, unknown> {
325
+ for await (const [key, value] of sublevel.iterator({ snapshot: snapshotOption })) {
326
+ // skip count key
327
+ if (key === LocalStateCollection.countKey) continue
328
+ yield [key, value] as [string, Uint8Array]
329
+ }
330
+ },
331
+ }
332
+ }
333
+
334
+ /**
335
+ * Creates a LocalStateCollection for the specified path using binary encoding.
336
+ */
337
+ static create(db: AbstractLevel<string, unknown>, path: string): LocalStateCollection {
338
+ const sublevel = db.sublevel<string, Uint8Array>(path, { valueEncoding: "buffer" })
339
+
340
+ return new LocalStateCollection(db, sublevel as Sublevel)
341
+ }
342
+ }
@@ -0,0 +1,2 @@
1
+ export { LocalStateCollection } from "./collection"
2
+ export { LocalStateBackend, localStateBackendConfig } from "./backend"