@nocobase/app-plugin-releases 0.1.0-beta.2 → 0.1.0-beta.21

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 (270) hide show
  1. package/CHANGELOG.md +349 -0
  2. package/LICENSE.txt +129 -0
  3. package/README.md +91 -16
  4. package/dist/client/components/environment-form.d.ts +1 -1
  5. package/dist/client/components/environment-form.d.ts.map +1 -1
  6. package/dist/client/components/environment-form.js +9 -2
  7. package/dist/client/components/environment-form.js.map +1 -1
  8. package/dist/client/components/page-container.js +1 -1
  9. package/dist/client/components/page-container.js.map +1 -1
  10. package/dist/client/components/page-header.d.ts.map +1 -1
  11. package/dist/client/components/page-header.js +1 -1
  12. package/dist/client/components/page-header.js.map +1 -1
  13. package/dist/client/components/release-badges.d.ts.map +1 -1
  14. package/dist/client/components/release-badges.js +13 -1
  15. package/dist/client/components/release-badges.js.map +1 -1
  16. package/dist/client/components/route-overlay.d.ts +4 -1
  17. package/dist/client/components/route-overlay.d.ts.map +1 -1
  18. package/dist/client/components/route-overlay.js +32 -3
  19. package/dist/client/components/route-overlay.js.map +1 -1
  20. package/dist/client/components/runtime-policy-fields.d.ts +7 -1
  21. package/dist/client/components/runtime-policy-fields.d.ts.map +1 -1
  22. package/dist/client/components/runtime-policy-fields.js +19 -5
  23. package/dist/client/components/runtime-policy-fields.js.map +1 -1
  24. package/dist/client/components/runtime-state.d.ts +6 -0
  25. package/dist/client/components/runtime-state.d.ts.map +1 -0
  26. package/dist/client/components/runtime-state.js +67 -0
  27. package/dist/client/components/runtime-state.js.map +1 -0
  28. package/dist/client/components/shared-host.d.ts +20 -0
  29. package/dist/client/components/shared-host.d.ts.map +1 -0
  30. package/dist/client/components/shared-host.js +88 -0
  31. package/dist/client/components/shared-host.js.map +1 -0
  32. package/dist/client/components/ui/progress.d.ts +9 -0
  33. package/dist/client/components/ui/progress.d.ts.map +1 -0
  34. package/dist/client/components/ui/progress.js +20 -0
  35. package/dist/client/components/ui/progress.js.map +1 -0
  36. package/dist/client/components/use-route-overlay.d.ts +2 -9
  37. package/dist/client/components/use-route-overlay.d.ts.map +1 -1
  38. package/dist/client/components/use-route-overlay.js +5 -3
  39. package/dist/client/components/use-route-overlay.js.map +1 -1
  40. package/dist/client/components/variable-alerts.d.ts.map +1 -1
  41. package/dist/client/components/variable-alerts.js +6 -18
  42. package/dist/client/components/variable-alerts.js.map +1 -1
  43. package/dist/client/hooks/use-app-summary.d.ts +1 -1
  44. package/dist/client/hooks/use-app-summary.d.ts.map +1 -1
  45. package/dist/client/hooks/use-app-summary.js +4 -1
  46. package/dist/client/hooks/use-app-summary.js.map +1 -1
  47. package/dist/client/hooks/use-releases.d.ts +4 -1
  48. package/dist/client/hooks/use-releases.d.ts.map +1 -1
  49. package/dist/client/hooks/use-releases.js +6 -2
  50. package/dist/client/hooks/use-releases.js.map +1 -1
  51. package/dist/client/lib/app-state.d.ts +2 -2
  52. package/dist/client/lib/app-state.d.ts.map +1 -1
  53. package/dist/client/lib/app-state.js +10 -3
  54. package/dist/client/lib/app-state.js.map +1 -1
  55. package/dist/client/lib/errors.d.ts.map +1 -1
  56. package/dist/client/lib/errors.js +7 -0
  57. package/dist/client/lib/errors.js.map +1 -1
  58. package/dist/client/lib/format.d.ts +2 -0
  59. package/dist/client/lib/format.d.ts.map +1 -1
  60. package/dist/client/lib/format.js +7 -0
  61. package/dist/client/lib/format.js.map +1 -1
  62. package/dist/client/lib/paths.d.ts +5 -0
  63. package/dist/client/lib/paths.d.ts.map +1 -1
  64. package/dist/client/lib/paths.js +10 -0
  65. package/dist/client/lib/paths.js.map +1 -1
  66. package/dist/client/lib/permissions.d.ts +5 -0
  67. package/dist/client/lib/permissions.d.ts.map +1 -1
  68. package/dist/client/lib/permissions.js +8 -0
  69. package/dist/client/lib/permissions.js.map +1 -1
  70. package/dist/client/lib/runtime-state.d.ts +25 -0
  71. package/dist/client/lib/runtime-state.d.ts.map +1 -0
  72. package/dist/client/lib/runtime-state.js +58 -0
  73. package/dist/client/lib/runtime-state.js.map +1 -0
  74. package/dist/client/locales/de-DE.d.ts +5 -0
  75. package/dist/client/locales/de-DE.d.ts.map +1 -0
  76. package/dist/client/locales/de-DE.js +876 -0
  77. package/dist/client/locales/de-DE.js.map +1 -0
  78. package/dist/client/locales/en-US.d.ts +83 -9
  79. package/dist/client/locales/en-US.d.ts.map +1 -1
  80. package/dist/client/locales/en-US.js +80 -9
  81. package/dist/client/locales/en-US.js.map +1 -1
  82. package/dist/client/locales/es-ES.d.ts +5 -0
  83. package/dist/client/locales/es-ES.d.ts.map +1 -0
  84. package/dist/client/locales/es-ES.js +882 -0
  85. package/dist/client/locales/es-ES.js.map +1 -0
  86. package/dist/client/locales/fr-FR.d.ts +5 -0
  87. package/dist/client/locales/fr-FR.d.ts.map +1 -0
  88. package/dist/client/locales/fr-FR.js +882 -0
  89. package/dist/client/locales/fr-FR.js.map +1 -0
  90. package/dist/client/locales/id-ID.d.ts +5 -0
  91. package/dist/client/locales/id-ID.d.ts.map +1 -0
  92. package/dist/client/locales/id-ID.js +876 -0
  93. package/dist/client/locales/id-ID.js.map +1 -0
  94. package/dist/client/locales/index.d.ts.map +1 -1
  95. package/dist/client/locales/index.js +10 -0
  96. package/dist/client/locales/index.js.map +1 -1
  97. package/dist/client/locales/ja-JP.d.ts +5 -0
  98. package/dist/client/locales/ja-JP.d.ts.map +1 -0
  99. package/dist/client/locales/ja-JP.js +876 -0
  100. package/dist/client/locales/ja-JP.js.map +1 -0
  101. package/dist/client/locales/ko-KR.d.ts +5 -0
  102. package/dist/client/locales/ko-KR.d.ts.map +1 -0
  103. package/dist/client/locales/ko-KR.js +879 -0
  104. package/dist/client/locales/ko-KR.js.map +1 -0
  105. package/dist/client/locales/pt-BR.d.ts +5 -0
  106. package/dist/client/locales/pt-BR.d.ts.map +1 -0
  107. package/dist/client/locales/pt-BR.js +882 -0
  108. package/dist/client/locales/pt-BR.js.map +1 -0
  109. package/dist/client/locales/ru-RU.d.ts +5 -0
  110. package/dist/client/locales/ru-RU.d.ts.map +1 -0
  111. package/dist/client/locales/ru-RU.js +891 -0
  112. package/dist/client/locales/ru-RU.js.map +1 -0
  113. package/dist/client/locales/vi-VN.d.ts +5 -0
  114. package/dist/client/locales/vi-VN.d.ts.map +1 -0
  115. package/dist/client/locales/vi-VN.js +876 -0
  116. package/dist/client/locales/vi-VN.js.map +1 -0
  117. package/dist/client/locales/zh-CN.d.ts.map +1 -1
  118. package/dist/client/locales/zh-CN.js +83 -9
  119. package/dist/client/locales/zh-CN.js.map +1 -1
  120. package/dist/client/locales/zh-TW.d.ts +5 -0
  121. package/dist/client/locales/zh-TW.d.ts.map +1 -0
  122. package/dist/client/locales/zh-TW.js +876 -0
  123. package/dist/client/locales/zh-TW.js.map +1 -0
  124. package/dist/client/pages/app-page.d.ts.map +1 -1
  125. package/dist/client/pages/app-page.js +14 -8
  126. package/dist/client/pages/app-page.js.map +1 -1
  127. package/dist/client/pages/apps-outlet.d.ts +8 -0
  128. package/dist/client/pages/apps-outlet.d.ts.map +1 -0
  129. package/dist/client/pages/apps-outlet.js +9 -0
  130. package/dist/client/pages/apps-outlet.js.map +1 -0
  131. package/dist/client/pages/environment-page.d.ts.map +1 -1
  132. package/dist/client/pages/environment-page.js +112 -24
  133. package/dist/client/pages/environment-page.js.map +1 -1
  134. package/dist/client/pages/environments-page.d.ts +8 -3
  135. package/dist/client/pages/environments-page.d.ts.map +1 -1
  136. package/dist/client/pages/environments-page.js +58 -10
  137. package/dist/client/pages/environments-page.js.map +1 -1
  138. package/dist/client/pages/new-app-page.d.ts +2 -4
  139. package/dist/client/pages/new-app-page.d.ts.map +1 -1
  140. package/dist/client/pages/new-app-page.js +29 -19
  141. package/dist/client/pages/new-app-page.js.map +1 -1
  142. package/dist/client/pages/new-app-redirect.d.ts +7 -0
  143. package/dist/client/pages/new-app-redirect.d.ts.map +1 -0
  144. package/dist/client/pages/new-app-redirect.js +8 -0
  145. package/dist/client/pages/new-app-redirect.js.map +1 -0
  146. package/dist/client/pages/request-page.js +1 -1
  147. package/dist/client/pages.d.ts +6 -2
  148. package/dist/client/pages.d.ts.map +1 -1
  149. package/dist/client/pages.js +8 -4
  150. package/dist/client/pages.js.map +1 -1
  151. package/dist/client/routes.d.ts.map +1 -1
  152. package/dist/client/routes.js +37 -10
  153. package/dist/client/routes.js.map +1 -1
  154. package/dist/database/migrations/.manifest.json +10 -2
  155. package/dist/database/migrations/202610020001_rel_create_tables.js +1 -1
  156. package/dist/database/migrations/202610020001_rel_create_tables.js.map +1 -1
  157. package/dist/database/migrations/202610260002_rel_bound_artifact_keys.d.ts +4 -0
  158. package/dist/database/migrations/202610260002_rel_bound_artifact_keys.d.ts.map +1 -0
  159. package/dist/database/migrations/202610260002_rel_bound_artifact_keys.js +51 -0
  160. package/dist/database/migrations/202610260002_rel_bound_artifact_keys.js.map +1 -0
  161. package/dist/database/migrations/202611020001_rel_environment_purpose.d.ts +8 -0
  162. package/dist/database/migrations/202611020001_rel_environment_purpose.d.ts.map +1 -0
  163. package/dist/database/migrations/202611020001_rel_environment_purpose.js +30 -0
  164. package/dist/database/migrations/202611020001_rel_environment_purpose.js.map +1 -0
  165. package/dist/server/access/caller.d.ts +6 -0
  166. package/dist/server/access/caller.d.ts.map +1 -1
  167. package/dist/server/access/caller.js +36 -0
  168. package/dist/server/access/caller.js.map +1 -1
  169. package/dist/server/artifact-config.d.ts +9 -0
  170. package/dist/server/artifact-config.d.ts.map +1 -0
  171. package/dist/server/artifact-config.js +49 -0
  172. package/dist/server/artifact-config.js.map +1 -0
  173. package/dist/server/composition.d.ts +6 -0
  174. package/dist/server/composition.d.ts.map +1 -1
  175. package/dist/server/composition.js +7 -1
  176. package/dist/server/composition.js.map +1 -1
  177. package/dist/server/config.d.ts +18 -2
  178. package/dist/server/config.d.ts.map +1 -1
  179. package/dist/server/drivers/host/driver.d.ts +13 -2
  180. package/dist/server/drivers/host/driver.d.ts.map +1 -1
  181. package/dist/server/drivers/host/driver.js +427 -81
  182. package/dist/server/drivers/host/driver.js.map +1 -1
  183. package/dist/server/drivers/types.d.ts +79 -1
  184. package/dist/server/drivers/types.d.ts.map +1 -1
  185. package/dist/server/drivers/types.js +6 -0
  186. package/dist/server/drivers/types.js.map +1 -1
  187. package/dist/server/index.d.ts +1 -1
  188. package/dist/server/index.d.ts.map +1 -1
  189. package/dist/server/index.js.map +1 -1
  190. package/dist/server/locales/de-DE.d.ts +5 -0
  191. package/dist/server/locales/de-DE.d.ts.map +1 -0
  192. package/dist/server/locales/de-DE.js +133 -0
  193. package/dist/server/locales/de-DE.js.map +1 -0
  194. package/dist/server/locales/es-ES.d.ts +5 -0
  195. package/dist/server/locales/es-ES.d.ts.map +1 -0
  196. package/dist/server/locales/es-ES.js +133 -0
  197. package/dist/server/locales/es-ES.js.map +1 -0
  198. package/dist/server/locales/fr-FR.d.ts +5 -0
  199. package/dist/server/locales/fr-FR.d.ts.map +1 -0
  200. package/dist/server/locales/fr-FR.js +133 -0
  201. package/dist/server/locales/fr-FR.js.map +1 -0
  202. package/dist/server/locales/id-ID.d.ts +5 -0
  203. package/dist/server/locales/id-ID.d.ts.map +1 -0
  204. package/dist/server/locales/id-ID.js +133 -0
  205. package/dist/server/locales/id-ID.js.map +1 -0
  206. package/dist/server/locales/index.d.ts.map +1 -1
  207. package/dist/server/locales/index.js +10 -0
  208. package/dist/server/locales/index.js.map +1 -1
  209. package/dist/server/locales/ja-JP.d.ts +5 -0
  210. package/dist/server/locales/ja-JP.d.ts.map +1 -0
  211. package/dist/server/locales/ja-JP.js +133 -0
  212. package/dist/server/locales/ja-JP.js.map +1 -0
  213. package/dist/server/locales/ko-KR.d.ts +5 -0
  214. package/dist/server/locales/ko-KR.d.ts.map +1 -0
  215. package/dist/server/locales/ko-KR.js +133 -0
  216. package/dist/server/locales/ko-KR.js.map +1 -0
  217. package/dist/server/locales/pt-BR.d.ts +5 -0
  218. package/dist/server/locales/pt-BR.d.ts.map +1 -0
  219. package/dist/server/locales/pt-BR.js +133 -0
  220. package/dist/server/locales/pt-BR.js.map +1 -0
  221. package/dist/server/locales/ru-RU.d.ts +5 -0
  222. package/dist/server/locales/ru-RU.d.ts.map +1 -0
  223. package/dist/server/locales/ru-RU.js +133 -0
  224. package/dist/server/locales/ru-RU.js.map +1 -0
  225. package/dist/server/locales/vi-VN.d.ts +5 -0
  226. package/dist/server/locales/vi-VN.d.ts.map +1 -0
  227. package/dist/server/locales/vi-VN.js +133 -0
  228. package/dist/server/locales/vi-VN.js.map +1 -0
  229. package/dist/server/locales/zh-TW.d.ts +5 -0
  230. package/dist/server/locales/zh-TW.d.ts.map +1 -0
  231. package/dist/server/locales/zh-TW.js +133 -0
  232. package/dist/server/locales/zh-TW.js.map +1 -0
  233. package/dist/server/providers/releases.d.ts.map +1 -1
  234. package/dist/server/providers/releases.js +12 -5
  235. package/dist/server/providers/releases.js.map +1 -1
  236. package/dist/server/routes/api.d.ts +1 -1
  237. package/dist/server/routes/api.d.ts.map +1 -1
  238. package/dist/server/routes/api.js +120 -20
  239. package/dist/server/routes/api.js.map +1 -1
  240. package/dist/server/routes/index.d.ts.map +1 -1
  241. package/dist/server/routes/index.js +10 -2
  242. package/dist/server/routes/index.js.map +1 -1
  243. package/dist/server/routes/schemas.d.ts +7 -2
  244. package/dist/server/routes/schemas.d.ts.map +1 -1
  245. package/dist/server/routes/schemas.js +320 -89
  246. package/dist/server/routes/schemas.js.map +1 -1
  247. package/dist/server/services/environments.d.ts +2 -2
  248. package/dist/server/services/environments.d.ts.map +1 -1
  249. package/dist/server/services/environments.js +21 -1
  250. package/dist/server/services/environments.js.map +1 -1
  251. package/dist/server/services/releases.d.ts +69 -8
  252. package/dist/server/services/releases.d.ts.map +1 -1
  253. package/dist/server/services/releases.js +574 -128
  254. package/dist/server/services/releases.js.map +1 -1
  255. package/dist/server/services/validation.d.ts +2 -0
  256. package/dist/server/services/validation.d.ts.map +1 -1
  257. package/dist/server/services/validation.js +8 -0
  258. package/dist/server/services/validation.js.map +1 -1
  259. package/dist/server/tokens.d.ts +15 -2
  260. package/dist/server/tokens.d.ts.map +1 -1
  261. package/dist/server/tokens.js.map +1 -1
  262. package/dist/shared/releases.d.ts +94 -3
  263. package/dist/shared/releases.d.ts.map +1 -1
  264. package/dist/shared/releases.js +37 -0
  265. package/dist/shared/releases.js.map +1 -1
  266. package/package.json +18 -18
  267. package/dist/client/pages/apps-page.d.ts +0 -4
  268. package/dist/client/pages/apps-page.d.ts.map +0 -1
  269. package/dist/client/pages/apps-page.js +0 -116
  270. package/dist/client/pages/apps-page.js.map +0 -1
