@highstate/backend 0.6.2 → 0.7.1

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.
package/dist/index.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  import { z } from 'zod';
2
- import { pickBy, mapKeys, mapValues, funnel, omit, pick } from 'remeda';
2
+ import { pickBy, mapKeys, mapValues, omit, pick } from 'remeda';
3
3
  import { BetterLock } from 'better-lock';
4
4
  import { basename, relative, dirname, resolve as resolve$1 } from 'node:path';
5
5
  import { findWorkspaceDir, readPackageJSON } from 'pkg-types';
@@ -10,7 +10,7 @@ import Watcher from 'watcher';
10
10
  import { resolve } from 'import-meta-resolve';
11
11
  import { readdir, readFile, writeFile, mkdir } from 'node:fs/promises';
12
12
  import { getInstanceId, isUnitModel, parseInstanceId } from '@highstate/contract';
13
- import { h as hubModelSchema, i as instanceModelSchema, c as createInputResolver, a as createInputHashResolver, b as createInstanceState, d as instanceTerminalSchema, e as instancePageSchema, f as instanceFileSchema, g as instanceStatusFieldSchema, j as instanceTriggerSchema, k as compositeInstanceSchema, p as projectOperationSchema, l as instanceStateSchema, m as isFinalOperationStatus, t as terminalSessionSchema, n as applyPartialInstanceState, o as createInstanceStateFrontendPatch } from './terminal-C4MfopTF.mjs';
13
+ import { h as hubModelSchema, i as instanceModelSchema, c as createInputResolver, a as createInputHashResolver, b as createInstanceState, d as instanceTerminalSchema, e as instancePageSchema, f as instanceFileSchema, g as instanceStatusFieldSchema, j as instanceTriggerSchema, k as compositeInstanceSchema, p as projectOperationSchema, l as instanceStateSchema, m as isFinalOperationStatus, t as terminalSessionSchema, n as createInstanceStateFrontendPatch, o as applyPartialInstanceState } from './terminal-CqIsctlZ.mjs';
14
14
  import { sha256 } from 'crypto-hash';
15
15
  import 'ajv';
16
16
  import { Readable, PassThrough } from 'node:stream';
@@ -57,11 +57,60 @@ function errorToString(error) {
57
57
  }
58
58
  return JSON.stringify(error);
59
59
  }
