@platformatic/foundation 3.68.0 → 4.0.0-new-config.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,116 @@
1
+ import { readdir } from 'node:fs/promises'
2
+ import { join } from 'node:path'
3
+ import { AmbiguousConfigurationFileError, LegacyConfigurationFileError } from './errors.js'
4
+
5
+ // The four recognized v4 filenames. Exactly one may exist in a directory.
6
+ export const configurationFileExtensions = ['ts', 'mts', 'js', 'mjs']
7
+ export const configurationFileNames = configurationFileExtensions.map(extension => `watt.config.${extension}`)
8
+
9
+ // The complete v3 candidate set. Legacy detection is unconditional and by filename alone —
10
+ // no parsing, no shape heuristics — so this table is the whole of it. It is deliberately
11
+ // duplicated from the v3 machinery rather than imported: that machinery leaves foundation.
12
+ export const legacyConfigurationFileExtensions = ['json', 'json5', 'yaml', 'yml', 'toml', 'tml']
13
+ export const legacyConfigurationFileSuffixes = [
14
+ 'runtime',
15
+ 'application',
16
+ 'service',
17
+ 'db',
18
+ 'gateway',
19
+ 'composer'
20
+ ]
21
+
22
+ export const legacyConfigurationFileNames = (function listLegacyConfigurationFileNames () {
23
+ const names = []
24
+
25
+ for (const extension of legacyConfigurationFileExtensions) {
26
+ names.push(`watt.${extension}`, `platformatic.${extension}`)
27
+ }
28
+
29
+ for (const suffix of legacyConfigurationFileSuffixes) {
30
+ for (const extension of legacyConfigurationFileExtensions) {
31
+ names.push(`watt.${suffix}.${extension}`, `platformatic.${suffix}.${extension}`)
32
+ }
33
+ }
34
+
35
+ return names
36
+ })()
37
+
38
+ const legacyConfigurationFileNamesSet = new Set(legacyConfigurationFileNames)
39
+ const configurationFileNamesSet = new Set(configurationFileNames)
40
+
41
+ // One directory read serves both tables. Returning the raw listing lets callers that already
42
+ // walked a directory answer both questions without a second stat storm.
43
+ export async function listDirectoryEntries (directory) {
44
+ try {
45
+ return await readdir(directory)
46
+ } catch (error) {
47
+ if (error.code === 'ENOENT' || error.code === 'ENOTDIR' || error.code === 'EACCES') {
48
+ return []
49
+ }
50
+
51
+ throw error
52
+ }
53
+ }
54
+
55
+ export function selectConfigurationFileNames (entries) {
56
+ // Ordered by the canonical extension order rather than by directory order, so the ambiguity
57
+ // error reads the same on every filesystem.
58
+ return configurationFileNames.filter(name => entries.includes(name))
59
+ }
60
+
61
+ export function selectLegacyConfigurationFileNames (entries) {
62
+ return entries.filter(entry => legacyConfigurationFileNamesSet.has(entry)).sort()
63
+ }
64
+
65
+ export function isConfigurationFileName (name) {
66
+ return configurationFileNamesSet.has(name)
67
+ }
68
+
69
+ export function isLegacyConfigurationFileName (name) {
70
+ return legacyConfigurationFileNamesSet.has(name)
71
+ }
72
+
73
+ // Consulting a directory means both checks, in this order: a legacy file is an error even next
74
+ // to a v4 one, so it is reported before the ambiguity check can shadow it.
75
+ export async function inspectDirectory (directory) {
76
+ const entries = await listDirectoryEntries(directory)
77
+ const legacy = selectLegacyConfigurationFileNames(entries)
78
+
79
+ if (legacy.length > 0) {
80
+ throw new LegacyConfigurationFileError(join(directory, legacy[0]))
81
+ }
82
+
83
+ const candidates = selectConfigurationFileNames(entries)
84
+
85
+ if (candidates.length > 1) {
86
+ throw new AmbiguousConfigurationFileError(directory, candidates.join(', '))
87
+ }
88
+
89
+ return candidates.length === 1 ? join(directory, candidates[0]) : null
90
+ }
91
+
92
+ // Synthesis is never refused on account of a configuration above, but it does say so — and the
93
+ // scan looks for the complete candidate set rather than only the v4 names, because a v3 monorepo
94
+ // is exactly where a configless subpackage is most likely to be found. Synthesizing there while an
95
+ // ancestor platformatic.json describes the application is the same silence with an older filename.
96
+ export async function findAnyConfigurationFile (directory) {
97
+ const entries = await listDirectoryEntries(directory)
98
+ const candidates = selectConfigurationFileNames(entries)
99
+
100
+ if (candidates.length > 0) {
101
+ return { path: join(directory, candidates[0]), legacy: false }
102
+ }
103
+
104
+ const legacy = selectLegacyConfigurationFileNames(entries)
105
+
106
+ return legacy.length > 0 ? { path: join(directory, legacy[0]), legacy: true } : null
107
+ }
108
+
109
+ // The env-root and watch-horizon scans are filename checks that execute nothing and decide
110
+ // nothing about which configuration boots, so they never raise the ambiguity error: a directory
111
+ // that has two candidates still has a configuration in it.
112
+ export async function hasConfigurationFile (directory) {
113
+ const entries = await listDirectoryEntries(directory)
114
+
115
+ return selectConfigurationFileNames(entries).length > 0
116
+ }
@@ -0,0 +1,81 @@
1
+ import { basename } from 'node:path'
2
+ import { InvalidApplicationIdError } from './errors.js'
3
+
4
+ // The id is not cosmetic: it is the mesh hostname, the injected PLT_<ID>_URL name, the metrics
5
+ // label, wattpm inject's argument and how siblings name each other in dependencies. A default
6
+ // that varied by boot style would move all five at once, so there is one derivation used at every
7
+ // position and under every boot style — though not always over the same inputs, since an explicit
8
+ // id lives on a root entry and a standalone boot never reads one.
9
+ export function stripPackageScope (name) {
10
+ return name.startsWith('@') ? name.slice(name.indexOf('/') + 1) : name
11
+ }
12
+
13
+ export function deriveApplicationId ({ id, packageName, directory } = {}) {
14
+ if (typeof id === 'string' && id.length > 0) {
15
+ return { id, source: 'configuration' }
16
+ }
17
+
18
+ if (typeof packageName === 'string' && packageName.length > 0) {
19
+ return { id: stripPackageScope(packageName), source: 'package.json' }
20
+ }
21
+
22
+ return { id: basename(directory ?? ''), source: 'directory' }
23
+ }
24
+
25
+ // The test is the DNS label grammar itself, not a list of bad characters: an enumeration of @, /,
26
+ // : and whitespace would have admitted my_app and api.v2, which are equally unusable as
27
+ // http://<id>.plt.local.
28
+ export const dnsLabelPattern = /^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/
29
+
30
+ export function isValidApplicationId (id) {
31
+ return typeof id === 'string' && dnsLabelPattern.test(id)
32
+ }
33
+
34
+ // Checked before the id reaches either consumer — the hostname and the topology-variable
35
+ // normalization. Silently rewriting my_app to my-app would move the mesh hostname, the injected
36
+ // variable and the metrics label without the user asking, so this reports rather than sanitizes.
37
+ export function assertValidApplicationId (id, source) {
38
+ if (!isValidApplicationId(id)) {
39
+ throw new InvalidApplicationIdError(JSON.stringify(id), source)
40
+ }
41
+
42
+ return id
43
+ }
44
+
45
+ export function getApplicationUrl (id) {
46
+ return `http://${id}.plt.local`
47
+ }
48
+
49
+ // Uppercased id, non-alphanumerics to underscore — the same normalization injection uses, which is
50
+ // what lets the loader strip exactly these keys from a per-app eval worker's environment.
51
+ export function topologyVariableName (id) {
52
+ return `PLT_${id.toUpperCase().replace(/[^A-Z0-9]/g, '_')}_URL`
53
+ }
54
+
55
+ // The label grammar removes most of the ways two ids could normalize to one variable, so what
56
+ // remains is a case difference — and DNS labels being case-insensitive, api-v2 and API-v2 are the
57
+ // same mesh hostname too. One check catches both collisions.
58
+ export function findTopologyVariableCollisions (ids) {
59
+ const byName = new Map()
60
+
61
+ for (const id of ids) {
62
+ const name = topologyVariableName(id)
63
+ const existing = byName.get(name)
64
+
65
+ if (existing) {
66
+ existing.push(id)
67
+ } else {
68
+ byName.set(name, [id])
69
+ }
70
+ }
71
+
72
+ const collisions = []
73
+
74
+ for (const [name, colliding] of byName) {
75
+ if (colliding.length > 1) {
76
+ collisions.push({ name, ids: colliding })
77
+ }
78
+ }
79
+
80
+ return collisions
81
+ }
@@ -0,0 +1,21 @@
1
+ // The v4 configuration loader. It is written new for v4 rather than carved out of the v3
2
+ // configuration machinery, and it shares nothing with it: the v3 parsers, replaceEnv, the YAML
3
+ // pre-pass, strictEnv and the $schema URL machinery move into wattpm-utils as migrate's private
4
+ // legacy reader. Only deliberately-kept pieces are carried over, each by explicit decision.
5
+ export * from './canonicalize.js'
6
+ export * from './capability-resolution.js'
7
+ export * from './classify.js'
8
+ export * from './context.js'
9
+ export * from './detect.js'
10
+ export * from './env.js'
11
+ export * from './evaluate.js'
12
+ export * from './errors.js'
13
+ export * as errors from './errors.js'
14
+ export * from './filenames.js'
15
+ export * from './identifiers.js'
16
+ export * from './load.js'
17
+ export * from './pipeline.js'
18
+ export * from './scope.js'
19
+ export * from './serving.js'
20
+ export * from './topology.js'
21
+ export * from './validate.js'