@fabrika/provider-zerops 0.4.7 → 0.4.8

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fabrika/provider-zerops",
3
- "version": "0.4.7",
3
+ "version": "0.4.8",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
@@ -43,13 +43,13 @@
43
43
  },
44
44
  "dependencies": {
45
45
  "zod": "^3.25.76",
46
- "@fabrika/auth": "0.4.7",
47
- "@fabrika/auth-core": "0.4.7",
48
- "@fabrika/iam-contract": "0.4.7",
49
- "@fabrika/installation-init": "0.4.7",
50
- "@fabrika/operations-contract": "0.4.7",
51
- "@fabrika/provider-contract": "0.4.7",
52
- "@fabrika/proxy-core": "0.4.7",
46
+ "@fabrika/auth": "0.4.8",
47
+ "@fabrika/auth-core": "0.4.8",
48
+ "@fabrika/iam-contract": "0.4.8",
49
+ "@fabrika/installation-init": "0.4.8",
50
+ "@fabrika/operations-contract": "0.4.8",
51
+ "@fabrika/provider-contract": "0.4.8",
52
+ "@fabrika/proxy-core": "0.4.8",
53
53
  "yaml": "^2.9.1"
54
54
  }
55
55
  }
@@ -0,0 +1,68 @@
1
+ // The connection budget of a contract application's PostgreSQL on Zerops (ADR-0043): how many connections each of the
2
+ // application's processes may open, and the RAM its database needs for that to be at least the minimum. PURE.
3
+
4
+ /**
5
+ * The RAM steps of a Zerops PostgreSQL service in GB; its `max_connections` follows the step its RAM is in, and a
6
+ * service between two steps has the lower one's (`docs/reference/zerops-platform.md`, "Connection limits"). Above 8 GB
7
+ * the steps are multiples of 8 GB; 16 GB is the first with the highest limit.
8
+ */
9
+ export const ZEROPS_POSTGRES_RAM_STEPS_GB: readonly number[] = [0.25, 0.5, 1, 2, 4, 8, 16]
10
+
11
+ /** The lowest step, and the smallest RAM a PostgreSQL service is given (`oltp-hobby`'s floor). */
12
+ export const ZEROPS_POSTGRES_MIN_RAM_GB = 0.25
13
+
14
+ /** Of a service's `max_connections`, what no application role can take: 3 kept for superusers, up to 4 Zerops' own. */
15
+ export const ZEROPS_POSTGRES_UNAVAILABLE_CONNECTIONS = 7
16
+
17
+ /**
18
+ * The most connections one container's start script holds at once: `fabrika db test-isolation` peaks at 4 since the
19
+ * test kit opens a file's connections together, and at 5 with a `@fabrika/db` before that (measured locally,
20
+ * 2026-10-16, with the Notes and CRM examples). Migrating and reading the schema take fewer, one after the other.
21
+ */
22
+ export const ZEROPS_START_CONNECTIONS = 5
23
+
24
+ /** The fewest connections a process may have: a job's fenced write and its cancellation each need one at once. */
25
+ export const MIN_PROCESS_CONNECTIONS = 2
26
+ /** The most a process gets, however large the database: the pool every process had before the platform sized it. */
27
+ export const MAX_PROCESS_CONNECTIONS = 10
28
+
29
+ /** Zerops' own maximum, which a runtime whose horizontal autoscaling reads nothing may scale to. */
30
+ export const ZEROPS_MAX_CONTAINERS = 10
31
+
32
+ /** What opens connections to the application's database at once. */
33
+ export interface ZeropsDatabaseDemand {
34
+ /** The deploy service's maximum container count. */
35
+ readonly maxContainers: number
36
+ /** The crontab lines that run `fabrika job run`, each its own process on one container. */
37
+ readonly jobs: number
38
+ }
39
+
40
+ /** `max_connections` of a Zerops PostgreSQL service with `ramGB` of RAM and this autoscaling profile. */
41
+ export function zeropsPostgresMaxConnections(ramGB: number, profile: string | undefined): number {
42
+ const step = ramGB < 8
43
+ ? Math.max(...ZEROPS_POSTGRES_RAM_STEPS_GB.filter((candidate) => candidate <= ramGB), ZEROPS_POSTGRES_MIN_RAM_GB)
44
+ : Math.floor(ramGB / 8) * 8
45
+ const [perGB, lowest, highest] = profile?.startsWith('olap-') === true ? [25, 10, 200] : [50, 20, 500]
46
+ return Math.min(highest, Math.max(lowest, Math.floor(perGB * step)))
47
+ }
48
+
49
+ /**
50
+ * The processes that may each hold a full pool at once: while a deploy replaces the containers, every old one still
51
+ * serves beside its replacement, and each crontab job runs in a process of its own.
52
+ */
53
+ const poolHolders = (demand: ZeropsDatabaseDemand): number => 2 * demand.maxContainers + demand.jobs
54
+
55
+ /**
56
+ * The connections each process may open so that all of them, with one container's start script, stay within what the
57
+ * application's roles can take; `null` when that is below `MIN_PROCESS_CONNECTIONS`.
58
+ */
59
+ export function zeropsProcessConnections(maxConnections: number, demand: ZeropsDatabaseDemand): number | null {
60
+ const available = maxConnections - ZEROPS_POSTGRES_UNAVAILABLE_CONNECTIONS - ZEROPS_START_CONNECTIONS
61
+ const share = Math.floor(available / poolHolders(demand))
62
+ return share < MIN_PROCESS_CONNECTIONS ? null : Math.min(share, MAX_PROCESS_CONNECTIONS)
63
+ }
64
+
65
+ /** The smallest RAM step at which each process gets at least `MIN_PROCESS_CONNECTIONS`, or null when none does. */
66
+ export function zeropsDatabaseMinRam(demand: ZeropsDatabaseDemand, profile: string | undefined): number | null {
67
+ return ZEROPS_POSTGRES_RAM_STEPS_GB.find((ram) => zeropsProcessConnections(zeropsPostgresMaxConnections(ram, profile), demand) !== null) ?? null
68
+ }
package/src/execution.ts CHANGED
@@ -93,6 +93,11 @@ export interface ZeropsLockedExecution {
93
93
  readonly contract: ZeropsContractRun
94
94
  /** A `${host_…}` reference to the application's PostgreSQL service, or null when it has none. */
95
95
  readonly databaseUrl: string | null
96
+ /**
97
+ * The most connections each process of the application opens (`FABRIKA_APP_DATABASE_CONNECTIONS`), as the deploy's
98
+ * `size-database` step sized it from the database's limit; null until then, and without a database.
99
+ */
100
+ readonly databaseConnections: number | null
96
101
  /** The hostname of the application's one object-storage service, or null when it has none. */
97
102
  readonly storageHostname: string | null
98
103
  readonly runtimePackages: readonly ZeropsRuntimePackage[]
@@ -337,6 +342,18 @@ const storageVariables = (hostname: string): Record<StorageVariable, string> =>
337
342
  FABRIKA_APP_STORAGE_SECRET_ACCESS_KEY: `\${${hostname}_secretAccessKey}`,
338
343
  })