@@ -13,9 +13,9 @@ import { createDriveManager, } from '@nocobase/drive';
13
13
  import { appendJournal, createDiagnosticLogger, pruneJournals, readJournal, reportLoggingFailure, } from '@nocobase/logging';
14
14
  import { parse as parseYaml } from 'yaml';
15
15
  import { BUSINESS_ACTIONS } from '../../shared/access.js';
16
- import { RESERVED_VARIABLE_NAMES } from '../../shared/releases.js';
16
+ import { RESERVED_VARIABLE_NAMES, runStateOf, } from '../../shared/releases.js';
17
17
  import { SYSTEM_CALLER, actorOf, } from '../access/caller.js';
18
- import { capabilitiesFor, } from '../drivers/types.js';
18
+ import { capabilitiesFor, UNRESTORED_APP_ERROR, } from '../drivers/types.js';
19
19
  import { ReleasesError, forbidden, notFound } from '../errors.js';
20
20
  import { RELEASE_VERSION_PATTERN, assertMountableAt, inspectArtifact, receiveArtifact, validateIdempotencyKey, } from './artifact.js';
21
21
  import { decodeDate, decodeLabels, decodeOptionalDate, decodeRecord, iso, matchesLabels, nullableNumber, nullableString, } from './codec.js';
@@ -24,10 +24,17 @@ import { maskConfig, restoreConfigSecrets } from './config-secrets.js';
24
24
  import { ReleaseArtifactStore, normalizeImage, } from './release-artifacts.js';
