@platformatic/runtime 3.64.0 → 3.65.0
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/index.d.ts +49 -0
- package/index.js +2 -0
- package/lib/errors.js +1 -1
- package/lib/runtime.js +112 -32
- package/lib/worker/controller.js +2 -2
- package/lib/worker/itc.js +8 -3
- package/lib/worker/main.js +16 -1
- package/package.json +16 -16
- package/schema.json +1 -1
package/index.d.ts
CHANGED
|
@@ -200,6 +200,40 @@ export interface RuntimeMetadata {
|
|
|
200
200
|
platformaticVersion: string
|
|
201
201
|
}
|
|
202
202
|
|
|
203
|
+
export interface WorkerLifecycleEvent {
|
|
204
|
+
application: string
|
|
205
|
+
worker: number
|
|
206
|
+
workersCount?: number
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export interface ProfileCapturedEvent extends WorkerLifecycleEvent {
|
|
210
|
+
id: string
|
|
211
|
+
type: 'cpu' | 'heap'
|
|
212
|
+
timestamp: number
|
|
213
|
+
sampleCount: number | null
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export interface RuntimeHealthSignal {
|
|
217
|
+
type: string
|
|
218
|
+
max?: number
|
|
219
|
+
mean?: number
|
|
220
|
+
p99?: number
|
|
221
|
+
timestamp?: number
|
|
222
|
+
[key: string]: unknown
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export interface WorkerCurrentHealth {
|
|
226
|
+
elu: number
|
|
227
|
+
heapUsed?: number | null
|
|
228
|
+
heapTotal?: number | null
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export interface HealthMetricsEvent extends WorkerLifecycleEvent {
|
|
232
|
+
id: string
|
|
233
|
+
currentHealth: WorkerCurrentHealth | null
|
|
234
|
+
healthSignals: RuntimeHealthSignal[]
|
|
235
|
+
}
|
|
236
|
+
|
|
203
237
|
export declare class ManagementClient {
|
|
204
238
|
constructor (allowedOperations?: string[])
|
|
205
239
|
|
|
@@ -400,6 +434,21 @@ export declare class Runtime extends EventEmitter {
|
|
|
400
434
|
// create()/start().
|
|
401
435
|
setApplicationConfigPatch (applicationId: string, patch: Array<Record<string, unknown>>): void
|
|
402
436
|
removeApplicationConfigPatch (applicationId: string): void
|
|
437
|
+
|
|
438
|
+
// Typed worker lifecycle events. Catch-all overloads preserve EventEmitter's
|
|
439
|
+
// open event surface so listeners for other runtime events still type-check.
|
|
440
|
+
on (event: 'application:worker:started', listener: (event: WorkerLifecycleEvent) => void): this
|
|
441
|
+
on (event: 'application:worker:exited', listener: (event: WorkerLifecycleEvent) => void): this
|
|
442
|
+
on (event: 'application:worker:profile:captured', listener: (event: ProfileCapturedEvent) => void): this
|
|
443
|
+
on (event: 'application:worker:health:metrics', listener: (event: HealthMetricsEvent) => void): this
|
|
444
|
+
on (event: string | symbol, listener: (...args: any[]) => void): this
|
|
445
|
+
|
|
446
|
+
off (event: 'application:worker:started', listener: (event: WorkerLifecycleEvent) => void): this
|
|
447
|
+
off (event: 'application:worker:exited', listener: (event: WorkerLifecycleEvent) => void): this
|
|
448
|
+
off (event: 'application:worker:profile:captured', listener: (event: ProfileCapturedEvent) => void): this
|
|
449
|
+
off (event: 'application:worker:health:metrics', listener: (event: HealthMetricsEvent) => void): this
|
|
450
|
+
off (event: string | symbol, listener: (...args: any[]) => void): this
|
|
451
|
+
|
|
403
452
|
getSchedulerJobs (): SchedulerJob[]
|
|
404
453
|
pauseSchedulerJob (name: string): Promise<SchedulerJob>
|
|
405
454
|
resumeSchedulerJob (name: string): Promise<SchedulerJob>
|
package/index.js
CHANGED
package/lib/errors.js
CHANGED
|
@@ -178,7 +178,7 @@ export const FailedToStopExtensionError = createError(
|
|
|
178
178
|
|
|
179
179
|
export const InvalidExtensionError = createError(
|
|
180
180
|
`${ERROR_PREFIX}_INVALID_EXTENSION`,
|
|
181
|
-
'The extension "%s" must export a setup function as its default export'
|
|
181
|
+
'The extension "%s" must export a setup function as its default export or as a named "setup" export'
|
|
182
182
|
)
|
|
183
183
|
|
|
184
184
|
export const ReservedITCHandlerNameError = createError(
|
package/lib/runtime.js
CHANGED
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
executeInParallel,
|
|
6
6
|
executeWithTimeout,
|
|
7
7
|
features,
|
|
8
|
+
kEnvFileFallbackKeys,
|
|
8
9
|
kMetadata,
|
|
9
10
|
kTimeout,
|
|
10
11
|
parseMemorySize
|
|
@@ -146,6 +147,24 @@ function formatMetricValue (value) {
|
|
|
146
147
|
return `${value}`
|
|
147
148
|
}
|
|
148
149
|
|
|
150
|
+
// Resolves the setup function of a runtime extension out of its module namespace.
|
|
151
|
+
//
|
|
152
|
+
// The canonical form is a default export, but transpilers and bundlers emit
|
|
153
|
+
// faux ESM modules, in which the real exports are properties of `module.exports`
|
|
154
|
+
// and therefore only reachable via an additional `default` hop. The setup
|
|
155
|
+
// function can also be exported as a named `setup` export, at both levels.
|
|
156
|
+
function resolveExtensionSetup (imported) {
|
|
157
|
+
const candidates = [imported?.default, imported?.setup, imported?.default?.default, imported?.default?.setup]
|
|
158
|
+
|
|
159
|
+
for (const candidate of candidates) {
|
|
160
|
+
if (typeof candidate === 'function') {
|
|
161
|
+
return candidate
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return null
|
|
166
|
+
}
|
|
167
|
+
|
|
149
168
|
// Always run operations in parallel to avoid deadlocks when services have dependencies
|
|
150
169
|
const DEFAULT_CONCURRENCY = availableParallelism() * 2
|
|
151
170
|
const MAX_BOOTSTRAP_ATTEMPTS = 5
|
|
@@ -990,45 +1009,45 @@ export class Runtime extends EventEmitter {
|
|
|
990
1009
|
|
|
991
1010
|
try {
|
|
992
1011
|
// Bound the whole retrieval with a single timeout budget: resolving the
|
|
993
|
-
//
|
|
994
|
-
//
|
|
1012
|
+
// workers round-trips to them when ensureStarted is set, and the profile
|
|
1013
|
+
// pulls do too — both hang if a worker event loop is blocked. Attach
|
|
995
1014
|
// a noop handler so that a late settlement after the timeout does not
|
|
996
1015
|
// surface as an unhandled rejection.
|
|
997
|
-
const pull = (
|
|
998
|
-
const service = await this.#getApplicationById(id, ensureStarted)
|
|
999
|
-
const result = await sendViaITC(service, 'getLastProfile', {
|
|
1000
|
-
...options,
|
|
1001
|
-
includeTimestamp: true,
|
|
1002
|
-
includeSampleCount: true
|
|
1003
|
-
})
|
|
1004
|
-
return { service, result }
|
|
1005
|
-
})()
|
|
1016
|
+
const pull = this.#pullLastProfiles(id, options, ensureStarted)
|
|
1006
1017
|
pull.catch(() => {})
|
|
1007
1018
|
|
|
1008
1019
|
const outcome = await executeWithTimeout(pull, timeout, kTimeout)
|
|
1009
1020
|
|
|
1010
1021
|
if (outcome !== kTimeout) {
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1022
|
+
let best = null
|
|
1023
|
+
|
|
1024
|
+
for (const { service, result } of outcome) {
|
|
1025
|
+
// An older capture module which does not support includeTimestamp
|
|
1026
|
+
// returns the raw profile.
|
|
1027
|
+
const value = result instanceof Uint8Array
|
|
1028
|
+
? { profile: result, timestamp: null, sampleCount: null }
|
|
1029
|
+
: { sampleCount: null, ...result }
|
|
1030
|
+
|
|
1031
|
+
// A strictly newer live window supersedes the preserved overload
|
|
1032
|
+
// profile: prune it so the preserved copy naturally expires once the
|
|
1033
|
+
// worker is healthy again and its profiles are being consumed.
|
|
1034
|
+
if (value.timestamp != null) {
|
|
1035
|
+
const key = `${service[kApplicationId]}:${service[kWorkerId]}:${type}`
|
|
1036
|
+
const preserved = this.#lastOverloadProfiles.get(key)
|
|
1037
|
+
|
|
1038
|
+
if (preserved && preserved.timestamp < value.timestamp) {
|
|
1039
|
+
this.#lastOverloadProfiles.delete(key)
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
// For an application-level id the newest window across the workers
|
|
1044
|
+
// wins, mirroring the preserved overload profile fallback below.
|
|
1045
|
+
if (!best || (value.timestamp != null && (best.timestamp == null || value.timestamp > best.timestamp))) {
|
|
1046
|
+
best = value
|
|
1028
1047
|
}
|
|
1029
1048
|
}
|
|
1030
1049
|
|
|
1031
|
-
return { ...
|
|
1050
|
+
return { ...best, preserved: false }
|
|
1032
1051
|
}
|
|
1033
1052
|
|
|
1034
1053
|
// The worker event loop is not responding (e.g. it is hard-blocked).
|
|
@@ -1057,6 +1076,62 @@ export class Runtime extends EventEmitter {
|
|
|
1057
1076
|
throw error
|
|
1058
1077
|
}
|
|
1059
1078
|
|
|
1079
|
+
// Pulls the last profile from the addressed worker, or from every worker of
|
|
1080
|
+
// the application when no explicit worker index is given: the application
|
|
1081
|
+
// "last profile" is the newest window among its workers, so one arbitrary
|
|
1082
|
+
// worker cannot answer for all of them. Per-worker failures with fallback
|
|
1083
|
+
// codes are ignored as long as at least one worker yields a profile.
|
|
1084
|
+
async #pullLastProfiles (id, options, ensureStarted) {
|
|
1085
|
+
const pullOptions = { ...options, includeTimestamp: true, includeSampleCount: true }
|
|
1086
|
+
|
|
1087
|
+
const pullWorker = async service => {
|
|
1088
|
+
const result = await sendViaITC(service, 'getLastProfile', pullOptions)
|
|
1089
|
+
return { service, result }
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
if (/^.+:\d+$/.test(id)) {
|
|
1093
|
+
return [await pullWorker(await this.#getApplicationById(id, ensureStarted))]
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
if (!this.#applications.has(id)) {
|
|
1097
|
+
throw new ApplicationNotFoundError(id, this.getApplicationsIds().join(', '))
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
const keys = this.#workers.getKeys(id)
|
|
1101
|
+
|
|
1102
|
+
// No worker is currently registered: resolve the id as usual so that the
|
|
1103
|
+
// canonical error is raised.
|
|
1104
|
+
if (keys.length === 0) {
|
|
1105
|
+
return [await pullWorker(await this.#getApplicationById(id, ensureStarted))]
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
const settled = await Promise.allSettled(
|
|
1109
|
+
keys.map(async key => {
|
|
1110
|
+
const service = await this.#getWorkerByIdOrNext(id, key.split(':')[1], ensureStarted)
|
|
1111
|
+
return pullWorker(service)
|
|
1112
|
+
})
|
|
1113
|
+
)
|
|
1114
|
+
|
|
1115
|
+
const profiles = []
|
|
1116
|
+
let firstError
|
|
1117
|
+
|
|
1118
|
+
for (const outcome of settled) {
|
|
1119
|
+
if (outcome.status === 'fulfilled') {
|
|
1120
|
+
profiles.push(outcome.value)
|
|
1121
|
+
} else if (!kLastProfileFallbackCodes.has(outcome.reason?.code)) {
|
|
1122
|
+
throw outcome.reason
|
|
1123
|
+
} else {
|
|
1124
|
+
firstError ??= outcome.reason
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
if (profiles.length === 0) {
|
|
1129
|
+
throw firstError
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
return profiles
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1060
1135
|
// The final profile of an overload pause is pushed by the worker and
|
|
1061
1136
|
// preserved in the main thread (see the profile:overload listener), so that
|
|
1062
1137
|
// the evidence of what saturated a worker survives the worker being blocked
|
|
@@ -2433,7 +2508,10 @@ export class Runtime extends EventEmitter {
|
|
|
2433
2508
|
codeRangeSizeMb
|
|
2434
2509
|
},
|
|
2435
2510
|
inspectorOptions,
|
|
2436
|
-
dirname: this.#root
|
|
2511
|
+
dirname: this.#root,
|
|
2512
|
+
// Keys of the worker environment which only come from an env file of the runtime:
|
|
2513
|
+
// the env file of the application is allowed to override those.
|
|
2514
|
+
envFileFallbackKeys: this.#env[kEnvFileFallbackKeys] ?? []
|
|
2437
2515
|
},
|
|
2438
2516
|
argv: applicationConfig.arguments,
|
|
2439
2517
|
execArgv,
|
|
@@ -4276,13 +4354,15 @@ export class Runtime extends EventEmitter {
|
|
|
4276
4354
|
for (const extension of extensions) {
|
|
4277
4355
|
const { path, options } = typeof extension === 'string' ? { path: extension } : extension
|
|
4278
4356
|
|
|
4279
|
-
let
|
|
4357
|
+
let imported
|
|
4280
4358
|
try {
|
|
4281
|
-
|
|
4359
|
+
imported = await import(pathToFileURL(path))
|
|
4282
4360
|
} catch (e) {
|
|
4283
4361
|
throw new FailedToLoadExtensionError(path, e.message, { cause: e })
|
|
4284
4362
|
}
|
|
4285
4363
|
|
|
4364
|
+
const setup = resolveExtensionSetup(imported)
|
|
4365
|
+
|
|
4286
4366
|
if (typeof setup !== 'function') {
|
|
4287
4367
|
throw new InvalidExtensionError(path)
|
|
4288
4368
|
}
|
package/lib/worker/controller.js
CHANGED
|
@@ -24,7 +24,7 @@ import { resolve } from 'node:path'
|
|
|
24
24
|
import { getActiveResourcesInfo } from 'node:process'
|
|
25
25
|
import { workerData } from 'node:worker_threads'
|
|
26
26
|
import { getGlobalDispatcher, setGlobalDispatcher } from 'undici'
|
|
27
|
-
import { ApplicationAlreadyStartedError, RuntimeNotStartedError } from '../errors.js'
|
|
27
|
+
import { ApplicationAlreadyStartedError, exitCodes, RuntimeNotStartedError } from '../errors.js'
|
|
28
28
|
import { getApplicationUrl } from '../utils.js'
|
|
29
29
|
import { markAsPlatformaticDispatcher, refreshGlobalDispatcher } from './interceptors.js'
|
|
30
30
|
|
|
@@ -43,7 +43,7 @@ function handleUnhandled (app, event, listeners, timeout, err, ...args) {
|
|
|
43
43
|
logger.error({ err: ensureLoggableError(err) }, `The ${label} threw an ${event} event.`)
|
|
44
44
|
|
|
45
45
|
// Give some time to the listeners, logger and ITC notifications to land before shutting down
|
|
46
|
-
setTimeout(() => process.exit(
|
|
46
|
+
setTimeout(() => process.exit(exitCodes.PROCESS_UNHANDLED_ERROR), timeout)
|
|
47
47
|
|
|
48
48
|
for (const listener of listeners) {
|
|
49
49
|
try {
|
package/lib/worker/itc.js
CHANGED
|
@@ -167,6 +167,10 @@ export async function setupITC (controller, application, dispatcher, sharedConte
|
|
|
167
167
|
}
|
|
168
168
|
})
|
|
169
169
|
|
|
170
|
+
// ITC handlers run concurrently, so later start/stop requests must wait for
|
|
171
|
+
// the actual in-flight start operation rather than a controller event.
|
|
172
|
+
let controllerStartPromise
|
|
173
|
+
|
|
170
174
|
const itc = new ITC({
|
|
171
175
|
name: controller.applicationConfig.id + '-worker',
|
|
172
176
|
port: parentPort,
|
|
@@ -175,14 +179,15 @@ export async function setupITC (controller, application, dispatcher, sharedConte
|
|
|
175
179
|
const status = controller.getStatus()
|
|
176
180
|
|
|
177
181
|
if (status === 'starting') {
|
|
178
|
-
await
|
|
182
|
+
await controllerStartPromise
|
|
179
183
|
} else {
|
|
180
184
|
// This gives a chance to a capability to perform custom logic
|
|
181
185
|
const events = getEvents()
|
|
182
186
|
events.emit('start')
|
|
183
187
|
|
|
184
188
|
try {
|
|
185
|
-
|
|
189
|
+
controllerStartPromise = controller.start()
|
|
190
|
+
await controllerStartPromise
|
|
186
191
|
} catch (e) {
|
|
187
192
|
await controller.stop(true)
|
|
188
193
|
|
|
@@ -222,7 +227,7 @@ export async function setupITC (controller, application, dispatcher, sharedConte
|
|
|
222
227
|
const status = controller.getStatus()
|
|
223
228
|
|
|
224
229
|
if (!force && status === 'starting') {
|
|
225
|
-
await
|
|
230
|
+
await controllerStartPromise
|
|
226
231
|
}
|
|
227
232
|
|
|
228
233
|
if (force || status.startsWith('start')) {
|
package/lib/worker/main.js
CHANGED
|
@@ -11,12 +11,14 @@ import { addPinoInstrumentation } from '@platformatic/telemetry'
|
|
|
11
11
|
import { Buffer } from 'node:buffer'
|
|
12
12
|
import { subscribe } from 'node:diagnostics_channel'
|
|
13
13
|
import { EventEmitter } from 'node:events'
|
|
14
|
+
import { readFile } from 'node:fs/promises'
|
|
14
15
|
import { ServerResponse } from 'node:http'
|
|
15
16
|
import inspector from 'node:inspector'
|
|
16
17
|
import { hostname } from 'node:os'
|
|
17
18
|
import { join, resolve } from 'node:path'
|
|
18
19
|
import { setDefaultHighWaterMark } from 'node:stream'
|
|
19
20
|
import { pathToFileURL } from 'node:url'
|
|
21
|
+
import { parseEnv } from 'node:util'
|
|
20
22
|
import { threadId, workerData } from 'node:worker_threads'
|
|
21
23
|
import pino from 'pino'
|
|
22
24
|
import { install as installUndiciGlobals } from 'undici'
|
|
@@ -240,8 +242,21 @@ async function main () {
|
|
|
240
242
|
const logger = getLogger()
|
|
241
243
|
logger.debug({ envfile }, 'Loading envfile...')
|
|
242
244
|
|
|
245
|
+
// Note that process.loadEnvFile is not used here as it never overrides an already defined
|
|
246
|
+
// variable. The worker environment is seeded from the environment the runtime resolved, which
|
|
247
|
+
// might contain values coming from an env file of the runtime. Those are only defaults, so the
|
|
248
|
+
// env file of this application, which is more specific, is allowed to override them. Real
|
|
249
|
+
// environment variables are never overridden.
|
|
250
|
+
const envFileFallbackKeys = new Set(workerData.envFileFallbackKeys ?? [])
|
|
251
|
+
|
|
243
252
|
try {
|
|
244
|
-
|
|
253
|
+
const applicationEnv = parseEnv(await readFile(envfile, 'utf-8'))
|
|
254
|
+
|
|
255
|
+
for (const [key, value] of Object.entries(applicationEnv)) {
|
|
256
|
+
if (!(key in process.env) || envFileFallbackKeys.has(key)) {
|
|
257
|
+
process.env[key] = value
|
|
258
|
+
}
|
|
259
|
+
}
|
|
245
260
|
} catch {
|
|
246
261
|
// Ignore if the file doesn't exist, similar to dotenv behavior
|
|
247
262
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@platformatic/runtime",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.65.0",
|
|
4
4
|
"description": "Platformatic runtime",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -39,14 +39,14 @@
|
|
|
39
39
|
"typescript": "^5.9.3",
|
|
40
40
|
"undici-oidc-interceptor": "^0.9.0",
|
|
41
41
|
"why-is-node-running": "^2.2.2",
|
|
42
|
-
"@platformatic/
|
|
43
|
-
"@platformatic/
|
|
44
|
-
"@platformatic/
|
|
45
|
-
"@platformatic/service": "3.
|
|
46
|
-
"@platformatic/
|
|
47
|
-
"@platformatic/sql-graphql": "3.
|
|
48
|
-
"@platformatic/
|
|
49
|
-
"@platformatic/
|
|
42
|
+
"@platformatic/composer": "3.65.0",
|
|
43
|
+
"@platformatic/gateway": "3.65.0",
|
|
44
|
+
"@platformatic/node": "3.65.0",
|
|
45
|
+
"@platformatic/service": "3.65.0",
|
|
46
|
+
"@platformatic/db": "3.65.0",
|
|
47
|
+
"@platformatic/sql-graphql": "3.65.0",
|
|
48
|
+
"@platformatic/sql-mapper": "3.65.0",
|
|
49
|
+
"@platformatic/wattpm-pprof-capture": "3.65.0"
|
|
50
50
|
},
|
|
51
51
|
"dependencies": {
|
|
52
52
|
"@fastify/accepts": "^5.0.0",
|
|
@@ -77,13 +77,13 @@
|
|
|
77
77
|
"undici": "^7.27.2",
|
|
78
78
|
"undici-thread-interceptor": "^1.3.1",
|
|
79
79
|
"ws": "^8.16.0",
|
|
80
|
-
"@platformatic/
|
|
81
|
-
"@platformatic/
|
|
82
|
-
"@platformatic/
|
|
83
|
-
"@platformatic/itc": "3.
|
|
84
|
-
"@platformatic/
|
|
85
|
-
"@platformatic/metrics": "3.
|
|
86
|
-
"@platformatic/
|
|
80
|
+
"@platformatic/basic": "3.65.0",
|
|
81
|
+
"@platformatic/generators": "3.65.0",
|
|
82
|
+
"@platformatic/globals": "3.65.0",
|
|
83
|
+
"@platformatic/itc": "3.65.0",
|
|
84
|
+
"@platformatic/telemetry": "3.65.0",
|
|
85
|
+
"@platformatic/metrics": "3.65.0",
|
|
86
|
+
"@platformatic/foundation": "3.65.0"
|
|
87
87
|
},
|
|
88
88
|
"engines": {
|
|
89
89
|
"node": ">=22.19.0"
|
package/schema.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"$id": "https://schemas.platformatic.dev/@platformatic/runtime/3.
|
|
2
|
+
"$id": "https://schemas.platformatic.dev/@platformatic/runtime/3.65.0.json",
|
|
3
3
|
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
4
4
|
"title": "Platformatic Runtime Config",
|
|
5
5
|
"type": "object",
|