@highstate/backend 0.9.16 → 0.9.18

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 (125) hide show
  1. package/dist/chunk-NAAIDR4U.js +8499 -0
  2. package/dist/chunk-NAAIDR4U.js.map +1 -0
  3. package/dist/chunk-OU5OQBLB.js +74 -0
  4. package/dist/chunk-OU5OQBLB.js.map +1 -0
  5. package/dist/{chunk-WHALQHEZ.js → chunk-Y7DXREVO.js} +502 -774
  6. package/dist/chunk-Y7DXREVO.js.map +1 -0
  7. package/dist/highstate.manifest.json +4 -4
  8. package/dist/index.js +2979 -2233
  9. package/dist/index.js.map +1 -1
  10. package/dist/library/package-resolution-worker.js +7 -5
  11. package/dist/library/package-resolution-worker.js.map +1 -1
  12. package/dist/library/worker/main.js +40 -41
  13. package/dist/library/worker/main.js.map +1 -1
  14. package/dist/magic-string.es-5ABAC4JN.js +1292 -0
  15. package/dist/magic-string.es-5ABAC4JN.js.map +1 -0
  16. package/dist/shared/index.js +3 -216
  17. package/dist/shared/index.js.map +1 -1
  18. package/package.json +9 -6
  19. package/src/artifact/encryption.ts +47 -7
  20. package/src/artifact/factory.ts +2 -2
  21. package/src/artifact/local.ts +2 -6
  22. package/src/business/__traces__/secret/update-instance-secrets/create-and-delete-secrets-simultaneously.md +356 -0
  23. package/src/business/__traces__/secret/update-instance-secrets/create-new-secrets-for-instance.md +274 -0
  24. package/src/business/__traces__/secret/update-instance-secrets/delete-existing-secrets.md +223 -0
  25. package/src/business/__traces__/secret/update-instance-secrets/no-op-when-no-changes.md +147 -0
  26. package/src/business/__traces__/secret/update-instance-secrets/update-existing-secrets.md +280 -0
  27. package/src/business/__traces__/worker/update-unit-registrations/add-new-unit-registration-when-other-exists.md +360 -0
  28. package/src/business/__traces__/worker/update-unit-registrations/add-new-unit-registration.md +215 -0
  29. package/src/business/__traces__/worker/update-unit-registrations/create-multiple-workers-with-different-identities.md +427 -0
  30. package/src/business/__traces__/worker/update-unit-registrations/handle-nonexistent-registration-id-gracefully.md +217 -0
  31. package/src/business/__traces__/worker/update-unit-registrations/no-op-when-no-changes.md +132 -0
  32. package/src/business/__traces__/worker/update-unit-registrations/recreate-worker-when-image-changes.md +454 -0
  33. package/src/business/__traces__/worker/update-unit-registrations/recreate-worker-when-image-version-changes.md +426 -0
  34. package/src/business/__traces__/worker/update-unit-registrations/recreate-worker-with-same-identity-reuses-service-account.md +372 -0
  35. package/src/business/__traces__/worker/update-unit-registrations/remove-one-of-multiple-unit-registrations.md +383 -0
  36. package/src/business/__traces__/worker/update-unit-registrations/remove-unit-registration.md +245 -0
  37. package/src/business/__traces__/worker/update-unit-registrations/update-existing-unit-registration-when-params-change.md +174 -0
  38. package/src/business/__traces__/worker/update-unit-registrations/update-params-and-image-simultaneously.md +432 -0
  39. package/src/business/__traces__/worker/update-unit-registrations/worker-with-multiple-registrations-not-deleted-when-one-removed.md +220 -0
  40. package/src/business/artifact.ts +2 -1
  41. package/src/business/index.ts +1 -0
  42. package/src/business/instance-lock.ts +3 -2
  43. package/src/business/instance-state.ts +202 -60
  44. package/src/business/project-unlock.ts +41 -23
  45. package/src/business/project.ts +299 -0
  46. package/src/business/secret.test.ts +178 -0
  47. package/src/business/secret.ts +139 -45
  48. package/src/business/worker.test.ts +614 -0
  49. package/src/business/worker.ts +289 -52
  50. package/src/common/clock.ts +18 -0
  51. package/src/common/index.ts +3 -0
  52. package/src/common/random.ts +68 -0
  53. package/src/common/test/index.ts +2 -0
  54. package/src/common/test/render.ts +98 -0
  55. package/src/common/test/tracer.ts +359 -0
  56. package/src/config.ts +5 -1
  57. package/src/hotstate/manager.ts +8 -8
  58. package/src/hotstate/validation.ts +0 -1
  59. package/src/library/abstractions.ts +20 -11
  60. package/src/library/local.ts +6 -13
  61. package/src/library/worker/evaluator.ts +30 -34
  62. package/src/library/worker/loader.lite.ts +13 -0
  63. package/src/library/worker/main.ts +8 -8
  64. package/src/library/worker/protocol.ts +0 -11
  65. package/src/lock/index.ts +1 -0
  66. package/src/lock/manager.ts +17 -2
  67. package/src/lock/test.ts +108 -0
  68. package/src/orchestrator/manager.ts +17 -36
  69. package/src/orchestrator/operation-workset.ts +34 -37
  70. package/src/orchestrator/operation.ts +129 -74
  71. package/src/project/abstractions.ts +27 -51
  72. package/src/project/evaluation.ts +248 -0
  73. package/src/project/index.ts +1 -1
  74. package/src/project/local.ts +75 -127
  75. package/src/pubsub/manager.ts +21 -13
  76. package/src/runner/abstractions.ts +29 -9
  77. package/src/runner/artifact-env.ts +3 -3
  78. package/src/runner/local.ts +29 -19
  79. package/src/runner/pulumi.ts +4 -1
  80. package/src/services.ts +77 -24
  81. package/src/shared/models/backend/library.ts +4 -4
  82. package/src/shared/models/backend/project.ts +25 -6
  83. package/src/shared/models/backend/unlock-method.ts +1 -1
  84. package/src/shared/models/base.ts +1 -84
  85. package/src/shared/models/project/api-key.ts +5 -2
  86. package/src/shared/models/project/artifact.ts +3 -33
  87. package/src/shared/models/project/index.ts +1 -2
  88. package/src/shared/models/project/lock.ts +3 -3
  89. package/src/shared/models/project/model.ts +14 -0
  90. package/src/shared/models/project/operation.ts +3 -3
  91. package/src/shared/models/project/page.ts +3 -3
  92. package/src/shared/models/project/secret.ts +4 -18
  93. package/src/shared/models/project/service-account.ts +2 -2
  94. package/src/shared/models/project/state.ts +32 -15
  95. package/src/shared/models/project/terminal.ts +4 -5
  96. package/src/shared/models/project/trigger.ts +1 -1
  97. package/src/shared/models/project/unlock-method.ts +9 -2
  98. package/src/shared/models/project/worker.ts +9 -7
  99. package/src/shared/resolvers/graph-resolver.ts +41 -26
  100. package/src/shared/resolvers/input.ts +47 -5
  101. package/src/shared/resolvers/validation.ts +23 -7
  102. package/src/shared/utils/args.ts +25 -0
  103. package/src/shared/utils/index.ts +1 -0
  104. package/src/state/abstractions.ts +98 -259
  105. package/src/state/encryption.ts +39 -0
  106. package/src/state/index.ts +1 -0
  107. package/src/state/local/backend.ts +29 -222
  108. package/src/state/local/collection.ts +105 -86
  109. package/src/state/manager.ts +358 -287
  110. package/src/state/memory/backend.ts +70 -0
  111. package/src/state/memory/collection.ts +270 -0
  112. package/src/state/memory/index.ts +2 -0
  113. package/src/state/repository/repository.index.ts +1 -1
  114. package/src/state/repository/repository.ts +71 -22
  115. package/src/state/test.ts +457 -0
  116. package/src/unlock/abstractions.ts +49 -0
  117. package/src/unlock/index.ts +2 -0
  118. package/src/unlock/memory.ts +32 -0
  119. package/src/worker/manager.ts +28 -0
  120. package/dist/chunk-RCB4AFGD.js +0 -159
  121. package/dist/chunk-RCB4AFGD.js.map +0 -1
  122. package/dist/chunk-WHALQHEZ.js.map +0 -1
  123. package/src/project/manager.ts +0 -574
  124. package/src/shared/models/project/component.ts +0 -45
  125. package/src/shared/models/project/instance.ts +0 -74
