@living-architecture/riviere-schema 0.2.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.
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Parts that make up a component ID.
3
+ */
4
+ export interface ComponentIdParts {
5
+ domain: string
6
+ module: string
7
+ type: string
8
+ name: string
9
+ }
10
+
11
+ /**
12
+ * Represents a structured component identifier.
13
+ *
14
+ * Component IDs follow the format `{domain}:{module}:{type}:{name}` in kebab-case.
15
+ *
16
+ * @example
17
+ * ```typescript
18
+ * const id = ComponentId.create({
19
+ * domain: 'orders',
20
+ * module: 'checkout',
21
+ * type: 'api',
22
+ * name: 'Create Order'
23
+ * })
24
+ * id.toString() // 'orders:checkout:api:create-order'
25
+ * ```
26
+ */
27
+ export class ComponentId {
28
+ private readonly _name: string
29
+ private readonly value: string
30
+
31
+ private constructor(name: string, value: string) {
32
+ this._name = name
33
+ this.value = value
34
+ }
35
+
36
+ /**
37
+ * Creates a ComponentId from individual parts.
38
+ *
39
+ * @param parts - Domain, module, type, and name segments
40
+ * @returns A new ComponentId instance
41
+ *
42
+ * @example
43
+ * ```typescript
44
+ * const id = ComponentId.create({
45
+ * domain: 'orders',
46
+ * module: 'checkout',
47
+ * type: 'api',
48
+ * name: 'Create Order'
49
+ * })
50
+ * ```
51
+ */
52
+ static create(parts: ComponentIdParts): ComponentId {
53
+ const nameSegment = parts.name.toLowerCase().replace(/\s+/g, '-')
54
+ const value = `${parts.domain}:${parts.module}:${parts.type}:${nameSegment}`
55
+ return new ComponentId(nameSegment, value)
56
+ }
57
+
58
+ /**
59
+ * Parses a string ID into a ComponentId instance.
60
+ *
61
+ * @param id - String in format `domain:module:type:name`
62
+ * @returns A ComponentId instance
63
+ * @throws If the format is invalid
64
+ *
65
+ * @example
66
+ * ```typescript
67
+ * const id = ComponentId.parse('orders:checkout:api:create-order')
68
+ * id.name() // 'create-order'
69
+ * ```
70
+ */
71
+ static parse(id: string): ComponentId {
72
+ const parts = id.split(':')
73
+ const name = parts[3]
74
+ if (parts.length !== 4 || name === undefined) {
75
+ throw new Error(`Invalid component ID format: '${id}'. Expected 'domain:module:type:name'`)
76
+ }
77
+ return new ComponentId(name, id)
78
+ }
79
+
80
+ /**
81
+ * Returns the full component ID string.
82
+ *
83
+ * @returns Full ID in format `domain:module:type:name`
84
+ */
85
+ toString(): string {
86
+ return this.value
87
+ }
88
+
89
+ /**
90
+ * Returns the name segment of the component ID.
91
+ *
92
+ * @returns The kebab-case name portion
93
+ */
94
+ name(): string {
95
+ return this._name
96
+ }
97
+ }
package/src/index.ts ADDED
@@ -0,0 +1,3 @@
1
+ export * from './schema';
2
+ export { parseRiviereGraph, isRiviereGraph, formatValidationErrors } from './validation';
3
+ export { ComponentId, type ComponentIdParts } from './component-id';
@@ -0,0 +1,169 @@
1
+ import type {
2
+ RiviereGraph,
3
+ Component,
4
+ UIComponent,
5
+ APIComponent,
6
+ Link,
7
+ GraphMetadata,
8
+ } from './schema'
9
+ import { parseRiviereGraph, formatValidationErrors } from './validation'
10
+
11
+ describe('formatValidationErrors()', () => {
12
+ it('returns generic message when errors is null', () => {
13
+ const result = formatValidationErrors(null)
14
+ expect(result).toBe('validation failed without specific errors')
15
+ })
16
+
17
+ it('returns generic message when errors is empty array', () => {
18
+ const result = formatValidationErrors([])
19
+ expect(result).toBe('validation failed without specific errors')
20
+ })
21
+
22
+ it('formats single error with path and message', () => {
23
+ const errors = [{ instancePath: '/version', message: 'must match pattern' }]
24
+ const result = formatValidationErrors(errors)
25
+ expect(result).toBe('/version: must match pattern')
26
+ })
27
+
28
+ it('formats multiple errors joined by newlines', () => {
29
+ const errors = [
30
+ { instancePath: '/version', message: 'must match pattern' },
31
+ { instancePath: '/components/0/type', message: 'must be equal to one of the allowed values' },
32
+ ]
33
+ const result = formatValidationErrors(errors)
34
+ expect(result).toBe('/version: must match pattern\n/components/0/type: must be equal to one of the allowed values')
35
+ })
36
+ })
37
+
38
+ describe('parseRiviereGraph()', () => {
39
+ it('parses valid graph and returns typed RiviereGraph', () => {
40
+ const input = {
41
+ version: '1.0',
42
+ metadata: { domains: { test: { description: 'Test', systemType: 'domain' } } },
43
+ components: [],
44
+ links: [],
45
+ }
46
+
47
+ const result = parseRiviereGraph(input)
48
+
49
+ expect(result.version).toBe('1.0')
50
+ expect(result.components).toHaveLength(0)
51
+ })
52
+
53
+ it('throws on invalid component type', () => {
54
+ const input = {
55
+ version: '1.0',
56
+ metadata: { domains: { test: { description: 'Test', systemType: 'domain' } } },
57
+ components: [{ id: 'x', type: 'InvalidType', name: 'X', domain: 'test', module: 'mod', sourceLocation: { repository: 'r', filePath: 'f' } }],
58
+ links: [],
59
+ }
60
+
61
+ expect(() => parseRiviereGraph(input)).toThrow()
62
+ })
63
+
64
+ it('throws on missing required field with error details', () => {
65
+ const input = {
66
+ metadata: { domains: { test: { description: 'Test', systemType: 'domain' } } },
67
+ components: [],
68
+ links: [],
69
+ }
70
+
71
+ expect(() => parseRiviereGraph(input)).toThrow(/Invalid RiviereGraph/)
72
+ })
73
+
74
+ it('throws on invalid version format', () => {
75
+ const input = {
76
+ version: 'not-a-version',
77
+ metadata: { domains: { test: { description: 'Test', systemType: 'domain' } } },
78
+ components: [],
79
+ links: [],
80
+ }
81
+
82
+ expect(() => parseRiviereGraph(input)).toThrow()
83
+ })
84
+ })
85
+
86
+ describe('riviere-schema types', () => {
87
+ it('compiles a minimal valid graph structure', () => {
88
+ const graph: RiviereGraph = {
89
+ version: '1.0',
90
+ metadata: {
91
+ domains: {
92
+ test: {
93
+ description: 'Test domain',
94
+ systemType: 'domain',
95
+ },
96
+ },
97
+ },
98
+ components: [
99
+ {
100
+ id: 'test:mod:ui:page',
101
+ type: 'UI',
102
+ name: 'Test Page',
103
+ domain: 'test',
104
+ module: 'mod',
105
+ route: '/test',
106
+ sourceLocation: {
107
+ repository: 'test-repo',
108
+ filePath: 'src/page.tsx',
109
+ },
110
+ },
111
+ ],
112
+ links: [],
113
+ }
114
+
115
+ expect(graph.version).toBe('1.0')
116
+ expect(graph.components).toHaveLength(1)
117
+ })
118
+
119
+ it('enforces discriminated union for component types', () => {
120
+ const uiComponent: UIComponent = {
121
+ id: 'test:mod:ui:page',
122
+ type: 'UI',
123
+ name: 'Page',
124
+ domain: 'test',
125
+ module: 'mod',
126
+ route: '/page',
127
+ sourceLocation: { repository: 'repo', filePath: 'file.ts' },
128
+ }
129
+
130
+ const apiComponent: APIComponent = {
131
+ id: 'test:mod:api:endpoint',
132
+ type: 'API',
133
+ name: 'Endpoint',
134
+ domain: 'test',
135
+ module: 'mod',
136
+ apiType: 'REST',
137
+ httpMethod: 'POST',
138
+ path: '/api/test',
139
+ sourceLocation: { repository: 'repo', filePath: 'api.ts' },
140
+ }
141
+
142
+ const components: Component[] = [uiComponent, apiComponent]
143
+ expect(components).toHaveLength(2)
144
+ })
145
+
146
+ it('enforces link structure', () => {
147
+ const link: Link = {
148
+ source: 'component-a',
149
+ target: 'component-b',
150
+ type: 'sync',
151
+ }
152
+
153
+ expect(link.source).toBe('component-a')
154
+ expect(link.target).toBe('component-b')
155
+ })
156
+
157
+ it('enforces metadata structure with required domains', () => {
158
+ const metadata: GraphMetadata = {
159
+ domains: {
160
+ orders: {
161
+ description: 'Order management',
162
+ systemType: 'domain',
163
+ },
164
+ },
165
+ }
166
+
167
+ expect(metadata.domains['orders']?.systemType).toBe('domain')
168
+ })
169
+ })
package/src/schema.ts ADDED
@@ -0,0 +1,184 @@
1
+ export interface SourceLocation {
2
+ repository: string
3
+ filePath: string
4
+ lineNumber?: number
5
+ endLineNumber?: number
6
+ methodName?: string
7
+ url?: string
8
+ }
9
+
10
+ export interface OperationParameter {
11
+ name: string
12
+ type: string
13
+ description?: string
14
+ }
15
+
16
+ export interface OperationSignature {
17
+ parameters?: OperationParameter[]
18
+ returnType?: string
19
+ }
20
+
21
+ export interface OperationBehavior {
22
+ reads?: string[]
23
+ validates?: string[]
24
+ modifies?: string[]
25
+ emits?: string[]
26
+ }
27
+
28
+ export interface StateTransition {
29
+ from: string
30
+ to: string
31
+ trigger?: string
32
+ }
33
+
34
+ export type ComponentType =
35
+ | 'UI'
36
+ | 'API'
37
+ | 'UseCase'
38
+ | 'DomainOp'
39
+ | 'Event'
40
+ | 'EventHandler'
41
+ | 'Custom'
42
+
43
+ interface ComponentBase {
44
+ id: string
45
+ name: string
46
+ domain: string
47
+ module: string
48
+ description?: string
49
+ sourceLocation: SourceLocation
50
+ metadata?: Record<string, unknown>
51
+ }
52
+
53
+ export interface UIComponent extends ComponentBase {
54
+ type: 'UI'
55
+ route: string
56
+ }
57
+
58
+ export type ApiType = 'REST' | 'GraphQL' | 'other'
59
+ export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'HEAD' | 'OPTIONS'
60
+
61
+ export interface APIComponent extends ComponentBase {
62
+ type: 'API'
63
+ apiType: ApiType
64
+ httpMethod?: HttpMethod
65
+ path?: string
66
+ operationName?: string
67
+ }
68
+
69
+ export interface UseCaseComponent extends ComponentBase {
70
+ type: 'UseCase'
71
+ }
72
+
73
+ export interface DomainOpComponent extends ComponentBase {
74
+ type: 'DomainOp'
75
+ operationName: string
76
+ entity?: string
77
+ signature?: OperationSignature
78
+ behavior?: OperationBehavior
79
+ stateChanges?: StateTransition[]
80
+ businessRules?: string[]
81
+ }
82
+
83
+ export interface EventComponent extends ComponentBase {
84
+ type: 'Event'
85
+ eventName: string
86
+ eventSchema?: string
87
+ }
88
+
89
+ export interface EventHandlerComponent extends ComponentBase {
90
+ type: 'EventHandler'
91
+ subscribedEvents: string[]
92
+ }
93
+
94
+ export interface CustomComponent extends ComponentBase {
95
+ type: 'Custom'
96
+ customTypeName: string
97
+ }
98
+
99
+ export type Component =
100
+ | UIComponent
101
+ | APIComponent
102
+ | UseCaseComponent
103
+ | DomainOpComponent
104
+ | EventComponent
105
+ | EventHandlerComponent
106
+ | CustomComponent
107
+
108
+ export type LinkType = 'sync' | 'async'
109
+
110
+ export interface PayloadDefinition {
111
+ type?: string
112
+ schema?: Record<string, unknown>
113
+ }
114
+
115
+ export interface Link {
116
+ id?: string
117
+ source: string
118
+ target: string
119
+ type?: LinkType
120
+ payload?: PayloadDefinition
121
+ sourceLocation?: SourceLocation
122
+ metadata?: Record<string, unknown>
123
+ }
124
+
125
+ export interface ExternalTarget {
126
+ name: string
127
+ domain?: string
128
+ repository?: string
129
+ url?: string
130
+ }
131
+
132
+ export interface ExternalLink {
133
+ id?: string
134
+ source: string
135
+ target: ExternalTarget
136
+ type?: LinkType
137
+ description?: string
138
+ sourceLocation?: SourceLocation
139
+ metadata?: Record<string, unknown>
140
+ }
141
+
142
+ export type SystemType = 'domain' | 'bff' | 'ui' | 'other'
143
+
144
+ export interface DomainMetadata {
145
+ description: string
146
+ systemType: SystemType
147
+ [key: string]: unknown
148
+ }
149
+
150
+ export type CustomPropertyType = 'string' | 'number' | 'boolean' | 'array' | 'object'
151
+
152
+ export interface CustomPropertyDefinition {
153
+ type: CustomPropertyType
154
+ description?: string
155
+ }
156
+
157
+ export interface CustomTypeDefinition {
158
+ description?: string
159
+ requiredProperties?: Record<string, CustomPropertyDefinition>
160
+ optionalProperties?: Record<string, CustomPropertyDefinition>
161
+ }
162
+
163
+ export interface SourceInfo {
164
+ repository: string
165
+ commit?: string
166
+ extractedAt?: string
167
+ }
168
+
169
+ export interface GraphMetadata {
170
+ name?: string
171
+ description?: string
172
+ generated?: string
173
+ sources?: SourceInfo[]
174
+ domains: Record<string, DomainMetadata>
175
+ customTypes?: Record<string, CustomTypeDefinition>
176
+ }
177
+
178
+ export interface RiviereGraph {
179
+ version: string
180
+ metadata: GraphMetadata
181
+ components: Component[]
182
+ links: Link[]
183
+ externalLinks?: ExternalLink[]
184
+ }
@@ -0,0 +1,32 @@
1
+ import Ajv from 'ajv'
2
+ import addFormats from 'ajv-formats'
3
+ import type { RiviereGraph } from './schema'
4
+ import rawSchema from '../riviere.schema.json' with { type: 'json' }
5
+
6
+ const ajv = new Ajv({ allErrors: true })
7
+ addFormats(ajv)
8
+
9
+ const validate = ajv.compile(rawSchema)
10
+
11
+ export function isRiviereGraph(data: unknown): data is RiviereGraph {
12
+ return validate(data) === true
13
+ }
14
+
15
+ interface ValidationErrorLike {
16
+ instancePath: string
17
+ message?: string
18
+ }
19
+
20
+ export function formatValidationErrors(errors: ValidationErrorLike[] | null | undefined): string {
21
+ if (!errors || errors.length === 0) {
22
+ return 'validation failed without specific errors'
23
+ }
24
+ return errors.map((e) => `${e.instancePath}: ${e.message}`).join('\n')
25
+ }
26
+
27
+ export function parseRiviereGraph(data: unknown): RiviereGraph {
28
+ if (isRiviereGraph(data)) {
29
+ return data
30
+ }
31
+ throw new Error(`Invalid RiviereGraph:\n${formatValidationErrors(validate.errors)}`)
32
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,13 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "files": [],
4
+ "include": [],
5
+ "references": [
6
+ {
7
+ "path": "./tsconfig.lib.json"
8
+ },
9
+ {
10
+ "path": "./tsconfig.spec.json"
11
+ }
12
+ ]
13
+ }
@@ -0,0 +1,28 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "baseUrl": ".",
5
+ "rootDir": "src",
6
+ "outDir": "dist",
7
+ "tsBuildInfoFile": "dist/tsconfig.lib.tsbuildinfo",
8
+ "emitDeclarationOnly": false,
9
+ "forceConsistentCasingInFileNames": true,
10
+ "types": ["node"]
11
+ },
12
+ "include": ["src/**/*.ts"],
13
+ "references": [],
14
+ "exclude": [
15
+ "vite.config.ts",
16
+ "vite.config.mts",
17
+ "vitest.config.ts",
18
+ "vitest.config.mts",
19
+ "src/**/*.test.ts",
20
+ "src/**/*.spec.ts",
21
+ "src/**/*.test.tsx",
22
+ "src/**/*.spec.tsx",
23
+ "src/**/*.test.js",
24
+ "src/**/*.spec.js",
25
+ "src/**/*.test.jsx",
26
+ "src/**/*.spec.jsx"
27
+ ]
28
+ }
@@ -0,0 +1,34 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "outDir": "./out-tsc/vitest",
5
+ "types": [
6
+ "vitest/globals",
7
+ "vitest/importMeta",
8
+ "vite/client",
9
+ "node",
10
+ "vitest"
11
+ ],
12
+ "forceConsistentCasingInFileNames": true
13
+ },
14
+ "include": [
15
+ "vite.config.ts",
16
+ "vite.config.mts",
17
+ "vitest.config.ts",
18
+ "vitest.config.mts",
19
+ "src/**/*.test.ts",
20
+ "src/**/*.spec.ts",
21
+ "src/**/*.test.tsx",
22
+ "src/**/*.spec.tsx",
23
+ "src/**/*.test.js",
24
+ "src/**/*.spec.js",
25
+ "src/**/*.test.jsx",
26
+ "src/**/*.spec.jsx",
27
+ "src/**/*.d.ts"
28
+ ],
29
+ "references": [
30
+ {
31
+ "path": "./tsconfig.lib.json"
32
+ }
33
+ ]
34
+ }
@@ -0,0 +1 @@
1
+ {"fileNames":[],"fileInfos":[],"root":[],"options":{"composite":true,"declarationMap":true,"emitDeclarationOnly":true,"exactOptionalPropertyTypes":true,"importHelpers":true,"module":199,"noEmitOnError":true,"noFallthroughCasesInSwitch":true,"noImplicitOverride":true,"noImplicitReturns":true,"noPropertyAccessFromIndexSignature":true,"noUncheckedIndexedAccess":true,"noUnusedLocals":true,"noUnusedParameters":true,"skipLibCheck":true,"strict":true,"target":9,"verbatimModuleSyntax":true},"version":"5.9.3"}
package/vite.config.ts ADDED
@@ -0,0 +1,20 @@
1
+ /// <reference types='vitest' />
2
+ import { defineConfig } from 'vite';
3
+
4
+ export default defineConfig(() => ({
5
+ root: import.meta.dirname,
6
+ cacheDir: '../node_modules/.vite/riviere-schema',
7
+ plugins: [],
8
+ test: {
9
+ name: '@living-architecture/riviere-schema',
10
+ watch: false,
11
+ globals: true,
12
+ environment: 'node',
13
+ include: ['{src,tests}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
14
+ reporters: ['default'],
15
+ coverage: {
16
+ reportsDirectory: './test-output/vitest/coverage',
17
+ provider: 'v8' as const,
18
+ },
19
+ },
20
+ }));
@@ -0,0 +1,24 @@
1
+ import { defineConfig } from 'vitest/config';
2
+
3
+ export default defineConfig(() => ({
4
+ root: __dirname,
5
+ cacheDir: '../../node_modules/.vite/packages/riviere-schema',
6
+ test: {
7
+ name: '@living-architecture/riviere-schema',
8
+ watch: false,
9
+ globals: true,
10
+ environment: 'node',
11
+ include: ['{src,tests}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
12
+ reporters: ['default'],
13
+ coverage: {
14
+ reportsDirectory: './test-output/vitest/coverage',
15
+ provider: 'v8' as const,
16
+ thresholds: {
17
+ lines: 100,
18
+ statements: 100,
19
+ functions: 100,
20
+ branches: 100,
21
+ },
22
+ },
23
+ },
24
+ }));