339
344
 
345
+ /** The crontab lines that run a job of the contract or of the framework, each on one container. */
346
+ const jobCrontab = (contract: ZeropsContractRun): { readonly timing: string; readonly command: string; readonly allContainers: boolean }[] => [
347
+ ...contract.jobs.map((job) => ({ timing: job.schedule, command: `${FABRIKA_BIN} job run ${job.name}`, allContainers: false })),
348
+ ...(contract.files === true
349
+ ? [{ timing: FILE_SWEEP_SCHEDULE, command: `${FABRIKA_BIN} job run ${ZEROPS_FILE_SWEEP_JOB}`, allContainers: false }]
350
+ : []),
351
+ ...(contract.scan === true ? [{ timing: '* * * * *', command: `${FABRIKA_BIN} job run ${ZEROPS_FILE_SCAN_JOB}`, allContainers: false }] : []),
352
+ ]
353
+
354
+ /** How many job processes the crontab may run at once, each opening the application's database. */
355
+ export const zeropsJobProcesses = (contract: ZeropsContractRun): number => jobCrontab(contract).length
356
+
340
357
  /** The generated selected setup: the authored build kept, everything else the platform's. */
341
358
  export function renderLockedSetup(
342
359
  setup: string,
@@ -352,23 +369,13 @@ export function renderLockedSetup(
352
369
  [FABRIKA_CONTRACT_DIGEST]: contract.digest,
353
370
  ...(release === null ? {} : { FABRIKA_RELEASE: release }),
354
371
  ...(execution.databaseUrl === null ? {} : { FABRIKA_APP_DATABASE_URL: execution.databaseUrl }),
372
+ ...(execution.databaseConnections === null ? {} : { FABRIKA_APP_DATABASE_CONNECTIONS: String(execution.databaseConnections) }),
355
373
  ...(execution.storageHostname === null ? {} : storageVariables(execution.storageHostname)),
356
374
  }
357
375
  const scan = contract.scan === true
358
376
  const packages = sortedUnique([...execution.runtimePackages, ...(scan ? [SCANNER_PACKAGE] : [])])
359
- const crontab = [
360
- ...contract.jobs.map((job) => ({ timing: job.schedule, command: `${FABRIKA_BIN} job run ${job.name}`, allContainers: false })),
361
- ...(contract.files === true
362
- ? [{ timing: FILE_SWEEP_SCHEDULE, command: `${FABRIKA_BIN} job run ${ZEROPS_FILE_SWEEP_JOB}`, allContainers: false }]
363
- : []),
364
- // Every container keeps its own signatures; one scan pass at a time holds the job's lease.
365
- ...(scan
366
- ? [
367
- { timing: '* * * * *', command: `${FABRIKA_BIN} job run ${ZEROPS_FILE_SCAN_JOB}`, allContainers: false },
368
- { timing: FRESHCLAM_SCHEDULE, command: FRESHCLAM, allContainers: true },
369
- ]
370
- : []),
371
- ]
377
+ // Every container keeps its own signatures; one scan pass at a time holds the job's lease.
378
+ const crontab = [...jobCrontab(contract), ...(scan ? [{ timing: FRESHCLAM_SCHEDULE, command: FRESHCLAM, allContainers: true }] : [])]
372
379
  // A container starts from the prepared image, whose signatures are as old as its build; a failed refresh leaves
373
380
  // them to the daily one, and the scanner refuses signatures older than three days, so the scan fails closed.
374
381
  const initCommands = [
package/src/manifest.ts CHANGED
@@ -410,23 +410,43 @@ const isDescriptorSetup = (value: JsonValue, setup: string): value is { readonly
410
410
  const zeropsProvisioningDatabaseUrl = (host: string): string =>
411
411
  `postgresql://\${${host}_superUser}:\${${host}_superUserPassword}@\${${host}_hostname}:\${${host}_port}/\${${host}_dbName}?sslmode=disable`
412
412
 
413
+ /** The PostgreSQL service a contract application uses: its own, or the namespace's shared one. */
414
+ export interface ApplicationDatabaseService {
415
+ readonly hostname: string
416
+ /** The namespace's `postgres`, which every application of a `cheap` namespace shares and the namespace sizes. */
417
+ readonly shared: boolean
418
+ }
419
+
413
420
  /**
414
421
  * The PostgreSQL service `FABRIKA_APP_DATABASE_URL` is generated for: the namespace's shared one, or the application's
415
422
  * one PostgreSQL service. Two candidates would make the generated migration pick one.
416
423
  */
417
- const applicationDatabaseUrl = (
424
+ const applicationDatabase = (
418
425
  services: FabrikaManifestImportDocument['services'],
419
426
  namespaceResources: readonly ZeropsNamespaceResourceRequirement[] | undefined,
420
- ): string | null => {
427
+ ): ApplicationDatabaseService | null => {
421
428
  const own = services.filter((service) => service.type.startsWith('postgresql')).map((service) => service.hostname)
422
429
  const shared = namespaceResources?.find((resource) => resource.resourceKey === 'service:postgres')
423
430
  if (own.length + (shared === undefined ? 0 : 1) > 1) {
424
431
  throw new Error('invalid fabrika manifest: a contract application has one PostgreSQL database; it declares more than one')
425
432
  }
426
- const host = shared?.hostname ?? own[0]
427
- return host === undefined ? null : zeropsProvisioningDatabaseUrl(host)
433
+ if (shared !== undefined) return { hostname: shared.hostname, shared: true }
434
+ const [hostname] = own
435
+ return hostname === undefined ? null : { hostname, shared: false }
428
436
  }
429
437
 
438
+ const applicationDatabaseUrl = (
439
+ services: FabrikaManifestImportDocument['services'],
440
+ namespaceResources: readonly ZeropsNamespaceResourceRequirement[] | undefined,
441
+ ): string | null => {
442
+ const database = applicationDatabase(services, namespaceResources)
443
+ return database === null ? null : zeropsProvisioningDatabaseUrl(database.hostname)
444
+ }
445
+
446
+ /** The PostgreSQL service of the application this manifest deploys, or null when it has none. */
447
+ export const applicationDatabaseOf = (manifest: FabrikaManifest): ApplicationDatabaseService | null =>
448
+ applicationDatabase(manifest.target.importDocument.services, manifest.target.namespaceResources)
449
+
430
450
  /** The hostname the `FABRIKA_APP_STORAGE_*` references name: the application's one object-storage service. */
431
451
  const applicationStorageHostname = (services: FabrikaManifestImportDocument['services']): string | null => {
432
452
  const own = services.filter((service) => OBJECT_STORAGE_TYPES.has(service.type)).map((service) => service.hostname)
@@ -444,6 +464,7 @@ export const lockedExecutionOf = (manifest: FabrikaManifest): ZeropsLockedExecut
444
464
  appId: manifest.app.id,
445
465
  contract,
446
466
  databaseUrl: applicationDatabaseUrl(manifest.target.importDocument.services, manifest.target.namespaceResources),
467
+ databaseConnections: null,
447
468
  storageHostname: applicationStorageHostname(manifest.target.importDocument.services),
448
469
  runtimePackages: manifest.target.runtimePackages ?? [],
449
470
  sourceMaps: null,
@@ -475,6 +496,7 @@ export function lockedExecutionFor(
475
496
  appId: manifest.app.id,
476
497
  contract,
477
498
  databaseUrl: applicationDatabaseUrl(manifest.target.importDocument.services, manifest.target.namespaceResources),
499
+ databaseConnections: null,
478
500
  storageHostname: applicationStorageHostname(manifest.target.importDocument.services),
479
501
  runtimePackages: manifest.target.runtimePackages ?? [],
480
502
  sourceMaps: artifactUpload === undefined ? null : sourceMapUpload(artifactUpload, access.contract),
package/src/plan.ts CHANGED
@@ -6,6 +6,7 @@ export type ZeropsStepKind =
6
6
  | 'verify-exposure'
7
7
  | 'write-service-env'
8
8
  | 'raise-start-memory'
9
+ | 'size-database'
9
10
  | 'reserve-version'
10
11
  | 'upload-source'
11
12
  | 'trigger-build'
@@ -49,6 +50,11 @@ export const buildZeropsPlan = (manifest: FabrikaManifest, target: ZeropsRuntime
49
50
  kind: 'raise-start-memory',
50
51
  description: "Raise the deploy service's minimum RAM to what its start script needs",
51
52
  })
53
+ steps.push({
54
+ id: 'size-database',
55
+ kind: 'size-database',
56
+ description: "Size each process's database connections to the database's limit, raising its minimum RAM where it is too low",
57
+ })
52
58
  steps.push({ id: 'reserve-version', kind: 'reserve-version', description: 'Reserve a Zerops app version' })
53
59
  steps.push({ id: 'upload-source', kind: 'upload-source', description: 'Upload the resolved repository snapshot' })
54
60
  steps.push({ id: 'trigger-build', kind: 'trigger-build', description: 'Trigger the Zerops build+deploy pipeline' })
package/src/provider.ts CHANGED
@@ -33,11 +33,28 @@ import {
33
33
  type ZeropsAppVersion,
34
34
  type ZeropsLogAccess,
35
35
  type ZeropsProcess,
36
+ type ZeropsService,
36
37
  type ZeropsServiceAutoscaling,
37
38
  } from './api'
38
39
  import { defaultZeropsCollaborators, type ZeropsCollaboratorFactory, type ZeropsCollaborators } from './collaborators'
39
40
  import { assertZeropsInvariants, renderYaml } from './compile'
40
- import { FABRIKA_CHECK_VERSION, PLATFORM_TYPESCRIPT_VERSION, renderLockedExecutionDescriptor, zeropsStartMinRam } from './execution'
41
+ import {
42
+ MIN_PROCESS_CONNECTIONS,
43
+ ZEROPS_MAX_CONTAINERS,
44
+ ZEROPS_POSTGRES_MIN_RAM_GB,
45
+ type ZeropsDatabaseDemand,
46
+ zeropsDatabaseMinRam,
47
+ zeropsPostgresMaxConnections,
48
+ zeropsProcessConnections,
49
+ } from './database-budget'
50
+ import {
51
+ FABRIKA_CHECK_VERSION,
52
+ PLATFORM_TYPESCRIPT_VERSION,
53
+ renderLockedExecutionDescriptor,
54
+ zeropsJobProcesses,
55
+ type ZeropsLockedExecution,
56
+ zeropsStartMinRam,
57
+ } from './execution'
41
58
  import {
42
59
  createdServices,
43
60
  type ExposureFinding,
@@ -61,6 +78,7 @@ import {
61
78
  SERVICE_MANAGED_KEYS,
62
79
  } from './managed-env'
63
80
  import {
81
+ applicationDatabaseOf,
64
82
  approvedSchemaOf,
65
83
  type FabrikaImportDocument,
66
84
  type FabrikaManifest,
@@ -288,6 +306,8 @@ interface Attempt {
288
306
  triggered: boolean
289
307
  /** What the build's source-map upload reported in its log, once a relayed line said so. */
290
308
  sourceMaps?: OperationsSourceMapUploadState
309
+ /** The most connections each process of the application opens, as `size-database` sized them. */
310
+ databaseConnections?: number
291
311
  }
292
312
 
293
313
  /** Everything one step's executor needs, bundled so the per-kind handlers stay small. */
@@ -667,27 +687,42 @@ const raiseStartMemory = async (env: StepEnv): Promise<void> => {
667
687
  log(` [dry-run] would raise the minimum RAM of ${hostname} to ${minRam} GB where it is lower`)
668
688
  return
669
689
  }
670
- const serviceId = run.target.serviceId
671
690
  assertRunning(signal)
672
- const service = await zerops.api.getService({ serviceId, signal })
691
+ const service = await zerops.api.getService({ serviceId: run.target.serviceId, signal })
692
+ const before = service.currentAutoscaling?.vertical.minResource.memoryGBytes ?? null
693
+ if (before !== null && before >= minRam) {
694
+ log(` minimum RAM of ${hostname}: ${before} GB, unchanged (its start needs ${minRam} GB)`)
695
+ return
696
+ }
697
+ const after = await raiseMinRam(env, service, hostname, minRam, 'its start needs')
698
+ log(` minimum RAM of ${hostname}: ${before ?? 'the platform default'} → ${after} GB`)
699
+ }
700
+
701
+ /** What raising a minimum RAM calls; the deploy's collaborators, and the live platform-facts probe of the same path. */
702
+ export interface MinRamRaise {
703
+ zerops: Pick<ZeropsCollaborators, 'api' | 'sleep'>
704
+ signal: AbortSignal
705
+ }
706
+
707
+ /**
708
+ * Sets `service`'s minimum RAM to `minRam` and answers the minimum it reads back. It sends the custom autoscaling it
709
+ * read WHOLE with only that field changed: the platform resets every field a request leaves out.
710
+ */
711
+ export const raiseMinRam = async (env: MinRamRaise, service: ZeropsService, hostname: string, minRam: number, need: string): Promise<number> => {
712
+ const { zerops, signal } = env
673
713
  const custom = service.customAutoscaling
674
714
  const current = service.currentAutoscaling
675
715
  if (custom === undefined || current === undefined) {
676
716
  throw new Error(`zerops: ${hostname} did not report its autoscaling whole, so its minimum RAM cannot be raised to ${minRam} GB`)
677
717
  }
678
- const before = current.vertical.minResource.memoryGBytes
679
- if (before !== null && before >= minRam) {
680
- log(` minimum RAM of ${hostname}: ${before} GB, unchanged (its start needs ${minRam} GB)`)
681
- return
682
- }
683
718
  const maxRam = current.vertical.maxResource.memoryGBytes
684
719
  if (maxRam !== null && maxRam < minRam) {
685
- throw new Error(`zerops: ${hostname} may use at most ${maxRam} GB of RAM and its start needs ${minRam} GB; raise verticalAutoscaling.maxRam`)
720
+ throw new Error(`zerops: ${hostname} may use at most ${maxRam} GB of RAM and ${need} ${minRam} GB; raise verticalAutoscaling.maxRam`)
686
721
  }
687
722
  assertRunning(signal)
688
723
  let process: ZeropsProcess | null
689
724
  try {
690
- process = await zerops.api.configureCustomAutoscaling({ serviceId, autoscaling: withMinRam(custom, minRam), signal })
725
+ process = await zerops.api.configureCustomAutoscaling({ serviceId: service.id, autoscaling: withMinRam(custom, minRam), signal })
691
726
  } catch (error) {
692
727
  if (error instanceof ZeropsApiError && error.status >= 400 && error.status < 500 && error.status !== 408) {
693
728
  throw new Error(`zerops: raising the minimum RAM of ${hostname} to ${minRam} GB was refused (${error.code === '' ? error.status : error.code})`)
@@ -697,11 +732,69 @@ const raiseStartMemory = async (env: StepEnv): Promise<void> => {
697
732
  if (process !== null) {
698
733
  await waitForProcess({ api: zerops.api, processId: process.id, sleep: zerops.sleep, signal, label: `the autoscaling change of ${hostname}` })
699
734
  }
700
- const after = (await zerops.api.getService({ serviceId, signal })).currentAutoscaling?.vertical.minResource.memoryGBytes ?? null
735
+ const after = (await zerops.api.getService({ serviceId: service.id, signal })).currentAutoscaling?.vertical.minResource.memoryGBytes ?? null
701
736
  if (after === null || after < minRam) {
702
737
  throw new Error(`zerops: ${hostname} reads back a minimum RAM of ${after ?? 'nothing'} GB after it was raised to ${minRam} GB`)
703
738
  }
704
- log(` minimum RAM of ${hostname}: ${before ?? 'the platform default'} → ${after} GB`)
739
+ return after
740
+ }
741
+
742
+ /**
743
+ * Sizes the connections each process of the application opens (`zeropsProcessConnections`) from its database's
744
+ * `max_connections`, which follows the database's minimum RAM, and the deploy service's maximum container count. When
745
+ * that leaves a process fewer than `MIN_PROCESS_CONNECTIONS`, it raises the minimum RAM of the application's own
746
+ * database to the smallest step that does not (`zeropsDatabaseMinRam`). A `cheap` namespace's shared database is the
747
+ * namespace's to size, so it is never raised: each process then gets the minimum, and the run log says it may not fit.
748
+ */
749
+ const sizeDatabase = async (env: StepEnv): Promise<void> => {
750
+ const { run, zerops, log, signal, dryRun, attempt } = env
751
+ const locked = lockedExecutionFor(run.artifact, run, dryRun)
752
+ const database = locked === null ? null : applicationDatabaseOf(run.artifact)
753
+ if (locked === null || database === null) {
754
+ log(' the application has no database')
755
+ return
756
+ }
757
+ const deployHostname = run.artifact.target.deployService
758
+ if (dryRun) {
759
+ log(` [dry-run] would size each process of ${deployHostname} to the connection limit of ${database.hostname}`)
760
+ return
761
+ }
762
+ assertRunning(signal)
763
+ const deploy = await zerops.api.getService({ serviceId: run.target.serviceId, signal })
764
+ const demand: ZeropsDatabaseDemand = {
765
+ maxContainers: deploy.currentAutoscaling?.horizontal.maxContainerCount ?? ZEROPS_MAX_CONTAINERS,
766
+ jobs: zeropsJobProcesses(locked.contract),
767
+ }
768
+ const service = await zerops.api.findService({ projectId: run.target.projectId, hostname: database.hostname, signal })
769
+ if (service === null) throw new Error(`zerops: the database ${database.hostname} does not exist, so its connections cannot be sized`)
770
+ const profile = service.autoscalingProfileId
771
+ // A minimum the service does not report is taken as the smallest step: its limit is then never overstated.
772
+ let ram = service.currentAutoscaling?.vertical.minResource.memoryGBytes ?? ZEROPS_POSTGRES_MIN_RAM_GB
773
+ const holders = `${demand.maxContainers} container(s) of ${deployHostname}, twice while a deploy replaces them, and ${demand.jobs} job process(es)`
774
+ let connections = zeropsProcessConnections(zeropsPostgresMaxConnections(ram, profile), demand)
775
+ if (connections === null && database.shared) {
776
+ connections = MIN_PROCESS_CONNECTIONS
777
+ log(` [warn] ${database.hostname} at ${ram} GB has too few connections for ${holders}; the namespace shares it, so raise its profile`)
778
+ }
779
+ if (connections === null) {
780
+ const minRam = zeropsDatabaseMinRam(demand, profile)
781
+ if (minRam === null) throw new Error(`zerops: no RAM of ${database.hostname} has enough connections for ${holders}`)
782
+ const before = ram
783
+ ram = await raiseMinRam(env, service, database.hostname, minRam, `${holders} need`)
784
+ log(` minimum RAM of ${database.hostname}: ${before} → ${ram} GB, for its connection limit`)
785
+ connections = zeropsProcessConnections(zeropsPostgresMaxConnections(ram, profile), demand) ?? MIN_PROCESS_CONNECTIONS
786
+ }
787
+ attempt.databaseConnections = connections
788
+ const limit = zeropsPostgresMaxConnections(ram, profile)
789
+ log(` ${database.hostname}: ${limit} connections at ${ram} GB; each process opens at most ${connections}, for ${holders}`)
790
+ }
791
+
792
+ /** The locked execution with the connections `size-database` sized; an application with a database is never sent without. */
793
+ const sizedExecution = (locked: ZeropsLockedExecution, attempt: Attempt): ZeropsLockedExecution => {
794
+ if (locked.databaseUrl === null) return locked
795
+ const connections = attempt.databaseConnections
796
+ if (connections === undefined) throw new Error('zerops: trigger-build has no database connection size (size-database did not run)')
797
+ return { ...locked, databaseConnections: connections }
705
798
  }
706
799
 
707
800
  /** Run one step's effect. Resolves on success, throws on failure (caught + recorded by `runSteps`). */
@@ -763,6 +856,11 @@ const runStep = async (spec: ZeropsStep, env: StepEnv): Promise<void> => {
763
856
  return
764
857
  }
765
858
 
859
+ case 'size-database': {
860
+ await sizeDatabase(env)
861
+ return
862
+ }
863
+
766
864
  case 'reserve-version': {
767
865
  if (dryRun) {
768
866
  log(` [dry-run] would reserve an app version on service ${target.serviceId}`)
@@ -830,7 +928,7 @@ const runStep = async (spec: ZeropsStep, env: StepEnv): Promise<void> => {
830
928
  const release = env.managedValues.FABRIKA_RELEASE
831
929
  const descriptor = locked === null
832
930
  ? overlayRelease(contents, artifact.target.zeropsSetup, release)
833
- : renderLockedExecutionDescriptor(contents, artifact.target.zeropsSetup, release, locked)
931
+ : renderLockedExecutionDescriptor(contents, artifact.target.zeropsSetup, release, sizedExecution(locked, attempt))
834
932
  if (locked !== null) {
835
933
  log(` generated run section; build checks @fabrika/check@${FABRIKA_CHECK_VERSION}, typescript@${PLATFORM_TYPESCRIPT_VERSION}`)
836
934
  if (locked.sourceMaps !== null) log(` the build uploads the source maps of ${locked.sourceMaps.assetRoots.length} asset root(s)`)