@@ -1,12 +1,9 @@
1
1
  import type { Logger } from "pino"
2
2
  import type { LockManager } from "../lock"
3
3
  import type { WorkerManager } from "../worker"
4
- import type { InstanceStateService } from "./instance-state"
5
- import { randomBytes } from "node:crypto"
6
- import { v7 as uuidv7 } from "uuid"
4
+ import type { RandomProvider } from "../common"
7
5
  import {
8
6
  getWorkerIdentity,
9
- type InstanceState,
10
7
  type ProjectApiKey,
11
8
  type ServiceAccount,
12
9
  type UnitWorker,
@@ -20,66 +17,186 @@ export class WorkerService {
20
17
  private readonly stateManager: StateManager,
21
18
  private readonly workerManager: WorkerManager,
22
19
  private readonly lockManager: LockManager,
23
- private readonly instanceStateService: InstanceStateService,
20
+ private readonly random: RandomProvider,
24
21
  private readonly logger: Logger,
25
22
  ) {}
26
23
 
27
- async handleUnitWorker(
24
+ /**
25
+ * Updates the worker registrations for the given project and instance.
26
+ * It creates new registrations for each unit worker, updates existing ones,
27
+ * and deletes registrations that are no longer present.
28
+ *
29
+ * @param projectId The ID of the project.
30
+ * @param instanceId The ID of the instance.
31
+ * @param existingRegistrationIds A mapping of unit worker names to existing registration IDs.
32
+ * @param unitWorkers The list of unit workers to register.
33
+ * @returns A new mapping of unit worker names to their registration IDs.
34
+ */
35
+ async updateUnitRegistrations(
28
36
  projectId: string,
29
- state: InstanceState,
30
- unitWorker: UnitWorker,
31
- ): Promise<void> {
32
- await this.lockManager.acquire(["worker-image", projectId, unitWorker.image], async () => {
37
+ instanceId: string,
38
+ existingRegistrationIds: Record<string, string>,
39
+ unitWorkers: UnitWorker[],
40
+ ): Promise<Record<string, string>> {
41
+ // we will lock on the worker images to ensure that
42
+ // concurrent units cannot create duplicate workers or leave dangling worker resources when removing them
43
+ // we will not lock on the registrations
44
+ // since it they are only updated within an operation
45
+ const lockKeys = unitWorkers.map(
46
+ unitWorker => ["worker-image", projectId, unitWorker.image] as const,
47
+ )
48
+
49
+ const newRegistrationIds: Record<string, string> = {}
50
+
51
+ return await this.lockManager.acquire(lockKeys, async () => {
33
52
  const existingRegistrations = await this.stateManager
34
53
  .getWorkerRegistrationRepository(projectId)
35
- .getManyItems(state.extra?.workerRegistrationIds ?? [])
54
+ .getManyRecord(Object.values(existingRegistrationIds))
36
55
 
37
- const existingReg = existingRegistrations.find(reg => reg.image === unitWorker.image)
38
- if (existingReg && JSON.stringify(existingReg.params) === JSON.stringify(unitWorker.params)) {
39
- this.logger.debug(
40
- `instance "%s" already registered with worker "%s" with the same parameters`,
41
- state.id,
42
- unitWorker.image,
43
- )
44
- return
56
+ const nameToExistingRegistration = new Map<string, WorkerUnitRegistration>()
57
+ for (const [name, registrationId] of Object.entries(existingRegistrationIds)) {
58
+ const existingRegistration = existingRegistrations[registrationId]
59
+ if (existingRegistration) {
60
+ nameToExistingRegistration.set(name, existingRegistration)
61
+ }
45
62
  }
46
63
 
47
- let workerId = existingReg?.workerId
48
- if (!workerId) {
49
- workerId = await this.ensureWorkerCreated(projectId, unitWorker)
50
- }
64
+ const batch = this.stateManager.batch()
51
65
 
52
- const registration: WorkerUnitRegistration = {
53
- id: existingReg?.id ?? uuidv7(),
54
- meta: existingReg?.meta ?? {},
55
- image: unitWorker.image,
56
- params: unitWorker.params,
57
- instanceId: state.id,
58
- workerId,
59
- }
66
+ // create or update registrations for each unit worker
67
+ for (const unitWorker of unitWorkers) {
68
+ const workerId = await this.ensureWorkerCreated(projectId, unitWorker)
69
+ const existingRegistration = nameToExistingRegistration.get(unitWorker.name)
60
70
 
61
- const batch = this.stateManager.batch()
71
+ if (
72
+ existingRegistration &&
73
+ existingRegistration.workerId === workerId &&
74
+ JSON.stringify(existingRegistration.params) === JSON.stringify(unitWorker.params)
75
+ ) {
76
+ // no changes
77
+ newRegistrationIds[unitWorker.name] = existingRegistration.id
78
+ continue
79
+ }
62
80
 
63
- await this.stateManager
64
- .getWorkerRegistrationRepository(projectId)
65
- .putItem(registration, batch)
81
+ const registration: WorkerUnitRegistration = {
82
+ id: existingRegistration?.id ?? this.random.uuidv7(),
83
+ meta: existingRegistration?.meta ?? {},
84
+ image: unitWorker.image,
85
+ params: unitWorker.params,
86
+ instanceId,
87
+ workerId,
88
+ }
89
+
90
+ const isNewRegistration = !existingRegistration
91
+ const isWorkerChange = existingRegistration?.workerId !== workerId
92
+ const isParamsChange =
93
+ existingRegistration &&
94
+ JSON.stringify(existingRegistration.params) !== JSON.stringify(unitWorker.params)
95
+
96
+ if (isNewRegistration) {
97
+ this.logger.info(
98
+ {
99
+ projectId,
100
+ registrationId: registration.id,
101
+ unitWorkerName: unitWorker.name,
102
+ workerId,
103
+ image: unitWorker.image,
104
+ },
105
+ `creating worker registration "%s" for unit worker "%s" in project "%s"`,
106
+ registration.id,
107
+ unitWorker.name,
108
+ projectId,
109
+ )
110
+ } else if (isWorkerChange) {
111
+ this.logger.info(
112
+ {
113
+ projectId,
114
+ registrationId: registration.id,
115
+ unitWorkerName: unitWorker.name,
116
+ oldWorkerId: existingRegistration?.workerId,
117
+ newWorkerId: workerId,
118
+ oldImage: existingRegistration?.image,
119
+ newImage: unitWorker.image,
120
+ },
121
+ `updating worker registration "%s" for unit worker "%s" in project "%s" (worker changed from "%s" to "%s")`,
122
+ registration.id,
123
+ unitWorker.name,
124
+ projectId,
125
+ existingRegistration?.workerId,
126
+ workerId,
127
+ )
128
+ } else if (isParamsChange) {
129
+ this.logger.info(
130
+ {
131
+ projectId,
132
+ registrationId: registration.id,
133
+ unitWorkerName: unitWorker.name,
134
+ workerId,
135
+ },
136
+ `updating worker registration "%s" for unit worker "%s" in project "%s" (params changed)`,
137
+ registration.id,
138
+ unitWorker.name,
139
+ projectId,
140
+ )
141
+ }
66
142
 
67
- if (!existingReg) {
68
- // update the index if this is a new registration
69
143
  await this.stateManager
70
- .getWorkerRegistrationIndexRepository(projectId, workerId)
71
- .indexRepository.put(registration.id, SAME_KEY, batch)
72
-
73
- // update the instance state with the new registration ID
74
- await this.instanceStateService.patchInstanceState(projectId, {
75
- id: state.id,
76
- extra: {
77
- workerRegistrationIds: [...(state.extra?.workerRegistrationIds ?? []), registration.id],
144
+ .getWorkerRegistrationRepository(projectId)
145
+ .putItem(registration, batch)
146
+
147
+ // update the registration indexes for new and old workers
148
+ if (existingRegistration?.workerId !== workerId) {
149
+ await this.stateManager
150
+ .getWorkerRegistrationIndexRepository(projectId, workerId)
151
+ .indexRepository.put(registration.id, SAME_KEY)
152
+
153
+ if (existingRegistration?.workerId) {
154
+ await this.stateManager
155
+ .getWorkerRegistrationIndexRepository(projectId, existingRegistration.workerId)
156
+ .indexRepository.delete(registration.id)
157
+
158
+ // check if the old worker should be deleted
159
+ await this.deleteWorkerIfHasNoRegistrations(projectId, existingRegistration.workerId)
160
+ }
161
+ }
162
+
163
+ newRegistrationIds[unitWorker.name] = registration.id
164
+ }
165
+
166
+ // delete registrations that are no longer present
167
+ for (const [name, existingRegistration] of nameToExistingRegistration.entries()) {
168
+ if (unitWorkers.some(unitWorker => unitWorker.name === name)) {
169
+ continue
170
+ }
171
+
172
+ this.logger.info(
173
+ {
174
+ projectId,
175
+ registrationId: existingRegistration.id,
176
+ unitWorkerName: name,
177
+ workerId: existingRegistration.workerId,
78
178
  },
79
- })
179
+ `deleting worker registration "%s" for unit worker "%s" in project "%s" (unit worker no longer present)`,
180
+ existingRegistration.id,
181
+ name,
182
+ projectId,
183
+ )
184
+
185
+ await this.stateManager
186
+ .getWorkerRegistrationRepository(projectId)
187
+ .delete(existingRegistration.id, batch)
188
+
189
+ await this.stateManager
190
+ .getWorkerRegistrationIndexRepository(projectId, existingRegistration.workerId)
191
+ .indexRepository.delete(existingRegistration.id)
192
+
193
+ // ensure the worker is deleted if it has no registrations left
194
+ await this.deleteWorkerIfHasNoRegistrations(projectId, existingRegistration.workerId)
80
195
  }
81
196
 
82
197
  await batch.write()
198
+
199
+ return newRegistrationIds
83
200
  })
84
201
  }
85
202
 
@@ -92,10 +209,22 @@ export class WorkerService {
92
209
 
93
210
  const existingWorker = workers.find(worker => worker.image === unitWorker.image)
94
211
  if (existingWorker) {
95
- this.logger.debug(`worker with image "%s" already exists, reusing it`, unitWorker.image)
212
+ this.logger.debug(
213
+ { projectId, workerId: existingWorker.id, image: unitWorker.image },
214
+ `worker with image "%s" already exists, reusing it`,
215
+ unitWorker.image,
216
+ )
96
217
  return existingWorker.id
97
218
  }
98
219
 
220
+ this.logger.info(
221
+ { projectId, image: unitWorker.image, identity },
222
+ `creating new worker for image "%s" with identity "%s" in project "%s"`,
223
+ unitWorker.image,
224
+ identity,
225
+ projectId,
226
+ )
227
+
99
228
  let serviceAccountId: string | undefined
100
229
 
101
230
  const siblingWorker = workers.find(worker => worker.identity === identity)
@@ -114,20 +243,29 @@ export class WorkerService {
114
243
  // the meta of the account will be updated by the worker itself
115
244
  if (!serviceAccountId) {
116
245
  const serviceAccount: ServiceAccount = {
117
- id: uuidv7(),
246
+ id: this.random.uuidv7(),
118
247
  meta: {},
119
248
  }
120
249
 
121
250
  serviceAccountId = serviceAccount.id
251
+
252
+ this.logger.info(
253
+ { projectId, serviceAccountId, identity },
254
+ `creating service account "%s" for worker identity "%s" in project "%s"`,
255
+ serviceAccountId,
256
+ identity,
257
+ projectId,
258
+ )
259
+
122
260
  await this.stateManager
123
261
  .getServiceAccountRepository(projectId)
124
262
  .putItem(serviceAccount, batch)
125
263
  }
126
264
 
127
265
  const apiKey: ProjectApiKey = {
128
- id: uuidv7(),
266
+ id: this.random.uuidv7(),
129
267
  meta: {},
130
- token: randomBytes(32).toString("hex"),
268
+ token: Buffer.from(this.random.bytes(32)).toString("hex"),
131
269
  scopes: [
132
270
  {
133
271
  type: "service-account",
@@ -140,7 +278,7 @@ export class WorkerService {
140
278
  await this.stateManager.getApiKeyRepository(projectId).putItem(apiKey, batch)
141
279
 
142
280
  const worker: Worker = {
143
- id: uuidv7(),
281
+ id: this.random.uuidv7(),
144
282
  meta: {},
145
283
  status: "starting",
146
284
  failedStartAttempts: 5,
@@ -150,12 +288,111 @@ export class WorkerService {
150
288
  apiKeyId: apiKey.id,
151
289
  }
152
290
 
291
+ this.logger.info(
292
+ { projectId, workerId: worker.id, image: unitWorker.image, identity, serviceAccountId },
293
+ `creating worker "%s" for image "%s" in project "%s"`,
294
+ worker.id,
295
+ unitWorker.image,
296
+ projectId,
297
+ )
298
+
153
299
  await this.stateManager.getWorkerRepository(projectId).putItem(worker, batch)
154
300
  await batch.write()
155
301
 
156
- this.workerManager.startWorker(projectId, worker)
302
+ void this.workerManager.startWorker(projectId, worker.id)
157
303
 
158
304
  return worker.id
159
305
  })
160
306
  }
307
+
308
+ private async deleteWorkerIfHasNoRegistrations(
309
+ projectId: string,
310
+ workerId: string,
311
+ ): Promise<void> {
312
+ await this.lockManager.acquire(["worker", workerId], async () => {
313
+ const registrations = await this.stateManager
314
+ .getWorkerRegistrationIndexRepository(projectId, workerId)
315
+ .getAllItems()
316
+
317
+ if (registrations.length > 0) {
318
+ // still has registrations, no need to delete
319
+ this.logger.debug(
320
+ { projectId, workerId, registrationCount: registrations.length },
321
+ `worker "%s" still has %d registrations, not deleting`,
322
+ workerId,
323
+ registrations.length,
324
+ )
325
+ return
326
+ }
327
+
328
+ const worker = await this.stateManager.getWorkerRepository(projectId).get(workerId)
329
+
330
+ if (!worker) {
331
+ this.logger.warn(
332
+ { projectId, workerId },
333
+ `worker "%s" not found in project "%s" while deleting`,
334
+ workerId,
335
+ projectId,
336
+ )
337
+ return
338
+ }
339
+
340
+ const batch = this.stateManager.batch()
341
+
342
+ this.logger.info(
343
+ { projectId, workerId, image: worker.image, identity: worker.identity },
344
+ `deleting worker "%s" with image "%s" in project "%s" (no registrations remaining)`,
345
+ workerId,
346
+ worker.image,
347
+ projectId,
348
+ )
349
+
350
+ await this.stateManager.getWorkerRepository(projectId).delete(workerId, batch)
351
+ await this.stateManager.getApiKeyRepository(projectId).delete(worker.apiKeyId, batch)
352
+
353
+ const workers = await this.stateManager.getWorkerRepository(projectId).getAllItems()
354
+ const hasSiblingWorker = workers.some(
355
+ siblingWorker =>
356
+ siblingWorker.identity === worker.identity && siblingWorker.id !== workerId,
357
+ )
358
+
359
+ if (!hasSiblingWorker) {
360
+ this.logger.info(
361
+ {
362
+ projectId,
363
+ workerId,
364
+ serviceAccountId: worker.serviceAccountId,
365
+ identity: worker.identity,
366
+ },
367
+ `deleting service account "%s" for worker "%s" in project "%s" (no sibling workers remaining)`,
368
+ worker.serviceAccountId,
369
+ workerId,
370
+ projectId,
371
+ )
372
+
373
+ await this.stateManager
374
+ .getServiceAccountRepository(projectId)
375
+ .delete(worker.serviceAccountId, batch)
376
+ } else {
377
+ this.logger.debug(
378
+ {
379
+ projectId,
380
+ workerId,
381
+ serviceAccountId: worker.serviceAccountId,
382
+ identity: worker.identity,
383
+ },
384
+ `not deleting service account "%s" for worker "%s" in project "%s" (has sibling workers)`,
385
+ worker.serviceAccountId,
386
+ workerId,
387
+ projectId,
388
+ )
389
+ }
390
+
391
+ // for now, we will keep the service account even if the last sibling worker is deleted
392
+ await batch.write()
393
+
394
+ // stop the worker and clear logs after it
395
+ this.workerManager.stopWorker(projectId, workerId)
396
+ })
397
+ }
161
398
  }
@@ -0,0 +1,18 @@
1
+ export interface ClockProvider {
2
+ /**
3
+ * Returns the current time in milliseconds since the Unix epoch.
4
+ */
5
+ now(): number
6
+ }
7
+
8
+ export class SystemClockProvider implements ClockProvider {
9
+ now(): number {
10
+ return Date.now()
11
+ }
12
+ }
13
+
14
+ export class ReproducibleClockProvider implements ClockProvider {
15
+ now(): number {
16
+ return 0
17
+ }
18
+ }
@@ -2,3 +2,6 @@ export * from "./utils"
2
2
  export * from "./local"
3
3
  export * from "./tree"
4
4
  export * from "./performance"
5
+ export * from "./test"
6
+ export * from "./clock"
7
+ export * from "./random"
@@ -0,0 +1,68 @@
1
+ import { randomBytes, randomUUID } from "node:crypto"
2
+ import { v4 as uuidv4, v7 as uuidv7 } from "uuid"
3
+
4
+ export interface RandomProvider {
5
+ /**
6
+ * Generates a random string of the specified length.
7
+ *
8
+ * @param length The length of the random string to generate.
9
+ */
10
+ bytes(length: number): Uint8Array
11
+
12
+ /**
13
+ * Generates a random UUID (version 4).
14
+ */
15
+ uuidv4(): string
16
+
17
+ /**
18
+ * Generates a random UUID (version 7).
19
+ */
20
+ uuidv7(): string
21
+ }
22
+
23
+ export class CryptoRandomProvider implements RandomProvider {
24
+ bytes(length: number): Uint8Array {
25
+ return randomBytes(length)
26
+ }
27
+
28
+ uuidv4(): string {
29
+ return randomUUID()
30
+ }
31
+
32
+ uuidv7(): string {
33
+ return uuidv7()
34
+ }
35
+ }
36
+
37
+ export class ReproducibleRandomProvider implements RandomProvider {
38
+ private readonly seed: Uint8Array
39
+ private counter = 0
40
+
41
+ constructor(seed: Uint8Array) {
42
+ this.seed = seed
43
+ }
44
+
45
+ bytes(length: number): Uint8Array {
46
+ const buffer = new Uint8Array(length)
47
+
48
+ for (let i = 0; i < length; i++) {
49
+ // use a simple counter to generate reproducible random bytes
50
+ buffer[i] = (this.seed[i % this.seed.length] + this.counter) % 256
51
+ this.counter += 1
52
+ }
53
+
54
+ return buffer
55
+ }
56
+
57
+ uuidv4(): string {
58
+ return uuidv4({ random: this.bytes(16) })
59
+ }
60
+
61
+ uuidv7(): string {
62
+ return uuidv7({
63
+ random: this.bytes(16),
64
+ seq: this.counter++,
65
+ msecs: this.counter++,
66
+ })
67
+ }
68
+ }
@@ -0,0 +1,2 @@
1
+ export * from "./render"
2
+ export * from "./tracer"
@@ -0,0 +1,98 @@
1
+ import { camelCaseToHumanReadable, registerKnownAbbreviations } from "@highstate/contract"
2
+ import * as md from "ts-markdown-builder"
3
+
4
+ type RenderTraceEntryField = {
5
+ value: unknown
6
+ raw?: boolean
7
+ alwaysRender?: boolean
8
+ }
9
+
10
+ type RenderTraceEntryOptions = {
11
+ icon: string
12
+ title: string
13
+ code?: string
14
+ codeBlock?: string
15
+ body?: string
16
+ fields?: Record<string, RenderTraceEntryField>
17
+ }
18
+
19
+ export function codeBlock(code: string): string {
20
+ return `\`\`\`js\n${code}\n\`\`\``
21
+ }
22
+
23
+ function isPrimitive(value: unknown): boolean {
24
+ return (
25
+ typeof value === "string" ||
26
+ typeof value === "number" ||
27
+ typeof value === "boolean" ||
28
+ value === null ||
29
+ value === undefined
30
+ )
31
+ }
32
+
33
+ export function renderMdValue(value: unknown): string {
34
+ if (typeof value === "string") {
35
+ return md.code(`"${value}"`)
36
+ }
37
+
38
+ if (isPrimitive(value)) {
39
+ return md.code(String(value))
40
+ }
41
+
42
+ if (value instanceof Error) {
43
+ return codeBlock(value.stack ?? value.message)
44
+ }
45
+
46
+ return codeBlock(JSON.stringify(value, null, 2))
47
+ }
48
+
49
+ registerKnownAbbreviations(["ID"])
50
+
51
+ export function renderTraceEntry(options: RenderTraceEntryOptions): string {
52
+ const blocks: string[] = [`${options.icon} ${options.title}`]
53
+
54
+ if (options.code) {
55
+ blocks.push(md.code(options.code))
56
+ }
57
+
58
+ if (options.codeBlock) {
59
+ blocks.push(codeBlock(options.codeBlock))
60
+ }
61
+
62
+ if (options.fields) {
63
+ for (const [key, field] of Object.entries(options.fields)) {
64
+ const prefix = md.bold(camelCaseToHumanReadable(key) + ":")
65
+
66
+ if (field.raw && typeof field.value === "string") {
67
+ blocks.push(`${prefix} ${field.value}`)
68
+ continue
69
+ }
70
+
71
+ if (!field.alwaysRender && field.value === undefined) {
72
+ continue
73
+ }
74
+
75
+ if (Array.isArray(field.value) && field.value.length > 0) {
76
+ blocks.push(prefix, ...field.value.map(item => renderMdValue(item)))
77
+ } else if (Array.isArray(field.value)) {
78
+ blocks.push(`${prefix} ${md.code("[]")}`)
79
+ } else if (
80
+ typeof field.value === "object" &&
81
+ field.value !== null &&
82
+ Object.keys(field.value).length === 0
83
+ ) {
84
+ blocks.push(`${prefix} ${md.code("{}")}`)
85
+ } else if (isPrimitive(field.value)) {
86
+ blocks.push(`${prefix} ${renderMdValue(field.value)}`)
87
+ } else {
88
+ blocks.push(prefix, renderMdValue(field.value))
89
+ }
90
+ }
91
+ }
92
+
93
+ if (options.body) {
94
+ blocks.push(options.body)
95
+ }
96
+
97
+ return md.joinBlocks(blocks)
98
+ }