25
25
  import { decryptText, encryptText } from './secrets.js';
26
26
  import { DEFAULT_INITIAL_ADMIN, INITIAL_ADMIN_PATHS, PUBLIC_ORIGIN_PATH, SAMPLE_DATA_PATH, appVariableView, assertVariableName, assertVariableValue, configValueAt, decodeVariablesManifest, generateVariable, hasRealValue, isSecretName, parseVariablesManifest, randomPassword, resolveVariables, } from './variables.js';
27
- import { APP_ID_PATTERN, assertAppId, assertPagination, normalizeLabels, normalizeName, normalizeRuntimePolicy, } from './validation.js';
27
+ import { APP_ID_PATTERN, assertAppId, assertPagination, normalizeLabels, normalizeName, normalizeRuntimePolicy, releaseArtifactKey, } from './validation.js';
28
28
  const DEFAULT_STARTUP_WAIT_MS = 5_000;
29
+ /** `<appId>-<version>.tar.gz`, with anything but letters, digits, `.`, `_` and `-` replaced. */
30
+ export function archiveFileName(appId, version) {
31
+ const safe = (text) => text.replace(/[^A-Za-z0-9._-]+/gu, '_').replace(/^\.+/u, '') || 'release';
32
+ return `${safe(appId)}-${safe(version)}.tar.gz`;
33
+ }
29
34
  export class ReleasesService {
30
35
  options;
36
+ dispatch = new Map();
37
+ progress = new Map();
31
38
  diagnostic;
32
39
  disk;
33
40
  artifacts;
@@ -38,6 +45,9 @@ export class ReleasesService {
38
45
  runs = new Map();
39
46
  restoring = null;
40
47
  closed = false;
48
+ artifactKey(appId, releaseId) {
49
+ return releaseArtifactKey(appId, releaseId, this.options.artifactPrefix);
50
+ }
41
51
  constructor(options) {
42
52
  this.options = options;
43
53
  this.diagnostic = createDiagnosticLogger(options.logger);
@@ -72,8 +82,12 @@ export class ReleasesService {
72
82
  query = query.where((eb) => searchIds.length > 0
73
83
  ? eb('id', 'in', [...searchIds])
74
84
  : eb('id', 'is', null));
75
- if (options.environmentId)
76
- query = query.where('environmentId', '=', options.environmentId);
85
+ const environmentIds = [
86
+ ...(options.environmentId ? [options.environmentId] : []),
87
+ ...(options.environmentIds ?? []),
88
+ ];
89
+ if (environmentIds.length > 0)
90
+ query = query.where('environmentId', 'in', [...new Set(environmentIds)]);
77
91
  const only = filter.only;
78
92
  if (only)
79
93
  query = query.where((eb) => only.length > 0 ? eb('id', 'in', [...only]) : eb('id', 'is', null));
@@ -85,13 +99,27 @@ export class ReleasesService {
85
99
  ]));
86
100
  }
87
101
  // Labels are opaque JSON, matched here rather than in SQL.
88
- const rows = (await query.execute())
102
+ let rows = (await query.execute())
89
103
  .map(decodeApp)
90
104
  .filter((app) => matchesLabels(app.labels, options.labels));
105
+ // A state filter needs every candidate's runtime before the page is cut: one batched status call per environment,
106
+ // reused for the page's summaries.
107
+ let statuses;
108
+ if (options.runStates?.length) {
109
+ const wanted = new Set(options.runStates);
110
+ statuses = await this.runtimeStatuses(rows);
111
+ const known = statuses;
112
+ rows = rows.filter((app) => wanted.has(runStateOf(runtimeStatus(known.get(app.environmentId), app.id))));
113
+ }
91
114
  const total = rows.length;
92
115
  const page = Math.min(requestedPage, Math.max(1, Math.ceil(total / pageSize)));
93
116
  const slice = rows.slice((page - 1) * pageSize, page * pageSize);
94
- return { items: await this.summarize(slice), total, page, pageSize };
117
+ return {
118
+ items: await this.summarize(slice, statuses),
119
+ total,
120
+ page,
121
+ pageSize,
122
+ };
95
123
  }
