@highstate/backend 0.7.0 → 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, envVars) {
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: {
@@ -113,25 +163,14 @@ class LocalPulumiHost {
113
163
  }
114
164
  });
115
165
  }
116
- async runEmpty(projectId, pulumiProjectName, pulumiStackName, fn, envVars) {
117
- return await this.runInline(
118
- projectId,
119
- pulumiProjectName,
120
- pulumiStackName,
121
- async () => {
122
- },
123
- fn,
124
- envVars
125
- );
126
- }
127
- // TODO: extract args to options object
128
- async runLocal(projectId, pulumiProjectName, pulumiStackName, programPathResolver, fn, stackConfig, envVars) {
166
+ async runLocal(options, fn) {
167
+ const { projectId, pulumiProjectName, pulumiStackName, projectPath, stackConfig, envVars } = options;
129
168
  return await this.lock.acquire(`${pulumiProjectName}.${pulumiStackName}`, async () => {
130
169
  const { LocalWorkspace } = await import('@pulumi/pulumi/automation/index.js');
131
170
  const stack = await LocalWorkspace.createOrSelectStack(
132
171
  {
133
172
  stackName: pulumiStackName,
134
- workDir: await programPathResolver()
173
+ workDir: projectPath
135
174
  },
136
175
  {
137
176
  projectSettings: {
@@ -260,10 +299,12 @@ class LocalSecretBackend {
260
299
  this.pulumiProjectHost.setPassword(projectId, password);
261
300
  try {
262
301
  await this.pulumiProjectHost.runLocal(
263
- projectId,
264
- this.projectName,
265
- projectId,
266
- () => this.projectPath,
302
+ {
303
+ projectId,
304
+ pulumiProjectName: this.projectName,
305
+ pulumiStackName: projectId,
306
+ projectPath: this.projectPath
307
+ },
267
308
  async (stack) => {
268
309
  this.logger.debug({ projectId }, "checking password");
269
310
  await stack.info(true);
@@ -283,10 +324,12 @@ class LocalSecretBackend {
283
324
  }
284
325
  get(projectId, instanceId) {
285
326
  return this.pulumiProjectHost.runLocal(
286
- projectId,
287
- this.projectName,
288
- projectId,
289
- () => this.projectPath,
327
+ {
328
+ projectId,
329
+ pulumiProjectName: this.projectName,
330
+ pulumiStackName: projectId,
331
+ projectPath: this.projectPath
332
+ },
290
333
  async (stack) => {
291
334
  this.logger.debug({ projectId, instanceId }, "getting secrets");
292
335
  const config = await stack.getAllConfig();
@@ -299,10 +342,12 @@ class LocalSecretBackend {
299
342
  }
300
343
  set(projectId, instanceId, values) {
301
344
  return this.pulumiProjectHost.runLocal(
302
- projectId,
303
- this.projectName,
304
- projectId,
305
- () => this.projectPath,
345
+ {
346
+ projectId,
347
+ pulumiProjectName: this.projectName,
348
+ pulumiStackName: projectId,
349
+ projectPath: this.projectPath
350
+ },
306
351
  async (stack) => {
307
352
  this.logger.debug({ projectId, instanceId }, "setting secrets");
308
353
  const componentSecrets = mapValues(
@@ -777,6 +822,9 @@ class ProjectLock {
777
822
  this.lock = lock;
778
823
  this.projectId = projectId;
779
824
  }
825
+ canImmediatelyAcquireLock(instanceId) {
826
+ return this.lock.canAcquire(`${this.projectId}/${instanceId}`);
827
+ }
780
828
  lockInstance(instanceId, fn) {
781
829
  return this.lock.acquire(`${this.projectId}/${instanceId}`, fn);
782
830
  }
@@ -1189,17 +1237,10 @@ class TerminalManager {
1189
1237
  logger.child({ service: "TerminalManager" })
1190
1238
  );
1191
1239
  }
1192
- persistHistory = funnel(
1193
- (entries) => {
1194
- this.logger.trace({ msg: "persisting history lines", count: entries.length });
1195
- void this.stateBackend.appendTerminalSessionHistory(entries);
1196
- },
1197
- {
1198
- minQuietPeriodMs: 100,
1199
- maxBurstDurationMs: 200,
1200
- reducer: arrayAccumulator
1201
- }
1202
- );
1240
+ persistHistory = createAsyncBatcher(async (entries) => {
1241
+ this.logger.trace({ msg: "persisting history lines", count: entries.length });
1242
+ await this.stateBackend.appendTerminalSessionHistory(entries);
1243
+ });
1203
1244
  }
1204
1245
 
1205
1246
  class InvalidInstanceStatusError extends Error {
@@ -1244,9 +1285,11 @@ class LocalRunnerBackend {
1244
1285
  }
1245
1286
  getState(options) {
1246
1287
  return this.pulumiProjectHost.runEmpty(
1247
- options.projectId,
1248
- options.instanceType,
1249
- LocalRunnerBackend.getStackName(options),
1288
+ {
1289
+ projectId: options.projectId,
1290
+ pulumiProjectName: options.instanceType,
1291
+ pulumiStackName: LocalRunnerBackend.getStackName(options)
1292
+ },
1250
1293
  async (stack) => {
1251
1294
  const info = await stack.info();
1252
1295
  const instanceId = getInstanceId(options.instanceType, options.instanceName);
@@ -1273,9 +1316,11 @@ class LocalRunnerBackend {
1273
1316
  }
1274
1317
  getTerminalFactory(options, terminalName) {
1275
1318
  return this.pulumiProjectHost.runEmpty(
1276
- options.projectId,
1277
- options.instanceType,
1278
- LocalRunnerBackend.getStackName(options),
1319
+ {
1320
+ projectId: options.projectId,
1321
+ pulumiProjectName: options.instanceType,
1322
+ pulumiStackName: LocalRunnerBackend.getStackName(options)
1323
+ },
1279
1324
  async (stack) => {
1280
1325
  const outputs = await stack.outputs();
1281
1326
  if (!outputs["$terminals"]) {
@@ -1292,9 +1337,11 @@ class LocalRunnerBackend {
1292
1337
  }
1293
1338
  getPageContent(options, pageName) {
1294
1339
  return this.pulumiProjectHost.runEmpty(
1295
- options.projectId,
1296
- options.instanceType,
1297
- LocalRunnerBackend.getStackName(options),
1340
+ {
1341
+ projectId: options.projectId,
1342
+ pulumiProjectName: options.instanceType,
1343
+ pulumiStackName: LocalRunnerBackend.getStackName(options)
1344
+ },
1298
1345
  async (stack) => {
1299
1346
  const outputs = await stack.outputs();
1300
1347
  if (!outputs["$pages"]) {
@@ -1311,9 +1358,11 @@ class LocalRunnerBackend {
1311
1358
  }
1312
1359
  getFileContent(options, fileName) {
1313
1360
  return this.pulumiProjectHost.runEmpty(
1314
- options.projectId,
1315
- options.instanceType,
1316
- LocalRunnerBackend.getStackName(options),
1361
+ {
1362
+ projectId: options.projectId,
1363
+ pulumiProjectName: options.instanceType,
1364
+ pulumiStackName: LocalRunnerBackend.getStackName(options)
1365
+ },
1317
1366
  async (stack) => {
1318
1367
  const outputs = await stack.outputs();
1319
1368
  if (!outputs["$files"]) {
@@ -1342,21 +1391,47 @@ class LocalRunnerBackend {
1342
1391
  ...mapValues(options.config, (value) => ({ value })),
1343
1392
  ...mapValues(options.secrets, (value) => ({ value, secret: true }))
1344
1393
  };
1345
- void this.updateWorker(options, configMap);
1394
+ void this.updateWorker(options, configMap, false);
1395
+ }
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);
1346
1411
  }
1347
- async updateWorker(options, configMap) {
1412
+ async updateWorker(options, configMap, preview) {
1348
1413
  const instanceId = LocalRunnerBackend.getInstanceId(options);
1349
1414
  try {
1415
+ const [projectPath, allowedDependencies] = await this.resolveProjectPath(
1416
+ options.instanceName,
1417
+ options.source
1418
+ );
1350
1419
  await this.pulumiProjectHost.runLocal(
1351
- options.projectId,
1352
- options.instanceType,
1353
- LocalRunnerBackend.getStackName(options),
1354
- () => 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
+ },
1355
1430
  async (stack) => {
1356
- await stack.setAllConfig(configMap);
1431
+ await this.setStackConfig(stack, configMap);
1357
1432
  this.updateState({
1358
1433
  id: instanceId,
1359
- status: "updating",
1434
+ status: preview ? "previewing" : "updating",
1360
1435
  currentResourceCount: 0,
1361
1436
  totalResourceCount: 0
1362
1437
  });
@@ -1364,8 +1439,11 @@ class LocalRunnerBackend {
1364
1439
  let totalResourceCount = 0;
1365
1440
  await runWithRetryOnError(
1366
1441
  async () => {
1367
- await stack.up({
1442
+ await stack[preview ? "preview" : "up"]({
1368
1443
  color: "always",
1444
+ refresh: options.refresh,
1445
+ signal: options.signal,
1446
+ diff: preview,
1369
1447
  onEvent: (event) => {
1370
1448
  if (event.resourcePreEvent) {
1371
1449
  totalResourceCount = updateResourceCount(
@@ -1389,8 +1467,7 @@ class LocalRunnerBackend {
1389
1467
  if (this.printOutput) {
1390
1468
  console.log(message);
1391
1469
  }
1392
- },
1393
- signal: options.signal
1470
+ }
1394
1471
  });
1395
1472
  const extraOutputs = await this.getExtraOutputsStatePatch(stack);
1396
1473
  this.updateState({
@@ -1403,15 +1480,14 @@ class LocalRunnerBackend {
1403
1480
  async (error) => {
1404
1481
  const isUnlocked = await this.pulumiProjectHost.tryUnlockStack(stack, error);
1405
1482
  if (isUnlocked) return true;
1406
- const isResolved = await this.tryInstallMissingDependencies(error);
1483
+ const isResolved = await this.tryInstallMissingDependencies(
1484
+ error,
1485
+ allowedDependencies
1486
+ );
1407
1487
  if (isResolved) return true;
1408
1488
  return false;
1409
1489
  }
1410
1490
  );
1411
- },
1412
- configMap,
1413
- {
1414
- HIGHSTATE_CACHE_DIR: this.cacheDir
1415
1491
  }
1416
1492
  );
1417
1493
  } catch (error) {
@@ -1422,6 +1498,12 @@ class LocalRunnerBackend {
1422
1498
  });
1423
1499
  }
1424
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
+ }
1425
1507
  async destroy(options) {
1426
1508
  const currentStatus = await this.validateStatus(options, [
1427
1509
  "not_created",
@@ -1437,10 +1519,18 @@ class LocalRunnerBackend {
1437
1519
  async destroyWorker(options) {
1438
1520
  const instanceId = LocalRunnerBackend.getInstanceId(options);
1439
1521
  try {
1440
- await this.pulumiProjectHost.runEmpty(
1441
- options.projectId,
1442
- options.instanceType,
1443
- 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
+ },
1444
1534
  async (stack) => {
1445
1535
  const summary = await stack.workspace.stack();
1446
1536
  let currentResourceCount = summary?.resourceCount ?? 0;
@@ -1454,6 +1544,9 @@ class LocalRunnerBackend {
1454
1544
  async () => {
1455
1545
  await stack.destroy({
1456
1546
  color: "always",
1547
+ refresh: options.refresh,
1548
+ remove: true,
1549
+ signal: options.signal,
1457
1550
  onEvent: (event) => {
1458
1551
  if (event.resOutputsEvent) {
1459
1552
  currentResourceCount = updateResourceCount(
@@ -1469,8 +1562,7 @@ class LocalRunnerBackend {
1469
1562
  if (this.printOutput) {
1470
1563
  console.log(message);
1471
1564
  }
1472
- },
1473
- signal: options.signal
1565
+ }
1474
1566
  });
1475
1567
  const extraOutputs = await this.getExtraOutputsStatePatch(stack);
1476
1568
  this.updateState({
@@ -1482,12 +1574,19 @@ class LocalRunnerBackend {
1482
1574
  },
1483
1575
  (error) => this.pulumiProjectHost.tryUnlockStack(stack, error)
1484
1576
  );
1485
- },
1486
- {
1487
- PULUMI_K8S_DELETE_UNREACHABLE: "true"
1488
1577
  }
1489
1578
  );
1490
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
+ }
1491
1590
  this.updateState({
1492
1591
  id: instanceId,
1493
1592
  status: "error",
@@ -1511,9 +1610,11 @@ class LocalRunnerBackend {
1511
1610
  const instanceId = LocalRunnerBackend.getInstanceId(options);
1512
1611
  try {
1513
1612
  await this.pulumiProjectHost.runEmpty(
1514
- options.projectId,
1515
- options.instanceType,
1516
- LocalRunnerBackend.getStackName(options),
1613
+ {
1614
+ projectId: options.projectId,
1615
+ pulumiProjectName: options.instanceType,
1616
+ pulumiStackName: LocalRunnerBackend.getStackName(options)
1617
+ },
1517
1618
  async (stack) => {
1518
1619
  const summary = await stack.workspace.stack();
1519
1620
  let currentResourceCount = 0;
@@ -1635,24 +1736,33 @@ class LocalRunnerBackend {
1635
1736
  static getInstanceId(options) {
1636
1737
  return getInstanceId(options.instanceType, options.instanceName);
1637
1738
  }
1638
- async resolveProjectPath(source) {
1739
+ async resolveProjectPath(instanceType, source) {
1639
1740
  if (source.type === "local") {
1640
- if (!source.path) {
1641
- throw new Error("Source path is required for local units");
1642
- }
1643
- 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, []];
1644
1744
  }
1645
1745
  if (!this.skipSourceCheck) {
1646
1746
  const packageName = source.version ? `${source.package}@${source.version}` : source.package;
1647
1747
  await ensureDependencyInstalled(packageName);
1648
1748
  }
1649
- const fullPath = source.path ? `${source.package}/${source.path}` : source.package;
1650
- const url = resolve(fullPath, import.meta.url);
1651
- const path = fileURLToPath(url);
1652
- const projectPath = dirname(path);
1653
- 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");
1654
1764
  }
1655
- async tryInstallMissingDependencies(error) {
1765
+ async tryInstallMissingDependencies(error, allowedDependencies) {
1656
1766
  if (!(error instanceof Error)) {
1657
1767
  return false;
1658
1768
  }
@@ -1662,6 +1772,11 @@ class LocalRunnerBackend {
1662
1772
  return false;
1663
1773
  }
1664
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
+ }
1665
1780
  await ensureDependencyInstalled(packageName);
1666
1781
  return true;
1667
1782
  }
@@ -2001,7 +2116,7 @@ class LocalStateBackend {
2001
2116
  const currentUser = await localPulumiHost.getCurrentUser();
2002
2117
  if (!currentUser) {
2003
2118
  throw new Error(
2004
- "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"
2005
2120
  );
2006
2121
  }
2007
2122
  if (!currentUser.url) {
@@ -2154,6 +2269,14 @@ class RuntimeOperation {
2154
2269
  this.persistSecrets.flush()
2155
2270
  ]);
2156
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
+ }
2157
2280
  }
2158
2281
  }
2159
2282
  resetPendingStateStatuses() {
@@ -2231,9 +2354,9 @@ class RuntimeOperation {
2231
2354
  { promiseCache: this.inputHashPromiseCache }
2232
2355
  );
2233
2356
  if (this.operation.type === "update") {
2234
- await this.extendWithNotCreatedDependencies();
2357
+ await this.extendWithDependencies();
2235
2358
  await this.updateOperation();
2236
- } else if (this.operation.type === "destroy") {
2359
+ } else if (this.operation.type === "destroy" && this.operation.options.destroyDependentInstances !== false) {
2237
2360
  this.extendWithCreatedDependents();
2238
2361
  await this.updateOperation();
2239
2362
  }
@@ -2280,7 +2403,8 @@ class RuntimeOperation {
2280
2403
  }
2281
2404
  async getUnitPromise(instance, component) {
2282
2405
  switch (this.operation.type) {
2283
- case "update": {
2406
+ case "update":
2407
+ case "preview": {
2284
2408
  return this.updateUnit(instance, component);
2285
2409
  }
2286
2410
  case "recreate": {
@@ -2368,11 +2492,12 @@ class RuntimeOperation {
2368
2492
  const secrets = await this.secretBackend.get(this.operation.projectId, instance.id);
2369
2493
  this.abortController.signal.throwIfAborted();
2370
2494
  logger.debug("secrets loaded", { count: Object.keys(secrets).length });
2371
- await this.runnerBackend.update({
2495
+ await this.runnerBackend[this.operation.type === "preview" ? "preview" : "update"]({
2372
2496
  projectId: this.operation.projectId,
2373
2497
  instanceType: instance.type,
2374
2498
  instanceName: instance.name,
2375
2499
  config: await this.prepareUnitConfig(instance),
2500
+ refresh: this.operation.options.refresh,
2376
2501
  secrets: mapValues(secrets, (value) => valueToString(value)),
2377
2502
  source: this.resolveUnitSource(component),
2378
2503
  signal: this.abortController.signal
@@ -2385,23 +2510,34 @@ class RuntimeOperation {
2385
2510
  finalStatuses: ["created", "error"]
2386
2511
  });
2387
2512
  await this.watchStateStream(stream);
2388
- const inputHash = await this.inputHashLock.acquire(async () => {
2389
- this.inputHashNodes.set(instance.id, {
2390
- instance,
2391
- resolvedInputs: this.resolvedInstanceInputs.get(instance.id),
2392
- state: this.stateMap.get(instance.id),
2393
- sourceHash: void 0
2394
- // TODO: implement source hash
2395
- });
2396
- this.inputHashPromiseCache.clear();
2397
- 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)
2398
2518
  });
2399
- this.updateInstanceState({ id: instance.id, inputHash });
2400
2519
  logger.debug("input hash after update", { inputHash });
2401
2520
  logger.info("unit updated");
2402
2521
  });
2403
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
+ }
2404
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
+ }
2405
2541
  const instance = this.instanceMap.get(state.id);
2406
2542
  if (!instance) {
2407
2543
  throw new Error(`Instance not found: ${state.id}`);
@@ -2414,12 +2550,13 @@ class RuntimeOperation {
2414
2550
  logger.info("updating unit to process before-destroy triggers...");
2415
2551
  const secrets = await this.secretBackend.get(this.operation.projectId, instance.id);
2416
2552
  this.abortController.signal.throwIfAborted();
2417
- logger.debug("secrets loaded", { count: Object.keys(secrets).length });
2553
+ logger.debug({ count: Object.keys(secrets).length }, "secrets loaded");
2418
2554
  await this.runnerBackend.update({
2419
2555
  projectId: this.operation.projectId,
2420
2556
  instanceType: instance.type,
2421
2557
  instanceName: instance.name,
2422
2558
  config: await this.prepareUnitConfig(instance, invokedTriggers),
2559
+ refresh: this.operation.options.refresh,
2423
2560
  secrets: mapValues(secrets, (value) => valueToString(value)),
2424
2561
  source: this.resolveUnitSource(component),
2425
2562
  signal: this.abortController.signal
@@ -2463,7 +2600,10 @@ class RuntimeOperation {
2463
2600
  projectId: this.operation.projectId,
2464
2601
  instanceType: type,
2465
2602
  instanceName: name,
2466
- 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
2467
2607
  });
2468
2608
  this.logger.debug("destroy request sent");
2469
2609
  const stream = this.runnerBackend.watch({
@@ -2497,7 +2637,10 @@ class RuntimeOperation {
2497
2637
  projectId: this.operation.projectId,
2498
2638
  instanceType: instance.type,
2499
2639
  instanceName: instance.name,
2500
- 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
2501
2644
  });
2502
2645
  logger.debug("destroy request sent");
2503
2646
  const destroyStream = this.runnerBackend.watch({
@@ -2518,6 +2661,7 @@ class RuntimeOperation {
2518
2661
  instanceType: instance.type,
2519
2662
  instanceName: instance.name,
2520
2663
  config: await this.prepareUnitConfig(instance),
2664
+ refresh: this.operation.options.refresh,
2521
2665
  secrets: mapValues(secrets, (value) => valueToString(value)),
2522
2666
  source: this.resolveUnitSource(component),
2523
2667
  signal: this.abortController.signal
@@ -2529,7 +2673,13 @@ class RuntimeOperation {
2529
2673
  instanceName: instance.name,
2530
2674
  finalStatuses: ["created", "error"]
2531
2675
  });
2532
- 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
+ });
2533
2683
  await this.watchStateStream(updateStream);
2534
2684
  logger.info("unit recreated");
2535
2685
  });
@@ -2667,9 +2817,11 @@ class RuntimeOperation {
2667
2817
  return;
2668
2818
  }
2669
2819
  const state = applyPartialInstanceState(this.stateMap, patch);
2670
- this.persistStates.call(state);
2671
- if (patch.secrets) {
2672
- 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
+ }
2673
2825
  }
2674
2826
  this.stateEE.emit(this.operation.projectId, createInstanceStateFrontendPatch(patch));
2675
2827
  }
@@ -2681,7 +2833,29 @@ class RuntimeOperation {
2681
2833
  children.push(state);
2682
2834
  this.childrenStateMap.set(state.parentId, children);
2683
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
+ }
2684
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) {
2685
2859
  const instance = this.instanceMap.get(state.id);
2686
2860
  if (!instance) {
2687
2861
  return;
@@ -2690,12 +2864,15 @@ class RuntimeOperation {
2690
2864
  for (const inputs of Object.values(instanceInputs)) {
2691
2865
  for (const input of inputs) {
2692
2866
  const dependents = this.dependentStateMap.get(input.input.instanceId) ?? [];
2693
- dependents.push(state);
2694
- 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
+ }
2695
2872
  }
2696
2873
  }
2697
2874
  }
2698
- async extendWithNotCreatedDependencies() {
2875
+ async extendWithDependencies() {
2699
2876
  const instanceIdsSet = /* @__PURE__ */ new Set();
2700
2877
  const traverse = async (instanceId) => {
2701
2878
  if (instanceIdsSet.has(instanceId)) {
@@ -2713,6 +2890,10 @@ class RuntimeOperation {
2713
2890
  }
2714
2891
  const state = this.stateMap.get(instance.id);
2715
2892
  const expectedInputHash = await this.resolveInputHash(instance.id);
2893
+ if (this.operation.options.forceUpdateDependencies) {
2894
+ instanceIdsSet.add(instanceId);
2895
+ return;
2896
+ }
2716
2897
  if (state?.status !== "created" || state.inputHash !== expectedInputHash) {
2717
2898
  instanceIdsSet.add(instanceId);
2718
2899
  }
@@ -2754,7 +2935,15 @@ class RuntimeOperation {
2754
2935
  if (instancePromise) {
2755
2936
  return instancePromise;
2756
2937
  }
2757
- 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
+ })();
2758
2947
  instancePromise = instancePromise.finally(() => this.instancePromiseMap.delete(instanceId));
2759
2948
  this.instancePromiseMap.set(instanceId, instancePromise);
2760
2949
  return instancePromise;
@@ -2763,6 +2952,8 @@ class RuntimeOperation {
2763
2952
  switch (this.operation.type) {
2764
2953
  case "update":
2765
2954
  return "updating";
2955
+ case "preview":
2956
+ return "previewing";
2766
2957
  case "recreate":
2767
2958
  return "updating";
2768
2959
  case "destroy":
@@ -2796,47 +2987,44 @@ class RuntimeOperation {
2796
2987
  }
2797
2988
  return component.source;
2798
2989
  }
2799
- persistStates = funnel(
2800
- (states) => {
2801
- this.logger.debug({ msg: "persisting states", count: states.length });
2802
- void this.stateBackend.putAffectedInstanceStates(
2803
- this.operation.projectId,
2804
- this.operation.id,
2805
- states
2806
- );
2807
- },
2808
- {
2809
- minQuietPeriodMs: 100,
2810
- maxBurstDurationMs: 1e3,
2811
- triggerAt: "end",
2812
- reducer: arrayAccumulator
2813
- }
2814
- );
2815
- persistLogs = funnel(
2816
- (entries) => {
2817
- this.logger.trace({ msg: "persisting logs", count: entries.length });
2818
- void this.stateBackend.appendInstanceLogs(this.operation.id, entries);
2819
- },
2820
- {
2821
- minQuietPeriodMs: 100,
2822
- maxBurstDurationMs: 200,
2823
- 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);
2824
3006
  }
2825
- );
2826
- persistSecrets = funnel(
2827
- (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) => {
2828
3022
  this.logger.debug({ msg: "persisting secrets", count: entries.length });
2829
3023
  for (const [instanceId, secrets] of entries) {
2830
- void this.secretBackend.get(this.operation.projectId, instanceId).then((existingSecrets) => {
2831
- Object.assign(existingSecrets, secrets);
2832
- return this.secretBackend.set(this.operation.projectId, instanceId, existingSecrets);
2833
- });
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);
2834
3027
  }
2835
- },
2836
- {
2837
- minQuietPeriodMs: 100,
2838
- maxBurstDurationMs: 200,
2839
- reducer: arrayAccumulator
2840
3028
  }
2841
3029
  );
2842
3030
  static abortMessagePatterns = [
@@ -2929,12 +3117,21 @@ class OperationManager {
2929
3117
  type: request.type,
2930
3118
  instanceIds: request.instanceIds,
2931
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
+ },
2932
3127
  error: null,
2933
3128
  startedAt: Date.now(),
2934
3129
  completedAt: null
2935
3130
  };
3131
+ this.logger.info({ operation }, "launching operation");
2936
3132
  await this.stateBackend.putOperation(operation);
2937
3133
  this.startOperation(operation);
3134
+ return operation;
2938
3135
  }
2939
3136
  /**
2940
3137
  * Cancels the current operation.