@empyria/common 0.1.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/.oxfmtrc.json ADDED
@@ -0,0 +1,9 @@
1
+ {
2
+ "$schema": "./node_modules/oxfmt/configuration_schema.json",
3
+ "ignorePatterns": [],
4
+ "useTabs": true,
5
+ "tabWidth": 4,
6
+ "singleQuote": true,
7
+ "trailingComma": "all",
8
+ "semi": false
9
+ }
package/AGENTS.md ADDED
@@ -0,0 +1,36 @@
1
+ # AGENTS.md
2
+
3
+ Shared backend services for higher-level components in **Principia**, a nanoservice
4
+ framework built primarily on Bun: environment variable schemas, error definitions,
5
+ Codogram (SMS/TOTP-style challenge codes), and Ata (JSON schema + validation).
6
+
7
+ ## Runtime
8
+
9
+ - Requires Bun `>=1.4.0` or Node.js `>=26`, inherited from
10
+ [@empyria/classification](https://github.com/imrefazekas/empyria-classification)'s use of
11
+ native `Temporal`. See that repo's `AGENTS.md` — it's a **git dependency**, so this package
12
+ only sees its pushed commits, not local working-tree changes.
13
+ - Plain ESM, no TypeScript, no build step.
14
+ - Relative imports **must** include explicit `.js` extensions (`from './Errors.js'`, not
15
+ `'./Errors'`) — Bun tolerates missing ones, Node's ESM resolver doesn't.
16
+
17
+ ## Validation: one library only
18
+
19
+ Schema validation runs on **`ata-validator`** exclusively (see [lib/Ata.js](./lib/Ata.js),
20
+ [lib/Defaulter.js](./lib/Defaulter.js)). `ajv` was deliberately removed — don't reintroduce it
21
+ or any second validator.
22
+
23
+ ## Layout
24
+
25
+ - [index.js](./index.js) is the package entry point; it re-exports everything under `lib/`.
26
+ When adding a new `lib/*.js` module, remember to add its `export * from './lib/X.js'` line
27
+ here too — `Codogram.js` was missing from the barrel for a while and no one noticed.
28
+ - `lib/Errors.js` defines `BaseErrors`, the catalog of pre-built error factories other modules
29
+ throw from (e.g. `throw new BaseErrors.EntityInvalid({...})`). Prefer adding to this catalog
30
+ over throwing ad hoc errors.
31
+ - Tests live in `test/`, flat, one file per `lib/` module, using `bun:test`. Run via `bun run test`.
32
+
33
+ ## Style
34
+
35
+ - Formatting is enforced by oxfmt ([.oxfmtrc.json](./.oxfmtrc.json)): tabs, single quotes, no
36
+ semicolons, trailing commas. Run `bun run format:fix` before committing.
package/README.md ADDED
@@ -0,0 +1,71 @@
1
+ # @empyria/common
2
+
3
+ Shared backend services for higher-level components in **Principia**, a nanoservice
4
+ framework built primarily on Bun: environment variable schemas, error definitions,
5
+ Codogram (SMS/TOTP-style challenge codes), and Ata (JSON schema building blocks and
6
+ validation).
7
+
8
+ ## Requirements
9
+
10
+ - Bun `>=1.4.0` or Node.js `>=26`
11
+ - Plain ESM, no build step, no TypeScript
12
+
13
+ Both requirements come from [@empyria/classification](https://github.com/imrefazekas/empyria-classification),
14
+ which this package depends on and which uses the native `Temporal` global for all
15
+ date/time handling.
16
+
17
+ ## Install
18
+
19
+ ```bash
20
+ bun add @empyria/common
21
+ ```
22
+
23
+ ## Usage
24
+
25
+ ```js
26
+ import { createEnv, BaseErrors, generateCodogram, validate, string } from '@empyria/common'
27
+
28
+ const envSchema = createEnv({ API_KEY: string() })
29
+
30
+ throw new BaseErrors.EntityInvalid({ type: 'User', id: 42 })
31
+
32
+ const pin = generateCodogram('PIN')
33
+ ```
34
+
35
+ Everything is re-exported from the package root via [index.js](./index.js). Individual
36
+ modules under `lib/` can also be imported directly if you only need one:
37
+
38
+ ```js
39
+ import { defineSchema } from '@empyria/common/lib/Ata.js'
40
+ ```
41
+
42
+ ## Modules
43
+
44
+ | Module | Purpose |
45
+ | -------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
46
+ | [index.js](./index.js) | Package entry point; re-exports every module below plus Moleculer meta helpers (`moleculerMeta`, `moleculerPrincipiaMeta`) and workflow ID helpers (`workflowName`, `workflowId`, `identifyWorkflow`). |
47
+ | [lib/Errors.js](./lib/Errors.js) | `PrincipiaError`, the `ErrorCreator` factory, and `BaseErrors` — Principia's catalog of pre-built, templated errors. |
48
+ | [lib/Env.js](./lib/Env.js) | `createEnv` — builds a service's environment-variable JSON schema from Principia-wide defaults. |
49
+ | [lib/Ata.js](./lib/Ata.js) | JSON schema building blocks (`string`, `number`, `bool`, `enumType`, `email`, `password`, `ip4`, `ip6`, ...) and validation (`defineSchema`, `createValidator`, `validate`), built on [ata-validator](https://www.npmjs.com/package/ata-validator). |
50
+ | [lib/Codogram.js](./lib/Codogram.js) | `generateCodogram` — one-time challenge codes (PIN, alphanumeric, alphabetic) for SMS/TOTP-style verification flows. |
51
+ | [lib/Defaulter.js](./lib/Defaulter.js) | `defaultsFromSchema` — builds a fully-populated default object for a JSON schema. |
52
+
53
+ Every exported function is documented with JSDoc directly in its source file — hovering
54
+ a function in VSCode or Zed shows its parameters and return type without any extra
55
+ tooling, since both editors read JSDoc from plain `.js` files automatically.
56
+
57
+ Tests live under [test/](./test/), one file per module, separate from the `lib/` sources.
58
+
59
+ ## Scripts
60
+
61
+ ```bash
62
+ bun run format # check formatting (oxfmt)
63
+ bun run format:fix # apply formatting
64
+ bun run lint # lint (oxlint)
65
+ bun run lint:fix # lint and fix
66
+ bun run test # run tests with coverage
67
+ ```
68
+
69
+ ## License
70
+
71
+ MIT © Imre Fazekas
package/index.js ADDED
@@ -0,0 +1,102 @@
1
+ import { randomUUID as uuid } from 'node:crypto'
2
+
3
+ import { TRANS } from '@empyria/classification'
4
+
5
+ import { PrincipiaError, ErrorCreator, BaseErrors } from './lib/Errors.js'
6
+ import { createEnv } from './lib/Env.js'
7
+
8
+ export * from './lib/Ata.js'
9
+
10
+ export * from './lib/Codogram.js'
11
+
12
+ export * from './lib/Defaulter.js'
13
+
14
+ export { PrincipiaError, ErrorCreator, BaseErrors, createEnv }
15
+
16
+ /** Role tag Moleculer assigns to every Principia microservice. */
17
+ export const MOLECULER_SERVICE_ROLE = 'microservice'
18
+
19
+ /** The federation's own actor/federation ID, used for system-originated calls. */
20
+ export const PRINCIPIA_FEDERATION_ID =
21
+ process.env.PRINCIPIA_FEDERATION_ID ?? '563ac2a1-3fe3-4c5f-b20f-6f33e5cbd680'
22
+
23
+ /**
24
+ * Builds a Moleculer call-context `meta` object carrying Principia's actor/tracing fields.
25
+ * @param {Object} params
26
+ * @param {string} params.actor - ID of the user/service making the call.
27
+ * @param {string} params.federation - Federation the call belongs to.
28
+ * @param {string} params.flowID - ID correlating this call with the rest of its business flow.
29
+ * @param {string} params.processID - ID correlating this call with the rest of its process/workflow run.
30
+ * @param {string} params.role - Caller's role (see {@link MOLECULER_SERVICE_ROLE}).
31
+ * @param {string} params.tokenKey - Auth token key for the call.
32
+ * @returns {{user: {actor: string, federation: string, flowID: string, processID: string, role: string}, tokenKey: string}}
33
+ */
34
+ export function moleculerMeta({ actor, federation, flowID, processID, role, tokenKey }) {
35
+ return {
36
+ user: {
37
+ actor,
38
+ federation,
39
+ flowID,
40
+ processID,
41
+ role,
42
+ },
43
+ tokenKey,
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Builds a {@link moleculerMeta} object for a system-originated (Principia-as-actor) call,
49
+ * generating a `flowID`/`processID` when not supplied.
50
+ * @param {Object} params
51
+ * @param {string} [params.flowID] - Flow correlation ID; a new UUID is generated if omitted.
52
+ * @param {string} [params.processID] - Process correlation ID; a new UUID is generated if omitted.
53
+ * @param {string} params.tokenKey - Auth token key for the call.
54
+ * @returns {ReturnType<typeof moleculerMeta>}
55
+ */
56
+ export function moleculerPrincipiaMeta({ flowID, processID, tokenKey }) {
57
+ return moleculerMeta({
58
+ actor: PRINCIPIA_FEDERATION_ID,
59
+ federation: PRINCIPIA_FEDERATION_ID,
60
+ flowID: flowID ?? uuid(),
61
+ processID: processID ?? uuid(),
62
+ role: MOLECULER_SERVICE_ROLE,
63
+ tokenKey,
64
+ })
65
+ }
66
+
67
+ /**
68
+ * Builds the stable `service:handler` name for a workflow, transliterated/upper-cased via `TRANS`.
69
+ * @param {Object} workflow
70
+ * @param {string} workflow.service - Owning service name.
71
+ * @param {string} workflow.handler - Handler name within that service.
72
+ * @returns {string} The workflow's normalized name.
73
+ */
74
+ export const workflowName = (workflow) => {
75
+ return TRANS(`${workflow?.service}:${workflow?.handler}`)
76
+ }
77
+
78
+ /**
79
+ * Builds a unique workflow instance ID: {@link workflowName} plus a fresh UUID.
80
+ * @param {Object} workflow - See {@link workflowName}.
81
+ * @returns {string} `"SERVICE:HANDLER:<uuid>"`.
82
+ */
83
+ export function workflowId(workflow) {
84
+ return `${workflowName(workflow)}:${uuid()}`
85
+ }
86
+
87
+ /**
88
+ * Parses a workflow ID (as produced by {@link workflowId}) back into its service/handler.
89
+ * @param {string} workflowId - A `"SERVICE:HANDLER:<uuid>"`-shaped ID.
90
+ * @returns {{service: string, handler: string}}
91
+ * @throws {PrincipiaError} If `workflowId` doesn't have at least three `:`-separated components.
92
+ */
93
+ export const identifyWorkflow = (workflowId) => {
94
+ const components = workflowId?.split(':')
95
+ if (!components || components.length < 3) {
96
+ throw new PrincipiaError('Invalid workflow ID format')
97
+ }
98
+ return {
99
+ service: components[0],
100
+ handler: components[1],
101
+ }
102
+ }
package/lib/Ata.js ADDED
@@ -0,0 +1,248 @@
1
+ import { Validator, renderPretty, renderCompact, renderJSON } from 'ata-validator'
2
+
3
+ import { cloneSelected, defined } from '@empyria/classification'
4
+ import { BaseErrors } from './Errors.js'
5
+
6
+ /** Regex pattern (string form) matching a single email address. */
7
+ export const P_EMAIL =
8
+ "[a-z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*$" // '[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,4}$'
9
+
10
+ /** Regex pattern (string form) requiring lower-case, upper-case, digit, and 8+ length. */
11
+ export const P_PASS = '(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.{8,})'
12
+
13
+ /** Regex pattern (string form) matching an IPv4 address. */
14
+ export const P_IPv4 =
15
+ '(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)(?:\\.(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)){3}$'
16
+
17
+ const v6segment = '[a-fA-F\\d]{1,4}'
18
+ /** Regex pattern (string form) matching an IPv6 address (including IPv4-mapped forms). */
19
+ export const P_IPv6 = `
20
+ (?:
21
+ (?:${v6segment}:){7}(?:${v6segment}|:)|
22
+ (?:${v6segment}:){6}(?:${P_IPv4}|:${v6segment}|:)|
23
+ (?:${v6segment}:){5}(?::${P_IPv4}|(?::${v6segment}){1,2}|:)|
24
+ (?:${v6segment}:){4}(?:(?::${v6segment}){0,1}:${P_IPv4}|(?::${v6segment}){1,3}|:)|
25
+ (?:${v6segment}:){3}(?:(?::${v6segment}){0,2}:${P_IPv4}|(?::${v6segment}){1,4}|:)|
26
+ (?:${v6segment}:){2}(?:(?::${v6segment}){0,3}:${P_IPv4}|(?::${v6segment}){1,5}|:)|
27
+ (?:${v6segment}:){1}(?:(?::${v6segment}){0,4}:${P_IPv4}|(?::${v6segment}){1,6}|:)|
28
+ (?::(?:(?::${v6segment}){0,5}:${P_IPv4}|(?::${v6segment}){1,7}|:))
29
+ )(?:%[0-9a-zA-Z]{1,})?$`
30
+ .replace(/\s*\/\/.*$/gm, '')
31
+ .replace(/\n/g, '')
32
+ .trim()
33
+
34
+ /**
35
+ * Builds a function that clones only the keys declared on a schema's `properties`.
36
+ * @param {Object} schema - JSON schema with a `properties` map.
37
+ * @returns {(obj: Object) => Object} Function that shallow-clones an object down to those keys.
38
+ */
39
+ export function cloneBySchema(schema) {
40
+ const keys = Object.keys(schema.properties)
41
+ return (obj) => cloneSelected(obj, keys)
42
+ }
43
+
44
+ /**
45
+ * Builds a JSON schema object from a flat map of property schemas.
46
+ * @param {Object} schemaDef - Map of property name to its JSON schema.
47
+ * @param {Object} [options={}]
48
+ * @param {boolean} [options.strict] - If true, disallows properties not listed in `schemaDef`.
49
+ * @returns {Object} A JSON schema: `{ type: 'object', properties, required: <all keys>, additionalProperties }`.
50
+ */
51
+ export function defineSchema(schemaDef, options = {}) {
52
+ const schema = {
53
+ type: 'object',
54
+ properties: schemaDef,
55
+ required: Object.keys(schemaDef),
56
+ additionalProperties: !options.strict,
57
+ }
58
+
59
+ return schema
60
+ }
61
+
62
+ /**
63
+ * Builds a validating function for a JSON schema.
64
+ * @param {Object} schema - JSON schema to validate against.
65
+ * @param {Object} [options] - Forwarded to `ata-validator`'s `Validator` constructor
66
+ * (`coerceTypes`, `removeAdditional`, `useDefaults`, ...). Left unset, validation is
67
+ * strict: a schema expecting `type: 'number'` rejects `"32"`. Pass `{ coerceTypes: true }`
68
+ * for input that's inherently string-typed, such as `process.env` (see `createEnv`).
69
+ * @returns {(data: *) => *} Function that returns `data` unchanged if valid.
70
+ * @throws {PrincipiaError} `BaseErrors.ValidationError` (with a pretty-rendered violation list
71
+ * in its `validation` param) if `data` doesn't satisfy the schema.
72
+ */
73
+ export function createValidator(schema, options) {
74
+ const v = new Validator(schema, options)
75
+
76
+ return (data) => {
77
+ const result = v.validate(data)
78
+ if (!result.valid) {
79
+ throw new BaseErrors.ValidationError({
80
+ name: schema.title || schema.name || '',
81
+ value: '',
82
+ validation: renderPretty(result.errors),
83
+ })
84
+ }
85
+ return data
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Validates `input` against `schema` in one call.
91
+ * @param {Object} schema - JSON schema to validate against.
92
+ * @param {*} input - Value to validate.
93
+ * @param {Object} [options] - Forwarded to {@link createValidator} (see its docs).
94
+ * @returns {*} `input` unchanged, if valid.
95
+ * @throws {PrincipiaError} `BaseErrors.ValidationError` if `input` doesn't satisfy the schema.
96
+ */
97
+ export function validate(schema, input, options) {
98
+ const validateInput = createValidator(schema, options)
99
+ const validatedInput = validateInput(input)
100
+
101
+ return validatedInput
102
+ }
103
+
104
+ /**
105
+ * Re-exported from `ata-validator`:
106
+ * - `renderCompact(errors)` renders a validation error list compactly (one line each).
107
+ * - `renderJSON(errors)` renders a validation error list as JSON.
108
+ */
109
+ export { renderCompact, renderJSON }
110
+
111
+ /**
112
+ * JSON schema for a boolean property.
113
+ * @param {boolean} [defaultValue] - Default value, if any.
114
+ * @returns {Object} `{ type: 'boolean', default? }`.
115
+ */
116
+ export function bool(defaultValue) {
117
+ const schema = {
118
+ type: 'boolean',
119
+ }
120
+ if (defined(defaultValue)) schema.default = Boolean(defaultValue)
121
+ return schema
122
+ }
123
+
124
+ /**
125
+ * JSON schema for a string property.
126
+ * @param {string} [defaultValue] - Default value, if any.
127
+ * @param {number} [minLength] - Minimum string length, if greater than 0.
128
+ * @returns {Object} `{ type: 'string', default?, minLength? }`.
129
+ */
130
+ export function string(defaultValue, minLength) {
131
+ const schema = {
132
+ type: 'string',
133
+ }
134
+ if (defined(defaultValue)) schema.default = `${defaultValue}`
135
+ if (minLength > 0) schema.minLength = minLength
136
+ return schema
137
+ }
138
+
139
+ /**
140
+ * JSON schema for an enumerated property.
141
+ * @param {Array} list - Allowed values.
142
+ * @param {*} [defaultValue] - Default value, if any.
143
+ * @returns {Object} `{ enum: list, default? }`.
144
+ */
145
+ export function enumType(list, defaultValue) {
146
+ const schema = {
147
+ enum: list,
148
+ }
149
+ if (defined(defaultValue)) schema.default = defaultValue
150
+ return schema
151
+ }
152
+
153
+ /**
154
+ * JSON schema for a log-level environment variable, defaulting to `'info'`.
155
+ * @returns {Object} Enum schema over the standard Pino/Moleculer log levels.
156
+ */
157
+ export function logLevel() {
158
+ return enumType(['fatal', 'error', 'warn', 'info', 'debug', 'trace', 'silent'], 'info')
159
+ }
160
+
161
+ /**
162
+ * JSON schema for a temp-folder path, defaulting to `'./tmp'`.
163
+ * @returns {Object} String schema.
164
+ */
165
+ export function tmpFile() {
166
+ return string('./tmp')
167
+ }
168
+
169
+ /**
170
+ * JSON schema for a log-file path, defaulting to `'./tmp/moleculer.log'`.
171
+ * @returns {Object} String schema.
172
+ */
173
+ export function logFile() {
174
+ return string('./tmp/moleculer.log')
175
+ }
176
+
177
+ /**
178
+ * JSON schema for a `NODE_ENV`-style environment variable, defaulting to `'local'`.
179
+ * Includes `'test'` because `bun test` (and most JS test runners) set `NODE_ENV=test`
180
+ * automatically — omitting it means any app validating `process.env` at import time
181
+ * (see `createEnv`) can never run its own test suite.
182
+ * @returns {Object} Enum schema over `'production' | 'development' | 'local' | 'test'`.
183
+ */
184
+ export function nodeEnv() {
185
+ return enumType(['production', 'development', 'local', 'test'], 'local')
186
+ }
187
+
188
+ /**
189
+ * JSON schema for a numeric property.
190
+ * @param {number} [defaultValue] - Default value, if any.
191
+ * @returns {Object} `{ type: 'number', default? }`.
192
+ */
193
+ export function number(defaultValue) {
194
+ const schema = {
195
+ type: 'number',
196
+ }
197
+ if (defined(defaultValue)) schema.default = Number(defaultValue)
198
+ return schema
199
+ }
200
+
201
+ /**
202
+ * JSON schema for an email-address string, pattern-matched against {@link P_EMAIL}.
203
+ * @returns {Object} String schema with `pattern` and `description` set.
204
+ */
205
+ export function email() {
206
+ return {
207
+ type: 'string',
208
+ pattern: P_EMAIL,
209
+ description: 'Email address',
210
+ }
211
+ }
212
+
213
+ /**
214
+ * JSON schema for a password string, pattern-matched against {@link P_PASS}
215
+ * (requires lower-case, upper-case, a digit, and at least 8 characters).
216
+ * @returns {Object} String schema with `pattern` and `description` set.
217
+ */
218
+ export function password() {
219
+ return {
220
+ type: 'string',
221
+ pattern: P_PASS,
222
+ description: 'password',
223
+ }
224
+ }
225
+
226
+ /**
227
+ * JSON schema for an IPv4-address string, pattern-matched against {@link P_IPv4}.
228
+ * @returns {Object} String schema with `pattern` and `description` set.
229
+ */
230
+ export function ip4() {
231
+ return {
232
+ type: 'string',
233
+ pattern: P_IPv4,
234
+ description: 'IP address v4',
235
+ }
236
+ }
237
+
238
+ /**
239
+ * JSON schema for an IPv6-address string, pattern-matched against {@link P_IPv6}.
240
+ * @returns {Object} String schema with `pattern` and `description` set.
241
+ */
242
+ export function ip6() {
243
+ return {
244
+ type: 'string',
245
+ pattern: P_IPv6,
246
+ description: 'IP address v6',
247
+ }
248
+ }
@@ -0,0 +1,46 @@
1
+ import { enumise } from '@empyria/classification'
2
+
3
+ import Clerobee from 'clerobee'
4
+ import { BaseErrors } from './Errors.js'
5
+
6
+ const clerobee = new Clerobee(process.env.PRINCIPIA_ID_LENGTH || 32)
7
+
8
+ const CPIN = 'PIN'
9
+ const CODE = 'Code'
10
+ const CABC = 'ABC'
11
+ const CABN = 'ABC Num'
12
+ /** The set of codogram type identifiers accepted by {@link generateCodogram}. */
13
+ const CODOGRAM_LIST = [CPIN, CODE, CABC, CABN]
14
+ /** {@link CODOGRAM_LIST}, normalized into a `{NAME: value}` enum via `enumise`. */
15
+ const CODOGRAMS = enumise(CODOGRAM_LIST)
16
+
17
+ const CPIN_LENGTH = 6
18
+ const CODE_LENGTH = 16
19
+ const CABC_LENGTH = 6
20
+ const CABN_LENGTH = 6
21
+
22
+ /**
23
+ * Generates a one-time codogram (challenge code) for SMS/TOTP-style verification flows.
24
+ * @param {string} type - One of {@link CODOGRAM_LIST}: `'PIN'` (numeric), `'Code'` (alphanumeric),
25
+ * `'ABC'` (alphabetic), or `'ABC Num'` (alphanumeric via Clerobee).
26
+ * @param {Object} [options={}]
27
+ * @param {number} [options.length] - Overrides the type's default length.
28
+ * @returns {number|string} A numeric PIN for `'PIN'`, otherwise an upper-cased string.
29
+ * @throws {PrincipiaError} `BaseErrors.UnrecognisedType` if `type` isn't one of {@link CODOGRAM_LIST}.
30
+ */
31
+ export function generateCodogram(type, options = {}) {
32
+ switch (type) {
33
+ case CPIN:
34
+ return Math.floor(Math.random() * Math.pow(10, options.length || CPIN_LENGTH))
35
+ case CODE:
36
+ return clerobee.generate(options.length || CODE_LENGTH).toUpperCase()
37
+ case CABC:
38
+ return clerobee.generateAbcString(options.length || CABC_LENGTH).toUpperCase()
39
+ case CABN:
40
+ return clerobee.generateAbcNumString(options.length || CABN_LENGTH).toUpperCase()
41
+ default:
42
+ throw new BaseErrors.UnrecognisedType({ type })
43
+ }
44
+ }
45
+
46
+ export { CODOGRAM_LIST, CODOGRAMS }
@@ -0,0 +1,45 @@
1
+ import { Validator } from 'ata-validator'
2
+
3
+ /**
4
+ * Recursively builds an object populated with each property's schema `default`
5
+ * (or a type-appropriate empty value for required properties without one).
6
+ * @param {Object} schema - A JSON schema (object type, with `properties`).
7
+ * @returns {Object} The built object; `{}` if `schema` isn't an object schema.
8
+ */
9
+ function buildEmpty(schema) {
10
+ if (schema.type === 'object' && schema.properties) {
11
+ const obj = {}
12
+ for (const [key, prop] of Object.entries(schema.properties)) {
13
+ if (prop.type === 'object') {
14
+ obj[key] = prop.default ?? buildEmpty(prop)
15
+ } else if (!schema.required?.includes(key)) {
16
+ continue
17
+ }
18
+
19
+ if (prop.type === 'array') {
20
+ obj[key] = prop.default ?? []
21
+ } else if (prop.type === 'string') {
22
+ obj[key] = prop.default ?? ''
23
+ } else if (prop.type === 'boolean') {
24
+ obj[key] = prop.default ?? false
25
+ } else if (prop.type === 'number' || prop.type === 'integer') {
26
+ obj[key] = prop.default ?? 0
27
+ }
28
+ }
29
+ return obj
30
+ }
31
+ return {}
32
+ }
33
+
34
+ /**
35
+ * Builds a fully-populated default object for a JSON schema: every property gets its
36
+ * schema `default`, falling back to a type-appropriate empty value, then runs the
37
+ * result through an ata-validator `Validator` to fill in anything `buildEmpty` missed.
38
+ * @param {Object} schema - A JSON schema (object type, with `properties`).
39
+ * @returns {Object} The schema's default object.
40
+ */
41
+ export function defaultsFromSchema(schema) {
42
+ const obj = buildEmpty(schema)
43
+ new Validator(schema).validate(obj)
44
+ return obj
45
+ }
package/lib/Env.js ADDED
@@ -0,0 +1,34 @@
1
+ import { defineSchema, bool, number, tmpFile, string, logLevel, logFile, nodeEnv } from './Ata.js'
2
+
3
+ /** Environment schema shared by every Principia microservice. */
4
+ const DEFAULT_SERVICE_SCHEMA = {
5
+ PRINCIPIA_ID_LENGTH: number(32),
6
+
7
+ TMP_FOLDER: tmpFile(),
8
+
9
+ LOG_LEVEL: logLevel(),
10
+ LOG_FILE: logFile(),
11
+
12
+ NODE_ENV: nodeEnv(),
13
+
14
+ RESET: bool(false),
15
+
16
+ MOLECULER_TRANSPORTER: string('TCP'),
17
+
18
+ MOLECULER_CACHE_ENABLED: bool(false),
19
+ MOLECULER_CACHE_PREFIX: string('Principia'),
20
+ MOLECULER_CACHE_URL: string('redis://redis:6379/15'),
21
+
22
+ RESTATE_ADMIN_URL: string('http://restate:9070'),
23
+ RESTATE_URL: string('http://restate:9071'),
24
+ }
25
+
26
+ /**
27
+ * Builds a JSON schema for a microservice's environment variables, merging the
28
+ * Principia-wide defaults with any service-specific additions/overrides.
29
+ * @param {Object} [specificSchema] - Additional or overriding property schemas, keyed by env var name.
30
+ * @returns {Object} A JSON schema object (see `defineSchema` in `./Ata.js`).
31
+ */
32
+ export function createEnv(specificSchema) {
33
+ return defineSchema(Object.assign({}, DEFAULT_SERVICE_SCHEMA, specificSchema))
34
+ }
package/lib/Errors.js ADDED
@@ -0,0 +1,166 @@
1
+ import { inherits } from 'util'
2
+
3
+ import { templating } from '@empyria/classification'
4
+
5
+ /**
6
+ * Principia's base error type. Carries a machine-readable `errorName`/`errorCode`
7
+ * alongside the usual `message`, and captures its own stack trace.
8
+ * @constructor
9
+ * @param {string} message - Human-readable error message.
10
+ * @param {string} [errorName] - Machine-readable error identifier.
11
+ * @param {number} [errorCode] - Numeric error code.
12
+ */
13
+ let PrincipiaError = function (message, errorName, errorCode) {
14
+ this.message = message
15
+ this.errorName = errorName
16
+ this.errorCode = errorCode
17
+ Error.captureStackTrace(this, PrincipiaError)
18
+ }
19
+ inherits(PrincipiaError, Error)
20
+
21
+ /**
22
+ * Builds a reusable error factory from a template.
23
+ * @param {Object} [options={}]
24
+ * @param {number} [options.errorCode] - Numeric error code, used as the fallback `errorName`.
25
+ * @param {string} [options.errorName] - Machine-readable error identifier.
26
+ * @param {string} [options.message] - Message template with `{param}` placeholders (see
27
+ * `templating` in `@empyria/classification`).
28
+ * @returns {(opts?: Object) => PrincipiaError} Function that, given template values, returns a
29
+ * new {@link PrincipiaError} with its `message` filled in and `params` set to the values used.
30
+ */
31
+ let ErrorCreator = function (options = {}) {
32
+ let errorCode = options.errorCode
33
+ let args = Object.assign({}, options)
34
+ delete args['message']
35
+ delete args['code']
36
+ delete args['errorCode']
37
+ let fnc = function (opts = {}) {
38
+ let newArgs = Object.assign({}, args, opts)
39
+ let message = templating(options.message, newArgs)
40
+ let resultError = new PrincipiaError(message, options.errorName || errorCode, errorCode)
41
+ resultError.params = newArgs
42
+ return resultError
43
+ }
44
+ return fnc
45
+ }
46
+
47
+ /**
48
+ * Principia's catalog of pre-built error factories, one per well-known failure case.
49
+ * Each entry is a function built via {@link ErrorCreator} — call it with the template's
50
+ * parameters (e.g. `BaseErrors.EntityInvalid({ type: 'User', id: 42 })`) to get a
51
+ * {@link PrincipiaError} instance ready to `throw`.
52
+ */
53
+ let BaseErrors = {
54
+ InvalidModel: ErrorCreator({
55
+ errorCode: 599993,
56
+ errorName: 'InvalidModel',
57
+ message: 'Model {name} is not a valid definition',
58
+ }),
59
+ Exceeded: ErrorCreator({
60
+ errorCode: 599994,
61
+ errorName: 'Exceeded',
62
+ message: 'Limit of {limit} is exceeded',
63
+ }),
64
+ NotConnected: ErrorCreator({
65
+ errorCode: 599995,
66
+ errorName: 'NotConnected',
67
+ message: '{service} is not connected',
68
+ }),
69
+ ConnectorMandatory: ErrorCreator({
70
+ errorCode: 599996,
71
+ errorName: 'ConnectorMandatory',
72
+ message: 'Presence of a Connector instance is mandatory',
73
+ }),
74
+ NotInited: ErrorCreator({
75
+ errorCode: 599997,
76
+ errorName: 'NotInited',
77
+ message: 'Principia is not inited',
78
+ }),
79
+ UnImplemented: ErrorCreator({
80
+ errorCode: 599998,
81
+ errorName: 'UnImplemented',
82
+ message: 'Service {service} is not implemented',
83
+ }),
84
+ ValidationError: ErrorCreator({
85
+ errorCode: 599999,
86
+ errorName: 'ValidationError',
87
+ message: '{name} ({value}) failed on input parameter schema validation: {validation}',
88
+ }),
89
+ DataNotRecognised: ErrorCreator({
90
+ errorCode: 600000,
91
+ errorName: 'DataNotRecognised',
92
+ message: 'Data type:{type} uid:{uid} is not recognised',
93
+ }),
94
+ UnrecognisedType: ErrorCreator({
95
+ errorCode: 600001,
96
+ errorName: 'UnrecognisedType',
97
+ message: 'Entity type {type} is not recognised',
98
+ }),
99
+ MissingData: ErrorCreator({
100
+ errorCode: 600002,
101
+ errorName: 'MissingData',
102
+ message: "Data '{type}' is missing",
103
+ }),
104
+ InformationInvalid: ErrorCreator({
105
+ errorCode: 600003,
106
+ errorName: 'InformationInvalid',
107
+ message: 'Data {type} is not valid',
108
+ }),
109
+ EntityInvalid: ErrorCreator({
110
+ errorCode: 600004,
111
+ errorName: 'EntityInvalid',
112
+ message: '{type}:{id} is not valid',
113
+ }),
114
+ DataNotOwned: ErrorCreator({
115
+ errorCode: 600005,
116
+ errorName: 'DataNotOwned',
117
+ message: 'Data type:{type} uid:{uid} is not owned',
118
+ }),
119
+ HistoryViolation: ErrorCreator({
120
+ errorCode: 600006,
121
+ errorName: 'HistoryViolation',
122
+ message: 'History violation with data type:{type} uid:{uid}',
123
+ }),
124
+ InvalidBinding: ErrorCreator({
125
+ errorCode: 600007,
126
+ errorName: 'InvalidBinding',
127
+ message: 'Binding {uid1} with {uid2} is not valid',
128
+ }),
129
+ ConnectedServiceMissing: ErrorCreator({
130
+ errorCode: 600008,
131
+ errorName: 'ConnectedServiceMissing',
132
+ message: 'Connected service {service} is missing',
133
+ }),
134
+ ServiceVoilation: ErrorCreator({
135
+ errorCode: 600009,
136
+ errorName: 'ServiceVoilation',
137
+ message: 'Requested {service} maneuver is not permitted.',
138
+ }),
139
+ Illegalchange: ErrorCreator({
140
+ errorCode: 600010,
141
+ errorName: 'Illegalchange',
142
+ message: 'Requested {alter} change on entity {entity} is not permitted.',
143
+ }),
144
+ InternalUnknownError: ErrorCreator({
145
+ errorCode: 600011,
146
+ errorName: 'InternalUnknownError',
147
+ message: 'Error: {message} {code}',
148
+ }),
149
+ AlternedData: ErrorCreator({
150
+ errorCode: 600012,
151
+ errorName: 'AlternedData',
152
+ message: 'Data {data} has been altered. {message}',
153
+ }),
154
+ AlreadyRegisteredError: ErrorCreator({
155
+ errorCode: 600013,
156
+ errorName: 'AlreadyRegisteredError',
157
+ message: 'User with {data} has already registered. Please sign in instead.',
158
+ }),
159
+ ServiceFailureError: ErrorCreator({
160
+ errorCode: 600014,
161
+ errorName: 'ServiceFailureError',
162
+ message: 'Error: {service} failed with: {message}',
163
+ }),
164
+ }
165
+
166
+ export { PrincipiaError, ErrorCreator, BaseErrors }
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "@empyria/common",
3
+ "version": "0.1.0",
4
+ "description": "Shared backend goodies for Principia",
5
+ "license": "MIT",
6
+ "author": "Imre Fazekas <imre.fazekas@icloud.com>",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/imrefazekas/empyria-common.git"
10
+ },
11
+ "type": "module",
12
+ "main": "./index.js",
13
+ "scripts": {
14
+ "format": "bun oxfmt --check",
15
+ "format:fix": "bun oxfmt",
16
+ "lint": "bun oxlint",
17
+ "lint:fix": "bun oxlint --fix",
18
+ "test": "bun test --coverage",
19
+ "test:watch": "bun test --watch"
20
+ },
21
+ "dependencies": {
22
+ "@empyria/classification": "0.1.0",
23
+ "ata-validator": "1.6.1",
24
+ "clerobee": "1.0.8",
25
+ "ms": "2.1.3"
26
+ },
27
+ "devDependencies": {
28
+ "oxfmt": "0.63.0",
29
+ "oxlint": "1.78.0"
30
+ },
31
+ "engines": {
32
+ "bun": ">=1.4.0",
33
+ "node": ">=26"
34
+ }
35
+ }
@@ -0,0 +1,185 @@
1
+ import { describe, test, expect } from 'bun:test'
2
+ import {
3
+ P_EMAIL,
4
+ P_PASS,
5
+ P_IPv4,
6
+ P_IPv6,
7
+ cloneBySchema,
8
+ defineSchema,
9
+ validate,
10
+ bool,
11
+ string,
12
+ enumType,
13
+ logLevel,
14
+ tmpFile,
15
+ logFile,
16
+ nodeEnv,
17
+ number,
18
+ email,
19
+ password,
20
+ ip4,
21
+ ip6,
22
+ renderCompact,
23
+ renderJSON,
24
+ } from '../lib/Ata.js'
25
+ import { PrincipiaError } from '../lib/Errors.js'
26
+
27
+ describe('P_EMAIL', () => {
28
+ const re = new RegExp(`^${P_EMAIL}`, 'i')
29
+
30
+ test('matches a well-formed email', () => {
31
+ expect(re.test('user@example.com')).toBe(true)
32
+ })
33
+
34
+ test('rejects a non-dot character standing in for the label separator', () => {
35
+ // Regression test: the pattern's inter-label separator used to be an unescaped
36
+ // `.` (matches any character) instead of a literal dot.
37
+ expect(re.test('user@example!com')).toBe(false)
38
+ })
39
+ })
40
+
41
+ describe('P_PASS', () => {
42
+ const re = new RegExp(P_PASS)
43
+
44
+ test('matches a password with lower, upper, digit, and 8+ length', () => {
45
+ expect(re.test('Abcdefg1')).toBe(true)
46
+ })
47
+
48
+ test('rejects a password missing a digit', () => {
49
+ expect(re.test('Abcdefgh')).toBe(false)
50
+ })
51
+ })
52
+
53
+ describe('P_IPv4', () => {
54
+ const re = new RegExp(`^${P_IPv4}`)
55
+
56
+ test('matches a valid IPv4 address', () => {
57
+ expect(re.test('192.168.0.1')).toBe(true)
58
+ })
59
+
60
+ test('rejects an out-of-range octet', () => {
61
+ expect(re.test('999.168.0.1')).toBe(false)
62
+ })
63
+ })
64
+
65
+ describe('P_IPv6', () => {
66
+ const re = new RegExp(`^${P_IPv6}`)
67
+
68
+ test('matches the loopback address', () => {
69
+ expect(re.test('::1')).toBe(true)
70
+ })
71
+
72
+ test('rejects a plain non-IPv6 string', () => {
73
+ expect(re.test('not-an-ip')).toBe(false)
74
+ })
75
+ })
76
+
77
+ describe('defineSchema', () => {
78
+ test('builds an object schema requiring every key', () => {
79
+ expect(defineSchema({ a: string('x') })).toEqual({
80
+ type: 'object',
81
+ properties: { a: { type: 'string', default: 'x' } },
82
+ required: ['a'],
83
+ additionalProperties: true,
84
+ })
85
+ })
86
+
87
+ test('disallows extra properties when strict', () => {
88
+ expect(defineSchema({ a: string() }, { strict: true }).additionalProperties).toBe(false)
89
+ })
90
+ })
91
+
92
+ describe('cloneBySchema', () => {
93
+ test('keeps only the keys declared on the schema', () => {
94
+ const cloner = cloneBySchema({ properties: { a: {}, b: {} } })
95
+ expect(cloner({ a: 1, b: 2, c: 3 })).toEqual({ a: 1, b: 2 })
96
+ })
97
+ })
98
+
99
+ describe('createValidator / validate', () => {
100
+ const schema = defineSchema({ name: string() })
101
+
102
+ test('returns the input unchanged when valid', () => {
103
+ expect(validate(schema, { name: 'Bob' })).toEqual({ name: 'Bob' })
104
+ })
105
+
106
+ test('throws a PrincipiaError with a rendered message when invalid', () => {
107
+ try {
108
+ validate(schema, {})
109
+ throw new Error('should have thrown')
110
+ } catch (err) {
111
+ expect(err).toBeInstanceOf(PrincipiaError)
112
+ expect(err.errorName).toBe('ValidationError')
113
+ expect(err.message).toContain('name')
114
+ }
115
+ })
116
+
117
+ test('is strict by default: a numeric string is rejected for a number schema', () => {
118
+ const numSchema = defineSchema({ port: number() })
119
+ expect(() => validate(numSchema, { port: '4040' })).toThrow(PrincipiaError)
120
+ })
121
+
122
+ test('options are forwarded to the underlying Validator (coerceTypes)', () => {
123
+ // Regression test: process.env values are always strings (e.g. `PORT=4040` is
124
+ // `"4040"`, not `4040`) — env-parsing callers need this to avoid rejecting every
125
+ // explicitly-set numeric/boolean variable. See apps/*/env.js in empyria-nano-services.
126
+ const numSchema = defineSchema({ port: number() })
127
+ expect(validate(numSchema, { port: '4040' }, { coerceTypes: true })).toEqual({ port: 4040 })
128
+ })
129
+ })
130
+
131
+ describe('schema builders', () => {
132
+ test('bool sets a default when provided', () => {
133
+ expect(bool(true)).toEqual({ type: 'boolean', default: true })
134
+ expect(bool()).toEqual({ type: 'boolean' })
135
+ })
136
+
137
+ test('string sets default and minLength when provided', () => {
138
+ expect(string('x', 3)).toEqual({ type: 'string', default: 'x', minLength: 3 })
139
+ })
140
+
141
+ test('number sets a default when provided', () => {
142
+ expect(number(5)).toEqual({ type: 'number', default: 5 })
143
+ })
144
+
145
+ test('enumType sets the allowed list and default', () => {
146
+ expect(enumType(['a', 'b'], 'a')).toEqual({ enum: ['a', 'b'], default: 'a' })
147
+ })
148
+
149
+ test('logLevel defaults to info', () => {
150
+ expect(logLevel().default).toBe('info')
151
+ expect(logLevel().enum).toContain('debug')
152
+ })
153
+
154
+ test('tmpFile defaults to ./tmp', () => {
155
+ expect(tmpFile()).toEqual({ type: 'string', default: './tmp' })
156
+ })
157
+
158
+ test('logFile defaults to ./tmp/moleculer.log', () => {
159
+ expect(logFile()).toEqual({ type: 'string', default: './tmp/moleculer.log' })
160
+ })
161
+
162
+ test('nodeEnv defaults to local and allows test', () => {
163
+ // Regression test: bun test (and most JS test runners) set NODE_ENV=test
164
+ // automatically. Omitting it from the enum would make it impossible for any app
165
+ // validating process.env at import time to run its own test suite.
166
+ expect(nodeEnv()).toEqual({
167
+ enum: ['production', 'development', 'local', 'test'],
168
+ default: 'local',
169
+ })
170
+ })
171
+
172
+ test('email/password/ip4/ip6 reference their patterns', () => {
173
+ expect(email().pattern).toBe(P_EMAIL)
174
+ expect(password().pattern).toBe(P_PASS)
175
+ expect(ip4().pattern).toBe(P_IPv4)
176
+ expect(ip6().pattern).toBe(P_IPv6)
177
+ })
178
+ })
179
+
180
+ describe('renderCompact / renderJSON re-exports', () => {
181
+ test('are re-exported from ata-validator', () => {
182
+ expect(typeof renderCompact).toBe('function')
183
+ expect(typeof renderJSON).toBe('function')
184
+ })
185
+ })
@@ -0,0 +1,41 @@
1
+ import { describe, test, expect } from 'bun:test'
2
+ import { generateCodogram, CODOGRAM_LIST, CODOGRAMS } from '../lib/Codogram.js'
3
+ import { PrincipiaError } from '../lib/Errors.js'
4
+
5
+ describe('generateCodogram', () => {
6
+ test('PIN: numeric within the default digit range', () => {
7
+ const result = generateCodogram('PIN')
8
+ expect(Number.isInteger(result)).toBe(true)
9
+ expect(result).toBeGreaterThanOrEqual(0)
10
+ expect(result).toBeLessThan(1000000)
11
+ })
12
+
13
+ test('Code: upper-cased alphanumeric of the requested length', () => {
14
+ const result = generateCodogram('Code', { length: 10 })
15
+ expect(result).toBe(result.toUpperCase())
16
+ expect(result.length).toBe(10)
17
+ })
18
+
19
+ test('ABC: upper-cased alphabetic string', () => {
20
+ const result = generateCodogram('ABC')
21
+ expect(result).toBe(result.toUpperCase())
22
+ expect(typeof result).toBe('string')
23
+ })
24
+
25
+ test('ABC Num: upper-cased alphanumeric string', () => {
26
+ const result = generateCodogram('ABC Num')
27
+ expect(result).toBe(result.toUpperCase())
28
+ expect(typeof result).toBe('string')
29
+ })
30
+
31
+ test('throws for an unrecognised type', () => {
32
+ expect(() => generateCodogram('nonsense')).toThrow(PrincipiaError)
33
+ })
34
+ })
35
+
36
+ describe('CODOGRAM_LIST / CODOGRAMS', () => {
37
+ test('CODOGRAMS is a normalized-name map over CODOGRAM_LIST', () => {
38
+ expect(CODOGRAM_LIST).toEqual(['PIN', 'Code', 'ABC', 'ABC Num'])
39
+ expect(CODOGRAMS).toEqual({ PIN: 'PIN', CODE: 'Code', ABC: 'ABC', ABC_NUM: 'ABC Num' })
40
+ })
41
+ })
@@ -0,0 +1,34 @@
1
+ import { describe, test, expect } from 'bun:test'
2
+ import { defaultsFromSchema } from '../lib/Defaulter.js'
3
+
4
+ describe('defaultsFromSchema', () => {
5
+ test('uses each property default when present', () => {
6
+ const schema = {
7
+ type: 'object',
8
+ properties: {
9
+ name: { type: 'string', default: 'Bob' },
10
+ age: { type: 'number', default: 30 },
11
+ },
12
+ required: ['name', 'age'],
13
+ }
14
+ expect(defaultsFromSchema(schema)).toEqual({ name: 'Bob', age: 30 })
15
+ })
16
+
17
+ test('falls back to a type-appropriate empty value for required properties without a default', () => {
18
+ const schema = {
19
+ type: 'object',
20
+ properties: {
21
+ name: { type: 'string' },
22
+ active: { type: 'boolean' },
23
+ count: { type: 'number' },
24
+ tags: { type: 'array' },
25
+ },
26
+ required: ['name', 'active', 'count', 'tags'],
27
+ }
28
+ expect(defaultsFromSchema(schema)).toEqual({ name: '', active: false, count: 0, tags: [] })
29
+ })
30
+
31
+ test('returns an empty object for a non-object schema', () => {
32
+ expect(defaultsFromSchema({ type: 'string' })).toEqual({})
33
+ })
34
+ })
@@ -0,0 +1,18 @@
1
+ import { describe, test, expect } from 'bun:test'
2
+ import { createEnv } from '../lib/Env.js'
3
+
4
+ describe('createEnv', () => {
5
+ test('includes the shared service defaults plus service-specific additions', () => {
6
+ const schema = createEnv({ CUSTOM_FLAG: { type: 'boolean' } })
7
+ expect(schema.type).toBe('object')
8
+ expect(schema.properties.NODE_ENV.default).toBe('local')
9
+ expect(schema.properties.PRINCIPIA_ID_LENGTH.default).toBe(32)
10
+ expect(schema.properties.CUSTOM_FLAG).toEqual({ type: 'boolean' })
11
+ expect(schema.required).toContain('CUSTOM_FLAG')
12
+ })
13
+
14
+ test('does not include connector-only settings', () => {
15
+ const schema = createEnv()
16
+ expect(schema.properties.PRINCIPIA_MONGO_URI).toBeUndefined()
17
+ })
18
+ })
@@ -0,0 +1,55 @@
1
+ import { describe, test, expect } from 'bun:test'
2
+ import { PrincipiaError, ErrorCreator, BaseErrors } from '../lib/Errors.js'
3
+
4
+ describe('PrincipiaError', () => {
5
+ test('carries message, errorName, and errorCode', () => {
6
+ const err = new PrincipiaError('Something broke', 'SomeError', 12345)
7
+ expect(err).toBeInstanceOf(Error)
8
+ expect(err.message).toBe('Something broke')
9
+ expect(err.errorName).toBe('SomeError')
10
+ expect(err.errorCode).toBe(12345)
11
+ expect(typeof err.stack).toBe('string')
12
+ })
13
+ })
14
+
15
+ describe('ErrorCreator', () => {
16
+ test('builds a factory that templates the message and stores params', () => {
17
+ const factory = ErrorCreator({
18
+ errorCode: 1,
19
+ errorName: 'Custom',
20
+ message: 'Value {value} is bad',
21
+ })
22
+ const err = factory({ value: 42 })
23
+ expect(err).toBeInstanceOf(PrincipiaError)
24
+ expect(err.message).toBe('Value 42 is bad')
25
+ expect(err.errorName).toBe('Custom')
26
+ expect(err.errorCode).toBe(1)
27
+ // errorName (unlike message/code/errorCode) is not stripped from the template args,
28
+ // so it ends up alongside the call-time params.
29
+ expect(err.params).toEqual({ errorName: 'Custom', value: 42 })
30
+ })
31
+
32
+ test('falls back to errorCode as errorName when none is given', () => {
33
+ const factory = ErrorCreator({ errorCode: 2, message: 'plain' })
34
+ expect(factory().errorName).toBe(2)
35
+ })
36
+ })
37
+
38
+ describe('BaseErrors', () => {
39
+ test('EntityInvalid uses its own error name, not a copy-pasted one', () => {
40
+ const err = BaseErrors.EntityInvalid({ type: 'User', id: 42 })
41
+ expect(err.errorName).toBe('EntityInvalid')
42
+ expect(err.errorCode).toBe(600004)
43
+ expect(err.message).toBe('User:42 is not valid')
44
+ })
45
+
46
+ test('UnrecognisedType templates its message', () => {
47
+ const err = BaseErrors.UnrecognisedType({ type: 'Widget' })
48
+ expect(err.message).toBe('Entity type Widget is not recognised')
49
+ })
50
+
51
+ test('ValidationError templates its message', () => {
52
+ const err = BaseErrors.ValidationError({ name: 'X', value: 'y', validation: 'z' })
53
+ expect(err.message).toBe('X (y) failed on input parameter schema validation: z')
54
+ })
55
+ })
@@ -0,0 +1,76 @@
1
+ import { describe, test, expect } from 'bun:test'
2
+ import {
3
+ MOLECULER_SERVICE_ROLE,
4
+ PRINCIPIA_FEDERATION_ID,
5
+ moleculerMeta,
6
+ moleculerPrincipiaMeta,
7
+ workflowName,
8
+ workflowId,
9
+ identifyWorkflow,
10
+ PrincipiaError,
11
+ } from '../index.js'
12
+
13
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
14
+
15
+ describe('moleculerMeta', () => {
16
+ test('shapes the actor/tracing fields under user, plus tokenKey', () => {
17
+ expect(
18
+ moleculerMeta({
19
+ actor: 'a',
20
+ federation: 'f',
21
+ flowID: 'flow',
22
+ processID: 'proc',
23
+ role: 'role',
24
+ tokenKey: 'key',
25
+ }),
26
+ ).toEqual({
27
+ user: { actor: 'a', federation: 'f', flowID: 'flow', processID: 'proc', role: 'role' },
28
+ tokenKey: 'key',
29
+ })
30
+ })
31
+ })
32
+
33
+ describe('moleculerPrincipiaMeta', () => {
34
+ test('uses the federation ID as actor/federation and generates missing IDs', () => {
35
+ // Regression test: this used to call an unimported `uuid()` and throw ReferenceError.
36
+ const meta = moleculerPrincipiaMeta({ tokenKey: 'key' })
37
+ expect(meta.user.actor).toBe(PRINCIPIA_FEDERATION_ID)
38
+ expect(meta.user.federation).toBe(PRINCIPIA_FEDERATION_ID)
39
+ expect(meta.user.role).toBe(MOLECULER_SERVICE_ROLE)
40
+ expect(meta.user.flowID).toMatch(UUID_RE)
41
+ expect(meta.user.processID).toMatch(UUID_RE)
42
+ })
43
+
44
+ test('keeps explicitly provided flowID/processID', () => {
45
+ const meta = moleculerPrincipiaMeta({ flowID: 'f1', processID: 'p1', tokenKey: 'key' })
46
+ expect(meta.user.flowID).toBe('f1')
47
+ expect(meta.user.processID).toBe('p1')
48
+ })
49
+ })
50
+
51
+ describe('workflowName', () => {
52
+ test('joins and normalizes service:handler', () => {
53
+ expect(workflowName({ service: 'my svc', handler: 'do-thing' })).toBe('MYSVC:DO-THING')
54
+ })
55
+ })
56
+
57
+ describe('workflowId', () => {
58
+ test('appends a UUID to the workflow name', () => {
59
+ const id = workflowId({ service: 'svc', handler: 'h' })
60
+ const [name, handler, uuidPart] = id.split(':')
61
+ expect(`${name}:${handler}`).toBe('SVC:H')
62
+ expect(uuidPart).toMatch(UUID_RE)
63
+ })
64
+ })
65
+
66
+ describe('identifyWorkflow', () => {
67
+ test('parses back the service and handler', () => {
68
+ const id = workflowId({ service: 'svc', handler: 'h' })
69
+ expect(identifyWorkflow(id)).toEqual({ service: 'SVC', handler: 'H' })
70
+ })
71
+
72
+ test('throws a PrincipiaError for a malformed ID', () => {
73
+ expect(() => identifyWorkflow('too:short')).toThrow(PrincipiaError)
74
+ expect(() => identifyWorkflow(undefined)).toThrow(PrincipiaError)
75
+ })
76
+ })