96
124
  async getApp(caller, appId) {
97
125
  const app = await this.requireApp(appId);
@@ -101,10 +129,21 @@ export class ReleasesService {
101
129
  if (action !== 'create' &&
102
130
  (await this.options.guard.canApp(caller, action, app)))
103
131
  allowed.push(action);
104
- const variables = await this.appVariablesOf(app).catch(() => null);
132
+ const [variables, shared] = await Promise.all([
133
+ this.appVariablesOf(app).catch(() => null),
134
+ this.hostOf(caller, app).catch(() => null),
135
+ ]);
136
+ const summary = (await this.summarize([app]))[0];
137
+ // A driver reporting an environment's restore error on its Apps would reveal the others it names: it is the
138
+ // environment's diagnosis, for those who may read it (`hostOf`).
139
+ const runtime = summary.runtime?.error && shared?.hiddenErrors.has(summary.runtime.error)
140
+ ? { ...summary.runtime, error: UNRESTORED_APP_ERROR }
141
+ : summary.runtime;
105
142
  return {
106
- ...(await this.summarize([app]))[0],
143
+ ...summary,
144
+ runtime,
107
145
  allowed,
146
+ host: shared?.view ?? null,
108
147
  ...(variables
109
148
  ? {
110
149
  variables: {
@@ -115,7 +154,97 @@ export class ReleasesService {
115
154
  : {}),
116
155
  };
117
156
  }
118
- async createApp(caller, input) {
157
+ /**
158
+ * The runtime the App shares: one status call to the driver. Its counts and memory are totals of the whole runtime;
159
+ * no other App is named, and an environment's restore error only reaches those who manage environments.
160
+ */
161
+ async hostOf(caller, app) {
162
+ const session = await this.session(app.environmentId);
163
+ const occupancy = (await session.occupancy?.()) ?? null;
164
+ if (!occupancy)
165
+ return null;
166
+ return await this.hostView(caller, occupancy);
167
+ }
168
+ /**
169
+ * The runtimes the environments with Apps share, each once however many environments run on it, for the
170
+ * environments page: one driver status call per such environment, then what `hostOf` reads per runtime. Requires the
171
+ * `rel.environments` `read` setting; restore errors are withheld as on an App's page.
172
+ */
173
+ async listHosts(caller) {
174
+ this.options.guard.requireSetting(caller, 'rel.environments', 'read');
175
+ // The sessions restore opened already: an environment without Apps runs nothing on any runtime.
176
+ const rows = await this.query()
177
+ .selectFrom('relApps')
178
+ .select('environmentId')
179
+ .distinct()
180
+ .execute();
181
+ const found = await Promise.all(rows.map(async (row) => {
182
+ const environmentId = String(row.environmentId);
183
+ const occupancy = await this.session(environmentId)
184
+ .then((session) => session.occupancy?.() ?? null)
185
+ .catch(() => null);
186
+ return occupancy ? { environmentId, occupancy } : null;
187
+ }));
188
+ const hosts = new Map();
189
+ for (const item of found) {
190
+ if (!item)
191
+ continue;
192
+ const host = hosts.get(item.occupancy.id);
193
+ if (host)
194
+ host.environmentIds.push(item.environmentId);
195
+ else
196
+ hosts.set(item.occupancy.id, {
197
+ occupancy: item.occupancy,
198
+ environmentIds: [item.environmentId],
199
+ });
200
+ }
201
+ const views = [];
202
+ for (const { occupancy, environmentIds } of hosts.values()) {
203
+ const { view } = await this.hostView(caller, occupancy);
204
+ const environments = [];
205
+ for (const id of environmentIds.sort())
206
+ environments.push({
207
+ id,
208
+ name: (await this.options.environments.find(id))?.name ?? id,
209
+ // Counted as the host's `running`, so the environment's Apps filtered to running or starting are as many.
210
+ running: occupancy.apps.filter((item) => item.environmentId === id &&
211
+ (item.state === 'running' || item.state === 'starting')).length,
212
+ });
213
+ views.push({ ...view, environments });
214
+ }
215
+ return views.sort((a, b) => a.id.localeCompare(b.id));
216
+ }
217
+ /** What `occupancy` says of the runtime, limited to what the caller may read. */
218
+ async hostView(caller, occupancy) {
219
+ const environmentNames = new Map();
220
+ for (const item of occupancy.failedEnvironments)
221
+ environmentNames.set(item.environmentId, (await this.options.environments.find(item.environmentId))?.name ??
222
+ null);
223
+ // An environment's restore error may name any of its Apps or their paths: only those who manage environments
224
+ // read it; everyone else learns that the restore failed.
225
+ const diagnoses = this.options.guard.hasSetting(caller, 'rel.environments', 'manage');
226
+ const hiddenErrors = new Set(diagnoses
227
+ ? []
228
+ : occupancy.failedEnvironments.flatMap((item) => item.error ? [item.error] : []));
229
+ return {
230
+ view: {
231
+ id: occupancy.id,
232
+ memory: occupancy.memory,
233
+ recycling: occupancy.recycling,
234
+ deployed: occupancy.apps.length,
235
+ // Running or starting Apps hold the runtime; a failed one holds nothing and is not counted.
236
+ running: occupancy.apps.filter((item) => item.state === 'running' || item.state === 'starting').length,
237
+ maxActive: occupancy.maxActive,
238
+ failedEnvironments: occupancy.failedEnvironments.map((item) => ({
239
+ id: item.environmentId,
240
+ name: environmentNames.get(item.environmentId) ?? null,
241
+ error: diagnoses ? item.error : null,
242
+ })),
243
+ },
244
+ hiddenErrors,
245
+ };
246
+ }
247
+ async createApp(caller, input, options = {}) {
119
248
  this.options.guard.requireCreate(caller);
120
249
  if (!input || typeof input.id !== 'string')
121
250
  throw new ReleasesError('An App ID is required.', 'INVALID_APP_ID', 'INVALID_ARGUMENT');
@@ -144,28 +273,32 @@ export class ReleasesService {
144
273
  if (await this.findApp(id))
145
274
  throw new ReleasesError('Application ID is unavailable. Choose a different ID.', 'APP_EXISTS', 'ALREADY_EXISTS');
146
275
  await this.assertEnvironmentCapacity(environment);
147
- await this.query()
148
- .insertInto('relApps')
149
- .values({
150
- id,
151
- environmentId: environment.id,
152
- name,
153
- description: typeof input.description === 'string' && input.description.trim()
154
- ? input.description.trim()
155
- : null,
156
- currentDeploymentId: null,
157
- enabled: false,
158
- activation: policy.activation,
159
- idleStopMinutes: policy.idleStopMinutes,
160
- dormantAfterHours: policy.dormantAfterHours,
161
- labels: JSON.stringify(labels),
162
- previewOf,
163
- createdBy: caller.userId,
164
- createdVia: caller.kind,
165
- createdAt: now,
166
- updatedAt: now,
167
- })
168
- .execute();
276
+ await this.options.database.transaction(async (connection) => {
277
+ await connection.query
278
+ .insertInto('relApps')
279
+ .values({
280
+ id,
281
+ environmentId: environment.id,
282
+ name,
283
+ description: typeof input.description === 'string' &&
284
+ input.description.trim()
285
+ ? input.description.trim()
286
+ : null,
287
+ currentDeploymentId: null,
288
+ enabled: false,
289
+ activation: policy.activation,
290
+ idleStopMinutes: policy.idleStopMinutes,
291
+ dormantAfterHours: policy.dormantAfterHours,
292
+ labels: JSON.stringify(labels),
293
+ previewOf,
294
+ createdBy: caller.userId,
295
+ createdVia: caller.kind,
296
+ createdAt: now,
297
+ updatedAt: now,
298
+ })
299
+ .execute();
300
+ await options.initialize?.(connection, id);
301
+ });
169
302
  });
170
303
  }
171
304
  catch (error) {
@@ -268,6 +401,48 @@ export class ReleasesService {
268
401
  const images = await this.artifacts.imagesOf([release.id]);
269
402
  return releaseView(release, images.get(release.id) ?? []);
270
403
  }
404
+ /**
405
+ * Several Apps' summaries in one batched read (one runtime status call per environment), for the assembling
406
+ * application: no caller is checked. Unknown ids are left out; the rest keep the order asked.
407
+ */
408
+ async summarizeApps(appIds) {
409
+ const ids = [...new Set(appIds)];
410
+ if (ids.length === 0)
411
+ return [];
412
+ const rows = await this.query()
413
+ .selectFrom('relApps')
414
+ .selectAll()
415
+ .where('id', 'in', ids)
416
+ .execute();
417
+ const byId = new Map(rows.map(decodeApp).map((app) => [app.id, app]));
418
+ const apps = ids.flatMap((id) => {
419
+ const app = byId.get(id);
420
+ return app ? [app] : [];
421
+ });
422
+ return this.summarize(apps);
423
+ }
424
+ /**
425
+ * A release's archive, to download: `view` on the App. An image release has none (`RELEASE_HAS_NO_ARCHIVE`), and an
426
+ * archive missing from the disk is `RELEASE_ARCHIVE_NOT_FOUND`. The stream is opened last, so a refusal never touches
427
+ * the disk.
428
+ */
429
+ async openArchive(caller, appId, releaseId) {
430
+ const app = await this.requireApp(appId);
431
+ await this.options.guard.requireApp(caller, 'view', app);
432
+ const release = await this.requireRelease(appId, releaseId);
433
+ const key = release.artifactKey;
434
+ if (!key)
435
+ throw new ReleasesError('This release is a set of images and has no archive to download.', 'RELEASE_HAS_NO_ARCHIVE', 'FAILED_PRECONDITION');
436
+ // A local disk opens its file lazily, so a missing archive would fail only after the response headers went out.
437
+ if (!(await this.disk.exists(key)))
438
+ throw notFound('Archive', 'RELEASE_ARCHIVE_NOT_FOUND', releaseId);
439
+ return {
440
+ stream: await this.disk.getStream(key),
441
+ size: release.size,
442
+ checksum: release.checksum,
443
+ fileName: archiveFileName(app.id, release.version),
444
+ };
445
+ }
271
446
  /**
272
447
  * Registers an image CI built and pushed, by digest, as a release of `version` (an image release, which Docker
273
448
  * environments pull). It needs `upload` on the App, as uploading an archive does. The same digest again answers its
@@ -418,7 +593,7 @@ export class ReleasesService {
418
593
  if (existing)
419
594
  return existing;
420
595
  const id = randomUUID();
421
- const artifactKey = `${appId}/${id}.tar.gz`;
596
+ const artifactKey = this.artifactKey(appId, id);
422
597
  const release = {
423
598
  id,
424
599
  appId,
@@ -438,8 +613,6 @@ export class ReleasesService {
438
613
  createdVia: caller.kind,
439
614
  createdAt: new Date(),
440
615
  };
441
- if (input.deploy)
442
- await this.requireNoPendingDeployment(appId);
443
616
  // A release whose required variables are not all set is kept, and not deployed: once they are set, it
444
617
  // deploys by its ID without another upload.
445
618
  const missing = input.deploy
@@ -457,8 +630,6 @@ export class ReleasesService {
457
630
  contentType: 'application/gzip',
458
631
  });
459
632
  await this.options.database.transaction(async (connection) => {
460
- if (deployment)
461
- await this.requireNoPendingDeployment(appId, connection);
462
633
  await connection.query
463
634
  .insertInto('relReleases')
464
635
  .values(encodeRelease(release))
@@ -514,8 +685,16 @@ export class ReleasesService {
514
685
  });
515
686
  if (missing.length > 0)
516
687
  throw variablesMissing(current, release, missing);
517
- if (deployment)
688
+ if (deployment) {
689
+ try {
690
+ await this.replaceQueued(deployment);
691
+ }
692
+ catch (error) {
693
+ await this.markFailed(deployment, error);
694
+ throw error;
695
+ }
518
696
  this.schedule(deployment);
697
+ }
519
698
  return { ...view, reused: false, deploymentId: deployment?.id ?? null };
520
699
  });
521
700
  }
@@ -574,7 +753,7 @@ export class ReleasesService {
574
753
  }
575
754
  const id = randomUUID();
576
755
  const artifactKey = release.artifactKey
577
- ? `${targetAppId}/${id}.tar.gz`
756
+ ? this.artifactKey(targetAppId, id)
578
757
  : null;
579
758
  const copy = {
580
759
  ...release,
@@ -654,6 +833,7 @@ export class ReleasesService {
654
833
  throw new ReleasesError('Configuration content is required.', 'INVALID_CONFIG_FILE', 'INVALID_ARGUMENT');
655
834
  assertConfigSize(content);
656
835
  return await this.withLock(appId, async () => {
836
+ await this.waitForAppDeployments(appId);
657
837
  const app = await this.requireApp(appId);
658
838
  await this.options.guard.requireApp(caller, 'configure', app);
659
839
  const deployment = await this.currentDeployment(app);
@@ -777,12 +957,12 @@ export class ReleasesService {
777
957
  .offset((page - 1) * pageSize)
778
958
  .execute();
779
959
  return {
780
- items: rows.map((row) => deploymentView(decodeDeployment(row), typeof row.releaseVersion === 'string'
960
+ items: rows.map((row) => this.progressView(deploymentView(decodeDeployment(row), typeof row.releaseVersion === 'string'
781
961
  ? {
782
962
  version: row.releaseVersion,
783
963
  checksum: String(row.releaseChecksum),
784
964
  }
785
- : null)),
965
+ : null))),
786
966
  total,
787
967
  page,
788
968
  pageSize,
@@ -798,7 +978,14 @@ export class ReleasesService {
798
978
  await this.options.guard.requireApp(caller, 'deploy', app);
799
979
  const deployment = await this.requireDeployment(appId, deploymentId);
800
980
  const release = await this.findRelease(appId, deployment.releaseId);
801
- return deploymentView(deployment, release ? { version: release.version, checksum: release.checksum } : null);
981
+ if (['queued', 'deploying', 'cancelling'].includes(deployment.status)) {
982
+ const progress = await (await this.session(app.environmentId)).progress?.(deployment.id, app.id);
983
+ if (progress)
984
+ this.progress.set(deployment.id, progress);
985
+ }
986
+ return this.progressView(deploymentView(deployment, release
987
+ ? { version: release.version, checksum: release.checksum }
988
+ : null));
802
989
  }
803
990
  /** Resolves once the deployment has finished, or with its current state at the timeout. */
804
991
  async waitForDeployment(deploymentId, timeoutMs = 120_000) {
@@ -812,7 +999,7 @@ export class ReleasesService {
812
999
  if (!row)
813
1000
  throw notFound('Deployment', 'DEPLOYMENT_NOT_FOUND', deploymentId);
814
1001
  const deployment = decodeDeployment(row);
815
- const finished = deployment.status !== 'queued' && deployment.status !== 'deploying';
1002
+ const finished = !['queued', 'deploying', 'cancelling'].includes(deployment.status);
816
1003
  if (finished || Date.now() >= deadline) {
817
1004
  // A finished deployment's run still announces it (events, the request it settled); wait for that too, so a
818
1005
  // waiter never sees the deployment done but its consequences pending.
@@ -867,6 +1054,7 @@ export class ReleasesService {
867
1054
  // --- Lifecycle ----------------------------------------------------------------------------------------------
868
1055
  async start(caller, appId) {
869
1056
  return await this.withLock(appId, async () => {
1057
+ await this.waitForAppDeployments(appId);
870
1058
  const app = await this.requireApp(appId);
871
1059
  await this.options.guard.requireApp(caller, 'operate', app);
872
1060
  const deployment = await this.currentDeployment(app);
@@ -887,6 +1075,7 @@ export class ReleasesService {
887
1075
  }
888
1076
  async stop(caller, appId) {
889
1077
  return await this.withLock(appId, async () => {
1078
+ await this.waitForAppDeployments(appId);
890
1079
  const app = await this.requireApp(appId);
891
1080
  await this.options.guard.requireApp(caller, 'operate', app);
892
1081
  if (!(await this.currentDeployment(app)))
@@ -904,6 +1093,7 @@ export class ReleasesService {
904
1093
  }
905
1094
  async restart(caller, appId) {
906
1095
  return await this.withLock(appId, async () => {
1096
+ await this.waitForAppDeployments(appId);
907
1097
  const app = await this.requireApp(appId);
908
1098
  await this.options.guard.requireApp(caller, 'operate', app);
909
1099
  if (!(await this.currentDeployment(app)))
@@ -927,18 +1117,20 @@ export class ReleasesService {
927
1117
  * not report every App as stopped or a finished deployment as still running.
928
1118
  */
929
1119
  async restore() {
930
- await this.clearExpiredInitialAdmins().catch((error) => this.diagnostic.warn('Failed to clear expired first administrators', error));
931
1120
  const interrupted = (await this.query()
932
1121
  .selectFrom('relDeployments')
933
1122
  .selectAll()
934
- .where('status', 'in', ['queued', 'deploying'])
1123
+ .where('status', 'in', ['queued', 'deploying', 'cancelling'])
1124
+ .orderBy('createdAt', 'asc')
1125
+ .orderBy('id', 'asc')
935
1126
  .execute()).map(decodeDeployment);
936
1127
  // A deployment the target recorded (the App Host's operation log) is finalised from what it reports; every other
937
1128
  // one was cut short.
938
1129
  const resumed = new Set();
939
1130
  const recorded = new Map();
940
1131
  for (const deployment of interrupted) {
941
- if (deployment.status !== 'deploying')
1132
+ if (deployment.status !== 'deploying' &&
1133
+ deployment.status !== 'cancelling')
942
1134
  continue;
943
1135
  const app = await this.findApp(deployment.appId);
944
1136
  if (!app)
@@ -963,7 +1155,7 @@ export class ReleasesService {
963
1155
  .set({
964
1156
  status: 'failed',
965
1157
  phase: 'completed',
966
- error: 'Deployment was interrupted by a restart.',
1158
+ error: 'Deployment was interrupted by a restart. Deploy the release again.',
967
1159
  finishedAt: new Date(),
968
1160
  })
969
1161
  .where('id', 'in', cutShort.map((deployment) => deployment.id))
@@ -1198,15 +1390,14 @@ export class ReleasesService {
1198
1390
  }
1199
1391
  /**
1200
1392
  * The first administrator the App's first deployment generated, for a person who may deploy it: until someone
1201
- * says it is saved, and for 24 hours (a preview App's until the App goes).
1393
+ * says it is saved or the App is removed.
1202
1394
  */
1203
1395
  async readInitialAdmin(caller, appId) {
1204
1396
  const app = await this.requireApp(appId);
1205
1397
  await this.requireInitialAdminReader(caller, app);
1206
- await this.clearExpiredInitialAdmins();
1207
1398
  const found = await this.initialAdminOf(app);
1208
1399
  if (!found)
1209
- throw new ReleasesError('This App has no first administrator to show: it was saved, it expired, or the application did not generate one.', 'INITIAL_ADMIN_UNAVAILABLE', 'NOT_FOUND');
1400
+ throw new ReleasesError('This App has no first administrator to show: it was saved or the application did not generate one.', 'INITIAL_ADMIN_UNAVAILABLE', 'NOT_FOUND');
1210
1401
  return found;
1211
1402
  }
1212
1403
  /** Forgets the generated first administrator once someone has saved it. */
@@ -1224,16 +1415,6 @@ export class ReleasesService {
1224
1415
  .where('initialAdmin', 'is not', null)
1225
1416
  .execute();
1226
1417
  }
1227
- /** Clears the first administrators whose 24 hours are over. */
1228
- async clearExpiredInitialAdmins(now = new Date()) {
1229
- await this.query()
1230
- .updateTable('relDeployments')
1231
- .set({ initialAdmin: null })
1232
- .where('initialAdmin', 'is not', null)
1233
- .where('initialAdminExpiresAt', 'is not', null)
1234
- .where('initialAdminExpiresAt', '<=', now)
1235
- .execute();
1236
- }
1237
1418
  async requireInitialAdminReader(caller, app) {
1238
1419
  // A password a person copies: never an agent's or a key's, whatever they may deploy.
1239
1420
  if (caller.kind !== 'human' &&
@@ -1250,18 +1431,14 @@ export class ReleasesService {
1250
1431
  .where('initialAdmin', 'is not', null)
1251
1432
  .orderBy('createdAt', 'desc')
1252
1433
  .execute();
1253
- const now = new Date();
1254
1434
  for (const deployment of rows.map(decodeDeployment)) {
1255
- if (deployment.initialAdminExpiresAt &&
1256
- deployment.initialAdminExpiresAt <= now)
1257
- continue;
1258
1435
  const admin = this.openInitialAdmin(deployment);
1259
1436
  if (!admin)
1260
1437
  continue;
1261
1438
  return {
1262
1439
  deploymentId: deployment.id,
1263
1440
  ...admin,
1264
- expiresAt: iso(deployment.initialAdminExpiresAt),
1441
+ expiresAt: null,
1265
1442
  };
1266
1443
  }
1267
1444
  return null;
@@ -1431,7 +1608,6 @@ export class ReleasesService {
1431
1608
  for (const [name, variable] of plan.oneShot)
1432
1609
  generated[name] = generateVariable(variable.generate);
1433
1610
  let initialAdmin = null;
1434
- let initialAdminExpiresAt = null;
1435
1611
  if (plan.initialAdmin) {
1436
1612
  const password = (await this.earlierInitialAdmin(app))?.password ?? randomPassword();
1437
1613
  generated[plan.initialAdmin.name] = password;
@@ -1441,10 +1617,6 @@ export class ReleasesService {
1441
1617
  password,
1442
1618
  };
1443
1619
  initialAdmin = encryptText(JSON.stringify(admin), [deploymentId], this.options.secrets, 'initial-admin');
1444
- // A preview App's goes with the preview; any other's is deleted after a day.
1445
- initialAdminExpiresAt = app.previewOf
1446
- ? null
1447
- : new Date(Date.now() + INITIAL_ADMIN_TTL_MS);
1448
1620
  }
1449
1621
  const resolved = resolveVariables({
1450
1622
  manifest: release.variables,
@@ -1462,7 +1634,6 @@ export class ReleasesService {
1462
1634
  : encryptText(JSON.stringify(resolved.env), [deploymentId], this.options.secrets, 'deployment-env'),
1463
1635
  variablesFingerprint: release.variables || names.length > 0 ? resolved.fingerprint : null,
1464
1636
  initialAdmin,
1465
- initialAdminExpiresAt,
1466
1637
  };
1467
1638
  }
1468
1639
  /**
@@ -1517,7 +1688,6 @@ export class ReleasesService {
1517
1688
  }
1518
1689
  // --- Internals ----------------------------------------------------------------------------------------------
1519
1690
  async startDeployment(caller, app, release, options, config, idempotency) {
1520
- await this.requireNoPendingDeployment(app.id);
1521
1691
  const deployment = await this.buildDeploymentRecord(app, release, caller, {
1522
1692
  kind: options.kind,
1523
1693
  rollbackTargetDeploymentId: options.rollbackTargetDeploymentId ?? null,
@@ -1526,7 +1696,6 @@ export class ReleasesService {
1526
1696
  });
1527
1697
  try {
1528
1698
  await this.options.database.transaction(async (connection) => {
1529
- await this.requireNoPendingDeployment(app.id, connection);
1530
1699
  await connection.query
1531
1700
  .insertInto('relDeployments')
1532
1701
  .values(encodeDeployment(deployment))
@@ -1553,6 +1722,13 @@ export class ReleasesService {
1553
1722
  await rm(deployment.configPath, { force: true });
1554
1723
  throw error;
1555
1724
  }
1725
+ try {
1726
+ await this.replaceQueued(deployment);
1727
+ }
1728
+ catch (error) {
1729
+ await this.markFailed(deployment, error);
1730
+ throw error;
1731
+ }
1556
1732
  this.schedule(deployment);
1557
1733
  return {
1558
1734
  ...deploymentView(deployment, {
@@ -1642,6 +1818,7 @@ export class ReleasesService {
1642
1818
  }
1643
1819
  async removeApp(app, caller) {
1644
1820
  await this.withLock(`publish:${app.id}`, () => this.withLock(app.id, async () => {
1821
+ await this.waitForAppDeployments(app.id);
1645
1822
  const current = await this.findApp(app.id);
1646
1823
  if (!current)
1647
1824
  return;
@@ -1692,7 +1869,24 @@ export class ReleasesService {
1692
1869
  actor: actorOf(caller),
1693
1870
  });
1694
1871
  }
1695
- async summarize(apps) {
1872
+ /** Each environment's driver status of these Apps, one call per environment; the error when its driver failed. */
1873
+ async runtimeStatuses(apps) {
1874
+ const statuses = new Map();
1875
+ for (const environmentId of new Set(apps.map((app) => app.environmentId)))
1876
+ try {
1877
+ const session = await this.session(environmentId);
1878
+ statuses.set(environmentId, await session.status(apps
1879
+ .filter((app) => app.environmentId === environmentId)
1880
+ .map((app) => app.id)));
1881
+ }
1882
+ catch (error) {
1883
+ statuses.set(environmentId, error instanceof Error ? error : new Error(String(error)));
1884
+ }
1885
+ return statuses;
1886
+ }
1887
+ async summarize(apps,
1888
+ /** Statuses already read for these Apps (a superset is fine), so they are not asked again. */
1889
+ known) {
1696
1890
  if (!apps.length)
1697
1891
  return [];
1698
1892
  if (this.restoring)
@@ -1707,10 +1901,10 @@ export class ReleasesService {
1707
1901
  .execute(),
1708
1902
  this.query()
1709
1903
  .selectFrom('relDeployments')
1710
- .select('appId')
1711
- .distinct()
1904
+ .select(['id', 'appId', 'status', 'createdAt'])
1712
1905
  .where('appId', 'in', ids)
1713
- .where('status', 'in', ['queued', 'deploying'])
1906
+ .orderBy('createdAt', 'asc')
1907
+ .where('status', 'in', ['queued', 'deploying', 'cancelling'])
1714
1908
  .execute(),
1715
1909
  this.query()
1716
1910
  .selectFrom('relApps')
@@ -1722,22 +1916,22 @@ export class ReleasesService {
1722
1916
  ]);
1723
1917
  const released = new Set(releases.map((row) => String(row.appId)));
1724
1918
  const pendingApps = new Set(pending.map((row) => String(row.appId)));
1919
+ const pendingByApp = new Map();
1920
+ for (const row of pending) {
1921
+ const previous = pendingByApp.get(String(row.appId));
1922
+ if (!previous ||
1923
+ (previous.status === 'queued' && row.status !== 'queued'))
1924
+ pendingByApp.set(String(row.appId), row);
1925
+ }
1725
1926
  const versions = new Map(currentReleases.map((row) => [String(row.appId), String(row.version)]));
1726
1927
  const environments = new Map();
1727
- const statuses = new Map();
1928
+ const statuses = known ?? (await this.runtimeStatuses(apps));
1728
1929
  const sessions = new Map();
1729
1930
  for (const environmentId of new Set(apps.map((app) => app.environmentId))) {
1730
1931
  environments.set(environmentId, await this.options.environments.find(environmentId));
1731
- const environmentApps = apps.filter((app) => app.environmentId === environmentId);
1732
- try {
1733
- const session = await this.session(environmentId);
1734
- sessions.set(environmentId, session);
1735
- statuses.set(environmentId, await session.status(environmentApps.map((app) => app.id)));
1736
- }
1737
- catch (error) {
1738
- sessions.set(environmentId, null);
1739
- statuses.set(environmentId, error instanceof Error ? error : new Error(String(error)));
1740
- }
1932
+ sessions.set(environmentId, statuses.get(environmentId) instanceof Error
1933
+ ? null
1934
+ : await this.session(environmentId).catch(() => null));
1741
1935
  }
1742
1936
  return apps.map((app) => {
1743
1937
  const environment = environments.get(app.environmentId) ?? null;
@@ -1758,6 +1952,15 @@ export class ReleasesService {
1758
1952
  currentVersion: versions.get(app.id) ?? null,
1759
1953
  hasReleases: released.has(app.id),
1760
1954
  hasPendingDeployment: pendingApps.has(app.id),
1955
+ pendingDeployment: (() => {
1956
+ const row = pendingByApp.get(app.id);
1957
+ return row
1958
+ ? {
1959
+ status: row.status,
1960
+ queueAhead: this.progress.get(String(row.id))?.queueAhead ?? null,
1961
+ }
1962
+ : null;
1963
+ })(),
1761
1964
  };
1762
1965
  });
1763
1966
  }
@@ -1790,6 +1993,8 @@ export class ReleasesService {
1790
1993
  .driverEnvironment(environmentId)
1791
1994
  .then(({ driver, target }) => driver.open(target, {
1792
1995
  desired: () => this.desiredSet(environmentId),
1996
+ deploymentBatchBusy: (excluding) => [...this.dispatch].some(([id, item]) => !excluding.has(id) &&
1997
+ (!item.environmentId || item.environmentId === environmentId)),
1793
1998
  }));
1794
1999
  session.catch(() => {
1795
2000
  if (this.sessions.get(environmentId) === session)
@@ -1989,7 +2194,7 @@ export class ReleasesService {
1989
2194
  }
1990
2195
  schedule(deployment) {
1991
2196
  this.logDeployment(deployment, 'queued', 'Deployment queued');
1992
- const run = this.withLock(deployment.appId, () => this.runDeployment(deployment.id))
2197
+ const run = this.dispatchDeployment(deployment)
1993
2198
  .catch((error) => this.diagnostic.error(`Deployment ${deployment.id} failed unexpectedly`, error))
1994
2199
  .finally(() => {
1995
2200
  this.running.delete(run);
@@ -1999,30 +2204,77 @@ export class ReleasesService {
1999
2204
  this.running.add(run);
2000
2205
  this.runs.set(deployment.id, run);
2001
2206
  }
2002
- async runDeployment(deploymentId) {
2207
+ async dispatchDeployment(deployment) {
2208
+ const app = await this.requireApp(deployment.appId);
2209
+ const session = await this.session(app.environmentId);
2210
+ try {
2211
+ // Drivers without a native queue keep the previous per-App serialization.
2212
+ if (!session.progress)
2213
+ return await this.withLock(app.id, () => this.runDeployment(deployment.id, deployment.appId, app.environmentId));
2214
+ const lifecycle = this.locks.get(app.id);
2215
+ if (lifecycle)
2216
+ await lifecycle.catch(() => undefined);
2217
+ return await this.runDeployment(deployment.id, deployment.appId, app.environmentId);
2218
+ }
2219
+ finally {
2220
+ // Metadata/initial-admission failures must not leave the batch busy forever either.
2221
+ this.dispatch.delete(deployment.id);
2222
+ try {
2223
+ session.deploymentSettled?.();
2224
+ }
2225
+ catch (error) {
2226
+ this.diagnostic.error('Failed to notify the deployment driver after settlement', error);
2227
+ }
2228
+ }
2229
+ }
2230
+ /** Lifecycle operations keep the deployment result current before reading its configuration. New dispatches wait on their App lock. */
2231
+ async waitForAppDeployments(appId) {
2232
+ const runs = [...this.dispatch]
2233
+ .filter(([, value]) => value.appId === appId)
2234
+ .map(([id]) => this.runs.get(id))
2235
+ .filter((run) => Boolean(run));
2236
+ await Promise.allSettled(runs);
2237
+ }
2238
+ async runDeployment(deploymentId, appId, environmentId) {
2239
+ const dispatch = { appId, submitted: false, cancelled: false, environmentId };
2240
+ this.dispatch.set(deploymentId, dispatch);
2003
2241
  const deployment = await this.requireDeploymentById(deploymentId);
2242
+ if (deployment.status !== 'queued') {
2243
+ this.dispatch.delete(deploymentId);
2244
+ return;
2245
+ }
2004
2246
  const app = await this.requireApp(deployment.appId);
2005
2247
  const release = await this.requireRelease(app.id, deployment.releaseId);
2006
- const previous = await this.currentDeployment(app);
2248
+ let settlement;
2007
2249
  let phaseWrites = Promise.resolve();
2008
2250
  let lastSequence = 0;
2251
+ let lastPhase;
2252
+ let switchStarted = false;
2009
2253
  let phaseError;
2010
- await this.updateDeployment(deploymentId, {
2011
- status: 'deploying',
2012
- phase: 'resolving',
2013
- startedAt: new Date(),
2014
- error: null,
2015
- previousDeploymentId: app.currentDeploymentId,
2016
- });
2017
2254
  let outcome;
2018
2255
  try {
2019
- this.logDeployment(deployment, 'resolving', 'Deployment started');
2020
2256
  const session = await this.session(app.environmentId);
2021
2257
  const logsEnabled = this.options.logging?.deployments?.enabled !== false;
2022
2258
  const spec = await this.deploymentSpec({ ...app, enabled: true }, deployment, 'running');
2023
2259
  // A driver that runs no images runs the archive; one that does says what it ran (`DeploymentEvent.artifact`).
2024
2260
  if (spec.images === undefined)
2025
2261
  await this.updateDeployment(deploymentId, artifactColumns({ kind: 'tarball' }));
2262
+ if (dispatch.cancelled) {
2263
+ await this.markCancelled(deployment);
2264
+ return;
2265
+ }
2266
+ if (!session.progress) {
2267
+ this.logDeployment(deployment, 'resolving', 'Deployment started');
2268
+ await this.updateDeployment(deploymentId, {
2269
+ status: 'deploying',
2270
+ phase: 'resolving',
2271
+ startedAt: new Date(),
2272
+ });
2273
+ }
2274
+ dispatch.submitted = true;
2275
+ // Reserve in runtime submission order without holding up Host admission or cancellation.
2276
+ settlement = this.reserveSettlement(app.id);
2277
+ let started = false;
2026
2278
  const observed = await session.apply(spec, (entry) => {
2027
2279
  if (typeof entry.sequence === 'number') {
2028
2280
  if (entry.sequence <= lastSequence)
@@ -2039,8 +2291,46 @@ export class ReleasesService {
2039
2291
  });
2040
2292
  if (typeof entry.phase === 'string') {
2041
2293
  const phase = entry.phase;
2294
+ // A completed capacity rejection may re-enter a cancellable wait before one retry.
2295
+ if (entry.cancellable === true)
2296
+ switchStarted = false;
2297
+ const first = !started &&
2298
+ phase !== 'queued' &&
2299
+ phase !== 'cancelled' &&
2300
+ phase !== 'cancelling';
2301
+ if (first)
2302
+ started = true;
2303
+ if (['switching', 'starting', 'health_check'].includes(phase))
2304
+ switchStarted = true;
2305
+ this.progress.set(deploymentId, {
2306
+ queueAhead: phase === 'queued' && typeof entry.queueAhead === 'number'
2307
+ ? entry.queueAhead
2308
+ : null,
2309
+ cancellable: !switchStarted &&
2310
+ !['completed', 'cancelled', 'cancelling'].includes(phase),
2311
+ });
2312
+ const changed = lastPhase !== phase;
2313
+ lastPhase = phase;
2042
2314
  phaseWrites = phaseWrites
2043
- .then(() => this.updateDeployment(deploymentId, { phase }))
2315
+ .then(async () => {
2316
+ // Queue-position events are transient; they cause no database writes.
2317
+ if (phase === 'queued' || !changed)
2318
+ return;
2319
+ await this.query()
2320
+ .updateTable('relDeployments')
2321
+ .set({
2322
+ phase,
2323
+ ...(first
2324
+ ? {
2325
+ status: 'deploying',
2326
+ startedAt: new Date(),
2327
+ }
2328
+ : {}),
2329
+ })
2330
+ .where('id', '=', deploymentId)
2331
+ .where('status', 'in', ['queued', 'deploying'])
2332
+ .execute();
2333
+ })
2044
2334
  .catch((error) => {
2045
2335
  phaseError =
2046
2336
  error instanceof Error ? error : new Error(String(error));
@@ -2059,24 +2349,64 @@ export class ReleasesService {
2059
2349
  await phaseWrites;
2060
2350
  if (phaseError)
2061
2351
  throw phaseError;
2352
+ if (observed.state === 'cancelled') {
2353
+ await this.markCancelled(deployment);
2354
+ return;
2355
+ }
2062
2356
  if (observed.state === 'failed' || observed.state === 'unknown')
2063
2357
  throw new Error(observed.error ?? 'The runtime did not report the deployment.');
2064
- await this.markSucceeded(deployment, app.id, previous);
2065
2358
  outcome = { ok: true };
2066
2359
  }
2067
2360
  catch (error) {
2068
2361
  await phaseWrites.catch(() => undefined);
2069
2362
  outcome = { ok: false, error: errorMessage(error) };
2070
- await this.markFailed(deployment, error);
2071
2363
  }
2072
2364
  finally {
2073
- const policy = this.options.logging?.deployments;
2074
- await pruneJournals(path.dirname(this.deploymentLogPath(app.id, deployment.id)), {
2075
- retentionDays: policy?.retentionDays ?? 30,
2076
- maxSizeMB: policy?.maxTotalSizeMB ?? 1024,
2077
- }, `${deployment.id}.log`).catch((error) => reportLoggingFailure('Deployment log cleanup failed', error));
2365
+ const settle = settlement ?? this.reserveSettlement(app.id);
2366
+ const result = settle(async () => {
2367
+ if (!outcome)
2368
+ return;
2369
+ // The predecessor is read only after its writes, cleanup and outcome event have finished.
2370
+ const previous = await this.currentDeployment(await this.requireApp(app.id));
2371
+ if (outcome.ok) {
2372
+ try {
2373
+ await this.markSucceeded(deployment, app.id, previous);
2374
+ }
2375
+ catch (error) {
2376
+ outcome = { ok: false, error: errorMessage(error) };
2377
+ }
2378
+ }
2379
+ if (!outcome.ok)
2380
+ await this.markFailed(deployment, new Error(outcome.error), previous);
2381
+ await this.emitOutcome(deployment, app, release, outcome);
2382
+ });
2383
+ try {
2384
+ // A queued cancellation is already terminal; it must not wait for the running predecessor.
2385
+ if (outcome)
2386
+ await result;
2387
+ }
2388
+ finally {
2389
+ this.dispatch.delete(deploymentId);
2390
+ this.progress.delete(deploymentId);
2391
+ const policy = this.options.logging?.deployments;
2392
+ await pruneJournals(path.dirname(this.deploymentLogPath(app.id, deployment.id)), {
2393
+ retentionDays: policy?.retentionDays ?? 30,
2394
+ maxSizeMB: policy?.maxTotalSizeMB ?? 1024,
2395
+ }, `${deployment.id}.log`).catch((error) => reportLoggingFailure('Deployment log cleanup failed', error));
2396
+ }
2078
2397
  }
2079
- await this.emitOutcome(deployment, app, release, outcome);
2398
+ }
2399
+ /** Reuse the local lock chain to order results while the Host continues executing its queue. */
2400
+ reserveSettlement(appId) {
2401
+ let finish;
2402
+ const ready = new Promise((resolve) => {
2403
+ finish = resolve;
2404
+ });
2405
+ const result = this.withLock(`deployment-result:${appId}`, async () => (await ready)());
2406
+ return (work) => {
2407
+ finish(work);
2408
+ return result;
2409
+ };
2080
2410
  }
2081
2411
  async markSucceeded(deployment, appId, previous) {
2082
2412
  this.logDeployment(deployment, 'completed', 'Deployment succeeded');
@@ -2084,7 +2414,12 @@ export class ReleasesService {
2084
2414
  await this.options.database.transaction(async (connection) => {
2085
2415
  await connection.query
2086
2416
  .updateTable('relDeployments')
2087
- .set({ status: 'succeeded', phase: 'completed', finishedAt })
2417
+ .set({
2418
+ status: 'succeeded',
2419
+ phase: 'completed',
2420
+ finishedAt,
2421
+ previousDeploymentId: previous?.id ?? null,
2422
+ })
2088
2423
  .where('id', '=', deployment.id)
2089
2424
  .execute();
2090
2425
  await connection.query
@@ -2101,7 +2436,7 @@ export class ReleasesService {
2101
2436
  if (previous?.configPath && previous.id !== deployment.id)
2102
2437
  await rm(previous.configPath, { force: true }).catch(() => undefined);
2103
2438
  }
2104
- async markFailed(deployment, error) {
2439
+ async markFailed(deployment, error, previous) {
2105
2440
  this.logDeployment(deployment, 'completed', 'Deployment failed', error);
2106
2441
  const finishedAt = new Date();
2107
2442
  await this.options.database.transaction(async (connection) => {
@@ -2112,6 +2447,9 @@ export class ReleasesService {
2112
2447
  phase: 'completed',
2113
2448
  error: errorMessage(error),
2114
2449
  finishedAt,
2450
+ ...(previous !== undefined
2451
+ ? { previousDeploymentId: previous?.id ?? null }
2452
+ : {}),
2115
2453
  })
2116
2454
  .where('id', '=', deployment.id)
2117
2455
  .execute();
@@ -2179,15 +2517,17 @@ export class ReleasesService {
2179
2517
  };
2180
2518
  }
2181
2519
  const current = await this.requireDeploymentById(deployment.id);
2182
- if (current.status !== 'deploying' && current.status !== 'queued')
2520
+ if (!['deploying', 'queued', 'cancelling'].includes(current.status))
2183
2521
  return;
2184
2522
  const app = await this.requireApp(deployment.appId);
2185
2523
  const release = await this.requireRelease(app.id, deployment.releaseId);
2186
2524
  let outcome;
2525
+ if (status?.state === 'cancelled') {
2526
+ await this.markCancelled(deployment);
2527
+ return;
2528
+ }
2187
2529
  if (status?.state === 'running') {
2188
- const previous = deployment.previousDeploymentId
2189
- ? await this.requireDeployment(app.id, deployment.previousDeploymentId).catch(() => null)
2190
- : null;
2530
+ const previous = await this.currentDeployment(app);
2191
2531
  this.logDeployment(deployment, 'switching', 'The runtime reports the deployment finished');
2192
2532
  await this.markSucceeded(deployment, app.id, previous);
2193
2533
  outcome = { ok: true };
@@ -2195,7 +2535,7 @@ export class ReleasesService {
2195
2535
  else {
2196
2536
  const error = status
2197
2537
  ? (status.error ?? 'The deployment failed.')
2198
- : 'Deployment was interrupted by a restart.';
2538
+ : 'Deployment was interrupted by a restart. Deploy the release again.';
2199
2539
  await this.markFailed(deployment, new Error(error));
2200
2540
  outcome = { ok: false, error };
2201
2541
  }
@@ -2211,15 +2551,120 @@ export class ReleasesService {
2211
2551
  this.runs.set(deployment.id, run);
2212
2552
  return run;
2213
2553
  }
2214
- async requireNoPendingDeployment(appId, connection) {
2215
- const pending = await (connection?.query ?? this.query())
2554
+ progressView(view) {
2555
+ const progress = this.progress.get(view.id);
2556
+ return {
2557
+ ...view,
2558
+ queueAhead: progress?.queueAhead ?? null,
2559
+ cancellable: ['queued', 'deploying'].includes(view.status) &&
2560
+ (progress?.cancellable ?? view.phase === 'queued'),
2561
+ };
2562
+ }
2563
+ async cancelDeployment(caller, appId, deploymentId) {
2564
+ const app = await this.requireApp(appId);
2565
+ await this.options.guard.requireApp(caller, 'deploy', app);
2566
+ await this.withLock(`cancel:${deploymentId}`, async () => {
2567
+ const deployment = await this.requireDeployment(appId, deploymentId);
2568
+ if (deployment.status === 'cancelled' ||
2569
+ deployment.status === 'cancelling')
2570
+ return;
2571
+ if (!['queued', 'deploying'].includes(deployment.status))
2572
+ throw new ReleasesError('The deployment has finished.', 'DEPLOYMENT_NOT_CANCELLABLE', 'FAILED_PRECONDITION');
2573
+ const result = await this.requestCancellation(deployment, false);
2574
+ if (result === 'tooLate' || result === 'notFound')
2575
+ throw new ReleasesError('The deployment has started switching or has finished.', 'DEPLOYMENT_NOT_CANCELLABLE', 'FAILED_PRECONDITION');
2576
+ });
2577
+ return this.getDeployment(caller, appId, deploymentId);
2578
+ }
2579
+ async replaceQueued(replacement) {
2580
+ const rows = await this.query()
2216
2581
  .selectFrom('relDeployments')
2217
- .select('id')
2218
- .where('appId', '=', appId)
2219
- .where('status', 'in', ['queued', 'deploying'])
2220
- .executeTakeFirst();
2221
- if (pending)
2222
- throw new ReleasesError('A deployment is already in progress.', 'DEPLOYMENT_IN_PROGRESS', 'FAILED_PRECONDITION');
2582
+ .selectAll()
2583
+ .where('appId', '=', replacement.appId)
2584
+ .where('status', '=', 'queued')
2585
+ .where('id', '!=', replacement.id)
2586
+ .execute();
2587
+ for (const row of rows)
2588
+ await this.requestCancellation(decodeDeployment(row), true, replacement.id);
2589
+ }
2590
+ async requestCancellation(deployment, queuedOnly, replacedBy) {
2591
+ const dispatch = this.dispatch.get(deployment.id);
2592
+ let result;
2593
+ if ((dispatch && !dispatch.submitted) ||
2594
+ (!dispatch && deployment.status === 'queued')) {
2595
+ if (dispatch)
2596
+ dispatch.cancelled = true;
2597
+ result = 'cancelled';
2598
+ }
2599
+ else {
2600
+ const app = await this.requireApp(deployment.appId);
2601
+ const session = await this.session(app.environmentId);
2602
+ if (!session.cancel)
2603
+ throw new ReleasesError('The deployment driver does not support cancellation.', 'CANCELLATION_UNSUPPORTED', 'FAILED_PRECONDITION');
2604
+ result = await session.cancel(deployment.id, app.id, queuedOnly);
2605
+ }
2606
+ if (result === 'cancelled')
2607
+ await this.markCancelled(deployment, replacedBy);
2608
+ else if (result === 'cancelling') {
2609
+ this.logDeployment(deployment, 'cancelling', 'Cancellation requested; waiting for the current step to finish');
2610
+ await this.query()
2611
+ .updateTable('relDeployments')
2612
+ .set({ status: 'cancelling' })
2613
+ .where('id', '=', deployment.id)
2614
+ .where('status', 'in', ['queued', 'deploying'])
2615
+ .execute();
2616
+ }
2617
+ return result;
2618
+ }
2619
+ markCancelled(deployment, replacedBy) {
2620
+ return this.withLock(`settle:${deployment.id}`, () => this.markCancelledUnlocked(deployment, replacedBy));
2621
+ }
2622
+ async markCancelledUnlocked(deployment, replacedBy) {
2623
+ const current = await this.requireDeploymentById(deployment.id);
2624
+ if (!['queued', 'deploying', 'cancelling'].includes(current.status))
2625
+ return;
2626
+ const reason = replacedBy
2627
+ ? 'Replaced by a newer deployment.'
2628
+ : 'Deployment cancelled.';
2629
+ const finishedAt = new Date();
2630
+ if (deployment.configPath)
2631
+ await rm(deployment.configPath, { force: true });
2632
+ await this.options.database.transaction(async (connection) => {
2633
+ await connection.query
2634
+ .updateTable('relDeployments')
2635
+ .set({
2636
+ status: 'cancelled',
2637
+ phase: 'cancelled',
2638
+ error: reason,
2639
+ finishedAt,
2640
+ })
2641
+ .where('id', '=', deployment.id)
2642
+ .where('status', 'in', ['queued', 'deploying', 'cancelling'])
2643
+ .execute();
2644
+ await this.settleRequest(connection, deployment, 'cancelled', finishedAt);
2645
+ });
2646
+ if (this.options.logging?.deployments?.enabled !== false)
2647
+ this.appendDeploymentLog(deployment, {
2648
+ time: finishedAt.toISOString(),
2649
+ level: 'info',
2650
+ appId: deployment.appId,
2651
+ deploymentId: deployment.id,
2652
+ phase: 'cancelled',
2653
+ msg: reason,
2654
+ reason: replacedBy ? 'superseded' : 'requested',
2655
+ ...(replacedBy ? { replacedBy } : {}),
2656
+ });
2657
+ const app = await this.requireApp(deployment.appId);
2658
+ const release = await this.requireRelease(app.id, deployment.releaseId);
2659
+ await this.options.events
2660
+ .emit({
2661
+ type: 'deployment.cancelled',
2662
+ app: appView(app),
2663
+ release: releaseView(release),
2664
+ deployment: deploymentView(await this.requireDeploymentById(deployment.id), { version: release.version, checksum: release.checksum }),
2665
+ actor: { userId: deployment.actorId, kind: deployment.actorKind },
2666
+ })
2667
+ .catch((error) => this.diagnostic.error('Failed to announce deployment cancellation', error));
2223
2668
  }
2224
2669
  async findRelease(appId, releaseId) {
2225
2670
  const row = await this.query()
@@ -2330,8 +2775,6 @@ export class ReleasesService {
2330
2775
  return this.options.database.connection().query;
2331
2776
  }
2332
2777
  }
2333
- // --- Variables --------------------------------------------------------------------------------------------------
2334
- const INITIAL_ADMIN_TTL_MS = 24 * 60 * 60 * 1000;
2335
2778
  /** A variable's value from the App or the environment, when one of them sets it. */
2336
2779
  function suppliedValue(context, name) {
2337
2780
  return (context.appVariables.find((variable) => variable.name === name)?.value ??
@@ -2576,7 +3019,8 @@ function normalizeBuild(value) {
2576
3019
  throw new ReleasesError('The build is text of at most 255 characters.', 'INVALID_BUILD', 'INVALID_ARGUMENT');
2577
3020
  return value;
2578
3021
  }
2579
- function runtimeStatus(status, appId) {
3022
+ /** What the driver observed of an App, as an App summary reports its runtime. */
3023
+ export function runtimeStatus(status, appId) {
2580
3024
  if (!status || status instanceof Error)
2581
3025
  return {
2582
3026
  available: false,
@@ -2598,11 +3042,14 @@ function runtimeStatus(status, appId) {
2598
3042
  };
2599
3043
  return {
2600
3044
  available: true,
2601
- state: item.state,
3045
+ state: item.state === 'cancelled' ? 'failed' : item.state,
2602
3046
  version: item.version,
2603
3047
  startedAt: item.startedAt,
2604
3048
  error: item.error,
2605
3049
  lastAccessedAt: item.lastAccessedAt ?? null,
3050
+ ...(item.holds?.length ? { holds: item.holds } : {}),
3051
+ ...(item.waiting ? { waiting: item.waiting } : {}),
3052
+ ...(item.unrestored ? { unrestored: true } : {}),
2606
3053
  };
2607
3054
  }
2608
3055
  function decodeActorKind(value) {
@@ -2686,7 +3133,6 @@ function decodeDeployment(row) {
2686
3133
  env: nullableString(row.env),
2687
3134
  variablesFingerprint: nullableString(row.variablesFingerprint),
2688
3135
  initialAdmin: nullableString(row.initialAdmin),
2689
- initialAdminExpiresAt: decodeOptionalDate(row.initialAdminExpiresAt),
2690
3136
  initialAdminSavedBy: nullableString(row.initialAdminSavedBy),
2691
3137
  initialAdminSavedAt: decodeOptionalDate(row.initialAdminSavedAt),
2692
3138
  createdAt: decodeDate(row.createdAt),