60
- function arrayAccumulator(values, value) {
61
- if (!values) {
62
- return [value];
60
+ function createAsyncBatcher(fn, { waitMs = 100, maxWaitTimeMs = 1e3 } = {}) {
61
+ let batch = [];
62
+ let activeTimeout = null;
63
+ let maxWaitTimeout = null;
64
+ let firstCallTimestamp = null;
65
+ async function processBatch() {
66
+ if (batch.length === 0) return;
67
+ const currentBatch = batch;
68
+ batch = [];
69
+ await fn(currentBatch);
70
+ if (maxWaitTimeout) {
71
+ clearTimeout(maxWaitTimeout);
72
+ maxWaitTimeout = null;
73
+ }
74
+ firstCallTimestamp = null;
75
+ }
76
+ function schedule() {
77
+ if (activeTimeout) clearTimeout(activeTimeout);
78
+ activeTimeout = setTimeout(() => {
79
+ activeTimeout = null;
80
+ void processBatch();
81
+ }, waitMs);
82
+ if (!firstCallTimestamp) {
83
+ firstCallTimestamp = Date.now();
84
+ maxWaitTimeout = setTimeout(() => {
85
+ if (activeTimeout) clearTimeout(activeTimeout);
86
+ activeTimeout = null;
87
+ void processBatch();
88
+ }, maxWaitTimeMs);
89
+ }
63
90
  }
64
- return [...values, value];
91
+ return {
92
+ /**
93
+ * Add an item to the batch.
94
+ */
95
+ call(item) {
96
+ batch.push(item);
97
+ schedule();
98
+ },
99
+ /**
100
+ * Immediately flush the pending batch (if any).
101
+ */
102
+ async flush() {
103
+ if (activeTimeout) {
104
+ clearTimeout(activeTimeout);
105
+ activeTimeout = null;
106
+ }
107
+ if (maxWaitTimeout) {
108
+ clearTimeout(maxWaitTimeout);
109
+ maxWaitTimeout = null;
110
+ }
111
+ await processBatch();
112
+ }
113
+ };
65
114
  }
66
115
 
67
116
  class LocalPulumiHost {
@@ -79,14 +128,15 @@ class LocalPulumiHost {
79
128
  return null;
80
129
  }
81
130
  }
82
- async runInline(projectId, pulumiProjectName, pulumiStackName, program, fn) {
131
+ async runEmpty(options, fn) {
132
+ const { projectId, pulumiProjectName, pulumiStackName, envVars } = options;
83
133
  return await this.lock.acquire(`${pulumiProjectName}.${pulumiStackName}`, async () => {
84
134
  const { LocalWorkspace } = await import('@pulumi/pulumi/automation/index.js');
85
135
  const stack = await LocalWorkspace.createOrSelectStack(
86
136
  {
87
137
  projectName: pulumiProjectName,
88
138
  stackName: pulumiStackName,
89
- program
139
+ program: () => Promise.resolve()
90
140
  },
91
141
  {
92
142
  projectSettings: {
@@ -95,7 +145,8 @@ class LocalPulumiHost {
95
145
  },
96
146
  envVars: {
97
147
  PULUMI_CONFIG_PASSPHRASE: this.getPassword(projectId),
98
- PULUMI_K8S_AWAIT_ALL: "true"
148
+ PULUMI_K8S_AWAIT_ALL: "true",
149
+ ...envVars
99
150
  }
100
151
  }
101
152
  );
@@ -112,18 +163,14 @@ class LocalPulumiHost {
112
163
  }
113
164
  });
114
165
  }
115
- async runEmpty(projectId, pulumiProjectName, pulumiStackName, fn) {
116
- return await this.runInline(projectId, pulumiProjectName, pulumiStackName, async () => {
117
- }, fn);
118
- }
119
- // TODO: extract args to options object
120
- async runLocal(projectId, pulumiProjectName, pulumiStackName, programPathResolver, fn, stackConfig) {
166
+ async runLocal(options, fn) {
167
+ const { projectId, pulumiProjectName, pulumiStackName, projectPath, stackConfig, envVars } = options;
121
168
  return await this.lock.acquire(`${pulumiProjectName}.${pulumiStackName}`, async () => {
122
169
  const { LocalWorkspace } = await import('@pulumi/pulumi/automation/index.js');
123
170
  const stack = await LocalWorkspace.createOrSelectStack(
124
171
  {
125
172
  stackName: pulumiStackName,
126
- workDir: await programPathResolver()
173
+ workDir: projectPath
127
174
  },
128
175
  {
129
176
  projectSettings: {
@@ -137,7 +184,8 @@ class LocalPulumiHost {
137
184
  } : void 0,
138
185
  envVars: {
139
186
  PULUMI_CONFIG_PASSPHRASE: this.getPassword(projectId),
140
- PULUMI_K8S_AWAIT_ALL: "true"
187
+ PULUMI_K8S_AWAIT_ALL: "true",
188
+ ...envVars
141
189
  }
142
190
  }
143
191
  );
@@ -251,10 +299,12 @@ class LocalSecretBackend {
251
299
  this.pulumiProjectHost.setPassword(projectId, password);
252
300
  try {
253
301
  await this.pulumiProjectHost.runLocal(
254
- projectId,
255
- this.projectName,
256
- projectId,
257
- () => this.projectPath,
302
+ {
303
+ projectId,
304
+ pulumiProjectName: this.projectName,
305
+ pulumiStackName: projectId,
306
+ projectPath: this.projectPath
307
+ },
258
308
  async (stack) => {
259
309
  this.logger.debug({ projectId }, "checking password");
260
310
  await stack.info(true);
@@ -274,10 +324,12 @@ class LocalSecretBackend {
274
324
  }
275
325
  get(projectId, instanceId) {
276
326
  return this.pulumiProjectHost.runLocal(
277
- projectId,
278
- this.projectName,
279
- projectId,
280
- () => this.projectPath,
327
+ {
328
+ projectId,
329
+ pulumiProjectName: this.projectName,
330
+ pulumiStackName: projectId,
331
+ projectPath: this.projectPath
332
+ },
281
333
  async (stack) => {
282
334
  this.logger.debug({ projectId, instanceId }, "getting secrets");
283
335
  const config = await stack.getAllConfig();
@@ -290,10 +342,12 @@ class LocalSecretBackend {
290
342
  }
291
343
  set(projectId, instanceId, values) {
292
344
  return this.pulumiProjectHost.runLocal(
293
- projectId,
294
- this.projectName,
295
- projectId,
296
- () => this.projectPath,
345
+ {
346
+ projectId,
347
+ pulumiProjectName: this.projectName,
348
+ pulumiStackName: projectId,
349
+ projectPath: this.projectPath
350
+ },
297
351
  async (stack) => {
298
352
  this.logger.debug({ projectId, instanceId }, "setting secrets");
299
353
  const componentSecrets = mapValues(
@@ -768,6 +822,9 @@ class ProjectLock {
768
822
  this.lock = lock;
769
823
  this.projectId = projectId;
770
824
  }
825
+ canImmediatelyAcquireLock(instanceId) {
826
+ return this.lock.canAcquire(`${this.projectId}/${instanceId}`);
827
+ }
771
828
  lockInstance(instanceId, fn) {
772
829
  return this.lock.acquire(`${this.projectId}/${instanceId}`, fn);
773
830
  }
@@ -1180,17 +1237,10 @@ class TerminalManager {
1180
1237
  logger.child({ service: "TerminalManager" })
1181
1238
  );
1182
1239
  }
1183
- persistHistory = funnel(
1184
- (entries) => {
1185
- this.logger.trace({ msg: "persisting history lines", count: entries.length });
1186
- void this.stateBackend.appendTerminalSessionHistory(entries);
1187
- },
1188
- {
1189
- minQuietPeriodMs: 100,
1190
- maxBurstDurationMs: 200,
1191
- reducer: arrayAccumulator
1192
- }
1193
- );
1240
+ persistHistory = createAsyncBatcher(async (entries) => {
1241
+ this.logger.trace({ msg: "persisting history lines", count: entries.length });
1242
+ await this.stateBackend.appendTerminalSessionHistory(entries);
1243
+ });
1194
1244
  }
1195
1245
 
1196
1246
  class InvalidInstanceStatusError extends Error {
@@ -1206,14 +1256,16 @@ const localRunnerBackendConfig = z.object({
1206
1256
  HIGHSTATE_BACKEND_RUNNER_LOCAL_SKIP_SOURCE_CHECK: z.boolean({ coerce: true }).default(false),
1207
1257
  HIGHSTATE_BACKEND_RUNNER_LOCAL_SKIP_STATE_CHECK: z.boolean({ coerce: true }).default(false),
1208
1258
  HIGHSTATE_BACKEND_RUNNER_LOCAL_PRINT_OUTPUT: z.boolean({ coerce: true }).default(true),
1209
- HIGHSTATE_BACKEND_RUNNER_LOCAL_SOURCE_BASE_PATH: z.string().optional()
1259
+ HIGHSTATE_BACKEND_RUNNER_LOCAL_SOURCE_BASE_PATH: z.string().optional(),
1260
+ HIGHSTATE_BACKEND_RUNNER_LOCAL_CACHE_DIR: z.string().optional()
1210
1261
  });
1211
1262
  class LocalRunnerBackend {
1212
- constructor(skipSourceCheck, skipStateCheck, printOutput, sourceBasePath, pulumiProjectHost) {
1263
+ constructor(skipSourceCheck, skipStateCheck, printOutput, sourceBasePath, cacheDir, pulumiProjectHost) {
1213
1264
  this.skipSourceCheck = skipSourceCheck;
1214
1265
  this.skipStateCheck = skipStateCheck;
1215
1266
  this.printOutput = printOutput;
1216
1267
  this.sourceBasePath = sourceBasePath;
1268
+ this.cacheDir = cacheDir;
1217
1269
  this.pulumiProjectHost = pulumiProjectHost;
1218
1270
  }
1219
1271
  events = new EventEmitter();
@@ -1233,9 +1285,11 @@ class LocalRunnerBackend {
1233
1285
  }
1234
1286
  getState(options) {
1235
1287
  return this.pulumiProjectHost.runEmpty(
1236
- options.projectId,
1237
- options.instanceType,
1238
- LocalRunnerBackend.getStackName(options),
1288
+ {
1289
+ projectId: options.projectId,
1290
+ pulumiProjectName: options.instanceType,
1291
+ pulumiStackName: LocalRunnerBackend.getStackName(options)
1292
+ },
1239
1293
  async (stack) => {
1240
1294
  const info = await stack.info();
1241
1295
  const instanceId = getInstanceId(options.instanceType, options.instanceName);
@@ -1262,9 +1316,11 @@ class LocalRunnerBackend {
1262
1316
  }
1263
1317
  getTerminalFactory(options, terminalName) {
1264
1318
  return this.pulumiProjectHost.runEmpty(
1265
- options.projectId,
1266
- options.instanceType,
1267
- LocalRunnerBackend.getStackName(options),
1319
+ {
1320
+ projectId: options.projectId,
1321
+ pulumiProjectName: options.instanceType,
1322
+ pulumiStackName: LocalRunnerBackend.getStackName(options)
1323
+ },
1268
1324
  async (stack) => {
1269
1325
  const outputs = await stack.outputs();
1270
1326
  if (!outputs["$terminals"]) {
@@ -1281,9 +1337,11 @@ class LocalRunnerBackend {
1281
1337
  }
1282
1338
  getPageContent(options, pageName) {
1283
1339
  return this.pulumiProjectHost.runEmpty(
1284
- options.projectId,
1285
- options.instanceType,
1286
- LocalRunnerBackend.getStackName(options),
1340
+ {
1341
+ projectId: options.projectId,
1342
+ pulumiProjectName: options.instanceType,
1343
+ pulumiStackName: LocalRunnerBackend.getStackName(options)
1344
+ },
1287
1345
  async (stack) => {
1288
1346
  const outputs = await stack.outputs();
1289
1347
  if (!outputs["$pages"]) {
@@ -1300,9 +1358,11 @@ class LocalRunnerBackend {
1300
1358
  }
1301
1359
  getFileContent(options, fileName) {
1302
1360
  return this.pulumiProjectHost.runEmpty(
1303
- options.projectId,
1304
- options.instanceType,
1305
- LocalRunnerBackend.getStackName(options),
1361
+ {
1362
+ projectId: options.projectId,
1363
+ pulumiProjectName: options.instanceType,
1364
+ pulumiStackName: LocalRunnerBackend.getStackName(options)
1365
+ },
1306
1366
  async (stack) => {
1307
1367
  const outputs = await stack.outputs();
1308
1368
  if (!outputs["$files"]) {
@@ -1331,21 +1391,47 @@ class LocalRunnerBackend {
1331
1391
  ...mapValues(options.config, (value) => ({ value })),
1332
1392
  ...mapValues(options.secrets, (value) => ({ value, secret: true }))
1333
1393
  };
1334
- void this.updateWorker(options, configMap);
1394
+ void this.updateWorker(options, configMap, false);
1335
1395
  }
1336
- async updateWorker(options, configMap) {
1396
+ async preview(options) {
1397
+ const currentStatus = await this.validateStatus(options, [
1398
+ "not_created",
1399
+ "previewing",
1400
+ "created",
1401
+ "error"
1402
+ ]);
1403
+ if (currentStatus === "previewing") {
1404
+ return;
1405
+ }
1406
+ const configMap = {
1407
+ ...mapValues(options.config, (value) => ({ value })),
1408
+ ...mapValues(options.secrets, (value) => ({ value, secret: true }))
1409
+ };
1410
+ void this.updateWorker(options, configMap, true);
1411
+ }
1412
+ async updateWorker(options, configMap, preview) {
1337
1413
  const instanceId = LocalRunnerBackend.getInstanceId(options);
1338
1414
  try {
1415
+ const [projectPath, allowedDependencies] = await this.resolveProjectPath(
1416
+ options.instanceName,
1417
+ options.source
1418
+ );
1339
1419
  await this.pulumiProjectHost.runLocal(
1340
- options.projectId,
1341
- options.instanceType,
1342
- LocalRunnerBackend.getStackName(options),
1343
- () => this.resolveProjectPath(options.source),
1420
+ {
1421
+ projectId: options.projectId,
1422
+ pulumiProjectName: options.instanceType,
1423
+ pulumiStackName: LocalRunnerBackend.getStackName(options),
1424
+ projectPath,
1425
+ stackConfig: configMap,
1426
+ envVars: {
1427
+ HIGHSTATE_CACHE_DIR: this.cacheDir
1428
+ }
1429
+ },
1344
1430
  async (stack) => {
1345
- await stack.setAllConfig(configMap);
1431
+ await this.setStackConfig(stack, configMap);
1346
1432
  this.updateState({
1347
1433
  id: instanceId,
1348
- status: "updating",
1434
+ status: preview ? "previewing" : "updating",
1349
1435
  currentResourceCount: 0,
1350
1436
  totalResourceCount: 0
1351
1437
  });
@@ -1353,8 +1439,11 @@ class LocalRunnerBackend {
1353
1439
  let totalResourceCount = 0;
1354
1440
  await runWithRetryOnError(
1355
1441
  async () => {
1356
- await stack.up({
1442
+ await stack[preview ? "preview" : "up"]({
1357
1443
  color: "always",
1444
+ refresh: options.refresh,
1445
+ signal: options.signal,
1446
+ diff: preview,
1358
1447
  onEvent: (event) => {
1359
1448
  if (event.resourcePreEvent) {
1360
1449
  totalResourceCount = updateResourceCount(
@@ -1378,8 +1467,7 @@ class LocalRunnerBackend {
1378
1467
  if (this.printOutput) {
1379
1468
  console.log(message);
1380
1469
  }
1381
- },
1382
- signal: options.signal
1470
+ }
1383
1471
  });
1384
1472
  const extraOutputs = await this.getExtraOutputsStatePatch(stack);
1385
1473
  this.updateState({
@@ -1392,13 +1480,15 @@ class LocalRunnerBackend {
1392
1480
  async (error) => {
1393
1481
  const isUnlocked = await this.pulumiProjectHost.tryUnlockStack(stack, error);
1394
1482
  if (isUnlocked) return true;
1395
- const isResolved = await this.tryInstallMissingDependencies(error);
1483
+ const isResolved = await this.tryInstallMissingDependencies(
1484
+ error,
1485
+ allowedDependencies
1486
+ );
1396
1487
  if (isResolved) return true;
1397
1488
  return false;
1398
1489
  }
1399
1490
  );
1400
- },
1401
- configMap
1491
+ }
1402
1492
  );
1403
1493
  } catch (error) {
1404
1494
  this.updateState({
@@ -1408,6 +1498,12 @@ class LocalRunnerBackend {
1408
1498
  });
1409
1499
  }
1410
1500
  }
1501
+ async setStackConfig(stack, configMap) {
1502
+ const currentConfig = await stack.getAllConfig();
1503
+ const currentConfigKeys = Object.keys(currentConfig);
1504
+ await stack.removeAllConfig(currentConfigKeys);
1505
+ await stack.setAllConfig(configMap);
1506
+ }
1411
1507
  async destroy(options) {
1412
1508
  const currentStatus = await this.validateStatus(options, [
1413
1509
  "not_created",
@@ -1423,10 +1519,18 @@ class LocalRunnerBackend {
1423
1519
  async destroyWorker(options) {
1424
1520
  const instanceId = LocalRunnerBackend.getInstanceId(options);
1425
1521
  try {
1426
- await this.pulumiProjectHost.runEmpty(
1427
- options.projectId,
1428
- options.instanceType,
1429
- LocalRunnerBackend.getStackName(options),
1522
+ const [projectPath] = await this.resolveProjectPath(options.instanceName, options.source);
1523
+ await this.pulumiProjectHost.runLocal(
1524
+ {
1525
+ projectId: options.projectId,
1526
+ pulumiProjectName: options.instanceType,
1527
+ pulumiStackName: LocalRunnerBackend.getStackName(options),
1528
+ projectPath,
1529
+ envVars: {
1530
+ HIGHSTATE_CACHE_DIR: this.cacheDir,
1531
+ PULUMI_K8S_DELETE_UNREACHABLE: options.deleteUnreachable ? "true" : ""
1532
+ }
1533
+ },
1430
1534
  async (stack) => {
1431
1535
  const summary = await stack.workspace.stack();
1432
1536
  let currentResourceCount = summary?.resourceCount ?? 0;
@@ -1440,6 +1544,9 @@ class LocalRunnerBackend {
1440
1544
  async () => {
1441
1545
  await stack.destroy({
1442
1546
  color: "always",
1547
+ refresh: options.refresh,
1548
+ remove: true,
1549
+ signal: options.signal,
1443
1550
  onEvent: (event) => {
1444
1551
  if (event.resOutputsEvent) {
1445
1552
  currentResourceCount = updateResourceCount(
@@ -1455,8 +1562,7 @@ class LocalRunnerBackend {
1455
1562
  if (this.printOutput) {
1456
1563
  console.log(message);
1457
1564
  }
1458
- },
1459
- signal: options.signal
1565
+ }
1460
1566
  });
1461
1567
  const extraOutputs = await this.getExtraOutputsStatePatch(stack);
1462
1568
  this.updateState({
@@ -1471,6 +1577,16 @@ class LocalRunnerBackend {
1471
1577
  }
1472
1578
  );
1473
1579
  } catch (error) {
1580
+ const { StackNotFoundError } = await import('@pulumi/pulumi/automation');
1581
+ if (error instanceof StackNotFoundError) {
1582
+ this.updateState({
1583
+ id: instanceId,
1584
+ status: "not_created",
1585
+ totalResourceCount: 0,
1586
+ currentResourceCount: 0
1587
+ });
1588
+ return;
1589
+ }
1474
1590
  this.updateState({
1475
1591
  id: instanceId,
1476
1592
  status: "error",
@@ -1494,9 +1610,11 @@ class LocalRunnerBackend {
1494
1610
  const instanceId = LocalRunnerBackend.getInstanceId(options);
1495
1611
  try {
1496
1612
  await this.pulumiProjectHost.runEmpty(
1497
- options.projectId,
1498
- options.instanceType,
1499
- LocalRunnerBackend.getStackName(options),
1613
+ {
1614
+ projectId: options.projectId,
1615
+ pulumiProjectName: options.instanceType,
1616
+ pulumiStackName: LocalRunnerBackend.getStackName(options)
1617
+ },
1500
1618
  async (stack) => {
1501
1619
  const summary = await stack.workspace.stack();
1502
1620
  let currentResourceCount = 0;
@@ -1618,24 +1736,33 @@ class LocalRunnerBackend {
1618
1736
  static getInstanceId(options) {
1619
1737
  return getInstanceId(options.instanceType, options.instanceName);
1620
1738
  }
1621
- async resolveProjectPath(source) {
1739
+ async resolveProjectPath(instanceType, source) {
1622
1740
  if (source.type === "local") {
1623
- if (!source.path) {
1624
- throw new Error("Source path is required for local units");
1625
- }
1626
- return resolve$1(this.sourceBasePath, source.path);
1741
+ const path = source.path ?? instanceType.replace(/\./g, "/");
1742
+ const projectPath = resolve$1(this.sourceBasePath, path);
1743
+ return [projectPath, []];
1627
1744
  }
1628
1745
  if (!this.skipSourceCheck) {
1629
1746
  const packageName = source.version ? `${source.package}@${source.version}` : source.package;
1630
1747
  await ensureDependencyInstalled(packageName);
1631
1748
  }
1632
- const fullPath = source.path ? `${source.package}/${source.path}` : source.package;
1633
- const url = resolve(fullPath, import.meta.url);
1634
- const path = fileURLToPath(url);
1635
- const projectPath = dirname(path);
1636
- return projectPath;
1749
+ const workerPathUrl = resolve(
1750
+ `@highstate/backend/source-resolution-worker`,
1751
+ import.meta.url
1752
+ );
1753
+ const workerPath = fileURLToPath(workerPathUrl);
1754
+ const worker = new Worker(workerPath, {
1755
+ workerData: { source, skipSourceCheck: this.skipSourceCheck }
1756
+ });
1757
+ for await (const [event] of on(worker, "message")) {
1758
+ const eventData = event;
1759
+ if (eventData.type === "result") {
1760
+ return [eventData.projectPath, eventData.allowedDependencies];
1761
+ }
1762
+ }
1763
+ throw new Error("Worker ended without sending the result");
1637
1764
  }
1638
- async tryInstallMissingDependencies(error) {
1765
+ async tryInstallMissingDependencies(error, allowedDependencies) {
1639
1766
  if (!(error instanceof Error)) {
1640
1767
  return false;
1641
1768
  }
@@ -1645,6 +1772,11 @@ class LocalRunnerBackend {
1645
1772
  return false;
1646
1773
  }
1647
1774
  const packageName = match[1];
1775
+ if (!allowedDependencies.includes(packageName)) {
1776
+ throw new Error(
1777
+ `Dependency '${packageName}' was requested to be auto-installed, but it is not allowed. Please add it to the 'peerDependencies' in the package.json of the unit.`
1778
+ );
1779
+ }
1648
1780
  await ensureDependencyInstalled(packageName);
1649
1781
  return true;
1650
1782
  }
@@ -1657,11 +1789,22 @@ class LocalRunnerBackend {
1657
1789
  const [projectPath] = await resolveMainLocalProject();
1658
1790
  sourceBasePath = resolve$1(projectPath, "units");
1659
1791
  }
1792
+ let cacheDir = config.HIGHSTATE_BACKEND_RUNNER_LOCAL_CACHE_DIR;
1793
+ if (!cacheDir) {
1794
+ const homeDir = process.env.HOME ?? process.env.USERPROFILE;
1795
+ if (!homeDir) {
1796
+ throw new Error(
1797
+ "Failed to determine the home directory, please set HIGHSTATE_BACKEND_RUNNER_LOCAL_CACHE_DIR"
1798
+ );
1799
+ }
1800
+ cacheDir = resolve$1(homeDir, ".cache", "highstate");
1801
+ }
1660
1802
  return new LocalRunnerBackend(
1661
1803
  config.HIGHSTATE_BACKEND_RUNNER_LOCAL_SKIP_SOURCE_CHECK,
1662
1804
  config.HIGHSTATE_BACKEND_RUNNER_LOCAL_SKIP_STATE_CHECK,
1663
1805
  config.HIGHSTATE_BACKEND_RUNNER_LOCAL_PRINT_OUTPUT,
1664
1806
  sourceBasePath,
1807
+ cacheDir,
1665
1808
  pulumiProjectHost
1666
1809
  );
1667
1810
  }
@@ -1973,7 +2116,7 @@ class LocalStateBackend {
1973
2116
  const currentUser = await localPulumiHost.getCurrentUser();
1974
2117
  if (!currentUser) {
1975
2118
  throw new Error(
1976
- "The pulumi is not authenticated, please login first or specify the state location manually"
2119
+ "The pulumi state is not specified, please run `pulumi login` or specify the state location manually before restarting the service"
1977
2120
  );
1978
2121
  }
1979
2122
  if (!currentUser.url) {
@@ -2126,6 +2269,14 @@ class RuntimeOperation {
2126
2269
  this.persistSecrets.flush()
2127
2270
  ]);
2128
2271
  this.logger.debug("operation finished, all entries persisted");
2272
+ if (this.operation.type === "preview") {
2273
+ const states = await this.stateBackend.getInstanceStates(this.operation.projectId);
2274
+ for (const state of states) {
2275
+ if (this.operation.instanceIds.includes(state.id)) {
2276
+ this.stateEE.emit(this.operation.projectId, createInstanceStateFrontendPatch(state));
2277
+ }
2278
+ }
2279
+ }
2129
2280
  }
2130
2281
  }
2131
2282
  resetPendingStateStatuses() {
@@ -2203,9 +2354,9 @@ class RuntimeOperation {
2203
2354
  { promiseCache: this.inputHashPromiseCache }
2204
2355
  );
2205
2356
  if (this.operation.type === "update") {
2206
- await this.extendWithNotCreatedDependencies();
2357
+ await this.extendWithDependencies();
2207
2358
  await this.updateOperation();
2208
- } else if (this.operation.type === "destroy") {
2359
+ } else if (this.operation.type === "destroy" && this.operation.options.destroyDependentInstances !== false) {
2209
2360
  this.extendWithCreatedDependents();
2210
2361
  await this.updateOperation();
2211
2362
  }
@@ -2252,7 +2403,8 @@ class RuntimeOperation {
2252
2403
  }
2253
2404
  async getUnitPromise(instance, component) {
2254
2405
  switch (this.operation.type) {
2255
- case "update": {
2406
+ case "update":
2407
+ case "preview": {
2256
2408
  return this.updateUnit(instance, component);
2257
2409
  }
2258
2410
  case "recreate": {
@@ -2340,11 +2492,12 @@ class RuntimeOperation {
2340
2492
  const secrets = await this.secretBackend.get(this.operation.projectId, instance.id);
2341
2493
  this.abortController.signal.throwIfAborted();
2342
2494
  logger.debug("secrets loaded", { count: Object.keys(secrets).length });
2343
- await this.runnerBackend.update({
2495
+ await this.runnerBackend[this.operation.type === "preview" ? "preview" : "update"]({
2344
2496
  projectId: this.operation.projectId,
2345
2497
  instanceType: instance.type,
2346
2498
  instanceName: instance.name,
2347
2499
  config: await this.prepareUnitConfig(instance),
2500
+ refresh: this.operation.options.refresh,
2348
2501
  secrets: mapValues(secrets, (value) => valueToString(value)),
2349
2502
  source: this.resolveUnitSource(component),
2350
2503
  signal: this.abortController.signal
@@ -2357,23 +2510,34 @@ class RuntimeOperation {
2357
2510
  finalStatuses: ["created", "error"]
2358
2511
  });
2359
2512
  await this.watchStateStream(stream);
2360
- const inputHash = await this.inputHashLock.acquire(async () => {
2361
- this.inputHashNodes.set(instance.id, {
2362
- instance,
2363
- resolvedInputs: this.resolvedInstanceInputs.get(instance.id),
2364
- state: this.stateMap.get(instance.id),
2365
- sourceHash: void 0
2366
- // TODO: implement source hash
2367
- });
2368
- this.inputHashPromiseCache.clear();
2369
- return await this.resolveInputHash(instance.id);
2513
+ const inputHash = await this.getUpToDateInputHash(instance);
2514
+ this.updateInstanceState({
2515
+ id: instance.id,
2516
+ inputHash,
2517
+ dependencyIds: dependencies.map((dependency) => dependency.id)
2370
2518
  });
2371
- this.updateInstanceState({ id: instance.id, inputHash });
2372
2519
  logger.debug("input hash after update", { inputHash });
2373
2520
  logger.info("unit updated");
2374
2521
  });
2375
2522
  }
2523
+ async getUpToDateInputHash(instance) {
2524
+ return await this.inputHashLock.acquire(async () => {
2525
+ this.inputHashNodes.set(instance.id, {
2526
+ instance,
2527
+ resolvedInputs: this.resolvedInstanceInputs.get(instance.id),
2528
+ state: this.stateMap.get(instance.id),
2529
+ sourceHash: void 0
2530
+ // TODO: implement source hash
2531
+ });
2532
+ this.inputHashPromiseCache.clear();
2533
+ return await this.resolveInputHash(instance.id);
2534
+ });
2535
+ }
2376
2536
  async processBeforeDestroyTriggers(state, component, logger) {
2537
+ if (!this.operation.options.invokeDestroyTriggers) {
2538
+ logger.debug("destroy triggers are disabled for the operation");
2539
+ return;
2540
+ }
2377
2541
  const instance = this.instanceMap.get(state.id);
2378
2542
  if (!instance) {
2379
2543
  throw new Error(`Instance not found: ${state.id}`);
@@ -2386,12 +2550,13 @@ class RuntimeOperation {
2386
2550
  logger.info("updating unit to process before-destroy triggers...");
2387
2551
  const secrets = await this.secretBackend.get(this.operation.projectId, instance.id);
2388
2552
  this.abortController.signal.throwIfAborted();
2389
- logger.debug("secrets loaded", { count: Object.keys(secrets).length });
2553
+ logger.debug({ count: Object.keys(secrets).length }, "secrets loaded");
2390
2554
  await this.runnerBackend.update({
2391
2555
  projectId: this.operation.projectId,
2392
2556
  instanceType: instance.type,
2393
2557
  instanceName: instance.name,
2394
2558
  config: await this.prepareUnitConfig(instance, invokedTriggers),
2559
+ refresh: this.operation.options.refresh,
2395
2560
  secrets: mapValues(secrets, (value) => valueToString(value)),
2396
2561
  source: this.resolveUnitSource(component),
2397
2562
  signal: this.abortController.signal
@@ -2435,7 +2600,10 @@ class RuntimeOperation {
2435
2600
  projectId: this.operation.projectId,
2436
2601
  instanceType: type,
2437
2602
  instanceName: name,
2438
- signal: this.abortController.signal
2603
+ refresh: this.operation.options.refresh,
2604
+ signal: this.abortController.signal,
2605
+ source: this.resolveUnitSource(component),
2606
+ deleteUnreachable: this.operation.options.deleteUnreachableResources
2439
2607
  });
2440
2608
  this.logger.debug("destroy request sent");
2441
2609
  const stream = this.runnerBackend.watch({
@@ -2469,7 +2637,10 @@ class RuntimeOperation {
2469
2637
  projectId: this.operation.projectId,
2470
2638
  instanceType: instance.type,
2471
2639
  instanceName: instance.name,
2472
- signal: this.abortController.signal
2640
+ refresh: this.operation.options.refresh,
2641
+ signal: this.abortController.signal,
2642
+ source: this.resolveUnitSource(component),
2643
+ deleteUnreachable: this.operation.options.deleteUnreachableResources
2473
2644
  });
2474
2645
  logger.debug("destroy request sent");
2475
2646
  const destroyStream = this.runnerBackend.watch({
@@ -2490,6 +2661,7 @@ class RuntimeOperation {
2490
2661
  instanceType: instance.type,
2491
2662
  instanceName: instance.name,
2492
2663
  config: await this.prepareUnitConfig(instance),
2664
+ refresh: this.operation.options.refresh,
2493
2665
  secrets: mapValues(secrets, (value) => valueToString(value)),
2494
2666
  source: this.resolveUnitSource(component),
2495
2667
  signal: this.abortController.signal
@@ -2501,7 +2673,13 @@ class RuntimeOperation {
2501
2673
  instanceName: instance.name,
2502
2674
  finalStatuses: ["created", "error"]
2503
2675
  });
2504
- this.updateInstanceState({ id: instance.id, inputHash: null });
2676
+ const inputHash = await this.getUpToDateInputHash(instance);
2677
+ const dependencies = this.getInstanceDependencies(instance);
2678
+ this.updateInstanceState({
2679
+ id: instance.id,
2680
+ inputHash,
2681
+ dependencyIds: dependencies.map((dependency) => dependency.id)
2682
+ });
2505
2683
  await this.watchStateStream(updateStream);
2506
2684
  logger.info("unit recreated");
2507
2685
  });
@@ -2639,9 +2817,11 @@ class RuntimeOperation {
2639
2817
  return;
2640
2818
  }
2641
2819
  const state = applyPartialInstanceState(this.stateMap, patch);
2642
- this.persistStates.call(state);
2643
- if (patch.secrets) {
2644
- this.persistSecrets.call([patch.id, patch.secrets]);
2820
+ if (this.operation.type !== "preview") {
2821
+ this.persistStates.call(state);
2822
+ if (patch.secrets) {
2823
+ this.persistSecrets.call([patch.id, patch.secrets]);
2824
+ }
2645
2825
  }
2646
2826
  this.stateEE.emit(this.operation.projectId, createInstanceStateFrontendPatch(patch));
2647
2827
  }
@@ -2653,7 +2833,29 @@ class RuntimeOperation {
2653
2833
  children.push(state);
2654
2834
  this.childrenStateMap.set(state.parentId, children);
2655
2835
  }
2836
+ removeStateFromParent(state) {
2837
+ if (!state.parentId) {
2838
+ return;
2839
+ }
2840
+ const children = this.childrenStateMap.get(state.parentId) ?? [];
2841
+ const index = children.findIndex((child) => child.id === state.id);
2842
+ if (index !== -1) {
2843
+ children.splice(index, 1);
2844
+ this.childrenStateMap.set(state.parentId, children);
2845
+ }
2846
+ }
2656
2847
  addDependentState(state) {
2848
+ const instance = this.instanceMap.get(state.id);
2849
+ if (!instance) {
2850
+ return;
2851
+ }
2852
+ for (const dependency of state.dependencyIds) {
2853
+ const dependents = this.dependentStateMap.get(dependency) ?? [];
2854
+ dependents.push(state);
2855
+ this.dependentStateMap.set(dependency, dependents);
2856
+ }
2857
+ }
2858
+ removeDependentState(state) {
2657
2859
  const instance = this.instanceMap.get(state.id);
2658
2860
  if (!instance) {
2659
2861
  return;
@@ -2662,12 +2864,15 @@ class RuntimeOperation {
2662
2864
  for (const inputs of Object.values(instanceInputs)) {
2663
2865
  for (const input of inputs) {
2664
2866
  const dependents = this.dependentStateMap.get(input.input.instanceId) ?? [];
2665
- dependents.push(state);
2666
- this.dependentStateMap.set(input.input.instanceId, dependents);
2867
+ const index = dependents.findIndex((dependent) => dependent.id === state.id);
2868
+ if (index !== -1) {
2869
+ dependents.splice(index, 1);
2870
+ this.dependentStateMap.set(input.input.instanceId, dependents);
2871
+ }
2667
2872
  }
2668
2873
  }
2669
2874
  }
2670
- async extendWithNotCreatedDependencies() {
2875
+ async extendWithDependencies() {
2671
2876
  const instanceIdsSet = /* @__PURE__ */ new Set();
2672
2877
  const traverse = async (instanceId) => {
2673
2878
  if (instanceIdsSet.has(instanceId)) {
@@ -2685,6 +2890,10 @@ class RuntimeOperation {
2685
2890
  }
2686
2891
  const state = this.stateMap.get(instance.id);
2687
2892
  const expectedInputHash = await this.resolveInputHash(instance.id);
2893
+ if (this.operation.options.forceUpdateDependencies) {
2894
+ instanceIdsSet.add(instanceId);
2895
+ return;
2896
+ }
2688
2897
  if (state?.status !== "created" || state.inputHash !== expectedInputHash) {
2689
2898
  instanceIdsSet.add(instanceId);
2690
2899
  }
@@ -2726,7 +2935,15 @@ class RuntimeOperation {
2726
2935
  if (instancePromise) {
2727
2936
  return instancePromise;
2728
2937
  }
2729
- instancePromise = this.projectLock.lockInstance(instanceId, fn);
2938
+ instancePromise = (async () => {
2939
+ const wasImmediatelyAcquired = this.projectLock.canImmediatelyAcquireLock(instanceId);
2940
+ await this.projectLock.lockInstance(instanceId, async () => {
2941
+ if (!wasImmediatelyAcquired) {
2942
+ await this.refetchState(instanceId);
2943
+ }
2944
+ return await fn();
2945
+ });
2946
+ })();
2730
2947
  instancePromise = instancePromise.finally(() => this.instancePromiseMap.delete(instanceId));
2731
2948
  this.instancePromiseMap.set(instanceId, instancePromise);
2732
2949
  return instancePromise;
@@ -2735,6 +2952,8 @@ class RuntimeOperation {
2735
2952
  switch (this.operation.type) {
2736
2953
  case "update":
2737
2954
  return "updating";
2955
+ case "preview":
2956
+ return "previewing";
2738
2957
  case "recreate":
2739
2958
  return "updating";
2740
2959
  case "destroy":
@@ -2768,47 +2987,44 @@ class RuntimeOperation {
2768
2987
  }
2769
2988
  return component.source;
2770
2989
  }
2771
- persistStates = funnel(
2772
- (states) => {
2773
- this.logger.debug({ msg: "persisting states", count: states.length });
2774
- void this.stateBackend.putAffectedInstanceStates(
2775
- this.operation.projectId,
2776
- this.operation.id,
2777
- states
2778
- );
2779
- },
2780
- {
2781
- minQuietPeriodMs: 100,
2782
- maxBurstDurationMs: 1e3,
2783
- triggerAt: "end",
2784
- reducer: arrayAccumulator
2785
- }
2786
- );
2787
- persistLogs = funnel(
2788
- (entries) => {
2789
- this.logger.trace({ msg: "persisting logs", count: entries.length });
2790
- void this.stateBackend.appendInstanceLogs(this.operation.id, entries);
2791
- },
2792
- {
2793
- minQuietPeriodMs: 100,
2794
- maxBurstDurationMs: 200,
2795
- reducer: arrayAccumulator
2990
+ async refetchState(instanceId) {
2991
+ this.logger.info({ instanceId }, "refetching state since the lock was not immediately acquired");
2992
+ const state = await this.stateBackend.getInstanceState(this.operation.projectId, instanceId);
2993
+ const oldState = this.stateMap.get(instanceId);
2994
+ if (oldState) {
2995
+ this.removeStateFromParent(oldState);
2996
+ this.removeDependentState(oldState);
2997
+ }
2998
+ if (state) {
2999
+ this.stateMap.set(instanceId, state);
3000
+ this.initialStatusMap.set(instanceId, state.status);
3001
+ this.tryAddStateToParent(state);
3002
+ this.addDependentState(state);
3003
+ } else {
3004
+ this.stateMap.delete(instanceId);
3005
+ this.initialStatusMap.delete(instanceId);
2796
3006
  }
2797
- );
2798
- persistSecrets = funnel(
2799
- (entries) => {
3007
+ }
3008
+ persistStates = createAsyncBatcher(async (states) => {
3009
+ this.logger.debug({ msg: "persisting states", count: states.length });
3010
+ await this.stateBackend.putAffectedInstanceStates(
3011
+ this.operation.projectId,
3012
+ this.operation.id,
3013
+ states
3014
+ );
3015
+ });
3016
+ persistLogs = createAsyncBatcher(async (entries) => {
3017
+ this.logger.trace({ msg: "persisting logs", count: entries.length });
3018
+ await this.stateBackend.appendInstanceLogs(this.operation.id, entries);
3019
+ });
3020
+ persistSecrets = createAsyncBatcher(
3021
+ async (entries) => {
2800
3022
  this.logger.debug({ msg: "persisting secrets", count: entries.length });
2801
3023
  for (const [instanceId, secrets] of entries) {
2802
- void this.secretBackend.get(this.operation.projectId, instanceId).then((existingSecrets) => {
2803
- Object.assign(existingSecrets, secrets);
2804
- return this.secretBackend.set(this.operation.projectId, instanceId, existingSecrets);
2805
- });
3024
+ const existingSecrets = await this.secretBackend.get(this.operation.projectId, instanceId);
3025
+ Object.assign(existingSecrets, secrets);
3026
+ await this.secretBackend.set(this.operation.projectId, instanceId, existingSecrets);
2806
3027
  }
2807
- },
2808
- {
2809
- minQuietPeriodMs: 100,
2810
- maxBurstDurationMs: 200,
2811
- reducer: arrayAccumulator
2812
3028
  }
2813
3029
  );
2814
3030
  static abortMessagePatterns = [
@@ -2901,12 +3117,21 @@ class OperationManager {
2901
3117
  type: request.type,
2902
3118
  instanceIds: request.instanceIds,
2903
3119
  status: "pending",
3120
+ options: {
3121
+ forceUpdateDependencies: request.options?.forceUpdateDependencies ?? false,
3122
+ destroyDependentInstances: request.options?.destroyDependentInstances ?? true,
3123
+ invokeDestroyTriggers: request.options?.invokeDestroyTriggers ?? false,
3124
+ deleteUnreachableResources: request.options?.deleteUnreachableResources ?? false,
3125
+ refresh: request.options?.refresh ?? false
3126
+ },
2904
3127
  error: null,
2905
3128
  startedAt: Date.now(),
2906
3129
  completedAt: null
2907
3130
  };
3131
+ this.logger.info({ operation }, "launching operation");
2908
3132
  await this.stateBackend.putOperation(operation);
2909
3133
  this.startOperation(operation);
3134
+ return operation;
2910
3135
  }
2911
3136
  /**
2912
3137
  * Cancels the current operation.